host, web, engine: the cheap LOWs of the final review, first batch (durability, contract, one owner of a fact)

Final review, LOW "durability and contract LOWs of earlier reviews that still
hold", item by item:

(1) A wake for an automation used a plain ensureReady outside the lock and did
not re-read stop_reason -> fixed. Lifecycle.wakeForAutomation takes the per-Dot
lock and asks again, from the same place the pass asked (stoppedDotsWithAutomationDue),
whether the Dot is still one to wake; a person's stop that came in front of the
wake keeps the computer off. Test: two idle-asleep Dots with a due automation, the
person stops one before the wakes run; only the other starts.
(2) The person's stop of a sleeping computer wrote no event -> fixed in the commit
of the commit points (computer.stopped {reason: "user"} in the same write).
(3) automationsNote (web) and `status` (CLI) said "Paused" for a Dot with no
automation -> fixed: paused is said when the engine had reported a run that is due.
(4) A failed setCredentials restarted the runner with the binding read before the
stop, so messages delivered meanwhile were sent again -> fixed: the binding is read
again after the stop (its cursor is the stop's). Test: the Dot answers during the
credential check, the write is refused, and the answer arrives once (it arrived
twice before the fix: the test was run against the old code).
(5) A fresh engine.sqlite restarts the outbox seq while computers.event_cursor
stays high -> REJECTED, not cheap or safe: the new store's events would also collide
on events (dot_id, guest_seq) unique, which the host reads as "already stored", so
a cursor reset is not enough; a correct fix is a store id in the agent health, an
epoch in the events key and the cursor, and a migration, across the engine,
dot-agentd's health passthrough, shared, the database and the lifecycle. No code
path or documented operation replaces engine.sqlite in an existing Dot (the disk
is the VM's, and a destroy deletes the Dot), so it stays open for the owner.
(6) migrate knew migrations by name only -> fixed: a database that records a
migration this build does not ship is refused with a message naming it, before
anything runs (tests on both adapters, and the fresh database).
(7) dot.updated had two meanings, shown alike -> fixed with the discriminating
field the Dot's ERROR already carries: Activity says "The Dot failed" with the
error, and "Configuration updated" for a save (architecture 9.2 says both).

Final review, LOW "web layer still owns or copies facts":
- approval-view.ts takes WORKSPACE and HOME from GUEST_PATHS (the one owner of
  guest paths); its resolution stays its own, because the rule differs (the
  engine's file tools read a relative path against the workspace and resolve "..",
  the host's file routes read it against home and refuse "..").
- FRAME_ERROR_CODES `satisfies Record<string, IdentityErrorCode>`.
- The JPEG rule is the engine's (BrowserManager.frame answers frame_failed for any
  other media type, with a fake-server test) and the host's copy of it is deleted
  with its test (the vm-manager test that checked it: one test fewer, its rule now
  tested at the owner).
- The note of an approval: APPROVAL_NOTE_MAX (500) is shared, the web input uses it
  and the host refuses a longer one with a 400 (tests on both).
- APPROVAL_PROMPT_TEXT_MAX (300, moved from channels' ARGUMENTS_MAX) is shared and
  the channel setting's description renders it.
- One relink type (ChannelRelink; DotRelink is it without the Dot).
- A file above MAX_HOST_FILE_BYTES is no longer offered as a download (the host
  answers 413); the preview says why (tests).

Final review, LOW "stale comments": approval-text.ts and spend.py reworded to the
documented rules (the query of an approval URL stays; a failed request is not counted,
though a cut stream may have been billed).

Test floors: one vitest test fewer (the host's JPEG check), the rest are additions;
the floors are measured at the end.
This commit is contained in:
feder-cr committed 2026-10-06 18:30:40 +02:00
1 parent cbc28853c5
commit fdcd2071f1
34 files changed
+302 -93

No files matched your search

+2 -2
View File
@@ -185,9 +185,9 @@ function oneLine(text: string, max = 60): string {
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
}
/** What `status` says about the Dot's automations: when the next one is due, or that the person's stop has paused them. */
/** What `status` says about the Dot's automations: when the next one is due, or that the person's stop has paused them (when one was due: a Dot with none has nothing to pause). */
function nextAutomation(computer: ComputerAnswer): string {
if (computer.stop_reason === "user") return "paused: the computer was stopped by you (start it to resume)";
if (computer.stop_reason === "user" && computer.next_automation_at !== null) return "paused: the computer was stopped by you (start it to resume)";
return computer.next_automation_at ?? "none due";
}
+12 -1
View File
@@ -86,6 +86,8 @@ let base: string;
const requests: Recorded[] = [];
let stopped = false;
let stoppedByPerson = false;
/** The Dot has no automation due: the engine reported none. */
let noAutomationDue = false;
/** What the fake server answers about WhatsApp. */
let whatsappLinked = false;
let whatsappOff = false;
@@ -123,7 +125,7 @@ async function handle(req: IncomingMessage, res: ServerResponse) {
case `GET /api/dots/${dot.id}`:
return send(res, 200, dot);
case `GET /api/dots/${dot.id}/computer`:
return send(res, 200, stoppedByPerson ? { ...computer, state: "STOPPED", stop_reason: "user", ready: false } : 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`:
return send(res, 200, { tasks: [task] });
case `POST /api/dots/${dot.id}/tasks`:
@@ -211,6 +213,7 @@ beforeEach(() => {
requests.length = 0;
stopped = false;
stoppedByPerson = false;
noAutomationDue = false;
whatsappLinked = false;
whatsappOff = false;
linkFrames = [];
@@ -313,6 +316,14 @@ describe("commands", () => {
expect((await cli(["tasks", "fare-watch"])).stdout).toContain("COMPLETED");
});
it("status does not say paused for a Dot the person stopped that has no automation due", async () => {
stoppedByPerson = true;
noAutomationDue = true;
const status = await cli(["status", "fare-watch"]);
expect(status.stdout).toMatch(/next automation\s+none due/);
expect(status.stdout).not.toMatch(/paused/);
});
it("status says the automations are paused while the person's stop lasts, and stop says so when it is asked for", async () => {
stoppedByPerson = true;
const status = await cli(["status", "fare-watch"]);
+31 -14
View File
@@ -291,20 +291,37 @@ export class Lifecycle {
*/
async ensureReady(dotId: string): Promise<void> {
if (this.#ready.has(dotId)) return;
await this.#mutex.run(dotId, async () => {
if (this.#ready.has(dotId)) return;
const computer = await this.#db.computers.get(dotId);
if (!computer) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} has no computer`);
if (computer.state === "DELETING") throw new ControlPlaneError(409, "dot_deleting", `Dot ${dotId} is being deleted`);
if (computer.state === "PROVISIONING") {
throw new ControlPlaneError(409, "dot_provisioning", `Dot ${dotId} is still being provisioned`);
}
const vm = await this.#driver.state(dotId);
if (vm.state === "RUNNING" && vm.guestPort !== null) {
await this.#adoptRunning(dotId, { ...vm, guestPort: vm.guestPort });
} else {
await this.#startLocked(dotId);
}
await this.#mutex.run(dotId, () => this.#ensureReadyLocked(dotId));
}
async #ensureReadyLocked(dotId: string): Promise<void> {
if (this.#ready.has(dotId)) return;
const computer = await this.#db.computers.get(dotId);
if (!computer) throw new ControlPlaneError(404, "not_found", `Dot ${dotId} has no computer`);
if (computer.state === "DELETING") throw new ControlPlaneError(409, "dot_deleting", `Dot ${dotId} is being deleted`);
if (computer.state === "PROVISIONING") {
throw new ControlPlaneError(409, "dot_provisioning", `Dot ${dotId} is still being provisioned`);
}
const vm = await this.#driver.state(dotId);
if (vm.state === "RUNNING" && vm.guestPort !== null) {
await this.#adoptRunning(dotId, { ...vm, guestPort: vm.guestPort });
} else {
await this.#startLocked(dotId);
}
}
/**
* Start a stopped computer for an automation that is due, if it still is one: the pass that chose it read the
* database before this turn of the per-Dot lock came, and the person may have stopped the computer since (a stop
* that is not started for its automations), or the guest reported another time. The rule is read again, under the
* lock, from the same place the pass read it (`stoppedDotsWithAutomationDue`); true when the computer was started.
*/
async wakeForAutomation(dotId: string): Promise<boolean> {
if (this.#ready.has(dotId)) return false;
return this.#mutex.run(dotId, async () => {
if (!(await this.stoppedDotsWithAutomationDue()).includes(dotId)) return false;
await this.#ensureReadyLocked(dotId);
return true;
});
}
+5 -1
View File
@@ -19,6 +19,7 @@ import type {
UsageAnswer,
} from "@invisible-dots/shared";
import {
APPROVAL_NOTE_MAX,
checkHomePath,
checkOpenRouterKey,
COMPUTER_STOPPED,
@@ -171,7 +172,7 @@ export class Scheduler {
for (const dotId of await this.lifecycle.stoppedDotsWithAutomationDue()) {
if (this.lifecycle.isBusy(dotId)) continue;
this.#log.info("waking a stopped Dot: an automation is due", { dotId });
this.#runInBackground("wake for an automation", dotId, () => this.lifecycle.ensureReady(dotId));
this.#runInBackground("wake for an automation", dotId, () => this.lifecycle.wakeForAutomation(dotId));
}
}
@@ -705,6 +706,9 @@ export class Scheduler {
if (note !== undefined && typeof note !== "string") {
throw new ControlPlaneError(400, "invalid_request", "note must be a string");
}
if (note !== undefined && note.length > APPROVAL_NOTE_MAX) {
throw new ControlPlaneError(400, "invalid_request", `note must be at most ${APPROVAL_NOTE_MAX} characters`);
}
if (always !== undefined && always !== true) {
throw new ControlPlaneError(400, "invalid_request", "always must be true");
}
+43 -1
View File
@@ -1,6 +1,6 @@
import type { Database } from "@invisible-dots/database";
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
import { type Automation, IDENTITY_ERROR_STATUS, type IdentityErrorCode, type StoredEvent } from "@invisible-dots/shared";
import { APPROVAL_NOTE_MAX, type Automation, IDENTITY_ERROR_STATUS, type IdentityErrorCode, type StoredEvent } from "@invisible-dots/shared";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { Scheduler, type SchedulerOptions } from "../src/index.js";
import { FakeDriver, FakeGuestError, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
@@ -334,6 +334,17 @@ describe.each(testAdapters())("Scheduler with a fake driver and a fake guest (%s
expect(guest.inbound.filter((e) => e.type === "approval.received")).toHaveLength(2);
});
it("refuses a note longer than the one the web takes, and leaves the approval pending", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "long-note");
const id = driver.guestOf(dot.id).requestApproval(undefined);
await waitFor(async () => (await db.approvals.get(id)) !== null, "stored");
await expect(scheduler.resolveApproval(id, "approve", { note: "x".repeat(APPROVAL_NOTE_MAX + 1) })).rejects.toMatchObject({ status: 400, code: "invalid_request", message: `note must be at most ${APPROVAL_NOTE_MAX} characters` });
expect((await db.approvals.get(id))?.status).toBe("pending");
expect((await scheduler.resolveApproval(id, "approve", { note: "x".repeat(APPROVAL_NOTE_MAX) })).note).toHaveLength(APPROVAL_NOTE_MAX);
});
it("approve with always refuses what cannot be allowed for good and leaves the approval pending", async () => {
const { scheduler, driver } = make();
const dot = await readyDot(scheduler, "always-refused");
@@ -545,6 +556,37 @@ describe.each(testAdapters())("Scheduler with a fake driver and a fake guest (%s
expect(guest.automations.get("job_1")?.last_run_at_ms).toBe(ranAt);
});
it("does not start for an automation when the person's stop came in front of the wake the pass had chosen, and still starts one nothing came in front of", async () => {
const { scheduler, driver, clock } = makeWithLead();
const stopped = await readyDot(scheduler, "stop-comes-first", "10m");
const free = await readyDot(scheduler, "nothing-comes-first", "10m");
for (const dot of [stopped, free]) {
const guest = driver.guestOf(dot.id);
guest.now = () => clock.now().getTime();
guest.putAutomation(automation({ schedule: { kind: "every", every_ms: 60 * MIN }, next_run_at_ms: clock.now().getTime() + 20 * MIN }));
await waitFor(async () => (await db.computers.get(dot.id))?.next_automation_at !== null, "the report stored");
}
clock.advance(11 * MIN);
expect((await scheduler.idleCheck()).sort()).toEqual([stopped.id, free.id].sort());
await scheduler.settle();
clock.advance(9 * MIN);
// The pass would wake both: it read the database, and the wakes wait for their turn of the per-Dot lock.
expect((await scheduler.lifecycle.stoppedDotsWithAutomationDue()).sort()).toEqual([stopped.id, free.id].sort());
const bootsOf = (id: string) => driver.guestOf(id).boots;
const before = { stopped: bootsOf(stopped.id), free: bootsOf(free.id) };
// The person's stop gets there first.
await scheduler.stopComputer(stopped.id);
await scheduler.settle();
expect(await db.computers.get(stopped.id)).toMatchObject({ state: "STOPPED", stop_reason: "user" });
expect(await scheduler.lifecycle.wakeForAutomation(stopped.id)).toBe(false);
expect(await scheduler.lifecycle.wakeForAutomation(free.id)).toBe(true);
await scheduler.settle();
expect(bootsOf(stopped.id)).toBe(before.stopped);
expect(bootsOf(free.id)).toBe(before.free + 1);
expect((await db.computers.get(stopped.id))?.state).toBe("STOPPED");
});
it("keeps a Dot the person stopped off, whatever its automations say, until the person starts it again", async () => {
const { scheduler, driver, clock } = makeWithLead();
const dot = await readyDot(scheduler, "stopped-by-me", "10m");
+4 -9
View File
@@ -345,17 +345,12 @@ export class GuestClient {
}
/**
* JPEG bytes of the open identity's window. The route's contract is a JPEG (architecture section 5.3) and the host
* labels the bytes `image/jpeg` all the way to the browser, so any other media type is refused here, as a failed
* frame, and not served mislabelled.
* JPEG bytes of the open identity's window. The route's contract is a JPEG (architecture section 5.3), and the engine
* is its one owner: it answers `frame_failed` for any other media type, so the host labels the bytes `image/jpeg`
* all the way to the browser without looking again.
*/
async getBrowserIdentityFrame(id: string): Promise<Buffer> {
const route = `GET ${this.agentPath(AGENT_ROUTES.browserIdentityFrame(id))}`;
const answer = await this.send({ path: this.agentPath(AGENT_ROUTES.browserIdentityFrame(id)) });
if (answer.contentType !== "image/jpeg") {
throw new GuestRequestError(route, 502, `the frame of browser identity "${id}" is ${answer.contentType || "of no media type"}, not image/jpeg`, "frame_failed");
}
return answer.body;
return (await this.send({ path: this.agentPath(AGENT_ROUTES.browserIdentityFrame(id)) })).body;
}
closeBrowserIdentity(id: string): Promise<void> {
@@ -159,13 +159,6 @@ describe("GuestClient", () => {
expect(seen.every((s) => s.auth === `Bearer ${TOKEN}`)).toBe(true);
});
it("refuses a frame that is not a JPEG, which the control plane would otherwise serve labelled as one", async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const { port } = await serve((_req, res) => res.writeHead(200, { "content-type": "image/png" }).end(png));
const client = new GuestClient(port, TOKEN);
await expect(client.getBrowserIdentityFrame("shop-abc123")).rejects.toMatchObject({ status: 502, code: "frame_failed", message: expect.stringContaining("image/png") });
});
it("calls the automation and tool routes with the id as one encoded path segment", async () => {
const row = { id: "job 1", name: "n", enabled: false };
const { port, seen } = await serve((req, res) => {
@@ -10,6 +10,7 @@ import { cn } from "../../lib/utils";
import { ErrorAlert } from "../ErrorAlert";
import { ApprovalCard as ApprovalCardElement } from "../elements/approval-card";
import { FAMILY_ICON } from "../tool-family-icon";
import { APPROVAL_NOTE_MAX } from "@invisible-dots/shared/browser";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
@@ -47,7 +48,7 @@ function NoteField({ ask, answers, disabled }: { ask: ApprovalAsk; answers: Answ
<Label htmlFor={id} className="text-xs">
Note for the Dot (optional)
</Label>
<Input id={id} value={answers.noteOf(ask.id)} disabled={disabled} maxLength={500} onChange={(event) => answers.setNote(ask.id, event.target.value)} />
<Input id={id} value={answers.noteOf(ask.id)} disabled={disabled} maxLength={APPROVAL_NOTE_MAX} onChange={(event) => answers.setNote(ask.id, event.target.value)} />
</div>
);
}
@@ -1,10 +1,10 @@
"use client";
import type { FileEntry } from "@invisible-dots/shared/browser";
import { MAX_HOST_FILE_BYTES, type FileEntry } from "@invisible-dots/shared/browser";
import { DownloadIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "../../lib/api";
import { childPath, previewPlan } from "../../lib/files";
import { canDownload, childPath, previewPlan } from "../../lib/files";
import { formatBytes } from "../../lib/format";
import { saveFile } from "../../lib/save-file";
import { ErrorAlert } from "../ErrorAlert";
@@ -32,6 +32,7 @@ async function download(dotId: string, folder: string, name: string): Promise<vo
*/
export function FilePreview({ dotId, folder, entry, markdown = false }: { dotId: string; folder: string; entry: FileEntry; markdown?: boolean }) {
const plan = previewPlan(entry);
const downloadable = canDownload(entry);
const path = childPath(folder, entry.name);
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [error, setError] = useState<unknown>(null);
@@ -84,14 +85,18 @@ export function FilePreview({ dotId, folder, entry, markdown = false }: { dotId:
{entry.name}
</h3>
<span className="text-xs text-muted-foreground">{formatBytes(entry.size)}</span>
<Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => void save()}>
<DownloadIcon />
{saving ? "Downloading..." : "Download"}
</Button>
{downloadable ? (
<Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => void save()}>
<DownloadIcon />
{saving ? "Downloading..." : "Download"}
</Button>
) : null}
</div>
<ErrorAlert error={saveError} title="The file was not downloaded" />
{plan.show === "none" ? (
{!downloadable ? (
<p className="text-sm text-muted-foreground">This file is too big to be handed out ({formatBytes(entry.size)}, the limit is {formatBytes(MAX_HOST_FILE_BYTES)}). Read it on the computer itself, or copy it in parts.</p>
) : plan.show === "none" ? (
<p className="text-sm text-muted-foreground">
{plan.reason === "too-large" ? `This file is too big to show here (${formatBytes(entry.size)}). Download it to read it.` : "This kind of file is not shown here. Download it to open it."}
</p>
+4 -4
View File
@@ -5,7 +5,7 @@
* The arguments come from the engine's table (`tool_arguments` in nanobot/dots/permissions.py): all of a call's
* arguments, bar a secret the table redacts. So what is shown here is what the call will do, not a summary of it.
*/
import { isPermission, PERMISSION_INFO, redactProxy, type Permission, type PermissionInfo, type PermissionRisk, type ToolInfo } from "@invisible-dots/shared/browser";
import { GUEST_PATHS, isPermission, PERMISSION_INFO, redactProxy, type Permission, type PermissionInfo, 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";
@@ -38,10 +38,10 @@ export function askOfRecord(record: Approval): ApprovalAsk {
}
/** Where a Dot's own work goes: a relative path in a file tool means a path under it. */
export const WORKSPACE = "/home/dot/workspace";
export const WORKSPACE: string = GUEST_PATHS.workspace;
/** The Dot's own home, where `~` points. */
const HOME = "/home/dot";
/** The Dot's own home, where `~` points. (The host's file routes read a relative path against it; the engine's file tools, whose approval this is, read it against the workspace, and resolve `..`.) */
const HOME: string = GUEST_PATHS.home;
/** A path with its `.` and `..` segments resolved; `..` above the root stays at the root. */
function resolved(path: string): string {
+5 -13
View File
@@ -2,7 +2,8 @@
* What needs the person, per Dot, and how that is drawn. One function owns it, and the rail badges, the
* avatar ring, the document title and the favicon all read it, so they can never disagree.
*/
import { computerIsUp, type AgentState, type ChannelKind, type DotState, type VmState } from "@invisible-dots/shared/browser";
import { computerIsUp, type AgentState, type DotState, type VmState } from "@invisible-dots/shared/browser";
import type { ChannelRelink } from "./channels";
export interface DotAttention {
/** Approvals of this Dot that wait for an answer. */
@@ -15,12 +16,8 @@ export interface DotAttention {
relinks: readonly DotRelink[];
}
/** A channel of one Dot that needs linking again. */
export interface DotRelink {
kind: ChannelKind;
/** What the host says went wrong; null when it says nothing. */
detail: string | null;
}
/** A channel of one Dot that needs linking again: the one record of it (`ChannelRelink`, lib/channels.ts) without the Dot it is listed under. */
export type DotRelink = Omit<ChannelRelink, "dot_id">;
export const NO_ATTENTION: DotAttention = { pendingApprovals: 0, error: null, failedTasks: 0, relinks: [] };
@@ -42,17 +39,12 @@ export interface AttentionTask {
dot_id: string;
}
/** The part of a channel that needs linking again that the model reads. */
export interface AttentionRelink extends DotRelink {
dot_id: string;
}
/** Per Dot id; a Dot with nothing waiting is in the map with NO_ATTENTION's numbers. */
export function attentionByDot(
dots: readonly AttentionDot[],
approvals: readonly AttentionApproval[],
failedTasks: readonly AttentionTask[] = [],
relinks: readonly AttentionRelink[] = [],
relinks: readonly ChannelRelink[] = [],
): Map<string, DotAttention> {
const byDot = new Map<string, Omit<DotAttention, "relinks"> & { relinks: DotRelink[] }>();
for (const dot of dots) {
+2 -2
View File
@@ -4,7 +4,7 @@
* the facts (`ChannelRecord`, the codes, the link stream); this file only puts them in words.
*/
import type { InvisibleDotsClient } from "@invisible-dots/sdk";
import type { ChannelKind, ChannelPeerRecord, ChannelRecord, ChannelSettings, ChannelStatus, MessageOrigin } from "@invisible-dots/shared/browser";
import { APPROVAL_PROMPT_TEXT_MAX, type ChannelKind, type ChannelPeerRecord, type ChannelRecord, type ChannelSettings, type ChannelStatus, type MessageOrigin } from "@invisible-dots/shared/browser";
import type { Tone } from "./tone";
import type { Dot } from "./types";
@@ -101,7 +101,7 @@ export function settingTexts(kind: ChannelKind): Record<SettingName, SettingText
},
show_arguments: {
label: "Show what the Dot wants to run",
description: "An approval in the chat shows the tool's arguments, cut to 300 characters. Off, it shows the tool and the Dot's reason only.",
description: `An approval in the chat shows the tool's arguments, cut to ${APPROVAL_PROMPT_TEXT_MAX} characters. Off, it shows the tool and the Dot's reason only.`,
},
};
}
+5 -3
View File
@@ -98,11 +98,13 @@ export interface AutomationsNote {
/**
* What the Dot's automations are doing, from the host's record of the computer (which holds it while the computer is
* off, when the guest cannot be asked): paused because the person stopped it, due at the time the engine last
* reported (`next_automation_at`, which the host wakes a sleeping computer for), or none due.
* off, when the guest cannot be asked): paused because the person stopped it (when the engine had reported a run
* that is due, `next_automation_at`: a Dot with none has nothing to pause), due at the time the engine last reported
* (which the host wakes a sleeping computer for), or none due.
*/
export function automationsNote(computer: Pick<Computer, "state" | "stop_reason" | "next_automation_at">, now: number, locale?: string): AutomationsNote {
if (computer.stop_reason === "user") {
// Paused is said of automations that exist: the engine reported a run that is due, which the person's stop now holds back.
if (computer.stop_reason === "user" && computer.next_automation_at !== null) {
return { paused: true, text: "Paused: you stopped this computer, so its automations do not run. Start it to resume them." };
}
const due = computer.next_automation_at === null ? Number.NaN : new Date(computer.next_automation_at).getTime();
+5 -1
View File
@@ -191,7 +191,11 @@ const DESCRIBE: { [K in KnownType]: (data: Partial<EventData[K]>) => Draft } = {
"channel.changed": (d) => ({ title: `Channel ${text(d.change) || "changed"}`, detail: text(d.kind), tone: d.change === "removed" ? "warn" : "neutral" }),
"channel.peer.paired": (d) => ({ title: "Person paired", detail: joined(text(d.label), text(d.kind) && `on ${text(d.kind)}`), tone: "ok" }),
"dot.created": (d) => ({ title: "Dot created", detail: text(d.name), tone: "ok" }),
"dot.updated": (d) => ({ title: "Configuration updated", detail: text(d.name), tone: "neutral" }),
// Two things write it: a saved config (`name` only) and a Dot that went to ERROR (`status` and `error` too, lifecycle.ts).
"dot.updated": (d) =>
d.status === "ERROR"
? { title: "The Dot failed", detail: joined(text(d.name), text(d.error) && `- ${text(d.error)}`), tone: "error" }
: { title: "Configuration updated", detail: text(d.name), tone: "neutral" },
"dot.deleted": (d) => ({ title: "Dot deleted", detail: text(d.name), tone: "warn" }),
"computer.state": (d) => ({ title: "Computer state", detail: text(d.state), tone: d.state === "ERROR" ? "error" : "neutral" }),
"computer.started": () => ({ title: "Computer started", tone: "ok" }),
+6 -1
View File
@@ -3,7 +3,7 @@
* to list it and which of its files can be shown. Paths are the guest's, POSIX, under home; the API has already
* resolved them (`GET .../files/list` answers the path it listed), so none of this validates one.
*/
import { fileType, GUEST_PATHS, type FileEntry } from "@invisible-dots/shared/browser";
import { fileType, GUEST_PATHS, MAX_HOST_FILE_BYTES, type FileEntry } from "@invisible-dots/shared/browser";
/** A text file bigger than this is not read for a preview: the whole file crosses the wire, and a page of this size is no longer a glance. */
export const TEXT_PREVIEW_MAX_BYTES = 1024 * 1024;
@@ -41,6 +41,11 @@ export function sortEntries(entries: readonly FileEntry[]): FileEntry[] {
return [...entries].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.name.localeCompare(b.name));
}
/** Whether the control plane hands the file out at all: it refuses (413) a file above MAX_HOST_FILE_BYTES, so none is offered to be downloaded. */
export function canDownload(entry: Pick<FileEntry, "size">): boolean {
return entry.size <= MAX_HOST_FILE_BYTES;
}
export type PreviewPlan = { show: "text" | "image"; contentType: string } | { show: "none"; reason: "download-only" | "too-large" };
/** How to show a file: as text, as an image, or not at all (a kind a page does not draw, or a file too big for a glance). */
+7
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { APPROVAL_NOTE_MAX } from "@invisible-dots/shared/browser";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -116,6 +117,12 @@ describe("the approval card", () => {
expect(card.getByText("Your note: not on this machine")).toBeTruthy();
});
it("takes a note of the length the control plane takes, no longer", async () => {
const card = within(await renderCard(askOf("a1")));
await userEvent.click(card.getByRole("button", { name: "Add a note for the Dot" }));
expect((card.getByLabelText("Note for the Dot (optional)") as HTMLInputElement).maxLength).toBe(APPROVAL_NOTE_MAX);
});
it("does not send a note that is only blanks", async () => {
const card = within(await renderCard(askOf("a1")));
await userEvent.click(card.getByRole("button", { name: "Add a note for the Dot" }));
+15 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import type { BrowserIdentity } from "@invisible-dots/shared/browser";
import { MAX_HOST_FILE_BYTES, type BrowserIdentity } from "@invisible-dots/shared/browser";
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "sonner";
@@ -397,6 +397,20 @@ describe("the files", () => {
expect(requested(/GET \/api\/dots\/d1\/files$/)).toEqual([]);
});
it("does not offer to download a file the control plane would refuse, and says why", async () => {
plane.putFile("/home/dot/huge.bin", new Uint8Array(MAX_HOST_FILE_BYTES + 1));
await renderComputer({ view: "files", file: "huge.bin" });
const preview = await screen.findByRole("region", { name: "File huge.bin" });
expect(within(preview).getByText(/too big to be handed out/)).toBeTruthy();
expect(within(preview).queryByRole("button", { name: "Download" })).toBeNull();
expect(requested(/GET \/api\/dots\/d1\/files$/)).toEqual([]);
// One at the limit is handed out.
cleanup();
plane.putFile("/home/dot/limit.bin", new Uint8Array(MAX_HOST_FILE_BYTES));
await renderComputer({ view: "files", file: "limit.bin" });
expect(within(await screen.findByRole("region", { name: "File limit.bin" })).getByRole("button", { name: "Download" })).toBeTruthy();
});
it("does not show a file named text that holds bytes of something else", async () => {
await renderComputer({ view: "files", file: "lie.txt" });
const preview = await screen.findByRole("region", { name: "File lie.txt" });
+9
View File
@@ -120,6 +120,15 @@ describe("viewEvent", () => {
expect(viewEvent(event(23, "guest.event.refused", { seq: null, type: null, problem: "not JSON" }, "host")).detail).toBe("- not JSON");
});
it("tells a Dot that went to ERROR from a saved configuration, though both are dot.updated", () => {
expect(viewEvent(event(24, "dot.updated", { name: "fares" }, "host"))).toMatchObject({ title: "Configuration updated", detail: "fares", tone: "neutral" });
expect(viewEvent(event(25, "dot.updated", { name: "fares", status: "ERROR", error: "start failed: QEMU did not start" }, "host"))).toMatchObject({
title: "The Dot failed",
detail: "fares - start failed: QEMU did not start",
tone: "error",
});
});
it("describes host events and resolutions", () => {
expect(viewEvent(event(7, "computer.state", { state: "ERROR" }, "host"))).toMatchObject({
title: "Computer state",
+7 -2
View File
@@ -1,6 +1,6 @@
import type { FileEntry } from "@invisible-dots/shared/browser";
import { MAX_HOST_FILE_BYTES, type FileEntry } from "@invisible-dots/shared/browser";
import { describe, expect, it } from "vitest";
import { breadcrumbs, childPath, IMAGE_PREVIEW_MAX_BYTES, previewPlan, sortEntries, TEXT_PREVIEW_MAX_BYTES } from "../src/lib/files";
import { breadcrumbs, canDownload, childPath, IMAGE_PREVIEW_MAX_BYTES, previewPlan, sortEntries, TEXT_PREVIEW_MAX_BYTES } from "../src/lib/files";
const entry = (name: string, type: FileEntry["type"] = "file", size = 10): FileEntry => ({ name, type, size, mtime: "2026-03-10T12:00:00Z" });
@@ -52,6 +52,11 @@ describe("how a file is shown", () => {
expect(previewPlan(entry("page.html")).show).toBe("text");
});
it("offers a download only up to what the control plane hands out", () => {
expect(canDownload({ size: MAX_HOST_FILE_BYTES })).toBe(true);
expect(canDownload({ size: MAX_HOST_FILE_BYTES + 1 })).toBe(false);
});
it("offers a download alone for every other kind", () => {
expect(previewPlan(entry("archive.zip"))).toEqual({ show: "none", reason: "download-only" });
expect(previewPlan(entry("Makefile"))).toEqual({ show: "none", reason: "download-only" });
+4
View File
@@ -89,6 +89,10 @@ describe("automationsNote", () => {
expect(note.text).toMatch(/^Paused: you stopped this computer, so its automations do not run/);
});
it("does not say paused for a Dot that has no automation due: there is nothing to pause", () => {
expect(automationsNote({ state: "STOPPED", stop_reason: "user", next_automation_at: null }, now)).toEqual({ paused: false, text: "No automation is due." });
});
it("says when the next one is due, and that a computer asleep is started shortly before", () => {
const running = automationsNote({ state: "RUNNING", stop_reason: null, next_automation_at: "2026-10-06T12:00:00Z" }, now, "en-US");
expect(running.paused).toBe(false);
+4 -2
View File
@@ -680,7 +680,7 @@ command detached into another session outlives it.
| `POST /browser-identities` | `{ name, proxy? }` | `201 BrowserIdentity`; `400 invalid` (name, or a proxy that is not an `http`, `https`, `socks4` or `socks5` URL with a host and a written port: the browser's server refuses one without a port at a launch, with a message that prints the URL and its password), `409 limit` (`max_identities`) |
| `GET /browser-identities/:id` | | `BrowserIdentity`; `404 not_found` |
| `DELETE /browser-identities/:id` | | `204` after the identity's browser is closed and its directory removed; `404 not_found` |
| `GET /browser-identities/:id/frame` | | `200 image/jpeg` (`Cache-Control: no-store`), one frame of the identity's window, taken with the server's `browser_watch`; `404 not_found`; `409 not_open` when the identity is closed; `503 busy` when a call of the Dot held the identity for longer than 5 seconds; `502 frame_failed` (the server has no page to show) or `crashed` |
| `GET /browser-identities/:id/frame` | | `200 image/jpeg` (`Cache-Control: no-store`), one frame of the identity's window, taken with the server's `browser_watch`; `404 not_found`; `409 not_open` when the identity is closed; `503 busy` when a call of the Dot held the identity for longer than 5 seconds; `502 frame_failed` (the server has no page to show, or sent a frame that is not a JPEG: the engine is the one owner of that rule, the host passes the bytes on as `image/jpeg`) or `crashed` |
| `POST /browser-identities/:id/close` | | `204` after the identity's browser is closed through `browser_close` and its server has ended; the profile stays. Closing a closed identity is a `204` too; `404 not_found` |
| `GET /automations` | | `{ automations: Automation[] }`: every cron job of the Dot, paused ones too, as `{ id, name, enabled, schedule: { kind: "at"\|"every"\|"cron", at_ms?, every_ms?, expr?, tz? }, message, next_run_at_ms, last_run_at_ms, last_status, last_error, delete_after_run, created_at_ms }`; times are milliseconds since the epoch, `next_run_at_ms` is `null` while a job is paused |
| `PATCH /automations/:id` | `{ enabled: bool }` | `200 Automation` (a job already in that state is not touched: resuming a running job does not move its next run); `404 not_found`, `400 invalid_automation` |
@@ -848,7 +848,9 @@ that wrote it to the outbox committed: one written inside a transaction that
rolls back is never streamed, so its `seq` cannot reach the host and then be
reused for another event.
The control plane adds its own: `dot.created`, `dot.updated`, `dot.deleted`,
The control plane adds its own: `dot.created`, `dot.updated` (a saved config,
`{name}`, or a Dot that went to ERROR, `{name, status: "ERROR", error}`, which
the Activity page tells apart), `dot.deleted`,
`computer.state {state}`, `computer.started`, `computer.stopped`,
`task.created`, `task.cancelled`, `approval.resolved {approval_id, decision,
task_id?, note?, always?}` (`task_id` is the task the approval was asked in, so
@@ -103,6 +103,9 @@ CLOSE_TIMEOUT_S = 30.0
REQUEST_TIMEOUT_S = 120
# How long a frame waits for the identity's call in flight before it gives up with `busy`.
FRAME_WAIT_S = 5.0
# What a frame is: the route's contract (architecture section 5.3) is a JPEG, and the host labels the bytes as one all the
# way to the browser, so the engine refuses any other media type as a failed frame and the host does not check again.
FRAME_MEDIA_TYPE = "image/jpeg"
SessionState = Literal["opening", "open", "closing"]
@@ -483,6 +486,10 @@ class BrowserManager:
if not images:
raise BrowserIdentityError("frame_failed", f'no frame of identity "{identity_id}": the server sent no image')
mime, data = images[0]
if mime != FRAME_MEDIA_TYPE:
raise BrowserIdentityError(
"frame_failed", f'no frame of identity "{identity_id}": the server sent {mime or "no media type"}, not {FRAME_MEDIA_TYPE}'
)
try:
return mime, base64.b64decode(data, validate=True)
except ValueError:
+3 -1
View File
@@ -77,7 +77,9 @@ class TurnSpend:
def record(self, response: LLMResponse) -> None:
"""Count one response: its cost into the ledger, or the note that it had none."""
if response.finish_reason == "error":
# The request failed (and may be retried): nothing was charged.
# The request failed (and may be retried) and reported no cost: nothing is counted. A stream that was cut
# after tokens were streamed may have been billed, and OpenRouter reports its cost only in the last chunk,
# so that cost is not known here (architecture section 8.2).
return
if response.cost_usd is None:
self._store.write(lambda conn: dots_store.note_unpriced(conn, self._session_key))
@@ -1306,6 +1306,20 @@ async def test_a_frame_the_server_cannot_give_is_a_frame_failure_with_its_reason
assert manager.is_open(identity.id)
async def test_a_frame_that_is_not_a_jpeg_is_a_frame_failure_and_the_browser_stays_open(env: Env) -> None:
manager = env.manager()
identity = await manager.create("png frame")
write_control(env.mcp_home(identity.id), watch_png=True)
await manager.launch(identity.id)
with pytest.raises(BrowserIdentityError) as failed:
await manager.frame(identity.id)
assert failed.value.code == "frame_failed"
assert "image/png, not image/jpeg" in failed.value.message
assert manager.is_open(identity.id)
async def test_a_frame_finds_a_process_that_died_and_closes_the_identity_once(env: Env) -> None:
manager = env.manager()
identity = await manager.create("dead for the frame")
@@ -97,6 +97,7 @@ def write_control(mcp_home: Path, **control: Any) -> None:
whose text is the sentence of a lost browser: a page controls that text, and the browser is not lost.
refuse_close: `browser_close` fails.
fail_watch: `browser_watch` fails the way the real server does when the browser has no page.
watch_png: `browser_watch` answers with a PNG, which the frame route's contract (a JPEG) does not take.
A `browser_navigate` to `https://crash.test/now` ends the process without an answer, like a server killed in the
middle of a call; to `slow://...` it answers after 0.3 s. Each call is recorded when it arrives
@@ -232,6 +233,8 @@ async def _serve() -> None:
if name == "browser_watch":
if control.get("fail_watch"):
return text(f"the {role} browser has no page to watch", error=True)
if control.get("watch_png"):
return image(PNG, "image/png")
return image(JPEG, "image/jpeg")
if name == "browser_click":
if control.get("overlay_says_gone"):
+8 -9
View File
@@ -3,14 +3,13 @@
* wording, so every channel shows the same thing. 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 `ARGUMENTS_MAX` characters. A secret that has no place in an approval
* (a proxy password, the user and the query values of a URL) is replaced by the engine in the event itself (architecture
* section 6): this module shows the arguments as the event has them and redacts nothing.
* tool, which can hold private data, are cut to `APPROVAL_PROMPT_TEXT_MAX` characters. A secret that has no place in an approval
* (a proxy password, the user and the password of a URL) is replaced by the engine in the event itself (architecture
* section 6): this module shows the arguments as the event has them and redacts nothing. The query of a URL stays: it
* 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 type { ApprovalRecord } from "@invisible-dots/shared";
/** The most characters of the tool's arguments, and of the reason, a prompt shows. */
export const ARGUMENTS_MAX = 300;
import { APPROVAL_PROMPT_TEXT_MAX, type ApprovalRecord } from "@invisible-dots/shared";
const MORE = "...";
@@ -21,9 +20,9 @@ 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 reason = cut(approval.reason, ARGUMENTS_MAX);
const reason = cut(approval.reason, APPROVAL_PROMPT_TEXT_MAX);
if (reason !== "") lines.push(`Reason: ${reason}`);
const args = cut(JSON.stringify(approval.arguments), ARGUMENTS_MAX);
const args = cut(JSON.stringify(approval.arguments), APPROVAL_PROMPT_TEXT_MAX);
if (showArguments && args !== "{}") lines.push(`Arguments: ${args}`);
return lines.join("\n");
}
+4 -2
View File
@@ -288,8 +288,10 @@ export class ChannelHub {
return (await tx.channels.bindingById(binding.id)) ?? binding;
});
} catch (error) {
// Nothing changed: the channel goes on with the credentials it had.
if (binding.enabled && this.#state === "started") this.#run(binding);
// Nothing changed: the channel goes on with the credentials it had, from the cursor the stop above wrote (the
// binding read before it holds an older one, and what was delivered since would be sent again).
const current = (await this.#o.db.channels.bindingById(binding.id)) ?? binding;
if (current.enabled && this.#state === "started") this.#run(current);
if (isUniqueViolation(error, ACCOUNT_KEY)) throw accountInUse(kind, account);
throw error;
}
+5 -5
View File
@@ -1,6 +1,6 @@
import type { ApprovalRecord } from "@invisible-dots/shared";
import { APPROVAL_PROMPT_TEXT_MAX, type ApprovalRecord } from "@invisible-dots/shared";
import { describe, expect, it } from "vitest";
import { ARGUMENTS_MAX, approvalOutcomeText, approvalPromptText, approvalReplyHint, approvalReplyToken, parseApprovalReply } from "../src/approval-text.js";
import { approvalOutcomeText, approvalPromptText, approvalReplyHint, approvalReplyToken, parseApprovalReply } from "../src/approval-text.js";
import { CALLBACK_DATA_MAX_BYTES, encodeApprovalCallback, parseApprovalCallback } from "../src/telegram/callback.js";
const ID = "appr_11111111-2222-3333-4444-555555555555";
@@ -63,11 +63,11 @@ describe("what an approval prompt says", () => {
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];
expect(reason.slice("Reason: ".length)).toHaveLength(ARGUMENTS_MAX);
expect(args.slice("Arguments: ".length)).toHaveLength(ARGUMENTS_MAX);
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);
expect(args.endsWith("...")).toBe(true);
expect(text).not.toContain("x".repeat(ARGUMENTS_MAX));
expect(text).not.toContain("x".repeat(APPROVAL_PROMPT_TEXT_MAX));
expect(text.length).toBeLessThan(800);
});
@@ -328,6 +328,37 @@ describe.each(testAdapters())("the hub with the real Telegram adapter (%s)", { t
expect((await userMessages(dot.id)).map((e) => e.data.text)).toEqual(["from the first bot", "from the second bot"]);
});
it("goes on from where it had got when new credentials are refused after the channel was stopped, and sends nothing again", async () => {
const w = await world();
const { hub, dot } = await linked(w);
bots.say(TOKEN, "hello", ANN);
await waitFor(() => bots.sent(TOKEN, 10).length === 1, "the answer");
const other = "654321:OTHER-BOT-TOKEN-VALUE";
bots.addBot(other, "second_bot");
const two = await w.dot();
// The check finds the bot free, and takes its time: the Dot answers meanwhile, so the cursor written for it is newer
// than the binding the call read. Another Dot's binding lands before this one's write, after the channel was stopped.
const listBindings = db.channels.listBindings.bind(db.channels);
const spy = vi.spyOn(db.channels, "listBindings").mockImplementationOnce(async (...args) => {
const seen = await listBindings(...args);
dot.guest.emit("message.assistant", { text: "during the check" });
await waitFor(() => bots.sent(TOKEN, 10).length === 2, "the answer given during the check");
await db.channels.createBinding({ id: "chb_rival", dotId: two.id, kind: "telegram", settings: DEFAULT_CHANNEL_SETTINGS, eventCursor: 0, account: "second_bot" });
return seen;
});
try {
await expect(hub.setCredentials(dot.id, "telegram", { telegram_bot_token: other })).rejects.toMatchObject({ status: 409, code: "account_in_use" });
} finally {
spy.mockRestore();
}
// It runs again on the token it had, and its Dot's answer is not sent a second time.
await waitFor(() => bots.polling(TOKEN), "the first bot to be polled again");
await waitFor(async () => (await hub.list(dot.id))[0]!.status === "connected", "connected again");
await new Promise((resolve) => setTimeout(resolve, 300));
expect(bots.sent(TOKEN, 10).map((m) => m.text)).toEqual(["echo: hello", "during the check"]);
expect(await db.secrets.get(dot.id, "telegram_bot_token")).toBe(TOKEN);
});
it("refuses as account_in_use a bot that another Dot takes between the check and the insert, and stores nothing", async () => {
const w = await world();
bots.addBot(TOKEN, "dot_helper_bot");
+12
View File
@@ -75,6 +75,18 @@ export async function migrate(
const migrations = await loadMigrations(options.dir);
const log = options.log ?? (() => {});
const result: MigrateResult = { applied: [], alreadyApplied: [] };
// A database that records a migration this build does not ship was migrated by another version (a newer one, or a
// build from another branch): its schema is not the one this code reads and writes, so nothing runs against it.
const shipped = new Set(migrations.map((migration) => migration.version));
const recorded = (await db.query<{ present: boolean }>("SELECT to_regclass('schema_migrations') IS NOT NULL AS present")).rows[0]?.present
? (await db.query<{ version: string }>("SELECT version FROM schema_migrations ORDER BY version")).rows.map((row) => row.version)
: [];
const unknown = recorded.filter((version) => !shipped.has(version));
if (unknown.length > 0) {
throw new Error(
`the database holds migrations this build does not ship (${unknown.join(", ")}): it was migrated by another version of invisible_dots; run that version, or start from a new data directory`,
);
}
for (const migration of migrations) {
let applied: boolean;
try {
+22
View File
@@ -284,6 +284,28 @@ describe.each(testAdapters())("migrate on %s", { timeout: SETUP_TIMEOUT }, (kind
}
});
it("refuses a database that records a migration this build does not ship, and changes nothing in it", async () => {
const { db, dispose } = await emptyDb(kind);
try {
await migrate(db);
await db.query("INSERT INTO schema_migrations (version) VALUES ('0099_from_another_branch')");
await expect(migrate(db)).rejects.toThrow(/does not ship \(0099_from_another_branch\).*another version/);
const { rows } = await db.query<{ n: number }>("SELECT count(*)::int AS n FROM schema_migrations");
expect(rows[0]!.n).toBe((await loadMigrations()).length + 1);
} finally {
await dispose();
}
});
it("does not mind a database nothing was ever applied to", async () => {
const { db, dispose } = await emptyDb(kind);
try {
expect((await migrate(db)).applied).toEqual((await loadMigrations()).map((m) => m.version));
} finally {
await dispose();
}
});
it("a failing migration is rolled back and named in the error", async () => {
const { db, dispose } = await emptyDb(kind);
const dir = await mkdtemp(join(tmpdir(), "idots-mig-"));
+1 -1
View File
@@ -394,7 +394,7 @@ export interface AgentStateAnswer {
* (409) or a call of the Dot holds it (503). The others (`frame_failed`, `crashed`) are shown with the answer's own
* message; a computer that is off answers the host's `COMPUTER_STOPPED`.
*/
export const FRAME_ERROR_CODES = { notOpen: "not_open", busy: "busy" } as const;
export const FRAME_ERROR_CODES = { notOpen: "not_open", busy: "busy" } as const satisfies Record<string, IdentityErrorCode>;
export const BROWSER_IDENTITY_STATUSES = ["available", "open", "archived"] as const;
export type BrowserIdentityStatus = (typeof BROWSER_IDENTITY_STATUSES)[number];
+6
View File
@@ -83,6 +83,12 @@ export const CONVERSATION_LIST_LIMIT = 500;
*/
export const APPROVAL_LIST_LIMIT = 500;
/** The most characters of a tool's arguments, and of the reason, an approval prompt shows in a chat (a chat is read by a third party). */
export const APPROVAL_PROMPT_TEXT_MAX = 300;
/** The longest note a person can add to the answer of an approval, in characters (`POST /api/approvals/:id/approve|reject`). */
export const APPROVAL_NOTE_MAX = 500;
/** The order a list route can be asked for: `asc` is the oldest first (the default of each route), `desc` the newest first. */
export const LIST_ORDERS = ["asc", "desc"] as const;
export type ListOrder = (typeof LIST_ORDERS)[number];
+2 -3
View File
@@ -10,8 +10,7 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { DEFAULT_LIFECYCLE_OPTIONS } from "../../apps/scheduler/src/lifecycle.js";
import { USAGE } from "../../apps/cli/src/cli.js";
import { ARGUMENTS_MAX } from "../../packages/channels/src/approval-text.js";
import { ENV, MAX_HOST_FILE_BYTES, parseDotConfig } from "../../packages/shared/src/index.js";
import { APPROVAL_PROMPT_TEXT_MAX, ENV, MAX_HOST_FILE_BYTES, parseDotConfig } from "../../packages/shared/src/index.js";
import { PRESET_IDS, PRESETS } from "../../apps/web/src/lib/permission-presets.js";
const repo = resolve(fileURLToPath(new URL(".", import.meta.url)), "../..");
@@ -135,7 +134,7 @@ describe("the numbers the README gives", () => {
expect(flat).toContain("(90 seconds ahead by default)");
expect(MAX_HOST_FILE_BYTES).toBe(16 * 1024 * 1024);
expect(flat).toContain("at most 16 MiB");
expect(ARGUMENTS_MAX).toBe(300);
expect(APPROVAL_PROMPT_TEXT_MAX).toBe(300);
expect(flat).toContain("the arguments cut to 300 characters");
});