mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
api, web: the shell reads the failed tasks and the channels of every Dot in one request each
The web's shell, on every page, read each Dot's task list and each Dot's channels, at load and again for every Dot on each task.failed and channel event: 2N requests for N Dots, as its own comments said, because no route answered for every Dot. GET /api/tasks?status=&finished_after= lists the tasks of every Dot that ended in a state after a time, and GET /api/channels every channel with its Dot's id (never a credential); the SDK has endedTasks and everyChannel, and the shell makes one request for each.
This commit is contained in:
1 parent
b0514eac62
commit
5a65ada65d
14 files changed
+145
-91
No files matched your search
@@ -13,6 +13,7 @@ import type {
|
||||
ChannelPairingAnswer,
|
||||
ChannelRecord,
|
||||
ChannelsAnswer,
|
||||
EveryChannelAnswer,
|
||||
DoctorAnswer,
|
||||
DotsAnswer,
|
||||
EventsAnswer,
|
||||
@@ -246,6 +247,11 @@ export function buildServer(options: ServerOptions): FastifyInstance {
|
||||
return { tasks: await scheduler.listTasks(request.params.id, { limit, before }) };
|
||||
});
|
||||
|
||||
// The tasks of every Dot that ended in a state after a time, the latest end first (the web's failed tasks of the day).
|
||||
app.get<{ Querystring: { status?: unknown; finished_after?: unknown } }>("/api/tasks", async (request): Promise<TasksAnswer> => ({
|
||||
tasks: await scheduler.listEndedTasks(request.query.status, request.query.finished_after),
|
||||
}));
|
||||
|
||||
app.get<{ Params: Params }>("/api/tasks/:id", async (request) => scheduler.getTask(request.params.id));
|
||||
|
||||
app.post<{ Params: Params }>("/api/tasks/:id/cancel", async (request) => scheduler.cancelTask(request.params.id));
|
||||
@@ -328,6 +334,9 @@ export function buildServer(options: ServerOptions): FastifyInstance {
|
||||
async (request): Promise<ChannelsAnswer> => ({ channels: await channels.list(request.params.id), available: channels.kinds }),
|
||||
);
|
||||
|
||||
// Every channel of every Dot, each with its Dot's id (the web's shell watches the ones that need the person).
|
||||
app.get("/api/channels", async (): Promise<EveryChannelAnswer> => ({ channels: await channels.listEvery() }));
|
||||
|
||||
// Link the Dot to a Telegram bot, or give the linked one a new token (a revoked token is the only way back from needs_relink).
|
||||
app.put<{ Params: Params }>("/api/dots/:id/channels/telegram", async (request, reply): Promise<ChannelRecord> => {
|
||||
const token = bodyOf(request).token;
|
||||
|
||||
@@ -547,6 +547,40 @@ describe.each(testAdapters())("control-plane API (%s)", (kind) => {
|
||||
expect(await get("limit=200&before=nobody")).toBe(200);
|
||||
});
|
||||
|
||||
it("every Dot in one read: the tasks that failed since a time, latest first, and every channel with its Dot", async () => {
|
||||
const a = await readyDot("every-a");
|
||||
const b = await readyDot("every-b");
|
||||
const since = new Date(Date.now() - 1);
|
||||
const failed = async (dotId: string, description: string) => {
|
||||
const guest = driver.guestOf(dotId);
|
||||
guest.onInbound = () => {};
|
||||
const task = await api.createTask(dotId, { description });
|
||||
await waitFor(async () => (await db.tasks.runs(task.id))[0]?.delivered_at, "delivered");
|
||||
guest.emit("task.failed", { task_id: task.id, error: "boom" });
|
||||
await waitFor(async () => (await api.getTask(task.id)).status === "FAILED", "failed");
|
||||
return task.id;
|
||||
};
|
||||
const first = await failed(a.id, "first");
|
||||
const second = await failed(b.id, "second");
|
||||
const ours = (tasks: { id: string }[]) => tasks.map((t) => t.id).filter((id) => id === first || id === second);
|
||||
expect(ours(await api.endedTasks("FAILED", since))).toEqual([second, first]);
|
||||
expect(ours(await api.endedTasks("COMPLETED", since))).toEqual([]);
|
||||
expect(await api.endedTasks("FAILED", new Date(Date.now() + 60_000))).toEqual([]);
|
||||
const get = async (query: string) => (await fetch(`${base}/api/tasks?${query}`, { headers: { authorization: `Bearer ${TOKEN}` } })).status;
|
||||
expect(await get("status=RUNNING&finished_after=2026-01-01T00:00:00Z")).toBe(400);
|
||||
expect(await get("status=FAILED&finished_after=yesterday")).toBe(400);
|
||||
expect(await get("status=FAILED")).toBe(400);
|
||||
|
||||
await channels.add(a.id, "telegram");
|
||||
await channels.add(b.id, "telegram");
|
||||
const every = (await api.everyChannel()).filter((c) => c.dot_id === a.id || c.dot_id === b.id);
|
||||
expect(every.map((c) => [c.dot_id, c.kind])).toEqual([
|
||||
[a.id, "telegram"],
|
||||
[b.id, "telegram"],
|
||||
]);
|
||||
expect(JSON.stringify(every)).not.toMatch(/token/i);
|
||||
});
|
||||
|
||||
it("messages: the newest page of a long conversation, and a cursor that goes back through the rest", async () => {
|
||||
const dot = await readyDot("chatty");
|
||||
for (const [index, word] of ["one", "two", "three"].entries()) {
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type StoredEvent,
|
||||
type SystemAnswer,
|
||||
type Skill,
|
||||
type TaskState,
|
||||
type ToolListAnswer,
|
||||
} from "@invisible-dots/shared";
|
||||
import { Dispatcher } from "./dispatcher.js";
|
||||
@@ -456,6 +457,16 @@ export class Scheduler {
|
||||
return this.db.tasks.listByDot(dot.id, page);
|
||||
}
|
||||
|
||||
/** The tasks of every Dot that ended in a terminal `status` after `after`, the latest end first: one read for all of them. */
|
||||
async listEndedTasks(status: unknown, after: unknown): Promise<TaskRecord[]> {
|
||||
if (!TERMINAL_TASK_STATES.includes(status as TaskState)) {
|
||||
throw new ControlPlaneError(400, "invalid_request", `status must be one of ${TERMINAL_TASK_STATES.join(", ")}`);
|
||||
}
|
||||
const since = typeof after === "string" ? new Date(after) : new Date(Number.NaN);
|
||||
if (Number.isNaN(since.getTime())) throw new ControlPlaneError(400, "invalid_request", "finished_after must be an ISO 8601 time");
|
||||
return this.db.tasks.listEnded(status as TaskState, since);
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<TaskRecord> {
|
||||
const task = await this.db.tasks.get(id);
|
||||
if (!task) throw notFound("task", id);
|
||||
|
||||
@@ -220,22 +220,22 @@ export function NeedsYou({ filter }: { filter: Filter }) {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{failedTasks.unread > 0 && otherThings ? (
|
||||
{failedTasks.unreadable && otherThings ? (
|
||||
<Alert>
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>Some failed tasks may be missing</AlertTitle>
|
||||
<AlertTitle>Failed tasks may be missing</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>The tasks of {failedTasks.unread === 1 ? "one Dot" : `${failedTasks.unread} Dots`} could not be read.</p>
|
||||
<p>The tasks that failed today could not be read.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{relinks.unread > 0 && otherThings ? (
|
||||
{relinks.unreadable && otherThings ? (
|
||||
<Alert>
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>Some channels may be missing</AlertTitle>
|
||||
<AlertTitle>Channels may be missing</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>The channels of {relinks.unread === 1 ? "one Dot" : `${relinks.unread} Dots`} could not be read.</p>
|
||||
<p>The channels could not be read.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { HealthResponse } from "@invisible-dots/sdk";
|
||||
import { api } from "../../lib/api";
|
||||
import { attentionByDot, needsYouCount, NO_ATTENTION, withTitlePrefix, type DotAttention } from "../../lib/attention";
|
||||
import { readDismissed, writeDismissed } from "../../lib/dismissed";
|
||||
import { loadRelinks, type ChannelRelink, type RelinkRead } from "../../lib/channels";
|
||||
import { loadFailedTasks, recentFailures, type FailedTasks } from "../../lib/failed-tasks";
|
||||
import { loadRelinks, type ChannelRelink } from "../../lib/channels";
|
||||
import { loadFailedTasks, recentFailures } from "../../lib/failed-tasks";
|
||||
import { applyLiveEvent, dismissRestart, dotIdFromPath, liveOf, markRead, type LiveDot, type LiveDots } from "../../lib/dot-live";
|
||||
import { faviconHref } from "../../lib/favicon";
|
||||
import type { Approval, Dot, Task } from "../../lib/types";
|
||||
@@ -31,10 +31,10 @@ interface ShellData {
|
||||
attention: ReadonlyMap<string, DotAttention>;
|
||||
/** What needs the person, across every Dot: waiting approvals, Dots in ERROR, failed tasks and channels to link again. The number on the rail's Inbox entry. */
|
||||
needsYou: number;
|
||||
/** Tasks that failed in the last 24 hours and are not dismissed, newest first; and how many Dots' tasks could not be read. */
|
||||
failedTasks: { tasks: readonly Task[]; unread: number; loading: boolean };
|
||||
/** Channels that need linking again, and how many Dots' channels could not be read. */
|
||||
relinks: { items: readonly ChannelRelink[]; unread: number; loading: boolean };
|
||||
/** Tasks that failed in the last 24 hours and are not dismissed, newest first; and whether they could not be read. */
|
||||
failedTasks: { tasks: readonly Task[]; unreadable: boolean; loading: boolean };
|
||||
/** Channels that need linking again; and whether they could not be read. */
|
||||
relinks: { items: readonly ChannelRelink[]; unreadable: boolean; loading: boolean };
|
||||
/** Take a failed task out of the Inbox (remembered in this browser). */
|
||||
dismissFailedTask: (taskId: string) => void;
|
||||
live: LiveDots;
|
||||
@@ -53,9 +53,8 @@ export function AttentionProvider({ children }: { children: ReactNode }) {
|
||||
const dots = useResource(() => api.listDots(), "shell:dots");
|
||||
const approvals = useResource(() => api.listApprovals("pending"), "shell:approvals");
|
||||
const health = useResource(() => api.health(), "shell:health");
|
||||
const dotIds = (dots.data ?? []).map((dot) => dot.id).join(",");
|
||||
const failed = useResource<FailedTasks>(() => loadFailedTasks(api, dots.data ?? []), `shell:failed:${dotIds}`);
|
||||
const channels = useResource<RelinkRead>(() => loadRelinks(api, dots.data ?? []), `shell:channels:${dotIds}`);
|
||||
const failed = useResource<Task[]>(() => loadFailedTasks(api), "shell:failed");
|
||||
const channels = useResource<ChannelRelink[]>(() => loadRelinks(api), "shell:channels");
|
||||
const reloadHealth = health.reload;
|
||||
useEffect(() => {
|
||||
const timer = setInterval(reloadHealth, HEALTH_INTERVAL_MS);
|
||||
@@ -105,11 +104,11 @@ export function AttentionProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
// A failure leaves the Inbox a day after it happened, without anyone reloading anything.
|
||||
const now = Math.floor(useNow(60_000) / 60_000);
|
||||
const recent = useMemo(() => recentFailures(failed.data?.tasks ?? [], dismissed, now * 60_000), [failed.data, dismissed, now]);
|
||||
const failedTasks = useMemo(() => ({ tasks: recent, unread: failed.data?.unread ?? 0, loading: failed.data === undefined && failed.error === null }), [recent, failed.data, failed.error]);
|
||||
const recent = useMemo(() => recentFailures(failed.data ?? [], dismissed, now * 60_000), [failed.data, dismissed, now]);
|
||||
const failedTasks = useMemo(() => ({ tasks: recent, unreadable: failed.error !== null, loading: failed.data === undefined && failed.error === null }), [recent, failed.data, failed.error]);
|
||||
|
||||
const relinks = useMemo(
|
||||
() => ({ items: channels.data?.relinks ?? [], unread: channels.data?.unread ?? 0, loading: channels.data === undefined && channels.error === null }),
|
||||
() => ({ items: channels.data ?? [], unreadable: channels.error !== null, loading: channels.data === undefined && channels.error === null }),
|
||||
[channels.data, channels.error],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import type { InvisibleDotsClient } from "@invisible-dots/sdk";
|
||||
import { APPROVAL_PROMPT_TEXT_MAX, type ChannelKind, type ChannelPeerRecord, type ChannelRecord, type ChannelSettings, type ChannelStatus, type MessageOrigin } from "@invisible-dots/shared/browser";
|
||||
import type { Tone } from "./tone";
|
||||
import type { Dot } from "./types";
|
||||
|
||||
/** How a channel is named to the person. A `Record` over the contract's kinds, so a kind added there is a compile error here until it has a name. */
|
||||
export const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
||||
@@ -129,21 +128,7 @@ export function needsRelinking(record: Pick<ChannelRecord, "enabled" | "status">
|
||||
return record.enabled && record.status === "needs_relink";
|
||||
}
|
||||
|
||||
export interface RelinkRead {
|
||||
/** The channels that need linking again, Dots in the order given, a Dot's channels in the order the host lists them. */
|
||||
relinks: ChannelRelink[];
|
||||
/** Dots whose channels could not be read. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channels of every Dot that need the person. The host has no route for "every Dot's channels", so each Dot's list
|
||||
* is read; a Dot whose list cannot be read is counted, not hidden.
|
||||
*/
|
||||
export async function loadRelinks(client: Pick<InvisibleDotsClient, "channels">, dots: readonly Pick<Dot, "id">[]): Promise<RelinkRead> {
|
||||
const answers = await Promise.allSettled(dots.map((dot) => client.channels(dot.id)));
|
||||
const relinks = answers.flatMap((answer, index) =>
|
||||
answer.status === "fulfilled" ? answer.value.filter(needsRelinking).map((record): ChannelRelink => ({ dot_id: dots[index]!.id, kind: record.kind, detail: record.status_detail })) : [],
|
||||
);
|
||||
return { relinks, unread: answers.filter((answer) => answer.status === "rejected").length };
|
||||
/** The channels of every Dot that need the person, in the order the host lists them: one read of every Dot's (`GET /api/channels`). */
|
||||
export async function loadRelinks(client: Pick<InvisibleDotsClient, "everyChannel">): Promise<ChannelRelink[]> {
|
||||
return (await client.everyChannel()).filter(needsRelinking).map((record): ChannelRelink => ({ dot_id: record.dot_id, kind: record.kind, detail: record.status_detail }));
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
/**
|
||||
* Tasks that failed in the last 24 hours, which the Inbox puts under "Needs you" until the person has seen them and
|
||||
* says so. The host has no route for "failed tasks of every Dot", so each Dot's list (the newest tasks, which is where
|
||||
* a recent failure is) is read and the failed ones kept; a Dot whose list cannot be read is counted, not hidden.
|
||||
* says so: one read of every Dot's (`GET /api/tasks?status=FAILED`).
|
||||
*/
|
||||
import type { InvisibleDotsClient } from "@invisible-dots/sdk";
|
||||
import type { Dot, Task } from "./types";
|
||||
import type { Task } from "./types";
|
||||
|
||||
/** How long a failure stays in the Inbox. */
|
||||
export const FAILED_WINDOW_MS = 24 * 3_600_000;
|
||||
|
||||
export interface FailedTasks {
|
||||
/** Failed tasks, newest first, as of when they were read; `recentFailures` applies the window and the dismissals. */
|
||||
tasks: Task[];
|
||||
/** Dots whose tasks could not be read. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/** Whether a task failed less than a day before `now`. */
|
||||
export function failedRecently(task: Pick<Task, "status" | "finished_at">, now: number): boolean {
|
||||
if (task.status !== "FAILED" || task.finished_at === null) return false;
|
||||
@@ -23,14 +15,9 @@ export function failedRecently(task: Pick<Task, "status" | "finished_at">, now:
|
||||
return Number.isFinite(finished) && now - finished < FAILED_WINDOW_MS && finished <= now + 60_000;
|
||||
}
|
||||
|
||||
function newestFirst(a: Task, b: Task): number {
|
||||
return Date.parse(b.finished_at ?? "") - Date.parse(a.finished_at ?? "");
|
||||
}
|
||||
|
||||
export async function loadFailedTasks(client: Pick<InvisibleDotsClient, "listTasks">, dots: readonly Pick<Dot, "id">[], now = Date.now()): Promise<FailedTasks> {
|
||||
const answers = await Promise.allSettled(dots.map((dot) => client.listTasks(dot.id)));
|
||||
const tasks = answers.flatMap((answer) => (answer.status === "fulfilled" ? answer.value : [])).filter((task) => failedRecently(task, now));
|
||||
return { tasks: tasks.sort(newestFirst), unread: answers.filter((answer) => answer.status === "rejected").length };
|
||||
/** The tasks of every Dot that failed within the window, newest first, as of when they were read; `recentFailures` applies the window and the dismissals as time goes on. */
|
||||
export function loadFailedTasks(client: Pick<InvisibleDotsClient, "endedTasks">, now = Date.now()): Promise<Task[]> {
|
||||
return client.endedTasks("FAILED", new Date(now - FAILED_WINDOW_MS));
|
||||
}
|
||||
|
||||
/** The failures the Inbox shows: within the window, and not dismissed. */
|
||||
|
||||
@@ -130,27 +130,24 @@ describe("the channels that need the person", () => {
|
||||
expect(needsRelinking({ enabled: true, status: "connecting" })).toBe(false);
|
||||
});
|
||||
|
||||
it("reads every Dot's channels, keeps those to link again with the host's words, and counts the Dots it could not read", async () => {
|
||||
const answers: Record<string, ChannelRecord[] | Error> = {
|
||||
a: [record("telegram", { status: "needs_relink", status_detail: "Telegram refused the token" }), record("whatsapp")],
|
||||
b: [record("telegram"), record("whatsapp", { status: "needs_relink", status_detail: null })],
|
||||
c: new Error("down"),
|
||||
d: [record("telegram", { enabled: false, status: "needs_relink" })],
|
||||
};
|
||||
it("reads every Dot's channels in one request and keeps those to link again, with the host's words", async () => {
|
||||
let reads = 0;
|
||||
const client = {
|
||||
channels: async (id: string) => {
|
||||
const answer = answers[id]!;
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
everyChannel: async () => {
|
||||
reads += 1;
|
||||
return [
|
||||
{ dot_id: "a", ...record("telegram", { status: "needs_relink", status_detail: "Telegram refused the token" }) },
|
||||
{ dot_id: "a", ...record("whatsapp") },
|
||||
{ dot_id: "b", ...record("telegram") },
|
||||
{ dot_id: "b", ...record("whatsapp", { status: "needs_relink", status_detail: null }) },
|
||||
{ dot_id: "d", ...record("telegram", { enabled: false, status: "needs_relink" }) },
|
||||
];
|
||||
},
|
||||
};
|
||||
expect(await loadRelinks(client, [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }])).toEqual({
|
||||
relinks: [
|
||||
{ dot_id: "a", kind: "telegram", detail: "Telegram refused the token" },
|
||||
{ dot_id: "b", kind: "whatsapp", detail: null },
|
||||
],
|
||||
unread: 1,
|
||||
});
|
||||
expect(await loadRelinks(client, [])).toEqual({ relinks: [], unread: 0 });
|
||||
expect(await loadRelinks(client)).toEqual([
|
||||
{ dot_id: "a", kind: "telegram", detail: "Telegram refused the token" },
|
||||
{ dot_id: "b", kind: "whatsapp", detail: null },
|
||||
]);
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -104,21 +104,17 @@ describe("the tasks that failed lately", () => {
|
||||
expect(failedRecently({ status: "FAILED", finished_at: new Date(now + 3_600_000).toISOString() }, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("are read from every Dot's tasks, newest failure first, and a Dot that cannot be read is counted, not hidden", async () => {
|
||||
const tasksOf: Record<string, ReturnType<typeof taskRecord>[]> = {
|
||||
d1: [taskRecord("a", { dot_id: "d1", status: "FAILED", finished_at: ago(3_600_000) }), taskRecord("fine", { dot_id: "d1", status: "COMPLETED", finished_at: ago(1) })],
|
||||
d2: [taskRecord("b", { dot_id: "d2", status: "FAILED", finished_at: ago(60_000) }), taskRecord("stale", { dot_id: "d2", status: "FAILED", finished_at: ago(2 * FAILED_WINDOW_MS) })],
|
||||
};
|
||||
it("are read in one request for every Dot: the tasks that failed within the window, as the host orders them", async () => {
|
||||
const failed = [taskRecord("b", { dot_id: "d2", status: "FAILED", finished_at: ago(60_000) }), taskRecord("a", { dot_id: "d1", status: "FAILED", finished_at: ago(3_600_000) })];
|
||||
const asked: [string, string][] = [];
|
||||
const client = {
|
||||
listTasks: async (id: string) => {
|
||||
if (id === "broken") throw new Error("down");
|
||||
return tasksOf[id] ?? [];
|
||||
endedTasks: async (status: "COMPLETED" | "FAILED" | "CANCELLED", after: Date) => {
|
||||
asked.push([status, after.toISOString()]);
|
||||
return failed;
|
||||
},
|
||||
};
|
||||
const loaded = await loadFailedTasks(client, [{ id: "d1" }, { id: "d2" }, { id: "broken" }], now);
|
||||
expect(loaded.tasks.map((task) => task.id)).toEqual(["b", "a"]);
|
||||
expect(loaded.unread).toBe(1);
|
||||
expect(await loadFailedTasks(client, [], now)).toEqual({ tasks: [], unread: 0 });
|
||||
expect(await loadFailedTasks(client, now)).toEqual(failed);
|
||||
expect(asked).toEqual([["FAILED", new Date(now - FAILED_WINDOW_MS).toISOString()]]);
|
||||
});
|
||||
|
||||
it("leave the Inbox when they are dismissed, and when a day has passed since they failed", () => {
|
||||
|
||||
@@ -2107,6 +2107,7 @@ POST /api/dots/:id/messages body: { text }
|
||||
GET /api/dots/:id/messages ?limit=&order=asc|desc&before=<event id> conversation, from the event log (a user message carries `origin` when it came through a channel); oldest first by default, at most 500 a page; `order=desc` is the newest first and `before` (the `event_id` of the oldest message of the previous page, desc only) goes on, older, from there
|
||||
POST /api/dots/:id/tasks body: { description, priority?, scheduled_at? }
|
||||
GET /api/dots/:id/tasks ?limit=&before=<task id> the newest created first, at most 200 a page; `before` (the id of the last task of the previous page) goes on, older
|
||||
GET /api/tasks ?status=COMPLETED|FAILED|CANCELLED&finished_after=<ISO time> { tasks }: the tasks of every Dot that ended in that state after that time, the latest end first, at most 200
|
||||
GET /api/tasks/:id
|
||||
POST /api/tasks/:id/cancel
|
||||
|
||||
@@ -2124,6 +2125,7 @@ GET /api/dots/:id/browser-identities/:identityId/frame image/jpeg, only w
|
||||
POST /api/dots/:id/browser-identities/:identityId/close 204; the browser ends, the profile stays
|
||||
|
||||
GET /api/dots/:id/channels { channels: [{ kind, enabled, status, status_detail, account, settings, peers, created_at }], available: [kind] }; never a token; `available` is what this server runs (WhatsApp only when it was started with it, section 9.8)
|
||||
GET /api/channels { channels: [{ dot_id, kind, enabled, status, ... }] }: every channel of every Dot, each with its Dot's id; never a token
|
||||
PUT /api/dots/:id/channels/telegram body: { token } links the Dot to the bot (201), or gives the linked bot a new token (200); the token is checked with Telegram, stored encrypted, never returned
|
||||
PATCH /api/dots/:id/channels/:kind body: { settings?: { approvals?, notify_tasks?, show_arguments? }, enabled? } enabled false pauses the channel, its people and token stay
|
||||
POST /api/dots/:id/channels/whatsapp/link 202 the channel's record, waiting: starts linking WhatsApp (400 when the server does not run it, 409 `already_linked`)
|
||||
@@ -2394,9 +2396,9 @@ name>` and `?permission=<name>` (the old `/approvals` and `/dots/<id>/approvals`
|
||||
redirect to it). "Needs you" lists the waiting approvals, the one that has waited
|
||||
longest first, then the Dots in ERROR and the tasks that failed in the last 24
|
||||
hours (each dismissable; the dismissals are kept in this browser only, so a
|
||||
cleared browser shows them again). No route lists failed tasks of every Dot, so
|
||||
the shell reads each Dot's task list (the newest 200, where a recent failure is)
|
||||
and keeps the failed ones, and says when some Dot's list could not be read.
|
||||
cleared browser shows them again). The shell reads them for every Dot at once
|
||||
(`GET /api/tasks?status=FAILED&finished_after=`, a day back) and again on
|
||||
`task.failed`, and says when they could not be read.
|
||||
`lib/attention.ts` counts the three, and the rail's Inbox badge, the tab title and
|
||||
the favicon all read that one count. The approval card (S7) says what the Dot
|
||||
wants to do from the engine's tool table (`TOOL_LABELS`), the
|
||||
@@ -2425,8 +2427,8 @@ newest page again and keeps the older rows it reaches (when more was answered in
|
||||
between than a page holds, the older rows are dropped and read again on request).
|
||||
A channel that needs the person (its login was refused: a revoked Telegram token, a WhatsApp device removed on the phone; one the person paused does not count) is a
|
||||
card under "Channels to link again" with what the host says and a link to the Channels page, counts in the Inbox's number, the title and the favicon, and marks
|
||||
the Dot in the rail and its Channels tab. The shell reads each Dot's `GET /api/dots/:id/channels` for it and again on `channel.status` and `channel.changed` (the host announces a pause, a resume and a removal, which no status says, so the marks follow them without a reload); a Dot whose channels
|
||||
cannot be read is counted and said, not hidden.
|
||||
the Dot in the rail and its Channels tab. The shell reads every Dot's channels at once (`GET /api/channels`) and again on `channel.status` and `channel.changed` (the host announces a pause, a resume and a removal, which no status says, so the marks follow them without a reload); channels
|
||||
that cannot be read are said, not hidden.
|
||||
|
||||
The Computer page (`/dots/<id>/computer`) has three views, named in the address
|
||||
(`?view=screen|files|usage`, Screen when it says nothing, and so for an address of the
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { ChannelBindingRecord, ChannelPeerRow, Database } from "@invisible-dots/database";
|
||||
import { isUniqueViolation } from "@invisible-dots/database";
|
||||
import type { EventLog } from "@invisible-dots/events";
|
||||
import { type ApprovalRecord, CHANNEL_KINDS, type ChannelChange, type ChannelKind, type ChannelLinkFrame, type ChannelPairingAnswer, type ChannelRecord, type ChannelSettings, type Clock, ControlPlaneError, errorMessage, type Logger, type MessageAnswer, type MessageOrigin, newId, notFound, silentLogger, type StoredEvent, systemClock } from "@invisible-dots/shared";
|
||||
import { type ApprovalRecord, CHANNEL_KINDS, type ChannelChange, type ChannelKind, type ChannelLinkFrame, type ChannelPairingAnswer, type ChannelRecord, type ChannelSettings, type Clock, type DotChannelRecord, ControlPlaneError, errorMessage, type Logger, type MessageAnswer, type MessageOrigin, newId, notFound, silentLogger, type StoredEvent, systemClock } from "@invisible-dots/shared";
|
||||
import { DEFAULT_BACKOFF, type BackoffOptions } from "./backoff.js";
|
||||
import { parseApprovalReply } from "./approval-text.js";
|
||||
import { ChannelCredentialsError, type ApprovalAction, type ChannelSink, type ChannelStatusReport, type ChannelType, type InboundChat, type PairingAttempt } from "./channel.js";
|
||||
@@ -162,6 +162,12 @@ export class ChannelHub {
|
||||
return Promise.all(bindings.map(async (b) => this.#record(b, await this.#o.db.channels.peers(b.id))));
|
||||
}
|
||||
|
||||
/** Every channel of every Dot, each with its Dot's id: what the web's shell watches, in one read. Never a credential. */
|
||||
async listEvery(): Promise<DotChannelRecord[]> {
|
||||
const bindings = await this.#o.db.channels.listBindings();
|
||||
return Promise.all(bindings.map(async (b) => ({ dot_id: b.dot_id, ...this.#record(b, await this.#o.db.channels.peers(b.id)) })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the Dot to a channel and start it. `credentials` are checked with the channel first, then stored
|
||||
* encrypted as secrets of the Dot, by the names the channel type declares, and never returned. The
|
||||
|
||||
@@ -142,6 +142,15 @@ export class TasksRepository {
|
||||
return rows.map(toTask);
|
||||
}
|
||||
|
||||
/** The tasks of every Dot that ended in `status` after `after` (their `finished_at`), the latest end first. */
|
||||
async listEnded(status: TaskState, after: Date, limit: number = TASK_LIST_LIMIT): Promise<TaskRecord[]> {
|
||||
const { rows } = await this.q.query<TaskRow>(
|
||||
"SELECT * FROM tasks WHERE status = $1 AND finished_at > $2 ORDER BY finished_at DESC, id DESC LIMIT $3",
|
||||
[status, after, limit],
|
||||
);
|
||||
return rows.map(toTask);
|
||||
}
|
||||
|
||||
async runs(taskId: string): Promise<TaskRunRecord[]> {
|
||||
const { rows } = await this.q.query<TaskRunRow>(
|
||||
"SELECT * FROM task_runs WHERE task_id = $1 ORDER BY started_at, id",
|
||||
|
||||
@@ -28,10 +28,12 @@ import type {
|
||||
ConversationMessage,
|
||||
CreateTaskRequest,
|
||||
DoctorAnswer,
|
||||
DotChannelRecord,
|
||||
DotRecord,
|
||||
DotsAnswer,
|
||||
DotSummary,
|
||||
EventsAnswer,
|
||||
EveryChannelAnswer,
|
||||
FilesListAnswer,
|
||||
HealthResponse,
|
||||
IdentitiesAnswer,
|
||||
@@ -234,6 +236,11 @@ export class InvisibleDotsClient {
|
||||
return (await this.#json<TasksAnswer>("GET", `/api/dots/${enc(idOrName)}/tasks`, { query: { limit: page.limit, before: page.before } })).tasks;
|
||||
}
|
||||
|
||||
/** The tasks of every Dot that ended in `status` after `finishedAfter`, the latest end first. */
|
||||
async endedTasks(status: "COMPLETED" | "FAILED" | "CANCELLED", finishedAfter: Date): Promise<TaskRecord[]> {
|
||||
return (await this.#json<TasksAnswer>("GET", "/api/tasks", { query: { status, finished_after: finishedAfter.toISOString() } })).tasks;
|
||||
}
|
||||
|
||||
getTask(taskId: string): Promise<TaskRecord> {
|
||||
return this.#json("GET", `/api/tasks/${enc(taskId)}`);
|
||||
}
|
||||
@@ -304,6 +311,11 @@ export class InvisibleDotsClient {
|
||||
return (await this.channelsOverview(idOrName)).channels;
|
||||
}
|
||||
|
||||
/** Every channel of every Dot, each with its Dot's id. */
|
||||
async everyChannel(): Promise<DotChannelRecord[]> {
|
||||
return (await this.#json<EveryChannelAnswer>("GET", "/api/channels")).channels;
|
||||
}
|
||||
|
||||
/** The Dot's channels and the kinds this server can run (WhatsApp is there only when the server was started with it). */
|
||||
channelsOverview(idOrName: string): Promise<ChannelsAnswer> {
|
||||
return this.#json("GET", `/api/dots/${enc(idOrName)}/channels`);
|
||||
|
||||
@@ -195,6 +195,13 @@ export interface ChannelRecord {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** A channel and the Dot it belongs to, as `GET /api/channels` lists the channels of every Dot. */
|
||||
export type DotChannelRecord = ChannelRecord & { dot_id: string };
|
||||
|
||||
export interface EveryChannelAnswer {
|
||||
channels: DotChannelRecord[];
|
||||
}
|
||||
|
||||
export interface ChannelsAnswer {
|
||||
channels: ChannelRecord[];
|
||||
/** The kinds of channel this server can run: WhatsApp is on only when the server was started with it (opt-in). */
|
||||
|
||||
Reference in new issue
Block a user