mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
Merge branch 'clients-fixes' into fixes-batch
This commit is contained in:
commit
c8cb603941
64 files changed
+773
-485
No files matched your search
@@ -266,7 +266,7 @@ SOFTWARE.
|
||||
## OpenDots (CopilotKit)
|
||||
|
||||
`apps/web/src/lib/time.ts`, `apps/web/src/lib/use-visible-interval.ts`,
|
||||
`apps/web/src/lib/events/tool-labels.ts` and
|
||||
`packages/shared/src/labels.ts` and
|
||||
`apps/web/src/components/tasks/task-status.tsx` derive from OpenDots by CopilotKit
|
||||
(https://github.com/CopilotKit/OpenDots, commit 88f2a08); each file's first
|
||||
comment says what was changed.
|
||||
|
||||
+113
-45
@@ -12,9 +12,25 @@ import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import QRCode from "qrcode";
|
||||
import { ApiError, type DotSummary, type InvisibleDotsClient, type TaskRecord } from "@invisible-dots/sdk";
|
||||
import { CHANNEL_KINDS, COMPUTER_STOPPED, DEFAULT_WEB_LISTEN, ENV, type ChannelKind, type ChannelRecord, type ComputerAnswer, type McpSecretsAnswer, type StoredEvent, type ToolListAnswer } from "@invisible-dots/shared";
|
||||
import { ApiError, readRecentEvents, type ApprovalRecord, type DotSummary, type InvisibleDotsClient, type TaskRecord } from "@invisible-dots/sdk";
|
||||
import {
|
||||
accountLabel,
|
||||
CHANNEL_KINDS,
|
||||
COMPUTER_STOPPED,
|
||||
DEFAULT_WEB_LISTEN,
|
||||
ENV,
|
||||
MAX_EVENT_PAGE,
|
||||
permissionPhrase,
|
||||
toolLabel,
|
||||
type ChannelKind,
|
||||
type ChannelRecord,
|
||||
type ComputerAnswer,
|
||||
type McpSecretsAnswer,
|
||||
type StoredEvent,
|
||||
type ToolListAnswer,
|
||||
} from "@invisible-dots/shared";
|
||||
import { STORE_OPENROUTER_KEY } from "@invisible-dots/vm-manager";
|
||||
import cliPackage from "../package.json" with { type: "json" };
|
||||
import { apiUrl, AuthSetupError, connectApi, DEFAULT_URL } from "./api-client.js";
|
||||
import { addTelegramChannel } from "./commands.js";
|
||||
import { EXIT } from "./exit.js";
|
||||
@@ -22,7 +38,11 @@ import { EXIT } from "./exit.js";
|
||||
export { DEFAULT_URL } from "./api-client.js";
|
||||
export { EXIT } from "./exit.js";
|
||||
|
||||
export const CLI_VERSION = "0.1.0";
|
||||
/** The version of this package, which the bundle carries inside it (esbuild inlines the JSON). */
|
||||
export const CLI_VERSION: string = cliPackage.version;
|
||||
|
||||
/** How many tasks `status` shows: the newest, which is all it asks the server for. */
|
||||
const STATUS_TASKS = 10;
|
||||
|
||||
/**
|
||||
* The commands that act on this host. The real ones (host.ts) load QEMU
|
||||
@@ -83,16 +103,19 @@ Getting this host ready (the same commands on Linux and Windows):
|
||||
Using the server:
|
||||
invisible-dots init [file] [--force] write a sample Dot config (default dot.yaml), check the server
|
||||
invisible-dots create <file.yaml> create a Dot from a YAML config
|
||||
invisible-dots update <dot> <file.yaml> replace the Dot's config with a YAML config; refused if it was changed since this command read it
|
||||
invisible-dots delete <dot> --yes delete the Dot: its computer, its disk with everything on it, and its channels (cannot be undone)
|
||||
invisible-dots list list Dots
|
||||
invisible-dots status <dot> show a Dot, its computer and its recent tasks
|
||||
invisible-dots message <dot> <text...> send a chat message
|
||||
invisible-dots task <dot> <description...> [--priority N] [--at ISO-8601]
|
||||
queue a task
|
||||
invisible-dots tasks <dot> list a Dot's tasks
|
||||
invisible-dots cancel <task-id> cancel a task that has not finished
|
||||
invisible-dots computer <dot> start|stop|reboot
|
||||
invisible-dots browser <dot> identities list the Dot's browser identities
|
||||
invisible-dots mcp <dot> list the MCP servers the Dot's config declares: where each is on its computer, and which secrets are set
|
||||
invisible-dots approvals [--all] list pending (or all) approvals
|
||||
invisible-dots approvals [--all] list pending approvals (--all: every approval, the newest first)
|
||||
invisible-dots approve <approval-id> [--note text] [--always]
|
||||
--always also allows that permission for the Dot from now on
|
||||
invisible-dots reject <approval-id> [--note text]
|
||||
@@ -107,14 +130,14 @@ Using the server:
|
||||
invisible-dots channel link whatsapp --dot <dot>
|
||||
link a WhatsApp number (opt-in on the server, unofficial, a ban of the account is possible): shows a code to scan in WhatsApp > Linked devices
|
||||
invisible-dots channel list [--dot <dot>] list channels (of every Dot without --dot) with their status and paired people
|
||||
invisible-dots channel pair telegram --dot <dot>
|
||||
print a one-time link (valid 10 minutes) that pairs your Telegram account to the Dot
|
||||
invisible-dots channel remove telegram --dot <dot>
|
||||
unlink: the token and the paired people are deleted
|
||||
invisible-dots channel pair telegram|whatsapp --dot <dot>
|
||||
print a one-time link (valid 10 minutes) that pairs your Telegram or WhatsApp account to the Dot
|
||||
invisible-dots channel remove telegram|whatsapp --dot <dot>
|
||||
unlink: the token (or the linked device's keys) and the paired people are deleted
|
||||
invisible-dots logs <dot> [--tail N] [--no-follow]
|
||||
print recent events, then follow new ones
|
||||
|
||||
<dot> is a Dot name or id. Add --json for machine-readable output.
|
||||
<dot> is a Dot name or id. Add --json for machine-readable output. invisible-dots --version prints the version.
|
||||
|
||||
Environment:
|
||||
${ENV.HOME} the data directory (default ~/.invisible-dots)
|
||||
@@ -162,6 +185,7 @@ const OPTIONS = {
|
||||
compress: { type: "boolean" },
|
||||
all: { type: "boolean" },
|
||||
clear: { type: "boolean" },
|
||||
yes: { type: "boolean" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
version: { type: "boolean", short: "v" },
|
||||
} as const;
|
||||
@@ -244,12 +268,6 @@ const PAIRING_WORDS: Record<ChannelKind, { action: string; to: string }> = {
|
||||
whatsapp: { action: "press Send", to: "the number" },
|
||||
};
|
||||
|
||||
/** How a channel's account reads: a Telegram bot as @name, the number linked to WhatsApp as +number. */
|
||||
function accountLabel(channel: ChannelRecord): string {
|
||||
if (channel.account === null) return "-";
|
||||
return channel.kind === "whatsapp" ? `+${channel.account}` : `@${channel.account}`;
|
||||
}
|
||||
|
||||
function channelRows(rows: { dot: string; channel: ChannelRecord }[]): string {
|
||||
if (rows.length === 0) return "no channels: link one with invisible-dots channel add telegram --dot <dot>\n";
|
||||
return pad([
|
||||
@@ -258,13 +276,46 @@ function channelRows(rows: { dot: string; channel: ChannelRecord }[]): string {
|
||||
dot,
|
||||
c.kind,
|
||||
c.enabled ? c.status : "paused",
|
||||
accountLabel(c),
|
||||
accountLabel(c.kind, c.account) ?? "-",
|
||||
c.peers.length === 0 ? "-" : c.peers.map((p) => p.label).join(", "),
|
||||
c.status_detail ? oneLine(c.status_detail, 80) : "",
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The approvals in the words of the web client's approval card: the Dot by its name, what it wants to do, the permission with its risk. */
|
||||
function approvalRows(approvals: ApprovalRecord[], dotNames: ReadonlyMap<string, string>): string {
|
||||
return pad([
|
||||
["ID", "STATUS", "DOT", "WANTS TO", "PERMISSION", "REASON"],
|
||||
// A Dot that was deleted has no name any more: its id is what is left of it.
|
||||
...approvals.map((a) => [a.id, a.status, dotNames.get(a.dot_id) ?? a.dot_id, toolLabel(a.tool).ask, permissionPhrase(a.permission), oneLine(a.reason)]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A Dot config read from a YAML file named on the command line. */
|
||||
async function readConfigFile(io: CliIo, path: string): Promise<string> {
|
||||
const file = resolve(io.cwd, path);
|
||||
try {
|
||||
return await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
throw new UsageError(`cannot read ${file}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest `count` events of the Dot's log, oldest first: read from the end, a page at a time, never from its start. */
|
||||
async function newestEvents(client: InvisibleDotsClient, dotId: string, count: number): Promise<StoredEvent[]> {
|
||||
const found: StoredEvent[] = [];
|
||||
let before: number | undefined;
|
||||
while (found.length < count) {
|
||||
const want = Math.min(count - found.length, MAX_EVENT_PAGE);
|
||||
const page = await readRecentEvents(client, dotId, { count: want, before });
|
||||
found.unshift(...page);
|
||||
if (page.length < want) break;
|
||||
before = page[0]!.id;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** One line of a secret, from the terminal (echo off) or from stdin: never from the arguments, which end up in shell history and ps. */
|
||||
async function secretFromInput(io: CliIo, what: { prompt: string; noun: string; command: string; spaces?: true }): Promise<string> {
|
||||
if (io.stdinIsTTY) io.stderr(`paste the ${what.prompt}, then press Enter:\n`);
|
||||
@@ -356,17 +407,37 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
|
||||
return EXIT.ok;
|
||||
}
|
||||
case "create": {
|
||||
const file = resolve(io.cwd, need(args, 0, "<file.yaml>"));
|
||||
let yaml: string;
|
||||
try {
|
||||
yaml = await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
throw new UsageError(`cannot read ${file}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}`);
|
||||
}
|
||||
const yaml = await readConfigFile(io, need(args, 0, "<file.yaml>"));
|
||||
const dot = await (await api()).createDot(yaml);
|
||||
out(dot, `created Dot ${dot.name} (${dot.id}); its computer is being provisioned.\nfollow it with: invisible-dots logs ${dot.name}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
case "update": {
|
||||
const name = need(args, 0, "<dot>");
|
||||
const yaml = await readConfigFile(io, need(args, 1, "<file.yaml>"));
|
||||
if (args.length > 2) throw new UsageError("update takes a Dot and one YAML file");
|
||||
const c = await api();
|
||||
// The version read here makes the save conditional: a config changed meanwhile (in the web client, by an
|
||||
// "always allow") is a 409 and nothing is saved, instead of being overwritten unseen.
|
||||
const current = await c.getDot(name);
|
||||
const dot = await c.updateDot(current.id, yaml, current.config_version);
|
||||
out(dot, `updated Dot ${dot.name} (config version ${dot.config_version})\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
case "delete": {
|
||||
const name = need(args, 0, "<dot>");
|
||||
noArguments(args.slice(1), "delete <dot>");
|
||||
if (values.yes !== true) {
|
||||
throw new UsageError(
|
||||
`delete destroys Dot ${name}'s computer and its disk, with every file, memory note and browser identity on them, and its channels; it cannot be undone. To do it: invisible-dots delete ${name} --yes`,
|
||||
);
|
||||
}
|
||||
const c = await api();
|
||||
const dot = await c.getDot(name);
|
||||
const answer = await c.deleteDot(dot.id);
|
||||
out(answer, `deleting Dot ${dot.name}; what it did stays in its log: invisible-dots logs ${dot.id}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
case "list": {
|
||||
const dots = await (await api()).listDots();
|
||||
out(dots, dotRows(dots));
|
||||
@@ -375,7 +446,7 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
|
||||
case "status": {
|
||||
const name = need(args, 0, "<dot>");
|
||||
const c = await api();
|
||||
const [dot, computer, tasks] = await Promise.all([c.getDot(name), c.computer(name), c.listTasks(name)]);
|
||||
const [dot, computer, tasks] = await Promise.all([c.getDot(name), c.computer(name), c.listTasks(name, { limit: STATUS_TASKS })]);
|
||||
const text =
|
||||
pad([
|
||||
["name", dot.name],
|
||||
@@ -388,7 +459,7 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
|
||||
["next automation", nextAutomation(computer)],
|
||||
]) +
|
||||
"\n" +
|
||||
taskRows(tasks.slice(0, 10));
|
||||
taskRows(tasks);
|
||||
out({ dot, computer, tasks }, text);
|
||||
return EXIT.ok;
|
||||
}
|
||||
@@ -430,6 +501,13 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
|
||||
out(tasks, taskRows(tasks));
|
||||
return EXIT.ok;
|
||||
}
|
||||
case "cancel": {
|
||||
const id = need(args, 0, "<task-id>");
|
||||
noArguments(args.slice(1), "cancel <task-id>");
|
||||
const task = await (await api()).cancelTask(id);
|
||||
out(task, `task ${task.id} ${task.status}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
case "computer": {
|
||||
const name = need(args, 0, "<dot>");
|
||||
const action = need(args, 1, "start|stop|reboot");
|
||||
@@ -459,17 +537,13 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
|
||||
return EXIT.ok;
|
||||
}
|
||||
case "approvals": {
|
||||
const approvals = await (await api()).listApprovals(values.all ? undefined : "pending");
|
||||
const c = await api();
|
||||
// With --all the newest come first, as in the web client's history, so the cap of the list drops the oldest and
|
||||
// never the newest; the pending ones come oldest first, the one that has waited longest on top.
|
||||
const [approvals, dots] = await Promise.all([values.all ? c.listApprovals(undefined, { order: "desc" }) : c.listApprovals("pending"), c.listDots()]);
|
||||
out(
|
||||
approvals,
|
||||
approvals.length === 0
|
||||
? values.all
|
||||
? "no approvals\n"
|
||||
: "no pending approvals\n"
|
||||
: pad([
|
||||
["ID", "STATUS", "DOT", "TOOL", "REASON"],
|
||||
...approvals.map((a) => [a.id, a.status, a.dot_id, a.tool, oneLine(a.reason)]),
|
||||
]),
|
||||
approvals.length === 0 ? (values.all ? "no approvals\n" : "no pending approvals\n") : approvalRows(approvals, new Map(dots.map((d) => [d.id, d.name]))),
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
@@ -574,7 +648,7 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
|
||||
if (!values.json) io.stdout(`${await QRCode.toString(frame.code, { type: "terminal", small: true })}\n`);
|
||||
} else if (frame.state === "linked") {
|
||||
if (!values.json) {
|
||||
io.stdout(`linked WhatsApp${frame.account ? ` number +${frame.account}` : ""} to Dot ${name}\nnext: invisible-dots channel pair whatsapp --dot ${name}\n`);
|
||||
io.stdout(`linked WhatsApp${frame.account ? ` number ${accountLabel("whatsapp", frame.account)}` : ""} to Dot ${name}\nnext: invisible-dots channel pair whatsapp --dot ${name}\n`);
|
||||
}
|
||||
return EXIT.ok;
|
||||
} else if (frame.state === "failed") {
|
||||
@@ -628,16 +702,10 @@ export async function run(argv: string[], io: CliIo): Promise<number> {
|
||||
if (error instanceof ApiError && error.status === 404 && name.includes("_")) return { id: name };
|
||||
throw error;
|
||||
});
|
||||
let recent: StoredEvent[] = [];
|
||||
let after = 0;
|
||||
for (;;) {
|
||||
const page = await c.events(dot.id, { after, limit: 1000 });
|
||||
recent = [...recent, ...page].slice(-Math.max(tail, 1));
|
||||
if (page.length < 1000) break;
|
||||
after = page.at(-1)!.id;
|
||||
}
|
||||
const last = recent.at(-1)?.id ?? after;
|
||||
for (const event of tail === 0 ? [] : recent) io.stdout(values.json ? `${JSON.stringify(event)}\n` : `${formatEvent(event)}\n`);
|
||||
// The newest `tail` events, and at least the newest one, which following goes on from.
|
||||
const recent = await newestEvents(c, dot.id, Math.max(tail, 1));
|
||||
const last = recent.at(-1)?.id ?? 0;
|
||||
for (const event of recent.slice(recent.length - tail)) io.stdout(values.json ? `${JSON.stringify(event)}\n` : `${formatEvent(event)}\n`);
|
||||
if (values["no-follow"]) return EXIT.ok;
|
||||
for await (const event of c.stream({
|
||||
dotId: dot.id,
|
||||
|
||||
@@ -172,7 +172,6 @@ export function realHostCommands(): HostCommands {
|
||||
web: () => buildWeb(builds),
|
||||
images: () => imageBuild(io),
|
||||
env: io.env,
|
||||
tokenPath: hostPaths(io.env).apiTokenPath,
|
||||
...(io.signal ? { signal: io.signal } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -27,8 +27,6 @@ export interface SetupAllDeps {
|
||||
/** `image build`: the exit code, or a rejection with the reason. */
|
||||
images(): Promise<number>;
|
||||
env: Record<string, string | undefined>;
|
||||
/** Where `invisible-dots server` writes the API token, which the web client asks for. */
|
||||
tokenPath: string;
|
||||
/** Aborted by Ctrl-C. Checked between steps: only `image build` can stop in the middle of one. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -86,9 +84,7 @@ export async function runSetupAll(deps: SetupAllDeps): Promise<number> {
|
||||
out(`
|
||||
This computer is ready. To use it:
|
||||
1. start the server and leave it running: invisible-dots server
|
||||
2. open ${webAddress(deps.env)} in your browser and sign in with the first line of
|
||||
${deps.tokenPath}
|
||||
(the server creates that file the first time it starts)
|
||||
2. open ${webAddress(deps.env)} in your browser
|
||||
3. the page asks for your OpenRouter key, then lets you create your first Dot.
|
||||
`);
|
||||
return EXIT.ok;
|
||||
|
||||
+119
-7
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseDotConfig } from "@invisible-dots/shared";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import cliPackage from "../package.json" with { type: "json" };
|
||||
import { commandOf, EXIT, interruptIsAsked, run, SAMPLE_DOT, type CliIo, type HostCommands } from "../src/index.js";
|
||||
|
||||
const TOKEN = "cli-test-token-0123456789";
|
||||
@@ -71,7 +72,7 @@ const channel = {
|
||||
peers: [{ peer_id: "10", role: "owner", label: "Ann (@ann)", created_at: now }],
|
||||
created_at: now,
|
||||
};
|
||||
const events = Array.from({ length: 5 }, (_, i) => ({
|
||||
let events = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
dot_id: dot.id,
|
||||
type: i % 2 ? "agent.state" : "automation.next_run",
|
||||
@@ -92,6 +93,10 @@ let noAutomationDue = false;
|
||||
let whatsappLinked = false;
|
||||
let whatsappOff = false;
|
||||
let linkFrames: { state: string; [key: string]: unknown }[] = [];
|
||||
/** The config_version the fake server holds: an update that expects another one is the 409 of a concurrent change. */
|
||||
let configVersion = 1;
|
||||
/** Another client saves the Dot's config between a read and the PATCH that follows it. */
|
||||
let savedMeanwhile = false;
|
||||
|
||||
function send(res: ServerResponse, status: number, body?: unknown) {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
@@ -123,7 +128,17 @@ async function handle(req: IncomingMessage, res: ServerResponse) {
|
||||
case "GET /api/dots":
|
||||
return send(res, 200, { dots: [dot] });
|
||||
case `GET /api/dots/${dot.id}`:
|
||||
return send(res, 200, dot);
|
||||
return send(res, 200, { ...dot, config_version: configVersion });
|
||||
case `PATCH /api/dots/${dot.id}`:
|
||||
if (savedMeanwhile) configVersion++;
|
||||
if (body?.expected_config_version !== configVersion) return send(res, 409, { error: "dot_changed", message: "the Dot's config changed since it was read" });
|
||||
return send(res, 200, { ...dot, config_version: configVersion + 1 });
|
||||
case `DELETE /api/dots/${dot.id}`:
|
||||
return send(res, 202, { accepted: true });
|
||||
case "POST /api/tasks/task_02/cancel":
|
||||
return send(res, 200, { ...task, id: "task_02", status: "CANCELLED" });
|
||||
case "POST /api/tasks/task_01/cancel":
|
||||
return send(res, 409, { error: "task_finished", message: "task task_01 is already COMPLETED" });
|
||||
case `GET /api/dots/${dot.id}/computer`:
|
||||
return send(res, 200, { ...(stoppedByPerson ? { ...computer, state: "STOPPED", stop_reason: "user", ready: false } : computer), ...(noAutomationDue ? { next_automation_at: null } : {}) });
|
||||
case `GET /api/dots/${dot.id}/tasks`:
|
||||
@@ -196,8 +211,12 @@ async function handle(req: IncomingMessage, res: ServerResponse) {
|
||||
case `DELETE /api/dots/${dot.id}/channels/whatsapp`:
|
||||
return send(res, 204);
|
||||
case `GET /api/dots/${dot.id}/events`: {
|
||||
// The route's paging: after and before bound the ids, order=desc gives the newest first, limit keeps that many.
|
||||
const after = Number(url.searchParams.get("after") ?? 0);
|
||||
return send(res, 200, { events: events.filter((e) => e.id > after) });
|
||||
const before = Number(url.searchParams.get("before") ?? Infinity);
|
||||
const kept = events.filter((e) => e.id > after && e.id < before);
|
||||
const ordered = url.searchParams.get("order") === "desc" ? [...kept].reverse() : kept;
|
||||
return send(res, 200, { events: ordered.slice(0, Number(url.searchParams.get("limit") ?? 1000)) });
|
||||
}
|
||||
case "GET /api/stream": {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
@@ -235,6 +254,8 @@ beforeEach(() => {
|
||||
whatsappLinked = false;
|
||||
whatsappOff = false;
|
||||
linkFrames = [];
|
||||
configVersion = 1;
|
||||
savedMeanwhile = false;
|
||||
});
|
||||
|
||||
async function cli(
|
||||
@@ -267,7 +288,7 @@ describe("usage", () => {
|
||||
expect((await cli(["launch"])).stderr).toMatch(/unknown command "launch"/);
|
||||
expect((await cli(["list", "--bogus"])).code).toBe(EXIT.usage);
|
||||
expect((await cli(["status"])).stderr).toMatch(/missing <dot>/);
|
||||
expect((await cli(["--version"])).stdout).toMatch(/^\d+\.\d+\.\d+\n$/);
|
||||
expect((await cli(["--version"])).stdout).toBe(`${cliPackage.version}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,6 +356,8 @@ describe("commands", () => {
|
||||
expect(status.stdout).toMatch(/computer\s+RUNNING, ready/);
|
||||
expect(status.stdout).toMatch(/next automation\s+2030-01-01T09:00:00.000Z/);
|
||||
expect(status.stdout).toContain("task_01");
|
||||
// It shows the newest ten tasks, and asks for ten.
|
||||
expect(requests.find((r) => r.path === "/api/dots/fare-watch/tasks")?.query.get("limit")).toBe("10");
|
||||
expect((await cli(["tasks", "fare-watch"])).stdout).toContain("COMPLETED");
|
||||
});
|
||||
|
||||
@@ -388,10 +411,20 @@ describe("commands", () => {
|
||||
});
|
||||
|
||||
it("approvals, approve and reject", async () => {
|
||||
expect((await cli(["approvals"])).stdout).toContain("apr_1");
|
||||
expect(requests.at(-1)?.query.get("status")).toBe("pending");
|
||||
const pending = await cli(["approvals"]);
|
||||
expect(pending.stdout).toContain("apr_1");
|
||||
const listed = requests.find((r) => r.path === "/api/approvals");
|
||||
expect(listed?.query.get("status")).toBe("pending");
|
||||
expect(listed?.query.get("order")).toBeNull();
|
||||
// The Dot by its name, and the question and the permission in the web client's words.
|
||||
expect(pending.stdout.split("\n")[0]).toMatch(/^ID\s+STATUS\s+DOT\s+WANTS TO\s+PERMISSION\s+REASON$/);
|
||||
expect(pending.stdout).toMatch(/apr_1\s+pending\s+fare-watch\s+delete a browser identity\s+Delete browser identities \(high risk\)\s+cleanup/);
|
||||
requests.length = 0;
|
||||
await cli(["approvals", "--all"]);
|
||||
expect(requests.at(-1)?.query.get("status")).toBeNull();
|
||||
const all = requests.find((r) => r.path === "/api/approvals");
|
||||
expect(all?.query.get("status")).toBeNull();
|
||||
// The newest first, so the cap of the list drops the oldest and never the newest.
|
||||
expect(all?.query.get("order")).toBe("desc");
|
||||
const approved = await cli(["approve", "apr_1", "--note", "fine"]);
|
||||
expect(approved.stdout).toBe("approval apr_1 approved\n");
|
||||
expect(requests.at(-1)?.body).toEqual({ note: "fine" });
|
||||
@@ -591,6 +624,9 @@ describe("commands", () => {
|
||||
const lines = tail.stdout.trim().split("\n");
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toMatch(/#4 agent\.state \{"state":"IDLE"\}$/);
|
||||
// The log is read from its end, the newest two in one request, not paged from its start.
|
||||
const reads = requests.filter((r) => r.path === `/api/dots/${dot.id}/events`);
|
||||
expect(reads.map((r) => [r.query.get("order"), r.query.get("limit"), r.query.get("after")])).toEqual([["desc", "2", null]]);
|
||||
|
||||
const controller = new AbortController();
|
||||
let output = "";
|
||||
@@ -614,6 +650,76 @@ describe("commands", () => {
|
||||
expect(stream?.query.get("after")).toBe("5");
|
||||
expect(stream?.query.get("dot_id")).toBe(dot.id);
|
||||
});
|
||||
|
||||
it("logs --tail 0 prints nothing old and follows from the newest event, which it reads alone", async () => {
|
||||
const quiet = await cli(["logs", "fare-watch", "--tail", "0", "--no-follow"]);
|
||||
expect(quiet.code).toBe(EXIT.ok);
|
||||
expect(quiet.stdout).toBe("");
|
||||
expect(requests.filter((r) => r.path === `/api/dots/${dot.id}/events`).map((r) => r.query.get("limit"))).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("logs --tail beyond a page reads the newest pages from the end, older one by one, and stops at the start of the log", async () => {
|
||||
const saved = events;
|
||||
events = Array.from({ length: 2500 }, (_, i) => ({ ...saved[0]!, id: i + 1 }));
|
||||
try {
|
||||
const tail = await cli(["logs", "fare-watch", "--tail", "2200", "--no-follow"]);
|
||||
const lines = tail.stdout.trim().split("\n");
|
||||
expect(lines).toHaveLength(2200);
|
||||
expect(lines[0]).toContain("#301 ");
|
||||
expect(lines.at(-1)).toContain("#2500 ");
|
||||
const reads = requests.filter((r) => r.path === `/api/dots/${dot.id}/events`);
|
||||
expect(reads.map((r) => [r.query.get("limit"), r.query.get("before")])).toEqual([
|
||||
["1000", null],
|
||||
["1000", "1501"],
|
||||
["200", "501"],
|
||||
]);
|
||||
requests.length = 0;
|
||||
expect((await cli(["logs", "fare-watch", "--tail", "9000", "--no-follow"])).stdout.trim().split("\n")).toHaveLength(2500);
|
||||
expect(requests.filter((r) => r.path === `/api/dots/${dot.id}/events`)).toHaveLength(3);
|
||||
} finally {
|
||||
events = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("cancel cancels a task, and shows the server's 409 for one that has finished", async () => {
|
||||
const cancelled = await cli(["cancel", "task_02"]);
|
||||
expect(cancelled.code).toBe(EXIT.ok);
|
||||
expect(cancelled.stdout).toBe("task task_02 CANCELLED\n");
|
||||
expect(requests.at(-1)).toMatchObject({ method: "POST", path: "/api/tasks/task_02/cancel" });
|
||||
const finished = await cli(["cancel", "task_01"]);
|
||||
expect(finished.code).toBe(EXIT.failed);
|
||||
expect(finished.stderr).toMatch(/already COMPLETED \[409 task_finished\]/);
|
||||
expect((await cli(["cancel"])).stderr).toMatch(/missing <task-id>/);
|
||||
});
|
||||
|
||||
it("delete refuses without --yes and says what it destroys, and deletes with it", async () => {
|
||||
const refused = await cli(["delete", "fare-watch"]);
|
||||
expect(refused.code).toBe(EXIT.usage);
|
||||
expect(refused.stderr).toContain("cannot be undone");
|
||||
expect(refused.stderr).toContain("invisible-dots delete fare-watch --yes");
|
||||
expect(requests).toHaveLength(0);
|
||||
const deleted = await cli(["delete", "fare-watch", "--yes"]);
|
||||
expect(deleted.code).toBe(EXIT.ok);
|
||||
expect(requests.at(-1)).toMatchObject({ method: "DELETE", path: `/api/dots/${dot.id}` });
|
||||
expect(deleted.stdout).toBe(`deleting Dot fare-watch; what it did stays in its log: invisible-dots logs ${dot.id}\n`);
|
||||
});
|
||||
|
||||
it("update sends the YAML with the config version it read, so a change made meanwhile is a 409 and nothing is saved", async () => {
|
||||
await writeFile(join(configDir, "changed.yaml"), SAMPLE_DOT);
|
||||
configVersion = 4;
|
||||
const updated = await cli(["update", "fare-watch", "changed.yaml"]);
|
||||
expect(updated.code).toBe(EXIT.ok);
|
||||
expect(updated.stdout).toBe("updated Dot fare-watch (config version 5)\n");
|
||||
expect(requests.at(-1)).toMatchObject({ method: "PATCH", path: `/api/dots/${dot.id}`, body: { config: SAMPLE_DOT, expected_config_version: 4 } });
|
||||
|
||||
// The web client saved between this command's read and its write.
|
||||
savedMeanwhile = true;
|
||||
const raced = await cli(["update", "fare-watch", "changed.yaml"]);
|
||||
expect(raced.code).toBe(EXIT.failed);
|
||||
expect(raced.stderr).toMatch(/\[409 dot_changed\]/);
|
||||
expect((await cli(["update", "fare-watch", "missing.yaml"])).code).toBe(EXIT.usage);
|
||||
expect((await cli(["update", "fare-watch"])).stderr).toMatch(/missing <file.yaml>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host commands", () => {
|
||||
@@ -677,6 +783,12 @@ describe("host commands", () => {
|
||||
expect(failed.stderr).toBe("invisible-dots: another invisible-dots server (pid 42) is already running\n");
|
||||
});
|
||||
|
||||
it("lists both channels where a command takes either", async () => {
|
||||
const help = (await cli(["--help"])).stdout;
|
||||
expect(help).toContain("invisible-dots channel pair telegram|whatsapp --dot <dot>");
|
||||
expect(help).toContain("invisible-dots channel remove telegram|whatsapp --dot <dot>");
|
||||
});
|
||||
|
||||
it("lists setup --all in the help, first among the host commands", async () => {
|
||||
const help = (await cli(["--help"])).stdout;
|
||||
expect(help).toMatch(/invisible-dots setup --all .*everything in one run/);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { runSetupAll, SETUP_ALL_COMMAND, webAddress, type SetupAllDeps } from ".
|
||||
import type { StepResult } from "../src/setup/build.js";
|
||||
import type { HostOutcome } from "../src/setup/setup.js";
|
||||
|
||||
const TOKEN_PATH = "/home/someone/.invisible-dots/config/api.token";
|
||||
const DONE: StepResult = { ok: true, lines: ["done"] };
|
||||
|
||||
/** A run of `setup --all` against fake steps; `calls` is the order they ran in. */
|
||||
@@ -19,7 +18,6 @@ async function all(overrides: Partial<SetupAllDeps> = {}) {
|
||||
web: async () => (calls.push("web"), DONE),
|
||||
images: async () => (calls.push("images"), EXIT.ok),
|
||||
env: {},
|
||||
tokenPath: TOKEN_PATH,
|
||||
...overrides,
|
||||
};
|
||||
const code = await runSetupAll(deps);
|
||||
@@ -34,8 +32,8 @@ describe("setup --all", () => {
|
||||
expect(result.text).toMatch(/\[1\/4\] the guest daemon[\s\S]*\[2\/4\] QEMU[\s\S]*\[3\/4\] the web client[\s\S]*\[4\/4\] the guest images/);
|
||||
expect(result.text).toContain("built /repo/guest/dot-agentd/bin/dot-agentd");
|
||||
expect(result.text).toContain("1. start the server and leave it running: invisible-dots server");
|
||||
expect(result.text).toContain(`open http://${DEFAULT_WEB_LISTEN} in your browser and sign in with the first line of\n ${TOKEN_PATH}`);
|
||||
expect(result.text).toContain("the server creates that file the first time it starts");
|
||||
expect(result.text).toContain(`2. open http://${DEFAULT_WEB_LISTEN} in your browser\n`);
|
||||
expect(result.text).not.toMatch(/sign in|api\.token/);
|
||||
expect(result.text).toContain("OpenRouter key");
|
||||
});
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ test("the Activity tab reads the log as lines, filters by family at the host, fo
|
||||
const rows = page.getByRole("list", { name: "Events" }).getByRole("listitem");
|
||||
await expect(rows.filter({ hasText: "Ran a command" })).toHaveCount(2);
|
||||
const allowed = rows.filter({ hasText: "ls -la /home/dot" });
|
||||
await expect(allowed).toContainText("ok in 40 ms | exec [computer.exec] allow");
|
||||
// The refused call is an error line, and says it never ran.
|
||||
await expect(allowed).toContainText("done in 40 ms | exec [computer.exec] allow");
|
||||
// The refused call is a warning line (the config refused it; nothing failed), and says it never ran.
|
||||
const refused = rows.filter({ hasText: "rm -rf /" });
|
||||
await expect(refused).toContainText("denied in 0 ms | exec [computer.exec] deny");
|
||||
await expect(refused).toHaveAttribute("data-tone", "error");
|
||||
await expect(refused).toHaveAttribute("data-tone", "warn");
|
||||
await expect(rows.filter({ hasText: "You sent a message" })).toContainText("hello there");
|
||||
// The engine's own start, in words, not as empty data.
|
||||
await expect(rows.filter({ hasText: "The engine started" })).toContainText("The key was sent again");
|
||||
|
||||
@@ -160,7 +160,7 @@ test("the old addresses lead to the Inbox, a Dot's approvals to the Inbox filter
|
||||
await expect(page.getByRole("navigation", { name: "Dot sections" }).getByRole("link", { name: /Approvals/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the Inbox is answered from the keyboard alone: j and k choose, a allows once, d denies, and a command waits for its confirmation", async ({ page, fresh: harness }) => {
|
||||
test("the Inbox is answered from the keyboard alone: Tab to the list, j and k choose, a allows once, d denies, and a command waits for its confirmation", async ({ page, fresh: harness }) => {
|
||||
const dot = await harness.createDot("inbox-keys");
|
||||
const guest = harness.driver.guestOf(dot.id);
|
||||
const one = ask(guest, navigate("https://example.com/one"));
|
||||
@@ -171,6 +171,13 @@ test("the Inbox is answered from the keyboard alone: j and k choose, a allows on
|
||||
const selected = page.locator("article[aria-current=true]");
|
||||
await expect(selected).toHaveAttribute("data-approval-id", one);
|
||||
|
||||
// A key typed while the focus is elsewhere on the page answers nothing (WCAG 2.1.4): the keys are the list's.
|
||||
await page.keyboard.press("d");
|
||||
await page.keyboard.press("a");
|
||||
expect(await harness.api.listApprovals("pending")).toHaveLength(3);
|
||||
const list = page.getByRole("list", { name: "Approvals waiting for your answer" });
|
||||
while (!(await list.evaluate((element) => element === document.activeElement))) await page.keyboard.press("Tab");
|
||||
|
||||
await page.keyboard.press("a");
|
||||
await expect(selected.getByRole("status")).toHaveText("Allowed");
|
||||
await page.keyboard.press("j");
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --hostname 127.0.0.1 --port 3000",
|
||||
"build": "node scripts/build.mjs",
|
||||
"test": "vitest run",
|
||||
"test": "vitest run --root ../.. apps/web/",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@ import { AttentionProvider } from "../../components/shell/attention";
|
||||
import { AppShell } from "../../components/shell/AppShell";
|
||||
|
||||
/**
|
||||
* Everything behind the session: one live stream, what needs the person, and the frame with the rail. The login
|
||||
* page lives outside this group on purpose, because it has no session and so nothing here may call the API.
|
||||
* Every page of the app: one live stream, what needs the person, and the frame with the rail. The page for an address
|
||||
* with nothing behind it lives outside this group, so it shows without them.
|
||||
*/
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import type { Dot } from "../lib/types";
|
||||
import { DotHeader } from "./dot/DotHeader";
|
||||
@@ -49,6 +49,8 @@ export function DotShell({ dotId, children }: { dotId: string; children: ReactNo
|
||||
if (address) router.replace(`/dots/${encodeURIComponent(canonical)}${pathname.slice(address[0].length)}${window.location.search}${window.location.hash}`);
|
||||
}, [canonical, pathname, router]);
|
||||
|
||||
const value = useMemo(() => ({ dotId, dot }), [dotId, dot]);
|
||||
|
||||
// A Dot the control plane does not have (deleted, or never this host's): no header, tabs or composer for it.
|
||||
if (dot.error instanceof ApiError && dot.error.status === 404) {
|
||||
return (
|
||||
@@ -65,7 +67,7 @@ export function DotShell({ dotId, children }: { dotId: string; children: ReactNo
|
||||
}
|
||||
|
||||
return (
|
||||
<DotContext.Provider value={{ dotId, dot }}>
|
||||
<DotContext.Provider value={value}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<DotHeader dotId={dotId} dot={dot} />
|
||||
<DotTabs dotId={dotId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BrainIcon, CircleDotIcon, FlagIcon, GlobeIcon, HandIcon, MessageSquareIcon, MonitorIcon, SendIcon, SparklesIcon, type LucideIcon } from "lucide-react";
|
||||
import { toolLabel } from "../../lib/events/tool-labels";
|
||||
import { toolLabel } from "@invisible-dots/shared/browser";
|
||||
import type { EventFamily, EventView } from "../../lib/events/view";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import type { StoredEvent } from "../../lib/types";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { mergeEvents, readRecentEvents } from "@invisible-dots/sdk";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ACTIVITY_PAGE, typesFor } from "../../lib/activity";
|
||||
import { api } from "../../lib/api";
|
||||
import { mergeEvents, readRecentEvents } from "../../lib/event-log";
|
||||
import type { EventFamily } from "../../lib/events/view";
|
||||
import type { StoredEvent } from "../../lib/types";
|
||||
import { useLiveEvents } from "../events";
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { approvalBody, askTitle, boundedJson, isDestructive, permissionInfo, RECEIPT_WORD, RISK_LABEL, type ApprovalAsk } from "../../lib/approval-view";
|
||||
import { toolLabel } from "../../lib/events/tool-labels";
|
||||
import { approvalBody, askTitle, boundedJson, isDestructive, permissionInfo, RECEIPT_WORD, type ApprovalAsk } from "../../lib/approval-view";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { relativeTime } from "../../lib/time";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -11,7 +10,7 @@ import { ErrorAlert } from "../ErrorAlert";
|
||||
import { ApprovalCard as ApprovalCardElement } from "../elements/approval-card";
|
||||
import { DotAvatar } from "../shell/DotAvatar";
|
||||
import { FAMILY_ICON } from "../tool-family-icon";
|
||||
import { APPROVAL_NOTE_MAX } from "@invisible-dots/shared/browser";
|
||||
import { APPROVAL_NOTE_MAX, permissionPhrase, toolLabel } from "@invisible-dots/shared/browser";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
@@ -80,7 +79,7 @@ export function ApprovalCard({ ask, answers, dotName, selected = false, classNam
|
||||
{" · "}
|
||||
</>
|
||||
) : null}
|
||||
<span>{info ? `${info.label} (${RISK_LABEL[info.risk].toLowerCase()})` : ask.permission}</span>
|
||||
<span>{permissionPhrase(ask.permission)}</span>
|
||||
{" · "}
|
||||
<Link href={where.href} className="underline underline-offset-2 hover:text-foreground">
|
||||
{where.words}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { RISK_LABEL } from "@invisible-dots/shared/browser";
|
||||
import { api } from "../../lib/api";
|
||||
import { permissionInfo, RISK_LABEL, toolsCovered, type ApprovalAsk } from "../../lib/approval-view";
|
||||
import { permissionInfo, toolsCovered, type ApprovalAsk } from "../../lib/approval-view";
|
||||
import { useResource } from "../ui";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChannelKind, ChannelRecord } from "@invisible-dots/shared/browser";
|
||||
import { accountLabel, type ChannelKind, type ChannelRecord } from "@invisible-dots/shared/browser";
|
||||
import { MessageCircleIcon, SendIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { accountHref, accountLabel, CHANNEL_LABELS } from "../../lib/channels";
|
||||
import { accountHref, CHANNEL_LABELS } from "../../lib/channels";
|
||||
import { ChannelStateChip } from "./state-chip";
|
||||
|
||||
const ICONS = { telegram: SendIcon, whatsapp: MessageCircleIcon } as const satisfies Record<ChannelKind, unknown>;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// Derived from nanobot webui/src/components/settings/channels/ChannelQrConnectFlow.tsx at 9dc0aba, MIT; changed: the flow reads the host's stream of frames instead of polling a session (its states are the pure reducer in lib/channel-link.ts), the code is drawn as an SVG from the QR modules instead of a canvas data URL, there is no i18n and no client provider, and cancelling removes the channel the start created, or only pauses one that holds people.
|
||||
"use client";
|
||||
|
||||
import type { ChannelRecord } from "@invisible-dots/shared/browser";
|
||||
import { accountLabel, type ChannelRecord } from "@invisible-dots/shared/browser";
|
||||
import { CheckIcon, Loader2Icon, RotateCcwIcon, SmartphoneIcon } from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../lib/api";
|
||||
import { IDLE, linkEnded, reduceLink, STREAM_LOST, type LinkView } from "../../lib/channel-link";
|
||||
import { accountLabel } from "../../lib/channels";
|
||||
import { ErrorAlert } from "../ErrorAlert";
|
||||
import { useAction } from "../ui";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { accountLabel } from "@invisible-dots/shared/browser";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../lib/api";
|
||||
import { accountLabel } from "../../lib/channels";
|
||||
import { ErrorAlert } from "../ErrorAlert";
|
||||
import { Field } from "../new-dot/Field";
|
||||
import { useAction } from "../ui";
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
import { ChevronRightIcon, HandIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { clusterSummary, groupActivity, type ActivityItem, type ApprovalStep, type ToolStep } from "../../lib/chat-thread";
|
||||
import { formatDate, formatMillis } from "../../lib/format";
|
||||
import { clusterSummary, groupActivity, type ActivityItem } from "../../lib/chat-thread";
|
||||
import { formatMillis } from "../../lib/format";
|
||||
import { APPROVAL_WORD, TOOL_OUTCOME, type ApprovalStep, type ToolStep } from "../../lib/steps";
|
||||
import type { Tone } from "../../lib/tone";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { InlineApproval } from "../approvals/InlineApproval";
|
||||
import { FAMILY_ICON } from "../tool-family-icon";
|
||||
import { ActivityStep, type ActivityStepTone } from "./activity-step";
|
||||
import { TOOL_STATUS, ToolStatusWord } from "./tool-status";
|
||||
|
||||
const STATE_TONE: Record<ToolStep["state"], ActivityStepTone> = { ok: "neutral", error: "error", denied: "warn", interrupted: "warn" };
|
||||
/** A step's marker in the tone of how the call ended. */
|
||||
const MARKER: Record<Tone, ActivityStepTone> = { neutral: "neutral", info: "active", ok: "success", warn: "warn", error: "error" };
|
||||
|
||||
/** One tool call as a quiet line: what it did, what it acted on, and how it ended when that was not well. */
|
||||
export function ToolLine({ step }: { step: ToolStep }) {
|
||||
@@ -19,7 +22,7 @@ export function ToolLine({ step }: { step: ToolStep }) {
|
||||
return (
|
||||
<ActivityStep
|
||||
icon={FAMILY_ICON[step.family]}
|
||||
tone={STATE_TONE[step.state]}
|
||||
tone={MARKER[TOOL_OUTCOME[step.state].tone]}
|
||||
title={full}
|
||||
label={
|
||||
<>
|
||||
@@ -33,8 +36,6 @@ export function ToolLine({ step }: { step: ToolStep }) {
|
||||
);
|
||||
}
|
||||
|
||||
const APPROVAL_WORD: Record<Exclude<ApprovalStep["outcome"], "waiting">, string> = { approved: "Allowed", rejected: "Denied" };
|
||||
|
||||
/**
|
||||
* An approval the Dot asked for in the conversation, in the place it was asked. While it waits it is the approval card,
|
||||
* answerable here; once answered it is a receipt, which is what the log keeps.
|
||||
@@ -58,6 +59,7 @@ export function ApprovalLine({ dotId, step }: { dotId: string; step: ApprovalSte
|
||||
{APPROVAL_WORD[step.outcome]}
|
||||
{approved && step.always ? " for good" : ""}: {step.label.toLowerCase()}
|
||||
{step.reason ? <span className="font-normal"> ({step.reason})</span> : null}
|
||||
{step.note ? <span className="block text-xs font-normal">Note: {step.note}</span> : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { readEventLog, readEventRange, readRecentEvents } from "@invisible-dots/sdk";
|
||||
import { MAX_EVENT_PAGE } from "@invisible-dots/shared/browser";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import { buildThread, CHAT_ACTIVITY_EVENT_TYPES, isChatActivityEvent, mergeChatEvents, unsettled, type PendingMessage, type ThreadItem } from "../../lib/chat-thread";
|
||||
import { CHAT_PAGE_SIZE, withEarlierPage, withNewestPage, type ChatWindow } from "../../lib/chat-window";
|
||||
import { readEventLog, readEventRange, readRecentEvents } from "../../lib/event-log";
|
||||
import { CHAT_EVENT_TYPES } from "../../lib/messages";
|
||||
import type { ChatMessage, StoredEvent } from "../../lib/types";
|
||||
import { useLiveEvents, useLiveRefresh } from "../events";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Derived from AI Elements (Vercel) packages/elements/src/tool.tsx at 6a9d5b1, Apache-2.0; changed: the states are this app's own (a call is reported when it has ended, so there is no running state; a call that waits for the person belongs to the approval step), and the map gives each a word, an icon and a tone from this app's tokens, not a badge in fixed colors.
|
||||
|
||||
import { BanIcon, CheckCircle2Icon, CircleAlertIcon, XCircleIcon, type LucideIcon } from "lucide-react";
|
||||
import type { ToolState } from "../../lib/chat-thread";
|
||||
import { TOOL_OUTCOME, type ToolState } from "../../lib/steps";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface ToolStatus {
|
||||
@@ -15,10 +15,10 @@ export interface ToolStatus {
|
||||
}
|
||||
|
||||
export const TOOL_STATUS: Readonly<Record<ToolState, ToolStatus>> = {
|
||||
ok: { word: "done", icon: CheckCircle2Icon, tone: "text-ok", attention: false },
|
||||
error: { word: "failed", icon: XCircleIcon, tone: "text-danger", attention: true },
|
||||
denied: { word: "denied", icon: BanIcon, tone: "text-warn", attention: true },
|
||||
interrupted: { word: "interrupted", icon: CircleAlertIcon, tone: "text-warn", attention: true },
|
||||
ok: { word: TOOL_OUTCOME.ok.word, icon: CheckCircle2Icon, tone: "text-ok", attention: false },
|
||||
error: { word: TOOL_OUTCOME.error.word, icon: XCircleIcon, tone: "text-danger", attention: true },
|
||||
denied: { word: TOOL_OUTCOME.denied.word, icon: BanIcon, tone: "text-warn", attention: true },
|
||||
interrupted: { word: TOOL_OUTCOME.interrupted.word, icon: CircleAlertIcon, tone: "text-warn", attention: true },
|
||||
};
|
||||
|
||||
/** The word of a call's ending, shown beside it only when it is not an ordinary success. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { agentStateLabel, type AgentStateValue } from "../../lib/agent";
|
||||
import type { ApprovalStep, ToolStep } from "../../lib/chat-thread";
|
||||
import type { ApprovalStep, ToolStep } from "../../lib/steps";
|
||||
import type { RingState } from "../../lib/attention";
|
||||
import { DotAvatar } from "../shell/DotAvatar";
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ export function McpServersPanel({ draft, saved, change, errorOf, dotId, computer
|
||||
<Panel
|
||||
id="mcp-servers"
|
||||
title="MCP servers"
|
||||
description="Programs and services whose tools the Dot may use, as Claude Code and Codex use them. Only you add one; the Dot can install a program a server needs. Each server's permission is under Permissions and tools."
|
||||
description="Programs and services whose tools the Dot may use, as Claude Code and Codex use them. You add one here, or the Dot adds one itself once it has installed the program the server needs, asking you first unless you allowed it. Only you change or remove one. Each server's permission is under Permissions and tools."
|
||||
>
|
||||
{names.length === 0 ? <p className="text-sm text-muted-foreground">No MCP server.</p> : null}
|
||||
<ul className="divide-y rounded-lg border">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { computerIsUp, type PermissionDecision, type PermissionRisk } from "@invisible-dots/shared/browser";
|
||||
import { RISK_LABEL } from "../../lib/approval-view";
|
||||
import { computerIsUp, RISK_LABEL, type PermissionDecision, type PermissionRisk } from "@invisible-dots/shared/browser";
|
||||
import { setPermission } from "../../lib/config-fields";
|
||||
import { DECISION_LABEL, DECISION_MEANING, DECISIONS, permissionGroups, type PermissionRow } from "../../lib/permission-table";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@@ -27,7 +27,7 @@ export function DotEventScope({ dotId, children }: { dotId: string; children: Re
|
||||
return <ScopeContext.Provider value={dotId}>{children}</ScopeContext.Provider>;
|
||||
}
|
||||
|
||||
/** One SSE connection for every Dot, kept for as long as the signed-in pages are open and shared by every component below it. */
|
||||
/** One SSE connection for every Dot, kept for as long as the app's pages are open and shared by every component below it. */
|
||||
export function EventStreamProvider({ children }: { children: ReactNode }) {
|
||||
const listeners = useRef(new Set<Listener>());
|
||||
const [status, setStatus] = useState<StreamStatus>("connecting");
|
||||
|
||||
@@ -121,8 +121,8 @@ function Key({ children }: { children: string }) {
|
||||
/**
|
||||
* What needs the person (S6): the approvals that wait, oldest first, each answerable where it stands; Dots in an error
|
||||
* state; tasks that failed in the last day; channels that need linking again. An approval that was answered here
|
||||
* stays in its place as a receipt until the person leaves, so that nothing jumps away under the pointer. Keys: j and k
|
||||
* move, a allows once, d denies.
|
||||
* stays in its place as a receipt until the person leaves, so that nothing jumps away under the pointer. Keys, while
|
||||
* the focus is in the list of the approvals: j and k move, a allows once, d denies.
|
||||
*/
|
||||
export function NeedsYou({ filter }: { filter: Filter }) {
|
||||
const { dots, approvals, attention, failedTasks, relinks, dismissFailedTask } = useShell();
|
||||
@@ -143,15 +143,16 @@ export function NeedsYou({ filter }: { filter: Filter }) {
|
||||
const unlinked = otherThings ? relinks.items.filter((relink) => filter.dotId === null || relink.dot_id === filter.dotId) : [];
|
||||
const waitingCount = asks.filter((ask) => !answers.settled.has(ask.id)).length;
|
||||
|
||||
const keys = useInboxKeys(asks, answers, true, (dotId) => names.get(dotId) ?? dotId);
|
||||
const keys = useInboxKeys(asks, answers, (dotId) => names.get(dotId) ?? dotId);
|
||||
const loading = (approvals.data === undefined && !approvals.error) || (dots.data === undefined && !dots.error);
|
||||
// "Nothing needs you" is said only once everything has been read, failed tasks and channels included: the approvals do not wait for them.
|
||||
const empty = !loading && !failedTasks.loading && !relinks.loading && asks.length === 0 && broken.length === 0 && failed.length === 0 && unlinked.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keys: <Key>j</Key> and <Key>k</Key> move between the cards, <Key>a</Key> allows the selected one once, <Key>d</Key> denies it.
|
||||
<p id="inbox-keys" className="text-xs text-muted-foreground">
|
||||
Keys, once the list of approvals has the focus (click it or Tab to it): <Key>j</Key> and <Key>k</Key> move between the cards, <Key>a</Key> allows the selected one
|
||||
once, <Key>d</Key> denies it.
|
||||
</p>
|
||||
<p role="status" className="sr-only">
|
||||
{keys.announcement}
|
||||
@@ -167,7 +168,13 @@ export function NeedsYou({ filter }: { filter: Filter }) {
|
||||
|
||||
{!loading && asks.length > 0 ? (
|
||||
<Section id="inbox-approvals" title="Waiting for your answer" count={waitingCount}>
|
||||
<ul className="space-y-3">
|
||||
<ul
|
||||
aria-label="Approvals waiting for your answer"
|
||||
aria-describedby="inbox-keys"
|
||||
tabIndex={0}
|
||||
className="space-y-3 rounded-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background focus-visible:outline-hidden"
|
||||
{...keys.listProps}
|
||||
>
|
||||
{asks.map((ask) => (
|
||||
<li key={ask.id}>
|
||||
<ApprovalCard ask={ask} answers={answers} dotName={names.get(ask.dotId) ?? ask.dotId} selected={keys.selectedId === ask.id} />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState, type FocusEvent, type KeyboardEvent } from "react";
|
||||
import { askTitle, isDestructive, type ApprovalAsk } from "../../lib/approval-view";
|
||||
import type { Answers } from "../approvals/use-answers";
|
||||
|
||||
/** Whether a key press is meant for a field (or a menu, a dialog) and not for the page. */
|
||||
/** Whether a key press is meant for a field (or a menu, a dialog) and not for the list. */
|
||||
function forTheFocus(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return true;
|
||||
@@ -21,54 +21,55 @@ export interface InboxKeys {
|
||||
selectedId: string | null;
|
||||
/** What to say to a person who cannot see the selection move or the card take the focus: which card is selected now, and that a destructive "allow" waits for a confirmation. */
|
||||
announcement: string;
|
||||
/** For the list of the cards: the keys act only while the focus is in it, and a card the focus enters is selected. */
|
||||
listProps: { onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; onFocus: (event: FocusEvent<HTMLElement>) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Inbox from the keyboard: `j` and `k` move between the cards, `a` allows the one selected once and `d` denies it.
|
||||
* A card that is destructive (a command, a deleted identity, a file outside the workspace) is not allowed by a key
|
||||
* press alone: `a` moves the focus to its "Allow once" button, and the press of that button is the confirmation. A key
|
||||
* meant for a field, a menu or a dialog is left to it. A move says which card is selected now ("fares: Wants to run a
|
||||
* command. 2 of 3."), since the selection is drawn and not focused: a screen reader would otherwise not hear it.
|
||||
* The keys are the list's, not the page's: they act only while the focus is in the list of the cards (WCAG 2.1.4), so
|
||||
* a "d" typed with the focus anywhere else denies nothing. A card the focus enters (a click on it, Tab to one of its
|
||||
* buttons) is the one selected. A card that is destructive (a command, a deleted identity, a file outside the
|
||||
* workspace) is not allowed by a key press alone: `a` moves the focus to its "Allow once" button, and the press of
|
||||
* that button is the confirmation. A key meant for a field, a menu or a dialog is left to it. A move says which card
|
||||
* is selected now ("fares: Wants to run a command. 2 of 3."), since the selection is drawn and not focused: a screen
|
||||
* reader would otherwise not hear it.
|
||||
*/
|
||||
export function useInboxKeys(asks: readonly ApprovalAsk[], answers: Answers, enabled: boolean, nameOf: (dotId: string) => string): InboxKeys {
|
||||
export function useInboxKeys(asks: readonly ApprovalAsk[], answers: Answers, nameOf: (dotId: string) => string): InboxKeys {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const selectedId = asks.some((ask) => ask.id === selected) ? selected : (asks[0]?.id ?? null);
|
||||
const latest = useRef({ asks, answers, selectedId, nameOf });
|
||||
useEffect(() => {
|
||||
latest.current = { asks, answers, selectedId, nameOf };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
||||
if (!["j", "k", "a", "d"].includes(event.key) || forTheFocus(event.target) || document.querySelector("[role=dialog]") !== null) return;
|
||||
const { asks: list, answers: current, selectedId: id, nameOf: nameFor } = latest.current;
|
||||
const index = list.findIndex((ask) => ask.id === id);
|
||||
if (index < 0) return;
|
||||
const ask = list[index]!;
|
||||
setAnnouncement("");
|
||||
if (event.key === "j" || event.key === "k") {
|
||||
const at = Math.min(list.length - 1, Math.max(0, index + (event.key === "j" ? 1 : -1)));
|
||||
const next = list[at]!;
|
||||
setSelected(next.id);
|
||||
setAnnouncement(`${nameFor(next.dotId)}: ${askTitle(next)}. ${at + 1} of ${list.length}.`);
|
||||
cardOf(next.id)?.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
if (current.settled.has(ask.id) || current.sending.has(ask.id)) return;
|
||||
const onFocus = (event: FocusEvent<HTMLElement>) => {
|
||||
const id = (event.target as Element).closest("[data-approval-id]")?.getAttribute("data-approval-id");
|
||||
if (id && asks.some((ask) => ask.id === id)) setSelected(id);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
||||
if (!["j", "k", "a", "d"].includes(event.key) || forTheFocus(event.target) || document.querySelector("[role=dialog]") !== null) return;
|
||||
const index = asks.findIndex((ask) => ask.id === selectedId);
|
||||
if (index < 0) return;
|
||||
const ask = asks[index]!;
|
||||
setAnnouncement("");
|
||||
if (event.key === "j" || event.key === "k") {
|
||||
event.preventDefault();
|
||||
if (event.key === "d") void current.answer(ask, "reject");
|
||||
else if (!isDestructive(ask)) void current.answer(ask, "approve");
|
||||
else {
|
||||
cardOf(ask.id)?.querySelector<HTMLElement>("[data-action=allow]")?.focus();
|
||||
setAnnouncement("This can do real harm. Press Enter on Allow once to confirm.");
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [enabled]);
|
||||
const at = Math.min(asks.length - 1, Math.max(0, index + (event.key === "j" ? 1 : -1)));
|
||||
const next = asks[at]!;
|
||||
setSelected(next.id);
|
||||
setAnnouncement(`${nameOf(next.dotId)}: ${askTitle(next)}. ${at + 1} of ${asks.length}.`);
|
||||
cardOf(next.id)?.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
if (answers.settled.has(ask.id) || answers.sending.has(ask.id)) return;
|
||||
event.preventDefault();
|
||||
if (event.key === "d") void answers.answer(ask, "reject");
|
||||
else if (!isDestructive(ask)) void answers.answer(ask, "approve");
|
||||
else {
|
||||
cardOf(ask.id)?.querySelector<HTMLElement>("[data-action=allow]")?.focus();
|
||||
setAnnouncement("This can do real harm. Press Enter on Allow once to confirm.");
|
||||
}
|
||||
};
|
||||
|
||||
return { selectedId, announcement };
|
||||
return { selectedId, announcement, listProps: { onKeyDown, onFocus } };
|
||||
}
|
||||
@@ -44,7 +44,7 @@ interface ShellData {
|
||||
const Context = createContext<ShellData | null>(null);
|
||||
|
||||
/**
|
||||
* What every signed-in page shares: the Dots, the approvals that wait, the tasks that failed lately, the channels that
|
||||
* What every page of the app shares: the Dots, the approvals that wait, the tasks that failed lately, the channels that
|
||||
* need linking again, what the live stream says about each Dot, and whether the control plane answers. The rail, the
|
||||
* Dot header, the document title, the favicon and the setup pages
|
||||
* all read it, so they agree and the control plane is asked once, not once per component.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { BanIcon, CheckCircle2Icon, CircleDotIcon, FlagIcon, HandIcon, MessageSquareIcon, PlayIcon, TerminalIcon, XCircleIcon, type LucideIcon } from "lucide-react";
|
||||
import { toolLabel } from "../../lib/events/tool-labels";
|
||||
import { formatDate, formatDuration } from "../../lib/format";
|
||||
import { formatDate, formatMillis } from "../../lib/format";
|
||||
import { APPROVAL_WORD, TOOL_OUTCOME } from "../../lib/steps";
|
||||
import type { StoryStep } from "../../lib/task-events";
|
||||
import { priorityLabel } from "../../lib/task-view";
|
||||
import { relativeTime } from "../../lib/time";
|
||||
@@ -22,14 +22,8 @@ const ICON: Record<StoryStep["kind"], LucideIcon> = {
|
||||
|
||||
const ICON_TONE: Partial<Record<StoryStep["kind"], string>> = { completed: "text-ok", failed: "text-danger", approval: "text-warn" };
|
||||
|
||||
/** How a tool call ended, in a word: it ran, it failed, it was refused, or it was cut off. */
|
||||
function toolOutcome(step: Extract<StoryStep, { kind: "tool" }>): { word: string; bad: boolean } {
|
||||
if (step.interrupted) return { word: "interrupted", bad: true };
|
||||
if (step.decision === "deny") return { word: "denied", bad: true };
|
||||
return step.ok ? { word: "ok", bad: false } : { word: "failed", bad: true };
|
||||
}
|
||||
|
||||
const APPROVAL_WORD = { waiting: "Waiting for an answer", approved: "Allowed", rejected: "Denied" } as const;
|
||||
/** The color of how a call ended: only what is not an ordinary success has one. */
|
||||
const OUTCOME_CLASS = { neutral: "text-muted-foreground", info: "text-muted-foreground", ok: "text-muted-foreground", warn: "text-warn", error: "text-danger" } as const;
|
||||
|
||||
function Body({ step }: { step: StoryStep }) {
|
||||
switch (step.kind) {
|
||||
@@ -44,16 +38,16 @@ function Body({ step }: { step: StoryStep }) {
|
||||
case "progress":
|
||||
return <span className="whitespace-pre-wrap">{step.text}</span>;
|
||||
case "tool": {
|
||||
const outcome = toolOutcome(step);
|
||||
const outcome = TOOL_OUTCOME[step.state];
|
||||
return (
|
||||
<>
|
||||
<span title={step.tool} className="font-medium">
|
||||
{toolLabel(step.tool, step.tty).label}
|
||||
{step.label}
|
||||
</span>
|
||||
{step.target ? <span className="break-all text-muted-foreground"> {step.target}</span> : null}{" "}
|
||||
<span className={cn("text-xs", outcome.bad ? "text-danger" : "text-muted-foreground")}>
|
||||
<span className={cn("text-xs", OUTCOME_CLASS[outcome.tone])}>
|
||||
{outcome.word}
|
||||
{step.durationMs > 0 ? `, ${formatDuration(step.durationMs / 1000)}` : ""}
|
||||
{step.durationMs > 0 ? `, ${formatMillis(step.durationMs)}` : ""}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -65,7 +59,7 @@ function Body({ step }: { step: StoryStep }) {
|
||||
{APPROVAL_WORD[step.outcome]}
|
||||
{step.outcome === "approved" && step.always ? " for good" : ""}:
|
||||
</span>{" "}
|
||||
<span title={step.tool}>{toolLabel(step.tool).label.toLowerCase()}</span>
|
||||
<span title={step.tool}>{step.label.toLowerCase()}</span>
|
||||
{step.reason ? <span className="text-muted-foreground"> {step.reason}</span> : null}
|
||||
{step.note ? <span className="block text-xs text-muted-foreground">Note: {step.note}</span> : null}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ClockIcon, FileTextIcon, FilePenIcon, FingerprintIcon, GlobeIcon, MonitorIcon, TerminalIcon, WrenchIcon, type LucideIcon } from "lucide-react";
|
||||
import type { ToolFamily } from "../lib/events/tool-labels";
|
||||
import type { ToolFamily } from "@invisible-dots/shared/browser";
|
||||
|
||||
/** One icon per family of the Dot's tools: the chat's steps and the approval cards draw the same one for the same kind of call. */
|
||||
export const FAMILY_ICON: Record<ToolFamily, LucideIcon> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export interface Resource<T> {
|
||||
data: T | undefined;
|
||||
@@ -12,7 +12,8 @@ export interface Resource<T> {
|
||||
/**
|
||||
* Load data on mount and whenever `key` changes. Answers that arrive after a
|
||||
* newer load started are dropped, so a slow response never overwrites a
|
||||
* fresh one.
|
||||
* fresh one. The object returned is the same until one of its fields changes,
|
||||
* so a memo or a context value built from it changes only with it.
|
||||
*/
|
||||
export function useResource<T>(load: () => Promise<T>, key: string): Resource<T> {
|
||||
const [data, setData] = useState<T | undefined>(undefined);
|
||||
@@ -50,7 +51,7 @@ export function useResource<T>(load: () => Promise<T>, key: string): Resource<T>
|
||||
reload();
|
||||
}, [key, reload]);
|
||||
|
||||
return { data, error, loading, reload };
|
||||
return useMemo(() => ({ data, error, loading, reload }), [data, error, loading, reload]);
|
||||
}
|
||||
|
||||
/** Run an action, tracking its pending state and error. */
|
||||
|
||||
@@ -10,13 +10,12 @@ import {
|
||||
isPermissionName,
|
||||
mcpServerOf,
|
||||
permissionInfo as sharedPermissionInfo,
|
||||
toolLabel,
|
||||
type PermissionInfo,
|
||||
type PermissionName,
|
||||
type PermissionRisk,
|
||||
type ToolInfo,
|
||||
} from "@invisible-dots/shared/browser";
|
||||
import { additionDiff, replacementDiff, type DiffLine } from "./diff";
|
||||
import { toolLabel } from "./events/tool-labels";
|
||||
import { formatDate } from "./format";
|
||||
import type { Approval } from "./types";
|
||||
|
||||
@@ -103,8 +102,6 @@ export function permissionInfo(permission: string): (PermissionInfo & { permissi
|
||||
return info !== null && isPermissionName(permission) ? { ...info, permission } : null;
|
||||
}
|
||||
|
||||
export const RISK_LABEL: Record<PermissionRisk, string> = { low: "Low risk", medium: "Medium risk", high: "High risk" };
|
||||
|
||||
/** The question: what the Dot wants to do, as the engine's table words it. */
|
||||
export function askTitle(ask: ApprovalAsk): string {
|
||||
return `Wants to ${toolLabel(ask.tool).ask}`;
|
||||
|
||||
@@ -36,13 +36,6 @@ export function channelState(record: Pick<ChannelRecord, "enabled" | "status">):
|
||||
return record.enabled ? STATUS_VIEW[record.status] : { label: "Paused", tone: "neutral" };
|
||||
}
|
||||
|
||||
/** The account of a channel as a person writes it: a bot's `@name`, a phone number with its plus; null until the channel reports one. */
|
||||
export function accountLabel(kind: ChannelKind, account: string | null): string | null {
|
||||
if (account === null || account === "") return null;
|
||||
if (kind === "telegram") return `@${account}`;
|
||||
return account.startsWith("+") ? account : `+${account}`;
|
||||
}
|
||||
|
||||
/** Where a channel's account can be opened in a browser: a Telegram bot's page; WhatsApp has no page for a number. */
|
||||
export function accountHref(kind: ChannelKind, account: string | null): string | null {
|
||||
return kind === "telegram" && account !== null && /^[A-Za-z0-9_]+$/.test(account) ? `https://t.me/${account}` : null;
|
||||
|
||||
@@ -6,47 +6,10 @@
|
||||
* Only the chat's own turns are drawn here: a tool call or an approval that names a task belongs to that task's
|
||||
* story (the Tasks page), not to the conversation.
|
||||
*/
|
||||
import { toolLabel, type ToolFamily } from "./events/tool-labels";
|
||||
import type { ApprovalOutcome } from "./task-events";
|
||||
import { mergeEvents } from "./event-log";
|
||||
import { mergeEvents } from "@invisible-dots/sdk";
|
||||
import { stepsOf, type ApprovalStep, type ToolStep } from "./steps";
|
||||
import type { ChatMessage, StoredEvent } from "./types";
|
||||
|
||||
/**
|
||||
* How a tool call ended. The engine reports a call when it has ended, so a call that is still running has no event
|
||||
* yet (the chat's working row says the Dot is busy); a call that waits for the person is the approval's step.
|
||||
*/
|
||||
export type ToolState = "ok" | "error" | "denied" | "interrupted";
|
||||
|
||||
export interface ToolStep {
|
||||
kind: "tool";
|
||||
id: number;
|
||||
at: string;
|
||||
tool: string;
|
||||
label: string;
|
||||
family: ToolFamily;
|
||||
/** What the call acted on, as the engine reported it (one line); "" when it named nothing. */
|
||||
target: string;
|
||||
state: ToolState;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface ApprovalStep {
|
||||
kind: "approval";
|
||||
id: number;
|
||||
at: string;
|
||||
approvalId: string;
|
||||
tool: string;
|
||||
label: string;
|
||||
family: ToolFamily;
|
||||
reason: string;
|
||||
/** What the call needs the person's permission for, and with which arguments: what the approval card shows. */
|
||||
permission: string;
|
||||
arguments: Record<string, unknown>;
|
||||
outcome: ApprovalOutcome;
|
||||
/** The answer also allowed the permission for good. */
|
||||
always: boolean;
|
||||
}
|
||||
|
||||
export type ActivityItem = ToolStep | ApprovalStep;
|
||||
|
||||
export type ThreadItem =
|
||||
@@ -64,79 +27,15 @@ export function isChatActivityEvent(event: Pick<StoredEvent, "type">): boolean {
|
||||
/** Runs of tool steps longer than this fold into one line the person can open. */
|
||||
export const CLUSTER_AFTER = 3;
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function hasTask(event: Pick<StoredEvent, "data">): boolean {
|
||||
return text(event.data?.task_id) !== "";
|
||||
}
|
||||
|
||||
export function toolStateOf(data: Record<string, unknown>): ToolState {
|
||||
if (data.interrupted === true) return "interrupted";
|
||||
if (data.decision === "deny") return "denied";
|
||||
return data.ok === true ? "ok" : "error";
|
||||
/** Whether a call or an approval is the chat's: one that names a task is that task's. */
|
||||
function isTheChats(event: Pick<StoredEvent, "data">): boolean {
|
||||
const taskId = event.data?.task_id;
|
||||
return typeof taskId !== "string" || taskId === "";
|
||||
}
|
||||
|
||||
/** The steps of the chat's turns in the order they happened, from the events `isChatActivityEvent` accepts. */
|
||||
export function activityOf(events: readonly StoredEvent[]): ActivityItem[] {
|
||||
const items: ActivityItem[] = [];
|
||||
const approvals = new Map<string, ApprovalStep>();
|
||||
for (const event of [...events].sort((a, b) => a.id - b.id)) {
|
||||
const d = event.data ?? {};
|
||||
const base = { id: event.id, at: event.created_at };
|
||||
switch (event.type) {
|
||||
case "tool.called": {
|
||||
if (hasTask(event)) break;
|
||||
const tool = text(d.tool);
|
||||
const { label, family } = toolLabel(tool, d.tty === true);
|
||||
items.push({
|
||||
...base,
|
||||
kind: "tool",
|
||||
tool,
|
||||
label,
|
||||
family,
|
||||
target: text(d.target),
|
||||
state: toolStateOf(d),
|
||||
durationMs: typeof d.duration_ms === "number" ? d.duration_ms : 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "approval.requested": {
|
||||
if (hasTask(event)) break;
|
||||
const tool = text(d.tool);
|
||||
const { label, family } = toolLabel(tool);
|
||||
const step: ApprovalStep = {
|
||||
...base,
|
||||
kind: "approval",
|
||||
approvalId: text(d.approval_id),
|
||||
tool,
|
||||
label,
|
||||
family,
|
||||
reason: text(d.reason),
|
||||
permission: text(d.permission),
|
||||
arguments: typeof d.arguments === "object" && d.arguments !== null && !Array.isArray(d.arguments) ? (d.arguments as Record<string, unknown>) : {},
|
||||
outcome: "waiting",
|
||||
always: false,
|
||||
};
|
||||
approvals.set(step.approvalId, step);
|
||||
items.push(step);
|
||||
break;
|
||||
}
|
||||
case "approval.resolved": {
|
||||
// It names only the approval: one that was not the chat's is not in the map.
|
||||
const step = approvals.get(text(d.approval_id));
|
||||
if (step) {
|
||||
step.outcome = d.decision === "approve" ? "approved" : "rejected";
|
||||
step.always = d.always === true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
return stepsOf([...events].sort((a, b) => a.id - b.id), isTheChats);
|
||||
}
|
||||
|
||||
/** The thread: messages and activity merged by event id; an assistant message starts a group after anything but another assistant message. */
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* so the Dot's name is asked of the control plane there, with the web server's own credential, and only for a request
|
||||
* that reaches this server (it listens where the Dots' VMs cannot reach it, section 9.7). Anything that goes wrong leaves the title the tab alone.
|
||||
*/
|
||||
import { InvisibleDotsClient } from "@invisible-dots/sdk";
|
||||
import type { Metadata } from "next";
|
||||
import { apiBaseUrl, loadApiToken } from "./proxy";
|
||||
|
||||
@@ -12,14 +13,14 @@ const NAME_TIMEOUT_MS = 2000;
|
||||
|
||||
export async function dotPageTitle(dotIdOrName: string, tab: string): Promise<Metadata> {
|
||||
try {
|
||||
const token = await loadApiToken();
|
||||
const answer = await fetch(`${apiBaseUrl()}/api/dots/${encodeURIComponent(dotIdOrName)}`, {
|
||||
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(NAME_TIMEOUT_MS),
|
||||
const client = new InvisibleDotsClient({
|
||||
baseUrl: apiBaseUrl(),
|
||||
token: await loadApiToken(),
|
||||
timeoutMs: NAME_TIMEOUT_MS,
|
||||
// A Dot can be renamed: an answer Next kept would show the old name.
|
||||
fetch: (url, init) => fetch(url, { ...init, cache: "no-store" }),
|
||||
});
|
||||
if (!answer.ok) return { title: tab };
|
||||
const { name } = (await answer.json()) as { name?: unknown };
|
||||
const { name } = await client.getDot(dotIdOrName);
|
||||
return { title: typeof name === "string" && name !== "" ? `${tab} - ${name}` : tab };
|
||||
} catch {
|
||||
return { title: tab };
|
||||
|
||||
@@ -9,14 +9,16 @@
|
||||
*/
|
||||
import {
|
||||
parseMessageOrigin,
|
||||
toolLabel,
|
||||
type HostEventDataMap,
|
||||
type InboundEventDataMap,
|
||||
type OutboundEventDataMap,
|
||||
type StoredEvent,
|
||||
} from "@invisible-dots/shared/browser";
|
||||
import { CHANNEL_LABELS, viaChannel } from "../channels";
|
||||
import { formatMillis } from "../format";
|
||||
import { APPROVAL_WORD, TOOL_OUTCOME, toolStateOf } from "../steps";
|
||||
import type { Tone } from "../tone";
|
||||
import { toolLabel } from "./tool-labels";
|
||||
|
||||
/** What the Activity page filters by: one family per kind of thing that happens to a Dot. */
|
||||
export const EVENT_FAMILIES = ["chat", "tasks", "tools", "approvals", "memory", "browser", "computer", "channels", "dot"] as const;
|
||||
@@ -137,13 +139,6 @@ interface Draft {
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
/** How a tool call ended, in the words of the row. A denied call never ran; an interrupted one has no known outcome. */
|
||||
function outcome(data: Partial<OutboundEventDataMap["tool.called"]>): { word: string; tone: Tone } {
|
||||
if (data.interrupted === true) return { word: "interrupted", tone: "warn" };
|
||||
if (data.decision === "deny") return { word: "denied", tone: "error" };
|
||||
return data.ok === true ? { word: "ok", tone: "neutral" } : { word: "failed", tone: "error" };
|
||||
}
|
||||
|
||||
/** What a task event says, after the task it is about. */
|
||||
const withTask = (taskId: unknown, rest: string): string => joined(text(taskId) && `${text(taskId)}:`, rest);
|
||||
|
||||
@@ -163,8 +158,8 @@ const DESCRIBE: { [K in KnownType]: (data: Partial<EventData[K]>) => Draft } = {
|
||||
"task.failed": (d) => ({ title: "Task failed", detail: withTask(d.task_id, text(d.error)), tone: "error" }),
|
||||
"task.cancelled": (d) => ({ title: "Task cancelled", detail: text(d.task_id), tone: "neutral" }),
|
||||
"tool.called": (d) => {
|
||||
const { word, tone } = outcome(d);
|
||||
const duration = typeof d.duration_ms === "number" ? ` in ${Math.round(d.duration_ms)} ms` : "";
|
||||
const { word, tone } = TOOL_OUTCOME[toolStateOf(d)];
|
||||
const duration = typeof d.duration_ms === "number" ? ` in ${formatMillis(d.duration_ms)}` : "";
|
||||
const tool = text(d.tool);
|
||||
// What it acted on, how it ended, and which tool under which permission the policy decided on.
|
||||
const policy = joined(tool, text(d.permission) && `[${text(d.permission)}]`, text(d.decision));
|
||||
@@ -181,7 +176,7 @@ const DESCRIBE: { [K in KnownType]: (data: Partial<EventData[K]>) => Draft } = {
|
||||
tone: "warn",
|
||||
}),
|
||||
"approval.resolved": (d) => ({
|
||||
title: d.decision === "approve" ? (d.always === true ? "Approved, and always allowed" : "Approved") : d.decision === "reject" ? "Rejected" : "Approval resolved",
|
||||
title: d.decision === "approve" ? `${APPROVAL_WORD.approved}${d.always === true ? " for good" : ""}` : d.decision === "reject" ? APPROVAL_WORD.rejected : "Approval resolved",
|
||||
detail: joined(text(d.approval_id), text(d.note) && `- ${text(d.note)}`),
|
||||
tone: d.decision === "reject" ? "warn" : "ok",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* A tool call and an approval as a step of what the Dot did: the one reading of `tool.called`, `approval.requested`
|
||||
* and `approval.resolved` that the chat (`chat-thread.ts`), a task's story (`task-events.ts`) and the Activity page
|
||||
* (`events/view.ts`) draw, with one set of words for how a call ended and how an approval was answered. How long a
|
||||
* call took is `formatMillis` (format.ts) in all three.
|
||||
*/
|
||||
import { toolLabel, type ToolFamily } from "@invisible-dots/shared/browser";
|
||||
import type { Tone } from "./tone";
|
||||
import type { StoredEvent } from "./types";
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool call ended. The engine reports a call when it has ended, so a call that is still running has no event
|
||||
* yet; a call that waits for the person is the approval's step.
|
||||
*/
|
||||
export type ToolState = "ok" | "error" | "denied" | "interrupted";
|
||||
|
||||
export function toolStateOf(data: Record<string, unknown>): ToolState {
|
||||
if (data.interrupted === true) return "interrupted";
|
||||
if (data.decision === "deny") return "denied";
|
||||
return data.ok === true ? "ok" : "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* How a call ended, in a word and a tone. A denied call never ran: the Dot's config refused it, which is worth noticing
|
||||
* but is not a failure; an interrupted one has no known outcome.
|
||||
*/
|
||||
export const TOOL_OUTCOME: Readonly<Record<ToolState, { word: string; tone: Tone }>> = {
|
||||
ok: { word: "done", tone: "neutral" },
|
||||
error: { word: "failed", tone: "error" },
|
||||
denied: { word: "denied", tone: "warn" },
|
||||
interrupted: { word: "interrupted", tone: "warn" },
|
||||
};
|
||||
|
||||
export interface ToolStep {
|
||||
kind: "tool";
|
||||
id: number;
|
||||
at: string;
|
||||
tool: string;
|
||||
label: string;
|
||||
family: ToolFamily;
|
||||
/** What the call acted on, as the engine reported it (one line); "" when it named nothing. */
|
||||
target: string;
|
||||
state: ToolState;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type ApprovalOutcome = "waiting" | "approved" | "rejected";
|
||||
|
||||
/** How an approval stands, in the words of a step. */
|
||||
export const APPROVAL_WORD: Readonly<Record<ApprovalOutcome, string>> = { waiting: "Waiting for an answer", approved: "Allowed", rejected: "Denied" };
|
||||
|
||||
export interface ApprovalStep {
|
||||
kind: "approval";
|
||||
id: number;
|
||||
at: string;
|
||||
approvalId: string;
|
||||
tool: string;
|
||||
label: string;
|
||||
family: ToolFamily;
|
||||
reason: string;
|
||||
/** What the call needs the person's permission for, and with which arguments: what the approval card shows. */
|
||||
permission: string;
|
||||
arguments: Record<string, unknown>;
|
||||
outcome: ApprovalOutcome;
|
||||
/** What the person wrote to the Dot with the answer; "" for none. */
|
||||
note: string;
|
||||
/** The answer also allowed the permission for good. */
|
||||
always: boolean;
|
||||
}
|
||||
|
||||
/** A `tool.called` event as a step. */
|
||||
export function toolStep(event: Pick<StoredEvent, "id" | "created_at" | "data">): ToolStep {
|
||||
const d = event.data ?? {};
|
||||
const tool = text(d.tool);
|
||||
const { label, family } = toolLabel(tool, d.tty === true);
|
||||
return {
|
||||
kind: "tool",
|
||||
id: event.id,
|
||||
at: event.created_at,
|
||||
tool,
|
||||
label,
|
||||
family,
|
||||
target: text(d.target),
|
||||
state: toolStateOf(d),
|
||||
durationMs: typeof d.duration_ms === "number" ? d.duration_ms : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** An `approval.requested` event as a step that waits; `answerApproval` records its answer. */
|
||||
export function approvalStep(event: Pick<StoredEvent, "id" | "created_at" | "data">): ApprovalStep {
|
||||
const d = event.data ?? {};
|
||||
const tool = text(d.tool);
|
||||
const { label, family } = toolLabel(tool);
|
||||
return {
|
||||
kind: "approval",
|
||||
id: event.id,
|
||||
at: event.created_at,
|
||||
approvalId: text(d.approval_id),
|
||||
tool,
|
||||
label,
|
||||
family,
|
||||
reason: text(d.reason),
|
||||
permission: text(d.permission),
|
||||
arguments: typeof d.arguments === "object" && d.arguments !== null && !Array.isArray(d.arguments) ? (d.arguments as Record<string, unknown>) : {},
|
||||
outcome: "waiting",
|
||||
note: "",
|
||||
always: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** An `approval.resolved` event, written into the step of the approval it answers. */
|
||||
export function answerApproval(step: ApprovalStep, resolved: Pick<StoredEvent, "data">): void {
|
||||
const d = resolved.data ?? {};
|
||||
step.outcome = d.decision === "approve" ? "approved" : "rejected";
|
||||
step.note = text(d.note);
|
||||
step.always = d.always === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The steps of the tool calls and approvals among `events`, in the order of the events, and how each approval was
|
||||
* answered. `keep` says which `tool.called` and `approval.requested` events are the caller's (the chat's turns, one
|
||||
* task); an answer is found by its approval's id, so one to an approval that was not kept changes nothing.
|
||||
*/
|
||||
export function stepsOf(events: readonly StoredEvent[], keep: (event: StoredEvent) => boolean): Array<ToolStep | ApprovalStep> {
|
||||
const steps: Array<ToolStep | ApprovalStep> = [];
|
||||
const approvals = new Map<string, ApprovalStep>();
|
||||
for (const event of events) {
|
||||
if (event.type === "tool.called" && keep(event)) steps.push(toolStep(event));
|
||||
else if (event.type === "approval.requested" && keep(event)) {
|
||||
const step = approvalStep(event);
|
||||
approvals.set(step.approvalId, step);
|
||||
steps.push(step);
|
||||
} else if (event.type === "approval.resolved") {
|
||||
const step = approvals.get(text(event.data?.approval_id));
|
||||
if (step) answerApproval(step, event);
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
* drawer. The log is the one record of both (the task row keeps only its state, result and spend). Both are read by
|
||||
* the task (`task_id`, indexed in the database), so what it costs does not grow with the age of the Dot.
|
||||
*/
|
||||
import type { InvisibleDotsClient } from "@invisible-dots/sdk";
|
||||
import { mergeEvents, readEventLog, readRecentEvents } from "./event-log";
|
||||
import { mergeEvents, readEventLog, readRecentEvents, type InvisibleDotsClient } from "@invisible-dots/sdk";
|
||||
import { stepsOf, type ApprovalStep, type ToolStep } from "./steps";
|
||||
import type { StoredEvent } from "./types";
|
||||
|
||||
function text(value: unknown): string {
|
||||
@@ -59,85 +59,41 @@ export async function readTaskProgress(client: Pick<InvisibleDotsClient, "events
|
||||
return newest === undefined ? null : progressOfEvent(newest);
|
||||
}
|
||||
|
||||
export type ApprovalOutcome = "waiting" | "approved" | "rejected";
|
||||
|
||||
export type StoryStep =
|
||||
| { kind: "created"; id: number; at: string; description: string; priority: number | null }
|
||||
| { kind: "started"; id: number; at: string }
|
||||
| { kind: "progress"; id: number; at: string; text: string }
|
||||
| { kind: "tool"; id: number; at: string; tool: string; target: string; decision: string; ok: boolean; interrupted: boolean; tty: boolean; durationMs: number }
|
||||
| { kind: "approval"; id: number; at: string; approvalId: string; tool: string; reason: string; outcome: ApprovalOutcome; note: string; always: boolean }
|
||||
| ToolStep
|
||||
| ApprovalStep
|
||||
| { kind: "completed"; id: number; at: string; summary: string }
|
||||
| { kind: "failed"; id: number; at: string; error: string }
|
||||
| { kind: "cancelled"; id: number; at: string };
|
||||
|
||||
/** A step of the task's own life (created, started, said something, ended); null for an event that is none. */
|
||||
function lifeStep(event: StoredEvent): StoryStep | null {
|
||||
const base = { id: event.id, at: event.created_at };
|
||||
const d = event.data ?? {};
|
||||
switch (event.type) {
|
||||
case "task.created":
|
||||
return { ...base, kind: "created", description: text(d.description), priority: typeof d.priority === "number" ? d.priority : null };
|
||||
case "task.started":
|
||||
return { ...base, kind: "started" };
|
||||
case "task.progress":
|
||||
return { ...base, kind: "progress", text: text(d.text) };
|
||||
case "task.completed":
|
||||
return { ...base, kind: "completed", summary: text(d.summary) };
|
||||
case "task.failed":
|
||||
return { ...base, kind: "failed", error: text(d.error) };
|
||||
case "task.cancelled":
|
||||
return { ...base, kind: "cancelled" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The steps of one task, in the order they happened. An approval is one step: asked, and then how it was answered. */
|
||||
export function storyOf(events: readonly StoredEvent[], taskId: string): StoryStep[] {
|
||||
const steps: StoryStep[] = [];
|
||||
const approvals = new Map<string, Extract<StoryStep, { kind: "approval" }>>();
|
||||
for (const event of events) {
|
||||
const base = { id: event.id, at: event.created_at };
|
||||
const d = event.data ?? {};
|
||||
if (taskIdOf(event) !== taskId) continue;
|
||||
switch (event.type) {
|
||||
case "task.created":
|
||||
steps.push({ ...base, kind: "created", description: text(d.description), priority: typeof d.priority === "number" ? d.priority : null });
|
||||
break;
|
||||
case "task.started":
|
||||
steps.push({ ...base, kind: "started" });
|
||||
break;
|
||||
case "task.progress":
|
||||
steps.push({ ...base, kind: "progress", text: text(d.text) });
|
||||
break;
|
||||
case "tool.called":
|
||||
steps.push({
|
||||
...base,
|
||||
kind: "tool",
|
||||
tool: text(d.tool),
|
||||
target: text(d.target),
|
||||
decision: text(d.decision),
|
||||
ok: d.ok === true,
|
||||
interrupted: d.interrupted === true,
|
||||
tty: d.tty === true,
|
||||
durationMs: typeof d.duration_ms === "number" ? d.duration_ms : 0,
|
||||
});
|
||||
break;
|
||||
case "approval.requested": {
|
||||
const step: Extract<StoryStep, { kind: "approval" }> = {
|
||||
...base,
|
||||
kind: "approval",
|
||||
approvalId: text(d.approval_id),
|
||||
tool: text(d.tool),
|
||||
reason: text(d.reason),
|
||||
outcome: "waiting",
|
||||
note: "",
|
||||
always: false,
|
||||
};
|
||||
approvals.set(step.approvalId, step);
|
||||
steps.push(step);
|
||||
break;
|
||||
}
|
||||
case "approval.resolved": {
|
||||
const step = approvals.get(text(d.approval_id));
|
||||
if (step) {
|
||||
step.outcome = d.decision === "approve" ? "approved" : "rejected";
|
||||
step.note = text(d.note);
|
||||
step.always = d.always === true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "task.completed":
|
||||
steps.push({ ...base, kind: "completed", summary: text(d.summary) });
|
||||
break;
|
||||
case "task.failed":
|
||||
steps.push({ ...base, kind: "failed", error: text(d.error) });
|
||||
break;
|
||||
case "task.cancelled":
|
||||
steps.push({ ...base, kind: "cancelled" });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
const own = events.filter((event) => taskIdOf(event) === taskId);
|
||||
const life = own.flatMap((event) => lifeStep(event) ?? []);
|
||||
return [...life, ...stepsOf(own, () => true)].sort((a, b) => a.id - b.id);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ describe("the Activity page", () => {
|
||||
expect(titles()).toEqual(["Assistant replied", "Ran a command", "You sent a message", "The engine started"]);
|
||||
|
||||
const [reply, tool, message, started] = rows();
|
||||
expect(within(tool!).getByText("ls -la | ok in 12 ms | exec [computer.exec] allow")).toBeTruthy();
|
||||
expect(within(tool!).getByText("ls -la | done in 12 ms | exec [computer.exec] allow")).toBeTruthy();
|
||||
expect(within(tool!).getByText("tool.called")).toBeTruthy();
|
||||
expect(within(message!).getByText("via Telegram")).toBeTruthy();
|
||||
expect(within(message!).getByText("find fares")).toBeTruthy();
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CHANNEL_KINDS, CHANNEL_STATUSES, type ChannelKind, type ChannelPeerReco
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
accountHref,
|
||||
accountLabel,
|
||||
CHANNEL_LABELS,
|
||||
CHANNEL_NOTES,
|
||||
channelState,
|
||||
@@ -48,14 +47,6 @@ describe("the chip of a channel", () => {
|
||||
});
|
||||
|
||||
describe("the account of a channel", () => {
|
||||
it("is a bot's @name and a number with its plus, and nothing before the channel reports one", () => {
|
||||
expect(accountLabel("telegram", "dot_helper_bot")).toBe("@dot_helper_bot");
|
||||
expect(accountLabel("whatsapp", "15550001111")).toBe("+15550001111");
|
||||
expect(accountLabel("whatsapp", "+15550001111")).toBe("+15550001111");
|
||||
expect(accountLabel("telegram", null)).toBeNull();
|
||||
expect(accountLabel("whatsapp", "")).toBeNull();
|
||||
});
|
||||
|
||||
it("links a Telegram bot's page, and nothing for a number or for a name that is not a bot's", () => {
|
||||
expect(accountHref("telegram", "dot_helper_bot")).toBe("https://t.me/dot_helper_bot");
|
||||
expect(accountHref("telegram", "../evil")).toBeNull();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { StoredEvent } from "@invisible-dots/shared/browser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { activityOf, buildThread, CLUSTER_AFTER, clusterSummary, groupActivity, lastStepSinceUser, mergeChatEvents, toolStateOf, unsettled, type ToolStep } from "../src/lib/chat-thread";
|
||||
import { activityOf, buildThread, CLUSTER_AFTER, clusterSummary, groupActivity, lastStepSinceUser, mergeChatEvents, unsettled } from "../src/lib/chat-thread";
|
||||
import { toolStateOf, type ToolStep } from "../src/lib/steps";
|
||||
import type { ChatMessage } from "../src/lib/types";
|
||||
|
||||
let n = 0;
|
||||
@@ -58,6 +59,8 @@ describe("the activity of the chat", () => {
|
||||
expect(waiting).toMatchObject({ kind: "approval", outcome: "waiting", label: "Ran a command", reason: "to list files" });
|
||||
expect(activityOf([asked, event("approval.resolved", { approval_id: "a1", decision: "approve" })])[0]).toMatchObject({ outcome: "approved" });
|
||||
expect(activityOf([asked, event("approval.resolved", { approval_id: "a1", decision: "reject" })])[0]).toMatchObject({ outcome: "rejected" });
|
||||
// The note the person wrote with the answer is kept, as a task's story keeps it.
|
||||
expect(activityOf([asked, event("approval.resolved", { approval_id: "a1", decision: "reject", note: "not now" })])[0]).toMatchObject({ note: "not now" });
|
||||
// An answer to somebody else's approval changes nothing.
|
||||
expect(activityOf([asked, event("approval.resolved", { approval_id: "other", decision: "reject" })])[0]).toMatchObject({ outcome: "waiting" });
|
||||
});
|
||||
|
||||
@@ -354,9 +354,11 @@ describe("what the Dot did between its messages", () => {
|
||||
plane.store("d1", "approval.requested", ask("a1"));
|
||||
await renderChat();
|
||||
await screen.findByRole("article", { name: "Wants to run a command" });
|
||||
act(() => plane.push("d1", "approval.resolved", { approval_id: "a1", decision: "approve", always: true }));
|
||||
act(() => plane.push("d1", "approval.resolved", { approval_id: "a1", decision: "approve", always: true, note: "fine from now on" }));
|
||||
await waitFor(() => expect(screen.getByText(/^Allowed for good:/)).toBeTruthy());
|
||||
expect(screen.queryByRole("article", { name: "Wants to run a command" })).toBeNull();
|
||||
// The note sent with the answer stays on the receipt, as on a task's story.
|
||||
expect(screen.getByText("Note: fine from now on")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("follows the Dot live: a call that arrives shows at once, and an answer arrives from the log", async () => {
|
||||
|
||||
@@ -41,32 +41,34 @@ describe("viewEvent", () => {
|
||||
expect(entry.detail).toBe("browser_identity_delete [browser.identity.delete] - cleanup");
|
||||
});
|
||||
|
||||
it("marks denied and failed tool calls as errors and successful ones as neutral", () => {
|
||||
it("marks a failed tool call as an error, a denied one as a warning (it never ran) and a successful one as neutral", () => {
|
||||
const ok = viewEvent(
|
||||
event(4, "tool.called", { tool: "computer_exec", permission: "computer.exec", decision: "allow", ok: true, duration_ms: 41.6 }),
|
||||
);
|
||||
expect(ok).toMatchObject({ title: "Called computer_exec", detail: "ok in 42 ms | computer_exec [computer.exec] allow", tone: "neutral" });
|
||||
expect(ok).toMatchObject({ title: "Called computer_exec", detail: "done in 42 ms | computer_exec [computer.exec] allow", tone: "neutral" });
|
||||
const denied = viewEvent(
|
||||
event(5, "tool.called", { tool: "nope", permission: "", decision: "deny", ok: false, duration_ms: 0 }),
|
||||
);
|
||||
expect(denied).toMatchObject({ detail: "denied in 0 ms | nope deny", tone: "error" });
|
||||
expect(denied).toMatchObject({ detail: "denied in 0 ms | nope deny", tone: "warn" });
|
||||
const failed = viewEvent(event(6, "tool.called", { tool: "exec", permission: "computer.exec", decision: "allow", ok: false, duration_ms: 3 }));
|
||||
expect(failed).toMatchObject({ detail: "failed in 3 ms | exec [computer.exec] allow", tone: "error" });
|
||||
});
|
||||
|
||||
it("says what a tool call acted on and what policy decided, in the words of the tool", () => {
|
||||
const call = { permission: "computer.exec", decision: "allow", ok: true, duration_ms: 1200 };
|
||||
expect(viewEvent(event(20, "tool.called", { tool: "exec", target: "ls -la /home/dot", ...call }))).toMatchObject({
|
||||
title: "Ran a command",
|
||||
detail: "ls -la /home/dot | ok in 1200 ms | exec [computer.exec] allow",
|
||||
detail: "ls -la /home/dot | done in 1.2 s | exec [computer.exec] allow",
|
||||
tool: "exec",
|
||||
family: "tools",
|
||||
});
|
||||
expect(viewEvent(event(23, "tool.called", { tool: "exec", target: "python3", permission: "computer.exec", decision: "allow", ok: true, duration_ms: 50, tty: true }))).toMatchObject({
|
||||
title: "Started a terminal session",
|
||||
detail: "python3 | ok in 50 ms | exec [computer.exec] allow",
|
||||
detail: "python3 | done in 50 ms | exec [computer.exec] allow",
|
||||
});
|
||||
// A call that waited for the person says so in its decision; one cut off by a restart is neither a success nor a failure of the call.
|
||||
expect(viewEvent(event(21, "tool.called", { tool: "write_file", permission: "files.write", decision: "ask", ok: true, duration_ms: 3 })).detail).toBe(
|
||||
"ok in 3 ms | write_file [files.write] ask",
|
||||
"done in 3 ms | write_file [files.write] ask",
|
||||
);
|
||||
expect(viewEvent(event(22, "tool.called", { tool: "exec", target: "sleep 99", permission: "computer.exec", decision: "allow", ok: false, duration_ms: 0, interrupted: true }))).toMatchObject({
|
||||
detail: "sleep 99 | interrupted in 0 ms | exec [computer.exec] allow",
|
||||
@@ -89,8 +91,8 @@ describe("viewEvent", () => {
|
||||
});
|
||||
|
||||
it("says when an approval was answered for good, and the priority a task was created with", () => {
|
||||
expect(viewEvent(event(29, "approval.resolved", { approval_id: "apr_2", decision: "approve", always: true }, "host"))).toMatchObject({ title: "Approved, and always allowed", tone: "ok" });
|
||||
expect(viewEvent(event(30, "approval.resolved", { approval_id: "apr_3", decision: "approve" }, "host")).title).toBe("Approved");
|
||||
expect(viewEvent(event(29, "approval.resolved", { approval_id: "apr_2", decision: "approve", always: true }, "host"))).toMatchObject({ title: "Allowed for good", tone: "ok" });
|
||||
expect(viewEvent(event(30, "approval.resolved", { approval_id: "apr_3", decision: "approve" }, "host")).title).toBe("Allowed");
|
||||
expect(viewEvent(event(31, "task.created", { task_id: "t", description: "look", priority: 100 }, "host")).detail).toBe("look (priority 100)");
|
||||
expect(viewEvent(event(32, "task.created", { task_id: "t", description: "look" }, "host")).detail).toBe("look");
|
||||
});
|
||||
@@ -137,7 +139,7 @@ describe("viewEvent", () => {
|
||||
source: "host",
|
||||
});
|
||||
expect(viewEvent(event(8, "approval.resolved", { approval_id: "apr_1", decision: "reject", note: "no" }, "host")))
|
||||
.toMatchObject({ title: "Rejected", detail: "apr_1 - no", tone: "warn" });
|
||||
.toMatchObject({ title: "Denied", detail: "apr_1 - no", tone: "warn" });
|
||||
});
|
||||
|
||||
it("describes the channel events by their kind and status", () => {
|
||||
|
||||
@@ -312,11 +312,13 @@ describe("the keyboard", () => {
|
||||
];
|
||||
});
|
||||
|
||||
const approvalList = () => screen.getByRole("list", { name: "Approvals waiting for your answer" });
|
||||
const selected = () => screen.queryAllByRole("article").filter((card) => card.getAttribute("aria-current") === "true").map((card) => card.getAttribute("data-approval-id"));
|
||||
|
||||
it("selects the first card, moves with j and k and stops at the ends", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
expect(selected()).toEqual(["first"]);
|
||||
await userEvent.keyboard("j");
|
||||
expect(selected()).toEqual(["second"]);
|
||||
@@ -331,6 +333,7 @@ describe("the keyboard", () => {
|
||||
it("says which card is selected when j or k moves, since the selection is drawn and not focused", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
const spoken = () => screen.getAllByRole("status").map((status) => status.textContent).filter((text) => /\d of \d/.test(text ?? ""));
|
||||
expect(spoken()).toEqual([]);
|
||||
await userEvent.keyboard("j");
|
||||
@@ -344,6 +347,7 @@ describe("the keyboard", () => {
|
||||
it("allows the selected card once with a, and denies it with d, and moves nowhere by itself", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
await userEvent.keyboard("a");
|
||||
await waitFor(() => expect(plane.answers).toEqual([{ id: "first", decision: "approve", body: {} }]));
|
||||
await waitFor(() => expect(within(cards()[0]!).getByRole("status").textContent).toBe("Allowed"));
|
||||
@@ -363,12 +367,14 @@ describe("the keyboard", () => {
|
||||
it("sends the note written for the card with an answer made by key", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
await userEvent.click(within(cards()[0]!).getByRole("button", { name: "Add a note for the Dot" }));
|
||||
const field = within(cards()[0]!).getByLabelText("Note for the Dot (optional)");
|
||||
await userEvent.type(field, "go ahead");
|
||||
// The keys typed in the field are text, not shortcuts.
|
||||
expect(plane.answers).toEqual([]);
|
||||
(field as HTMLElement).blur();
|
||||
// Back to the list (a click on it): the keys are its own again.
|
||||
await userEvent.click(approvalList());
|
||||
await userEvent.keyboard("a");
|
||||
await waitFor(() => expect(plane.answers).toEqual([{ id: "first", decision: "approve", body: { note: "go ahead" } }]));
|
||||
});
|
||||
@@ -376,6 +382,7 @@ describe("the keyboard", () => {
|
||||
it("does not allow a destructive card by key: it moves the focus to Allow once and says to confirm", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
await userEvent.keyboard("jj");
|
||||
expect(selected()).toEqual(["third"]);
|
||||
await userEvent.keyboard("a");
|
||||
@@ -391,6 +398,7 @@ describe("the keyboard", () => {
|
||||
it("denies a destructive card at once, since a denial can do no harm", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
await userEvent.keyboard("jj");
|
||||
await userEvent.keyboard("d");
|
||||
await waitFor(() => expect(plane.answers).toEqual([{ id: "third", decision: "reject", body: {} }]));
|
||||
@@ -399,6 +407,7 @@ describe("the keyboard", () => {
|
||||
it("leaves the keys to a dialog that is open", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
await userEvent.keyboard("jj");
|
||||
await userEvent.click(within(cards()[2]!).getByRole("button", { name: "Always allow" }));
|
||||
await screen.findByRole("dialog");
|
||||
@@ -410,6 +419,7 @@ describe("the keyboard", () => {
|
||||
it("ignores a key with a modifier, and does nothing when no card waits", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
await userEvent.click(approvalList());
|
||||
await userEvent.keyboard("{Control>}a{/Control}");
|
||||
await userEvent.keyboard("{Alt>}d{/Alt}");
|
||||
expect(plane.answers).toEqual([]);
|
||||
@@ -422,9 +432,35 @@ describe("the keyboard", () => {
|
||||
expect(plane.answers).toEqual([]);
|
||||
});
|
||||
|
||||
it("says in words which keys there are", async () => {
|
||||
it("says in words which keys there are and where they act, and the list says it too", async () => {
|
||||
await renderInbox();
|
||||
expect(screen.getByText(/Keys:/).textContent).toContain("allows the selected one once");
|
||||
expect(screen.getByText(/Keys,/).textContent).toContain("once the list of approvals has the focus");
|
||||
expect(screen.getByText(/Keys,/).textContent).toContain("allows the selected one");
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
expect(approvalList().getAttribute("aria-describedby")).toBe(screen.getByText(/Keys,/).id);
|
||||
});
|
||||
|
||||
it("answers nothing for a key typed while the focus is not in the list: a single key is not the page's (WCAG 2.1.4)", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
await userEvent.keyboard("jdad");
|
||||
expect(plane.answers).toEqual([]);
|
||||
expect(selected()).toEqual(["first"]);
|
||||
// Tab reaches the list, and then the keys are its own.
|
||||
await userEvent.tab();
|
||||
while (document.activeElement !== approvalList()) await userEvent.tab();
|
||||
await userEvent.keyboard("d");
|
||||
await waitFor(() => expect(plane.answers).toEqual([{ id: "first", decision: "reject", body: {} }]));
|
||||
});
|
||||
|
||||
it("selects the card the focus enters, so the keys act on the card the person is on", async () => {
|
||||
await renderInbox();
|
||||
await waitFor(() => expect(cards()).toHaveLength(3));
|
||||
within(cards()[1]!).getByRole("button", { name: "Deny" }).focus();
|
||||
await waitFor(() => expect(selected()).toEqual(["second"]));
|
||||
await userEvent.keyboard("a");
|
||||
await waitFor(() => expect(plane.answers).toEqual([{ id: "second", decision: "approve", body: {} }]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -139,14 +139,14 @@ describe("storyOf", () => {
|
||||
expect(story.map((s) => s.kind)).toEqual(["created", "started", "progress", "tool", "approval", "tool", "tool", "completed"]);
|
||||
expect(story[0]).toMatchObject({ kind: "created", description: "Write it", priority: 10 });
|
||||
expect(story[2]).toMatchObject({ kind: "progress", text: "Reading the sources" });
|
||||
expect(story[3]).toMatchObject({ kind: "tool", tool: "exec", target: "ls -la", ok: true, durationMs: 1200, interrupted: false });
|
||||
expect(story[3]).toMatchObject({ kind: "tool", tool: "exec", label: "Ran a command", target: "ls -la", state: "ok", durationMs: 1200 });
|
||||
expect(story[7]).toMatchObject({ kind: "completed", summary: "Done: **report.md**" });
|
||||
});
|
||||
|
||||
it("keeps whether a call started a terminal session", () => {
|
||||
it("says a call that started a terminal session as that", () => {
|
||||
const [step] = storyOf([event("tool.called", { task_id: "t9", tool: "exec", decision: "allow", ok: true, duration_ms: 5, target: "python3", tty: true })], "t9");
|
||||
expect(step).toMatchObject({ kind: "tool", tty: true });
|
||||
expect(storyOf(events, "t1")[3]).toMatchObject({ kind: "tool", tty: false });
|
||||
expect(step).toMatchObject({ kind: "tool", label: "Started a terminal session" });
|
||||
expect(storyOf(events, "t1")[3]).toMatchObject({ kind: "tool", label: "Ran a command" });
|
||||
});
|
||||
|
||||
it("folds an approval and its answer into one step, found by the approval's id", () => {
|
||||
@@ -158,8 +158,8 @@ describe("storyOf", () => {
|
||||
|
||||
it("keeps a refused call and an interrupted one for what they are", () => {
|
||||
const tools = storyOf(events, "t1").filter((s) => s.kind === "tool");
|
||||
expect(tools[1]).toMatchObject({ tool: "browser_navigate", decision: "deny", ok: false });
|
||||
expect(tools[2]).toMatchObject({ interrupted: true, ok: false });
|
||||
expect(tools[1]).toMatchObject({ tool: "browser_navigate", state: "denied" });
|
||||
expect(tools[2]).toMatchObject({ tool: "exec", state: "interrupted" });
|
||||
});
|
||||
|
||||
it("ends a failed task with its error and a cancelled one with that", () => {
|
||||
|
||||
@@ -445,9 +445,9 @@ describe("the task drawer", () => {
|
||||
await waitFor(() => expect(within(drawer).getByText("Collecting the figures")).toBeTruthy());
|
||||
expect(within(drawer).queryByText("Another task's line")).toBeNull();
|
||||
expect(within(drawer).getByText("python3 collect.py")).toBeTruthy();
|
||||
expect(within(drawer).getByText(/ok, 2s/)).toBeTruthy();
|
||||
expect(within(drawer).getByText(/done, 2.5 s/)).toBeTruthy();
|
||||
expect(within(drawer).getByText(/Allowed:/)).toBeTruthy();
|
||||
// The engine's tool names are in the words of the chat (lib/events/tool-labels.ts), the name being the line's title.
|
||||
// The engine's tool names are in the words of the chat (TOOL_LABELS of packages/shared), the name being the line's title.
|
||||
expect(within(drawer).getByText("Ran a command").getAttribute("title")).toBe("exec");
|
||||
expect(within(drawer).getByText("wrote a file").getAttribute("title")).toBe("write_file");
|
||||
expect(within(drawer).getByText("$0.73")).toBeTruthy();
|
||||
|
||||
@@ -35,4 +35,19 @@ describe("useResource", () => {
|
||||
await act(async () => resolvers[0]!("older"));
|
||||
expect(result.current.data).toBe("newer");
|
||||
});
|
||||
|
||||
it("returns the same object until one of its fields changes, so what is built from it is not rebuilt for nothing", async () => {
|
||||
let finish: (value: string) => void = () => {};
|
||||
const { result, rerender } = renderHook(() => useResource(() => new Promise<string>((resolve) => (finish = resolve)), "one"));
|
||||
const loading = result.current;
|
||||
rerender();
|
||||
expect(result.current).toBe(loading);
|
||||
await act(async () => finish("loaded"));
|
||||
await waitFor(() => expect(result.current.data).toBe("loaded"));
|
||||
const loaded = result.current;
|
||||
expect(loaded).not.toBe(loading);
|
||||
rerender();
|
||||
rerender();
|
||||
expect(result.current).toBe(loaded);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// The root config leaves apps/web out; this one runs the web client's tests on their own.
|
||||
export default defineConfig({
|
||||
oxc: { jsx: { runtime: "automatic" } },
|
||||
test: {
|
||||
include: ["test/**/*.test.{ts,tsx}"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
+33
-27
@@ -2264,10 +2264,10 @@ The pages are React with Tailwind CSS 4. `src/app/tokens.css` is the one place
|
||||
that holds a color, a radius or a typeface (a light and a dark set, each pair
|
||||
checked for WCAG AA contrast by a test), `globals.css` maps it into Tailwind,
|
||||
and the primitives in `src/components/ui/` are shadcn/ui source, copied in
|
||||
(`THIRD_PARTY_NOTICES.md`). Every signed-in page sits in one frame: the rail
|
||||
(`THIRD_PARTY_NOTICES.md`). Every page sits in one frame: the rail
|
||||
(the Dots with a ring around each avatar that says what it is doing, the Inbox
|
||||
with what needs the person, the state of the API and of the live stream, the theme,
|
||||
sign out) and, for a Dot, its header and tab bar. One live stream serves all of
|
||||
with what needs the person, the state of the API and of the live stream, the theme)
|
||||
and, for a Dot, its header and tab bar. One live stream serves all of
|
||||
it; `lib/attention.ts` is the single owner of what needs the person, and the
|
||||
rail badges, the avatar ring, the tab title and the favicon all read it. There
|
||||
is no other stylesheet: every screen is utilities over the tokens. The browser tests (`apps/web/e2e`, Playwright) start the
|
||||
@@ -2275,8 +2275,9 @@ real control plane in-process over the fake VM layer and the built web client
|
||||
against it (`e2e/harness.ts`), so a test drives what a Dot's computer does.
|
||||
|
||||
Home (`/`) is a card per Dot: its avatar ring and name, the state with the
|
||||
recorded reason beside an ERROR, the model, what it spent today, the approvals
|
||||
that wait, Open chat and the computer's power menu. Each card's spend pill is
|
||||
recorded reason beside an ERROR, the model, what it spent today, its last
|
||||
activity (the newest chat, task, tool, approval or browser event, one
|
||||
`order=desc&limit=1` read and then the stream), the approvals that wait, Open chat and the computer's power menu. Each card's spend pill is
|
||||
scoped to its own Dot, so a message of one Dot re-reads only that Dot's usage.
|
||||
Search appears above six Dots, and with no Dot the page invites the first, and,
|
||||
while this computer is not ready for a Dot, shows the setup checklist under the
|
||||
@@ -2286,20 +2287,18 @@ an elevated terminal and an image build takes long, so they stay commands the
|
||||
person runs), and the key field when no key is stored. The checks run again
|
||||
when the person returns to the window while something is missing, and the
|
||||
checklist stays, turned green, once the last thing is done. A ready host shows
|
||||
none of it. The card has no "last activity": no route returns the newest event
|
||||
yet.
|
||||
none of it.
|
||||
|
||||
Settings (`/settings`, in the rail) are the host's, not a Dot's: the same checks
|
||||
(`useHostChecks` is the one list the checklist, this page and the create form's
|
||||
preflight draw), the OpenRouter key (a write-only field over
|
||||
`PUT /api/secrets/openrouter`; whether one is stored comes from the health
|
||||
answer, which the shell shares and asks again after a save, so the rail agrees
|
||||
at once; the answer says how many running Dots got it), the theme, Sign out
|
||||
(`DELETE /session`) and About: the version, the database kind, the data
|
||||
at once; the answer says how many running Dots got it), the theme and About:
|
||||
the version, the database kind, the data
|
||||
directory and the logs directory, which `GET /api/health` reports for the
|
||||
purpose (`database_kind`, `data_dir`, `logs_dir`), and `invisible-dots logs
|
||||
<dot>` for a Dot's own logs. The login page is a card with the token field
|
||||
focused; it is outside the shell and asks the API nothing.
|
||||
<dot>` for a Dot's own logs.
|
||||
|
||||
Create a Dot (`/new`) is a form in three steps (Identity, Brain, Computer and
|
||||
safety) with sliders inside `CONFIG_BOUNDS`, the idle timeout, the Careful,
|
||||
@@ -2354,8 +2353,8 @@ list with its state, its result as markdown or the reason it failed, and its
|
||||
story. The task route knows a task by its id alone, so a task of another Dot
|
||||
opened under this Dot's address is shown as missing. The story and the progress lines come from the Dot's event log, which
|
||||
the page reads from its start, asking only for the event types of a task's
|
||||
story (`lib/event-log.ts` is the one function that pages through the route's
|
||||
`types` filter) only once a task is running
|
||||
story (`readEventLog` of the SDK, `packages/sdk/src/event-log.ts`, is the one
|
||||
function that pages through the route's `types` filter) only once a task is running
|
||||
or open, and then keeps current from the live stream. The newest progress line of a running task is also under the name in
|
||||
the Dot header. A task that waits for an answer shows its approval as a card on its own card and in its
|
||||
drawer, answerable there (see the Inbox below).
|
||||
@@ -2366,11 +2365,14 @@ the `origin` of the message), the Dot's as markdown (no raw HTML, links open in
|
||||
referrer, an image is shown as a link to it, a fenced block has a copy button).
|
||||
Between them it shows what the Dot did to answer, read from the same event
|
||||
log: each `tool.called` that names no task as one quiet line (the words of
|
||||
`lib/events/tool-labels.ts`, which a test keeps equal to the engine's tool
|
||||
table, then the call's `target`, and how it ended when that was not well; more
|
||||
`TOOL_LABELS` in `packages/shared/src/labels.ts`, which a test keeps equal
|
||||
to the engine's tool table, then the call's `target`, and how it ended when that was not well; more
|
||||
than three in a row fold into one line that opens), and each `approval.requested` that names no task, where it
|
||||
was asked, as the approval card while it waits (answerable there) and as a receipt
|
||||
line once answered ("Allowed for good" when the answer was "always"). The messages route and the log are two
|
||||
line once answered ("Allowed for good" when the answer was "always", with the
|
||||
person's note). A call and an approval are read into a step one way for the
|
||||
chat, a task's story and the Activity page, with one set of words for how a
|
||||
call ended and one format for how long it took (`lib/steps.ts`). The messages route and the log are two
|
||||
views of one log, so a step is placed between two messages by event id
|
||||
(`lib/chat-thread.ts`). A message shows as soon as it is sent and is replaced by
|
||||
the logged one, which `POST .../messages` names by its `event_id`; one that was
|
||||
@@ -2397,8 +2399,8 @@ 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.
|
||||
`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 (`lib/events/tool-labels.ts`), the
|
||||
permission and its risk (`PERMISSION_INFO`), the Dot's reason, and the call's
|
||||
wants to do from the engine's tool table (`TOOL_LABELS`), the
|
||||
permission and its risk (`PERMISSION_INFO`, `permissionPhrase`), the Dot's reason, and the call's
|
||||
arguments in the form that reads best: a command, a diff for `write_file`,
|
||||
`edit_file` and `apply_patch`, an address, a schedule, with the raw arguments
|
||||
under Details (a proxy's password is never shown). It is destructive for a
|
||||
@@ -2409,10 +2411,12 @@ it covers (`GET /api/dots/<id>/tools`, when the computer answers), and then
|
||||
`POST /api/approvals/<id>/approve` with `always: true`, which changes the Dot's
|
||||
config (section 9.6). The host answers 409 `already_resolved` to the second
|
||||
answer; the card says the approval was answered somewhere else. An answered card
|
||||
stays in place as a receipt until the person leaves the page. Keys: `j` and `k`
|
||||
move between the cards, `a` allows the selected one once and `d` denies it; a
|
||||
destructive card is not allowed by a key, `a` moves the focus to its Allow once
|
||||
button, whose press is the confirmation. History lists every approval that is no
|
||||
stays in place as a receipt until the person leaves the page. Keys, while the
|
||||
focus is in the list of the cards (WCAG 2.1.4: a single key is never the whole
|
||||
page's): `j` and `k` move between the cards, `a` allows the selected one once and
|
||||
`d` denies it, and a card the focus enters is the one selected; a destructive
|
||||
card is not allowed by a key, `a` moves the focus to its Allow once button,
|
||||
whose press is the confirmation. History lists every approval that is no
|
||||
longer waiting, the last answered first: it asks `GET /api/approvals` for the
|
||||
answered statuses with `order=desc` and `limit=50`, and "Show older answers" asks
|
||||
for the next page with `before` the last id it holds, so the newest answer is
|
||||
@@ -2544,8 +2548,10 @@ and never given credentials; the others with `add`. The hub owns every policy:
|
||||
that fails is retried with backoff (a channel's `retry_after` is honoured); one
|
||||
the channel refuses for good (the person blocked the bot) is dropped.
|
||||
- **Approvals.** When the Dot asks (`approval.requested`) and the binding's
|
||||
`approvals` setting is on, every owner's chat gets a prompt: the tool, its
|
||||
permission, the reason and the arguments, each cut to 300 characters (the
|
||||
`approvals` setting is on, every owner's chat gets a prompt: what the Dot wants
|
||||
to do and the permission with its risk, in the words the web client's approval
|
||||
card uses (`packages/shared/src/labels.ts`), the reason and the
|
||||
arguments, each cut to 300 characters (the
|
||||
arguments can hold private data, and a chat is read by a third party; with
|
||||
`show_arguments` off the prompt leaves them out), with an
|
||||
Approve and a Reject button. Only a paired owner, in their private chat, can
|
||||
@@ -2977,9 +2983,9 @@ command is also how a run that stopped is continued. In order:
|
||||
|
||||
A step that fails ends the run with exit code 1 and the reason, and nothing
|
||||
after it is run; the message ends with the command to run again. At the end it
|
||||
prints how to start the server, the address of the web client
|
||||
(`INVISIBLE_DOTS_WEB_LISTEN` or its default) and the file whose first line is
|
||||
the token to sign in with (created when the server first starts). It does not
|
||||
prints how to start the server and the address of the web client
|
||||
(`INVISIBLE_DOTS_WEB_LISTEN` or its default), which has no login: the web
|
||||
server adds the API token itself (section 9.7). It does not
|
||||
start the server itself: `server` runs in the foreground until Ctrl+C. Ctrl+C
|
||||
asks `image build` to stop (it kills its builder VM) and ends the run between
|
||||
steps; a second Ctrl+C exits at once.
|
||||
|
||||
+7
-7
@@ -288,9 +288,9 @@ that failed in the last 24 hours, and a channel that has to be linked again. An
|
||||
approval says what the Dot wants to do, under which permission and why, shows a
|
||||
command, a diff of a file change, an address or a schedule in the form that
|
||||
reads best, and answers with Allow once, Always allow or Deny with a note. With
|
||||
the keyboard, `j` and `k` move between the cards, `a` allows the selected one
|
||||
once and `d` denies it; a command or a deletion is not allowed by a key press
|
||||
alone.
|
||||
the keyboard, once the list of approvals has the focus (click it or Tab to it),
|
||||
`j` and `k` move between the cards, `a` allows the selected one once and `d`
|
||||
denies it; a command or a deletion is not allowed by a key press alone.
|
||||
|
||||
**A Dot's page** has seven tabs:
|
||||
|
||||
@@ -328,8 +328,9 @@ while you have stopped it.
|
||||
| `invisible-dots message <dot> <text>` | a message in its one persistent conversation |
|
||||
| `invisible-dots task <dot> <text> [--priority N] [--at ISO-8601]` | a queued task; a higher priority runs first, `--at` sets the earliest start |
|
||||
| `invisible-dots tasks <dot>` | its tasks and their status |
|
||||
| `invisible-dots cancel <task-id>` | stop a task that has not finished |
|
||||
| `invisible-dots logs <dot> [--tail N] [--no-follow]` | its events, then new ones as they come |
|
||||
| `invisible-dots approvals [--all]` | pending approvals (all of them with `--all`) |
|
||||
| `invisible-dots approvals [--all]` | pending approvals (all of them, the newest first, with `--all`) |
|
||||
| `invisible-dots approve <id> [--always] [--note text]` | let that one call run; `--always` also allows its permission for the Dot from now on |
|
||||
| `invisible-dots reject <id> [--note text]` | refuse it; the model is told no |
|
||||
| `invisible-dots computer <dot> start\|stop\|reboot` | power its computer on or off by hand |
|
||||
@@ -339,11 +340,10 @@ while you have stopped it.
|
||||
| `invisible-dots channel list [--dot <dot>]` | linked chats and who is paired |
|
||||
| `invisible-dots channel remove telegram\|whatsapp --dot <dot>` | unlink a chat; its token or keys and paired people are deleted |
|
||||
| `invisible-dots secret openrouter --dot <dot>` | a key for one Dot instead of the global one |
|
||||
| `invisible-dots update <dot> <file.yaml>` | replace its configuration; refused if it changed since the command read it |
|
||||
| `invisible-dots delete <dot> --yes` | delete it: its computer, its disk and its channels; without `--yes` it only says what would go |
|
||||
|
||||
`<dot>` is a Dot's name or id, and `--json` gives machine-readable output.
|
||||
Editing a Dot's configuration and deleting a Dot are done in the web UI (its
|
||||
settings tab) or through the API (`PATCH` and `DELETE /api/dots/:id`); the
|
||||
command line has no command for either yet.
|
||||
|
||||
## Approvals
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* What an approval prompt says in a chat, and what it says once the approval is settled. One owner of the
|
||||
* wording, so every channel shows the same thing. Plain text: a channel adds its own means of answering.
|
||||
* wording, so every channel shows the same thing, and the question in the words of the web client's approval card
|
||||
* (`toolLabel`, `permissionPhrase`). Plain text: a channel adds its own means of answering.
|
||||
*
|
||||
* The prompt goes through a third party that can read it (a chat is not end-to-end encrypted), so the arguments of the
|
||||
* tool, which can hold private data, are cut to `APPROVAL_PROMPT_TEXT_MAX` characters. A secret that has no place in an approval
|
||||
@@ -9,7 +10,7 @@
|
||||
* is how a model that was talked into it sends data out, so it is what the approver has to see (a person who does not want
|
||||
* it in a chat turns `show_arguments` off).
|
||||
*/
|
||||
import { APPROVAL_PROMPT_TEXT_MAX, type ApprovalRecord } from "@invisible-dots/shared";
|
||||
import { APPROVAL_PROMPT_TEXT_MAX, permissionPhrase, toolLabel, type ApprovalRecord } from "@invisible-dots/shared";
|
||||
|
||||
const MORE = "...";
|
||||
|
||||
@@ -19,7 +20,7 @@ function cut(text: string, max: number): string {
|
||||
}
|
||||
|
||||
export function approvalPromptText(approval: ApprovalRecord, showArguments: boolean): string {
|
||||
const lines = [`The Dot asks to use ${approval.tool} (permission ${approval.permission}).`];
|
||||
const lines = [`The Dot wants to ${toolLabel(approval.tool).ask}.`, `Permission: ${permissionPhrase(approval.permission)}`];
|
||||
const reason = cut(approval.reason, APPROVAL_PROMPT_TEXT_MAX);
|
||||
if (reason !== "") lines.push(`Reason: ${reason}`);
|
||||
const args = cut(JSON.stringify(approval.arguments), APPROVAL_PROMPT_TEXT_MAX);
|
||||
|
||||
@@ -11,7 +11,7 @@ function approval(overrides: Partial<ApprovalRecord> = {}): ApprovalRecord {
|
||||
dot_id: "dot_1",
|
||||
task_id: null,
|
||||
tool: "exec",
|
||||
permission: "exec.run",
|
||||
permission: "computer.exec",
|
||||
arguments: { command: "ls -la" },
|
||||
reason: "the tool needs approval",
|
||||
status: "pending",
|
||||
@@ -54,15 +54,19 @@ describe("the data of an approval button", () => {
|
||||
});
|
||||
|
||||
describe("what an approval prompt says", () => {
|
||||
it("names the tool, the permission, the reason and the arguments", () => {
|
||||
it("says what the Dot wants to do and the permission with its risk, in the web client's words, then the reason and the arguments", () => {
|
||||
expect(approvalPromptText(approval(), true)).toBe(
|
||||
['The Dot asks to use exec (permission exec.run).', "Reason: the tool needs approval", 'Arguments: {"command":"ls -la"}'].join("\n"),
|
||||
["The Dot wants to run a command.", "Permission: Run commands (high risk)", "Reason: the tool needs approval", 'Arguments: {"command":"ls -la"}'].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("names a tool or a permission it has no words for as it is", () => {
|
||||
expect(approvalPromptText(approval({ tool: "rm_rf", permission: "exec.run", arguments: {}, reason: "" }), true)).toBe(["The Dot wants to call rm_rf.", "Permission: exec.run"].join("\n"));
|
||||
});
|
||||
|
||||
it("cuts the arguments and the reason to the limit, and shows that they were cut", () => {
|
||||
const text = approvalPromptText(approval({ arguments: { command: "x".repeat(2000) }, reason: "r".repeat(2000) }), true);
|
||||
const [, reason, args] = text.split("\n") as [string, string, string];
|
||||
const [, , reason, args] = text.split("\n") as [string, string, string, string];
|
||||
expect(reason.slice("Reason: ".length)).toHaveLength(APPROVAL_PROMPT_TEXT_MAX);
|
||||
expect(args.slice("Arguments: ".length)).toHaveLength(APPROVAL_PROMPT_TEXT_MAX);
|
||||
expect(reason.endsWith("...")).toBe(true);
|
||||
@@ -73,16 +77,16 @@ describe("what an approval prompt says", () => {
|
||||
|
||||
it("keeps a prompt on one line per fact: line breaks and runs of spaces in the arguments collapse", () => {
|
||||
const text = approvalPromptText(approval({ arguments: { command: "a\n\n b" }, reason: "line one\nline two" }), true);
|
||||
expect(text.split("\n")).toEqual(["The Dot asks to use exec (permission exec.run).", "Reason: line one line two", 'Arguments: {"command":"a\\n\\n b"}']);
|
||||
expect(text.split("\n")).toEqual(["The Dot wants to run a command.", "Permission: Run commands (high risk)", "Reason: line one line two", 'Arguments: {"command":"a\\n\\n b"}']);
|
||||
});
|
||||
|
||||
it("leaves out what is empty", () => {
|
||||
expect(approvalPromptText(approval({ arguments: {}, reason: "" }), true)).toBe("The Dot asks to use exec (permission exec.run).");
|
||||
expect(approvalPromptText(approval({ arguments: {}, reason: "" }), true)).toBe("The Dot wants to run a command.\nPermission: Run commands (high risk)");
|
||||
});
|
||||
|
||||
it("leaves the arguments out, in the prompt and in the outcome, when the channel is set not to show them", () => {
|
||||
const text = approvalPromptText(approval(), false);
|
||||
expect(text).toBe(["The Dot asks to use exec (permission exec.run).", "Reason: the tool needs approval"].join("\n"));
|
||||
expect(text).toBe(["The Dot wants to run a command.", "Permission: Run commands (high risk)", "Reason: the tool needs approval"].join("\n"));
|
||||
expect(text).not.toContain("ls -la");
|
||||
expect(approvalOutcomeText(approval({ status: "approved" }), false)).toBe(`${text}\n\nApproved.`);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ describe.each(testAdapters())("approvals over a channel, with the real Scheduler
|
||||
["20", id],
|
||||
]);
|
||||
expect(channel.prompts[0]!.text).toBe(
|
||||
["The Dot asks to use exec (permission browser.identity.delete).", "Reason: the tool needs approval", 'Arguments: {"identity_id":"shop-abc123"}'].join("\n"),
|
||||
["The Dot wants to run a command.", "Permission: Delete browser identities (high risk)", "Reason: the tool needs approval", 'Arguments: {"identity_id":"shop-abc123"}'].join("\n"),
|
||||
);
|
||||
// A prompt is recorded once its chat took it, so the record follows the channel.
|
||||
const bindingId = (await db.channels.listBindings(dot.id))[0]!.id;
|
||||
@@ -69,7 +69,7 @@ describe.each(testAdapters())("approvals over a channel, with the real Scheduler
|
||||
await hub.setSettings(dot.id, "telegram", { show_arguments: false });
|
||||
const id = await ask(w, dot);
|
||||
await waitFor(() => channel.prompts.length === 1, "the prompt");
|
||||
const question = ["The Dot asks to use exec (permission browser.identity.delete).", "Reason: the tool needs approval"].join("\n");
|
||||
const question = ["The Dot wants to run a command.", "Permission: Delete browser identities (high risk)", "Reason: the tool needs approval"].join("\n");
|
||||
expect(channel.prompts[0]!.text).toBe(question);
|
||||
expect(await channel.press(id, "approve", "10")).toBe("Approved.");
|
||||
await waitFor(() => channel.edits.length === 1, "the edit");
|
||||
|
||||
@@ -462,7 +462,7 @@ describe.each(testAdapters())("the hub with the real Telegram adapter (%s)", { t
|
||||
const id = dot.guest.requestApproval(undefined, "exec");
|
||||
await waitFor(() => bots.prompts(TOKEN, 10).length === 1, "the prompt");
|
||||
const [prompt] = bots.prompts(TOKEN, 10);
|
||||
expect(prompt!.text).toContain("The Dot asks to use exec");
|
||||
expect(prompt!.text).toContain("The Dot wants to run a command.");
|
||||
expect(prompt!.buttons.map((b) => b.text)).toEqual(["Approve", "Reject"]);
|
||||
expect(prompt!.buttons.every((b) => Buffer.byteLength(b.data) <= 64 && b.data.endsWith(id))).toBe(true);
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type ListOrder,
|
||||
type McpSecretsAnswer,
|
||||
type StoredEvent,
|
||||
type ToolInfo,
|
||||
type Skill,
|
||||
type SkillListAnswer,
|
||||
type ToolListAnswer,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
/**
|
||||
* Reading a Dot's event log through the one route that serves it, `GET /api/dots/:id/events`, which keeps the types
|
||||
* (and the task) a caller names in the database so only those cross the wire. No view reads the whole log of a Dot:
|
||||
* that costs more with every day it lives. A view reads what it shows, bounded on one side: the events of one task
|
||||
* (`taskId`), the events from a point on (`readEventLog` with `after`), the range between two points
|
||||
* (`readEventRange`), or the newest window (`readRecentEvents`, which the Activity page goes on from, older, a page
|
||||
* at a time, with a `before`). Each says which types it reads and, where a type alone is not enough, what else an
|
||||
* event must be.
|
||||
* (and the task) a caller names in the database so only those cross the wire. No client reads the whole log of a Dot:
|
||||
* that costs more with every day it lives. The web client's views and `invisible-dots logs` read what they show,
|
||||
* bounded on one side: the events of one task (`taskId`), the events from a point on (`readEventLog` with `after`),
|
||||
* the range between two points (`readEventRange`), or the newest window (`readRecentEvents`, which the Activity page
|
||||
* goes on from, older, a page at a time, with a `before`). Each says which types it reads and, where a type alone is
|
||||
* not enough, what else an event must be.
|
||||
*/
|
||||
import type { InvisibleDotsClient } from "@invisible-dots/sdk";
|
||||
import { MAX_EVENT_PAGE } from "@invisible-dots/shared/browser";
|
||||
import type { StoredEvent } from "./types";
|
||||
import { MAX_EVENT_PAGE, type StoredEvent } from "@invisible-dots/shared/browser";
|
||||
import type { InvisibleDotsClient } from "./client.js";
|
||||
|
||||
export interface EventLogQuery {
|
||||
/** The event type names to read; the control plane refuses a name it does not know. */
|
||||
types: readonly string[];
|
||||
/** The event type names to read (every type when omitted or empty); the control plane refuses a name it does not know. */
|
||||
types?: readonly string[];
|
||||
/** Narrows the `tool.called` events to those of these tools (`data.tool`), in the database; other types are not affected. */
|
||||
tools?: readonly string[];
|
||||
/** Only the events of this task (`data.task_id`), in the database. */
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./client.js";
|
||||
export * from "./event-log.js";
|
||||
export type * from "./types.js";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MAX_EVENT_PAGE, type StoredEvent } from "@invisible-dots/shared/browser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeEvents, readEventLog, readEventRange, readRecentEvents } from "../src/lib/event-log";
|
||||
import { mergeEvents, readEventLog, readEventRange, readRecentEvents } from "../src/index.js";
|
||||
|
||||
function log(count: number): StoredEvent[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i + 1, dot_id: "d1", type: i % 3 === 0 ? "tool.called" : "agent.state", data: {}, source: "guest", guest_seq: i, created_at: "2026-03-10T12:00:00Z" }) as StoredEvent);
|
||||
@@ -10,4 +10,5 @@ export * from "./ids.js";
|
||||
export * from "./protocol.js";
|
||||
export * from "./sse.js";
|
||||
export * from "./states.js";
|
||||
export * from "./labels.js";
|
||||
export * from "./tools.js";
|
||||
@@ -1,4 +1,12 @@
|
||||
// Derived from OpenDots (CopilotKit) src/client/ComputerToolCard.tsx at 88f2a08, MIT; changed: the map is over this engine's tool table (permissions.py) and not the demo's computer actions; each entry says in past tense what the call did, in the infinitive what it would do (for an approval card), and which family of tools it belongs to; a tool the table does not know is named as the model called it.
|
||||
// Derived from OpenDots (CopilotKit) src/client/ComputerToolCard.tsx at 88f2a08, MIT; changed: the map is over this engine's tool table (permissions.py) and not the demo's computer actions; each entry says in past tense what the call did, in the infinitive what it would do (for an approval card), and which family of tools it belongs to; a tool the table does not know is named as the model called it; the words for a permission and for a channel account below are this project's own.
|
||||
|
||||
/**
|
||||
* The words a person reads for a tool, a permission and a channel's account, the same in every client: the web client's
|
||||
* chat, tasks, approval cards and channel cards, the approval prompt of a Telegram or WhatsApp chat, and the command
|
||||
* line's tables.
|
||||
*/
|
||||
import type { ChannelKind } from "./events.js";
|
||||
import { permissionInfo, type PermissionRisk } from "./tools.js";
|
||||
|
||||
/** What kind of thing a tool does: the chat draws one icon per family. */
|
||||
export type ToolFamily = "command" | "read" | "write" | "automation" | "screen" | "browser-identity" | "browser" | "other";
|
||||
@@ -59,3 +67,21 @@ export function toolLabel(tool: string, tty = false): ToolLabel {
|
||||
if (tool === "exec" && tty) return { ...TOOL_LABELS.exec!, label: TERMINAL_LABEL };
|
||||
return Object.hasOwn(TOOL_LABELS, tool) ? TOOL_LABELS[tool]! : { label: `Called ${tool}`, ask: `call ${tool}`, family: "other" };
|
||||
}
|
||||
|
||||
export const RISK_LABEL: Record<PermissionRisk, string> = { low: "Low risk", medium: "Medium risk", high: "High risk" };
|
||||
|
||||
/**
|
||||
* A permission as an approval names it, "Run commands (high risk)"; a name with no words (one a Dot's config can no
|
||||
* longer hold) is shown as it is.
|
||||
*/
|
||||
export function permissionPhrase(permission: string): string {
|
||||
const info = permissionInfo(permission);
|
||||
return info === null ? permission : `${info.label} (${RISK_LABEL[info.risk].toLowerCase()})`;
|
||||
}
|
||||
|
||||
/** The account of a channel as a person writes it: a bot's `@name`, a phone number with its plus; null until the channel reports one. */
|
||||
export function accountLabel(kind: ChannelKind, account: string | null): string | null {
|
||||
if (account === null || account === "") return null;
|
||||
if (kind === "telegram") return `@${account}`;
|
||||
return account.startsWith("+") ? account : `+${account}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TOOL_LABELS, toolLabel } from "../src/lib/events/tool-labels";
|
||||
import { accountLabel, permissionPhrase, TOOL_LABELS, toolLabel } from "../src/index.js";
|
||||
|
||||
/** The tool names of the engine's table: the keys of `TOOL_PERMISSIONS` in nanobot/dots/permissions.py. */
|
||||
function engineTools(): string[] {
|
||||
@@ -53,3 +53,22 @@ describe("the words for the engine's tools", () => {
|
||||
expect(toolLabel("toString").family).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the words for a permission an approval names", () => {
|
||||
it("say its name and how risky it is, an MCP server's included, and keep a name with no words as it is", () => {
|
||||
expect(permissionPhrase("computer.exec")).toBe("Run commands (high risk)");
|
||||
expect(permissionPhrase("files.read")).toBe("Read files (low risk)");
|
||||
expect(permissionPhrase("mcp.time")).toBe("MCP server time (high risk)");
|
||||
expect(permissionPhrase("exec.run")).toBe("exec.run");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the account of a channel", () => {
|
||||
it("is a bot's @name and a number with its plus, and nothing before the channel reports one", () => {
|
||||
expect(accountLabel("telegram", "dot_helper_bot")).toBe("@dot_helper_bot");
|
||||
expect(accountLabel("whatsapp", "15550001111")).toBe("+15550001111");
|
||||
expect(accountLabel("whatsapp", "+15550001111")).toBe("+15550001111");
|
||||
expect(accountLabel("telegram", null)).toBeNull();
|
||||
expect(accountLabel("whatsapp", "")).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -49,7 +49,7 @@ tested by `tests/repo/e2e.test.ts` on every push:
|
||||
| j | the server restarts and adopts the running VM (same pid, browser still open); `invisible-dots computer stop` reaches STOPPED with QEMU gone, through the guest's own poweroff (`computer.stopped` says `forced: false`, in well under the 60 s after which QEMU is killed); the open browser was closed on the way down (`browser.identity.closed`); `start` reaches READY with no browser server running; the identity is launched again and its `.stealth-identity.json` is unchanged (by SHA-256); `title.txt`, the note, a `grep` that finds it, and the conversation are all still there; the guest's whole system journal, read inside the guest, holds no OpenRouter key |
|
||||
| k | with `computer.exec: ask`, a task stops at an approval; the **computer is killed** (SIGKILL of QEMU) while it waits; the control plane records `computer.stopped` with `reason: exited` and starts the computer again by itself (the Dot still has a task), a second `agent.started` follows, the engine resumes from its database with the same approval pending and the host re-pushes key and config; approved with a note, the task completes and a counter file proves the command ran exactly once |
|
||||
| l | a task runs a slow `exec`; while it is in flight the computer is killed the same way; after the restart the Dot's event log has that call once, as a `tool.called` with `interrupted: true`, and once the command's time is over no copy of it is running and its marker file was never written: the command died with its computer and was not run again (architecture 8.7) |
|
||||
| m | two MCP servers in the config, both `uvx mcp-server-time`: `time`, whose permission asks, and `keyed`, which names the secret `TIME_TOKEN`. `keyed` is `failed` because its secret is not set; a task's call of `mcp_time_get_current_time` stops at an approval for `mcp.time`, approved always, and runs; `GET .../tools` lists `time` connected with its tools offered, and its process runs as `dot`. The secret is set through `invisible-dots secret mcp` (stdin, not echoed), `keyed` connects at the next turn, `invisible-dots mcp` says so, and the secret is in the server's environment and on no command line in the guest |
|
||||
| m | two MCP servers in the config, both `uvx mcp-server-time`: `time`, whose permission asks, and `keyed`, which names the secret `TIME_TOKEN`. `keyed` is `failed` because its secret is not set; a task's call of `mcp_time_get_current_time` stops at an approval for `mcp.time`, approved always, and runs; `GET .../tools` lists `time` connected with its tools offered, and its process runs as `dot`. The secret is set through `invisible-dots secret mcp` (stdin, not echoed), `keyed` connects at the next turn, `invisible-dots mcp` says so, and the secret is in the server's environment and on no command line in the guest. Then the Dot adds a third, `clock`, itself with `add_mcp_server`: the call asks under `mcp.servers.add` with the whole entry in its arguments, is approved, and `clock` is in the config (its permission not left to ask), connected, and logged as `mcp.server.added` |
|
||||
| n | the key and the MCP secret are in none of the Dot's event and approval rows (read back decompressed through the API) and, once the Dot is stopped, in none of its files: the overlay disk, the seed, the serial log and QEMU's log |
|
||||
| o | `DELETE /api/dots/:id` removes the Dot, its QEMU process and `vms/<id>` |
|
||||
| p | the key and the MCP secret are in no event row read back after the delete, and in no file of the run's logs, `logs/` or the embedded database's directory |
|
||||
|
||||
+2
-2
@@ -86,8 +86,8 @@ export function route(template: string, params: Record<string, string> = {}, que
|
||||
}
|
||||
|
||||
/** The first word(s) of each `invisible-dots` command the run calls, and the flags it passes. */
|
||||
export const CLI_COMMANDS = ["doctor", "image build", "server", "secret openrouter", "create", "task", "message", "computer", "approve", "reject"] as const;
|
||||
export const CLI_FLAGS = ["--json", "--note", "--always", "--no-web"] as const;
|
||||
export const CLI_COMMANDS = ["doctor", "image build", "server", "secret openrouter", "secret mcp", "mcp", "create", "task", "message", "computer", "approve", "reject"] as const;
|
||||
export const CLI_FLAGS = ["--json", "--note", "--always", "--no-web", "--dot", "--version"] as const;
|
||||
|
||||
/** The Dot's tools the run asserts on (nanobot/dots/permissions.py `TOOL_PERMISSIONS`), with the permission each exercises. */
|
||||
export const TOOLS = {
|
||||
|
||||
@@ -127,6 +127,30 @@ describe("the e2e run's contract with the product", () => {
|
||||
for (const flag of CLI_FLAGS) expect(USAGE, flag).toContain(flag);
|
||||
});
|
||||
|
||||
it("names in run.ts only commands and flags it declares", () => {
|
||||
const run = read("tests/e2e/run.ts");
|
||||
// Each call of the CLI (`cli([...])`, `cliJson<T>([...])`): its leading words are the command, its quoted `--` words the flags.
|
||||
const calls = [...run.matchAll(/\bcli(?:Json<[^>]*>)?\(\[([^\]]*)\]/g)].map((m) => m[1]!);
|
||||
expect(calls.length).toBeGreaterThan(10);
|
||||
for (const call of calls) {
|
||||
const words = call.split(",").map((part) => part.trim());
|
||||
const head: string[] = [];
|
||||
for (const word of words) {
|
||||
const literal = /^"([a-z-]+)"$/.exec(word)?.[1];
|
||||
if (literal === undefined || literal.startsWith("--")) break;
|
||||
head.push(literal);
|
||||
}
|
||||
const command = head.join(" ");
|
||||
if (command !== "") {
|
||||
expect(
|
||||
CLI_COMMANDS.some((declared) => command === declared || command.startsWith(`${declared} `)),
|
||||
`run.ts runs "invisible-dots ${command}", which CLI_COMMANDS does not declare`,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const flag of call.matchAll(/"(--[a-z-]+)"/g)) expect(CLI_FLAGS as readonly string[], `run.ts passes ${flag[1]}`).toContain(flag[1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it("asserts only on tools of the engine's permission table, under the permission it names", () => {
|
||||
const table = read("invisible_engine_dots/nanobot/dots/permissions.py");
|
||||
const rows = new Map([...table.matchAll(/^\s+"(\w+)": ToolEntry\("([a-z.]+)"/gm)].map((m) => [m[1]!, m[2]!]));
|
||||
|
||||
Reference in new issue
Block a user