toolroll 0.9.47 → 0.9.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts CHANGED
@@ -40,7 +40,7 @@ export type ParseResult = {
40
40
  error: string;
41
41
  };
42
42
  type Write = (line: string) => void;
43
- export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> add a project; it is in the console at once (no path: the repo you are in)\n toolroll repos remove <path>\n take it out of the console; its tasks and results stay\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
43
+ export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> add a project; it is in the console at once (no path: the repo you are in)\n toolroll repos remove <path>\n take it out of the console; its tasks and results stay\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll health recent server latency, write waits and live streams\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
44
44
  export declare function parseArgs(argv: readonly string[]): ParseResult;
45
45
  /** The commands that operate the queue rather than report on the world. */
46
46
  /** Every verb the CLI routes OUTSIDE runOperate. Exported (with
package/dist/cli.js CHANGED
@@ -28,6 +28,7 @@ import { COMMAND_GUIDE, SURFACE_NOTES, SURFACE_SCHEMA_VERSION } from "./surface.
28
28
  import { runSessionCommand, SESSION_CLI_ACTIONS } from "./session-cli.js";
29
29
  import { maybeRunTeamCommand, TEAM_CLI_ACTIONS } from "./team-cli.js";
30
30
  import { maybeRunRemoteCommand } from "./remote-exec.js";
31
+ import { runHealthCommand } from "./health-cli.js";
31
32
  import { discover, inspectAll } from "./discover.js";
32
33
  import { readPulls } from "./pulls.js";
33
34
  import { detectGraphs, chooseBackend, setupOptions, LABELS as GRAPH_LABELS, } from "./graph.js";
@@ -67,6 +68,7 @@ Usage
67
68
  toolroll up app + builder for every saved project — the normal start
68
69
  toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)
69
70
  toolroll status running, queued, ready results, release check and plan windows
71
+ toolroll health recent server latency, write waits and live streams
70
72
  toolroll integrations which integrations work, and what to do about the ones that don't (--json)
71
73
  toolroll session native coding sessions through the running service
72
74
  toolroll connect save a private connection to your central service
@@ -383,6 +385,8 @@ export async function main(argv, write = line => console.log(line), mainOptions
383
385
  }
384
386
  async function dispatch(argv, write, mainOptions) {
385
387
  // A saved API token sends commands marked remote to the central server, before anything local opens.
388
+ if (argv[0] === 'health')
389
+ return runHealthCommand(argv.slice(1), write, mainOptions.team ?? mainOptions.remote);
386
390
  const central = await maybeRunRemoteCommand(argv, mainOptions.remote ?? mainOptions.team);
387
391
  if (typeof central === "number")
388
392
  return central;
@@ -304,6 +304,93 @@ export declare const COMMAND_OUTPUTS: {
304
304
  reason: z.ZodString;
305
305
  message: z.ZodOptional<z.ZodString>;
306
306
  }, z.core.$loose>], "ok">;
307
+ readonly health: z.ZodDiscriminatedUnion<[z.ZodObject<{
308
+ envelopeVersion: z.ZodLiteral<1>;
309
+ ok: z.ZodLiteral<true>;
310
+ command: z.ZodLiteral<"health">;
311
+ health: z.ZodObject<{
312
+ version: z.ZodLiteral<1>;
313
+ windowSeconds: z.ZodLiteral<300>;
314
+ uptimeSeconds: z.ZodNumber;
315
+ requests: z.ZodObject<{
316
+ count: z.ZodNumber;
317
+ p50Ms: z.ZodNullable<z.ZodNumber>;
318
+ p99Ms: z.ZodNullable<z.ZodNumber>;
319
+ maxMs: z.ZodNullable<z.ZodNumber>;
320
+ totalMs: z.ZodNumber;
321
+ }, z.core.$loose>;
322
+ routes: z.ZodRecord<z.ZodEnum<{
323
+ assets: "assets";
324
+ auth: "auth";
325
+ chat: "chat";
326
+ "chat-stream": "chat-stream";
327
+ cli: "cli";
328
+ console: "console";
329
+ decisions: "decisions";
330
+ "flow-stream": "flow-stream";
331
+ flows: "flows";
332
+ health: "health";
333
+ mcp: "mcp";
334
+ metrics: "metrics";
335
+ other: "other";
336
+ projects: "projects";
337
+ results: "results";
338
+ sessions: "sessions";
339
+ settings: "settings";
340
+ tasks: "tasks";
341
+ team: "team";
342
+ "team-stream": "team-stream";
343
+ }> & z.core.$partial, z.ZodObject<{
344
+ count: z.ZodNumber;
345
+ p50Ms: z.ZodNullable<z.ZodNumber>;
346
+ p99Ms: z.ZodNullable<z.ZodNumber>;
347
+ maxMs: z.ZodNullable<z.ZodNumber>;
348
+ totalMs: z.ZodNumber;
349
+ }, z.core.$loose>>;
350
+ eventLoop: z.ZodObject<{
351
+ count: z.ZodNumber;
352
+ p50Ms: z.ZodNullable<z.ZodNumber>;
353
+ p99Ms: z.ZodNullable<z.ZodNumber>;
354
+ maxMs: z.ZodNullable<z.ZodNumber>;
355
+ totalMs: z.ZodNumber;
356
+ }, z.core.$loose>;
357
+ writes: z.ZodObject<{
358
+ wait: z.ZodObject<{
359
+ count: z.ZodNumber;
360
+ p50Ms: z.ZodNullable<z.ZodNumber>;
361
+ p99Ms: z.ZodNullable<z.ZodNumber>;
362
+ maxMs: z.ZodNullable<z.ZodNumber>;
363
+ totalMs: z.ZodNumber;
364
+ }, z.core.$loose>;
365
+ hold: z.ZodObject<{
366
+ count: z.ZodNumber;
367
+ p50Ms: z.ZodNullable<z.ZodNumber>;
368
+ p99Ms: z.ZodNullable<z.ZodNumber>;
369
+ maxMs: z.ZodNullable<z.ZodNumber>;
370
+ totalMs: z.ZodNumber;
371
+ }, z.core.$loose>;
372
+ statement: z.ZodObject<{
373
+ count: z.ZodNumber;
374
+ p50Ms: z.ZodNullable<z.ZodNumber>;
375
+ p99Ms: z.ZodNullable<z.ZodNumber>;
376
+ maxMs: z.ZodNullable<z.ZodNumber>;
377
+ totalMs: z.ZodNumber;
378
+ }, z.core.$loose>;
379
+ }, z.core.$loose>;
380
+ streams: z.ZodObject<{
381
+ open: z.ZodNumber;
382
+ queuedBytes: z.ZodNumber;
383
+ maxQueuedBytes: z.ZodNumber;
384
+ }, z.core.$loose>;
385
+ budgetRefusals: z.ZodNumber;
386
+ }, z.core.$loose>;
387
+ }, z.core.$loose>, z.ZodObject<{
388
+ envelopeVersion: z.ZodLiteral<1>;
389
+ ok: z.ZodLiteral<false>;
390
+ command: z.ZodLiteral<"health">;
391
+ reason: z.ZodString;
392
+ message: z.ZodOptional<z.ZodString>;
393
+ }, z.core.$loose>], "ok">;
307
394
  readonly integrations: z.ZodDiscriminatedUnion<[z.ZodObject<{
308
395
  envelopeVersion: z.ZodLiteral<1>;
309
396
  ok: z.ZodLiteral<true>;
@@ -12,6 +12,7 @@ import { z } from "zod";
12
12
  import { ENVELOPE_VERSION, envelopeJson } from "../envelope.js";
13
13
  import { parseContract } from "./contract.js";
14
14
  import { LISTED_TASK_STATES } from "./lead-tools.js";
15
+ import { healthSnapshotSchema } from './health.js';
15
16
  const str = z.string();
16
17
  const int = z.number().int();
17
18
  const list = z.array(z.unknown());
@@ -167,6 +168,7 @@ export const COMMAND_OUTPUTS = {
167
168
  contract: contractOutput,
168
169
  scan: scanOutput,
169
170
  status: statusOutput,
171
+ health: answers('health', { health: healthSnapshotSchema }),
170
172
  integrations: integrationsOutput,
171
173
  ready: z.union([
172
174
  answers("ready", { count: int, tasks: rows({ id: str, title: str, state: str }) }),
@@ -0,0 +1,84 @@
1
+ import { z } from 'zod';
2
+ export declare const latencySummarySchema: z.ZodObject<{
3
+ count: z.ZodNumber;
4
+ p50Ms: z.ZodNullable<z.ZodNumber>;
5
+ p99Ms: z.ZodNullable<z.ZodNumber>;
6
+ maxMs: z.ZodNullable<z.ZodNumber>;
7
+ totalMs: z.ZodNumber;
8
+ }, z.core.$loose>;
9
+ export declare const healthSnapshotSchema: z.ZodObject<{
10
+ version: z.ZodLiteral<1>;
11
+ windowSeconds: z.ZodLiteral<300>;
12
+ uptimeSeconds: z.ZodNumber;
13
+ requests: z.ZodObject<{
14
+ count: z.ZodNumber;
15
+ p50Ms: z.ZodNullable<z.ZodNumber>;
16
+ p99Ms: z.ZodNullable<z.ZodNumber>;
17
+ maxMs: z.ZodNullable<z.ZodNumber>;
18
+ totalMs: z.ZodNumber;
19
+ }, z.core.$loose>;
20
+ routes: z.ZodRecord<z.ZodEnum<{
21
+ assets: "assets";
22
+ auth: "auth";
23
+ chat: "chat";
24
+ "chat-stream": "chat-stream";
25
+ cli: "cli";
26
+ console: "console";
27
+ decisions: "decisions";
28
+ "flow-stream": "flow-stream";
29
+ flows: "flows";
30
+ health: "health";
31
+ mcp: "mcp";
32
+ metrics: "metrics";
33
+ other: "other";
34
+ projects: "projects";
35
+ results: "results";
36
+ sessions: "sessions";
37
+ settings: "settings";
38
+ tasks: "tasks";
39
+ team: "team";
40
+ "team-stream": "team-stream";
41
+ }> & z.core.$partial, z.ZodObject<{
42
+ count: z.ZodNumber;
43
+ p50Ms: z.ZodNullable<z.ZodNumber>;
44
+ p99Ms: z.ZodNullable<z.ZodNumber>;
45
+ maxMs: z.ZodNullable<z.ZodNumber>;
46
+ totalMs: z.ZodNumber;
47
+ }, z.core.$loose>>;
48
+ eventLoop: z.ZodObject<{
49
+ count: z.ZodNumber;
50
+ p50Ms: z.ZodNullable<z.ZodNumber>;
51
+ p99Ms: z.ZodNullable<z.ZodNumber>;
52
+ maxMs: z.ZodNullable<z.ZodNumber>;
53
+ totalMs: z.ZodNumber;
54
+ }, z.core.$loose>;
55
+ writes: z.ZodObject<{
56
+ wait: z.ZodObject<{
57
+ count: z.ZodNumber;
58
+ p50Ms: z.ZodNullable<z.ZodNumber>;
59
+ p99Ms: z.ZodNullable<z.ZodNumber>;
60
+ maxMs: z.ZodNullable<z.ZodNumber>;
61
+ totalMs: z.ZodNumber;
62
+ }, z.core.$loose>;
63
+ hold: z.ZodObject<{
64
+ count: z.ZodNumber;
65
+ p50Ms: z.ZodNullable<z.ZodNumber>;
66
+ p99Ms: z.ZodNullable<z.ZodNumber>;
67
+ maxMs: z.ZodNullable<z.ZodNumber>;
68
+ totalMs: z.ZodNumber;
69
+ }, z.core.$loose>;
70
+ statement: z.ZodObject<{
71
+ count: z.ZodNumber;
72
+ p50Ms: z.ZodNullable<z.ZodNumber>;
73
+ p99Ms: z.ZodNullable<z.ZodNumber>;
74
+ maxMs: z.ZodNullable<z.ZodNumber>;
75
+ totalMs: z.ZodNumber;
76
+ }, z.core.$loose>;
77
+ }, z.core.$loose>;
78
+ streams: z.ZodObject<{
79
+ open: z.ZodNumber;
80
+ queuedBytes: z.ZodNumber;
81
+ maxQueuedBytes: z.ZodNumber;
82
+ }, z.core.$loose>;
83
+ budgetRefusals: z.ZodNumber;
84
+ }, z.core.$loose>;
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ import { ROUTE_FAMILIES } from '../server-telemetry.js';
3
+ const count = z.number().int().nonnegative();
4
+ const milliseconds = z.number().nonnegative();
5
+ export const latencySummarySchema = z.looseObject({ count, p50Ms: milliseconds.nullable(), p99Ms: milliseconds.nullable(), maxMs: milliseconds.nullable(), totalMs: milliseconds });
6
+ export const healthSnapshotSchema = z.looseObject({
7
+ version: z.literal(1), windowSeconds: z.literal(300), uptimeSeconds: z.number().nonnegative(),
8
+ requests: latencySummarySchema, routes: z.partialRecord(z.enum(ROUTE_FAMILIES), latencySummarySchema), eventLoop: latencySummarySchema,
9
+ writes: z.looseObject({ wait: latencySummarySchema, hold: latencySummarySchema, statement: latencySummarySchema }),
10
+ streams: z.looseObject({ open: count, queuedBytes: count, maxQueuedBytes: count }), budgetRefusals: count,
11
+ });
@@ -94,6 +94,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
+ version?: never;
97
98
  active: boolean;
98
99
  phase: string;
99
100
  detail: string;
@@ -107,7 +108,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
107
108
  wasRunning?: never;
108
109
  backupPath?: never;
109
110
  updateId?: never;
110
- version?: never;
111
111
  buildId?: never;
112
112
  workDir?: never;
113
113
  } | {
@@ -1,24 +1,16 @@
1
- import type { ServerResponse } from "node:http";
1
+ import { type LiveRoomOptions, type LiveRooms, type LiveViewer } from "./live-bus.js";
2
2
  import type { Store } from "./store.js";
3
3
  export type FlowPresence = {
4
4
  name: string;
5
5
  cards: number[];
6
6
  editing: boolean;
7
7
  };
8
- export type FlowViewer = {
9
- name: string;
8
+ export type FlowViewer = LiveViewer & {
10
9
  card: number | null;
11
10
  editing: boolean;
12
- response: ServerResponse;
13
- valid: () => boolean;
14
11
  };
15
12
  /** What changes when anything on the canvas changes: metadata only, kept short and opaque. */
16
13
  export declare function flowFingerprint(store: Store, flow: number, now?: Date): string | null;
17
- export type FlowRooms = {
18
- /** Keep this page's stream on the flow until it closes; the first event says where the flow stands. */
19
- join: (flow: number, viewer: FlowViewer) => void;
20
- /** Every stream, ended (the server is closing). */
21
- close: () => void;
22
- size: () => number;
23
- };
24
- export declare function createFlowRooms(fingerprint: (flow: number) => string | null, tickMs?: number): FlowRooms;
14
+ export type FlowRooms = LiveRooms<number, FlowViewer>;
15
+ /** Rooms keyed by flow. With a bus, a write reaches every open page at once; without one, only the safety net. */
16
+ export declare function createFlowRooms(fingerprint: (flow: number) => string | null, options?: LiveRoomOptions): FlowRooms;
package/dist/flow-live.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * The live canvas (v88): a flow open in several browsers stays the same in
3
3
  * all of them, and each shows who else has it open.
4
4
  *
5
- * - A change nudge. While anyone has a flow open, a small read each second
6
- * checks the flow's fingerprint (its revision, cards, moves, comments,
7
- * watchers, step runs, linked task progress and triggers); when it changes, every open page
5
+ * - A change nudge. When the live bus says the workspace was written
6
+ * (live-bus.ts), one small read checks the flow's fingerprint (its revision, cards, moves, comments,
7
+ * watchers, step runs, linked task progress and triggers); when it changed, every open page
8
8
  * hears "change" and reads the flow again the usual way. The nudge
9
9
  * carries no card data: what a page shows is always what its own read
10
10
  * returned, under its own checks.
@@ -13,13 +13,11 @@
13
13
  * name. It lives in memory only and goes when the page closes or hides.
14
14
  *
15
15
  * Streams are refresh hints only: nothing here writes, and a page that
16
- * can't keep one open falls back to reading every few seconds.
16
+ * can't keep one open falls back to reading every few seconds. With nothing
17
+ * written, an open flow costs no reads; a 30-second check heals a miss.
17
18
  */
18
19
  import { createHash } from "node:crypto";
19
- const TICK_MS = 1_000;
20
- const CHECK_EVERY = 15;
21
- const HEARTBEAT_EVERY = 15;
22
- const PRESENCE_SETTLE_MS = 150;
20
+ import { createLiveRooms } from "./live-bus.js";
23
21
  // Statements belong to their database, without keeping closed Stores alive.
24
22
  const fingerprintStatements = new WeakMap();
25
23
  function fingerprintStatement(store) {
@@ -94,17 +92,12 @@ export function flowFingerprint(store, flow, now = new Date()) {
94
92
  const row = fingerprintStatement(store).get({ $flow: flow, $now: now.toISOString() });
95
93
  return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
96
94
  }
97
- export function createFlowRooms(fingerprint, tickMs = TICK_MS) {
98
- const rooms = new Map();
99
- const write = (viewer, event, data) => {
100
- if (viewer.response.writableEnded || viewer.response.destroyed)
101
- return;
102
- viewer.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
103
- };
95
+ /** Rooms keyed by flow. With a bus, a write reaches every open page at once; without one, only the safety net. */
96
+ export function createFlowRooms(fingerprint, options = {}) {
104
97
  /** Everyone on the flow but you, one line per person however many pages they have open. */
105
- const others = (room, me) => {
98
+ const others = (viewers, me) => {
106
99
  const people = new Map();
107
- for (const one of room.viewers) {
100
+ for (const one of viewers) {
108
101
  if (one.name === me)
109
102
  continue;
110
103
  const seen = people.get(one.name) ?? { name: one.name, cards: [], editing: false };
@@ -115,97 +108,5 @@ export function createFlowRooms(fingerprint, tickMs = TICK_MS) {
115
108
  }
116
109
  return [...people.values()].sort((a, b) => a.name.localeCompare(b.name));
117
110
  };
118
- const announce = (room) => {
119
- // A page that moves from one card to the next leaves and joins at once: say it once, after it settles.
120
- if (room.settling !== null)
121
- clearTimeout(room.settling);
122
- room.settling = setTimeout(() => {
123
- room.settling = null;
124
- for (const viewer of room.viewers)
125
- write(viewer, "here", { people: others(room, viewer.name) });
126
- }, PRESENCE_SETTLE_MS);
127
- room.settling.unref();
128
- };
129
- const leave = (flow, viewer) => {
130
- const room = rooms.get(flow);
131
- if (room === undefined || !room.viewers.delete(viewer))
132
- return;
133
- if (!viewer.response.writableEnded)
134
- viewer.response.end();
135
- if (room.viewers.size > 0) {
136
- announce(room);
137
- return;
138
- }
139
- if (room.timer !== null)
140
- clearInterval(room.timer);
141
- if (room.settling !== null)
142
- clearTimeout(room.settling);
143
- rooms.delete(flow);
144
- };
145
- const tick = (flow, room) => {
146
- room.ticks += 1;
147
- if (room.ticks % CHECK_EVERY === 0) {
148
- // A signed-out or narrowed account stops hearing about the flow.
149
- for (const viewer of [...room.viewers])
150
- if (!viewer.valid()) {
151
- write(viewer, "gone", {});
152
- leave(flow, viewer);
153
- }
154
- if (!rooms.has(flow))
155
- return;
156
- }
157
- let now;
158
- try {
159
- now = fingerprint(flow);
160
- }
161
- catch {
162
- return; /* a busy database: the next tick reads again */
163
- }
164
- if (now === null) {
165
- for (const viewer of [...room.viewers]) {
166
- write(viewer, "gone", {});
167
- leave(flow, viewer);
168
- }
169
- return;
170
- }
171
- if (now !== room.last) {
172
- room.last = now;
173
- for (const viewer of room.viewers)
174
- write(viewer, "change", { at: now });
175
- }
176
- else if (room.ticks % HEARTBEAT_EVERY === 0) {
177
- for (const viewer of room.viewers)
178
- if (!viewer.response.writableEnded)
179
- viewer.response.write(": keep-alive\n\n");
180
- }
181
- };
182
- return {
183
- join(flow, viewer) {
184
- let room = rooms.get(flow);
185
- if (room === undefined) {
186
- let last = null;
187
- try {
188
- last = fingerprint(flow);
189
- }
190
- catch { /* the first tick reads it */ }
191
- room = { viewers: new Set(), last, timer: null, ticks: 0, settling: null };
192
- const created = room;
193
- room.timer = setInterval(() => tick(flow, created), tickMs);
194
- room.timer.unref();
195
- rooms.set(flow, room);
196
- }
197
- room.viewers.add(viewer);
198
- write(viewer, "change", { at: room.last });
199
- write(viewer, "here", { people: others(room, viewer.name) });
200
- announce(room);
201
- const gone = () => leave(flow, viewer);
202
- viewer.response.once("close", gone);
203
- },
204
- close() {
205
- for (const [flow, room] of [...rooms])
206
- for (const viewer of [...room.viewers])
207
- leave(flow, viewer);
208
- },
209
- size: () => [...rooms.values()].reduce((sum, room) => sum + room.viewers.size, 0),
210
- };
111
+ return createLiveRooms(fingerprint, others, options);
211
112
  }
@@ -0,0 +1,3 @@
1
+ import { type TeamCliOptions } from './team-cli.js';
2
+ /** Read the running server, never a fresh CLI process's empty counters or the local database. */
3
+ export declare function runHealthCommand(argv: readonly string[], write: (line: string) => void, options?: TeamCliOptions): Promise<number>;
@@ -0,0 +1,79 @@
1
+ import { bearerOf, centralProfile } from './team-cli.js';
2
+ import { envelopeJson } from './envelope.js';
3
+ import { healthSnapshotSchema } from './contracts/health.js';
4
+ /** Read the running server, never a fresh CLI process's empty counters or the local database. */
5
+ export async function runHealthCommand(argv, write, options = {}) {
6
+ const json = argv.includes('--json');
7
+ const fail = (message, reason = 'unavailable', code = 1) => {
8
+ write(json ? envelopeJson({ command: 'health', ok: false, reason, message }) : message);
9
+ return code;
10
+ };
11
+ if (argv.includes('--help')) {
12
+ write('toolroll health [--profile <name>] [--json]\nRecent latency, event-loop delay, write waits and live streams from the connected server. An instance operator must sign in with toolroll connect first.');
13
+ return 0;
14
+ }
15
+ let profileName;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ if (argv[i] === '--json')
18
+ continue;
19
+ if (argv[i] === '--profile' && profileName === undefined && argv[i + 1] && !argv[i + 1].startsWith('-')) {
20
+ profileName = argv[++i];
21
+ continue;
22
+ }
23
+ return fail('Use toolroll health [--profile <name>] [--json].', 'usage', 2);
24
+ }
25
+ try {
26
+ const profile = centralProfile(options, profileName);
27
+ if (!profile)
28
+ return fail('Connect to the server first with toolroll connect, then run toolroll health.', 'not-connected', 2);
29
+ const timeout = AbortSignal.timeout(30_000);
30
+ const response = await (options.fetch ?? fetch)(`${profile.origin}/health`, {
31
+ redirect: 'manual', credentials: 'omit', signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
32
+ headers: { authorization: bearerOf(profile), accept: json ? 'application/json' : 'text/plain' },
33
+ });
34
+ if (response.status !== 200) {
35
+ await response.body?.cancel();
36
+ return fail(response.status === 401 || response.status === 403 || response.status === 302
37
+ ? 'Sign in as an instance operator to read server health.'
38
+ : response.status === 404 ? 'This server does not provide performance health yet. Update the server.' : 'Server health is unavailable. Try again shortly.');
39
+ }
40
+ // A health response is bounded by the fixed route vocabulary. Refuse unrelated HTML and oversized replies.
41
+ const reader = response.body?.getReader();
42
+ if (!reader)
43
+ return fail('The server returned no health measurements.');
44
+ const chunks = [];
45
+ let size = 0;
46
+ try {
47
+ for (;;) {
48
+ const part = await reader.read();
49
+ if (part.done)
50
+ break;
51
+ size += part.value.byteLength;
52
+ if (size > 128 * 1024) {
53
+ await reader.cancel();
54
+ return fail('The server returned an unexpected health response.');
55
+ }
56
+ chunks.push(part.value);
57
+ }
58
+ }
59
+ finally {
60
+ reader.releaseLock();
61
+ }
62
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
63
+ if (json) {
64
+ const parsed = healthSnapshotSchema.safeParse(JSON.parse(text));
65
+ if (!parsed.success)
66
+ return fail('The server returned an unexpected health response.');
67
+ write(envelopeJson({ command: 'health', ok: true, health: parsed.data }));
68
+ }
69
+ else {
70
+ if (!response.headers.get('content-type')?.startsWith('text/plain') || !text.startsWith('Server health'))
71
+ return fail('The server returned an unexpected health response.');
72
+ write(text.trimEnd());
73
+ }
74
+ return 0;
75
+ }
76
+ catch {
77
+ return fail('Could not read server health. Check the saved connection and server availability.');
78
+ }
79
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Live views push on write. Nothing here polls once a second.
3
+ *
4
+ * - The bus. The database's own revision triggers (workspace-revision.ts)
5
+ * move one counter on every meaningful write. After each in-process
6
+ * transact() COMMIT, and whenever another process (the worker, the CLI)
7
+ * writes the database's write-ahead log, the revision is read once; when
8
+ * it moved, every subscriber hears `{ revision }`. A rolled-back or quiet
9
+ * write moves nothing and says nothing. A page left open with nothing
10
+ * being written costs no database work at all.
11
+ * - The rooms. Each open task or flow is a room keyed by what it shows.
12
+ * When the bus speaks, a room rechecks every viewer's access (a signed-out
13
+ * or narrowed account is dropped), takes its fingerprint once, and only if
14
+ * that moved tells its pages `change` with the fingerprint and revision —
15
+ * an invalidation, never content. The page reads itself again under its
16
+ * own checks, so redaction stays the page's.
17
+ * - The safety net. Every room also checks itself about every 30 s, so a
18
+ * missed signal heals, and sends a keep-alive between.
19
+ * - Back-pressure. Each page's stream is bounded: a page that falls behind
20
+ * gets one `reload` and ends so its EventSource can reconnect.
21
+ *
22
+ * Transport seam: a future WebSocket (live cursors) subscribes to the same
23
+ * LiveBus beside these SSE rooms; nothing in the Store or the triggers changes.
24
+ */
25
+ import { type FSWatcher } from "node:fs";
26
+ import type { ServerResponse } from "node:http";
27
+ import type { Store } from "./store.js";
28
+ export type LiveChange = {
29
+ revision: string;
30
+ };
31
+ export type LiveBus = {
32
+ subscribe: (listener: (change: LiveChange) => void) => () => void;
33
+ publish: (change: LiveChange) => void;
34
+ /** The last revision heard, if any. */
35
+ revision: () => string | null;
36
+ listeners: () => number;
37
+ };
38
+ export declare function createLiveBus(): LiveBus;
39
+ export type WorkspaceFollower = {
40
+ close: () => void; /** Read the revision now (tests, and a commit signal). */
41
+ check: () => void;
42
+ };
43
+ /**
44
+ * Publish the workspace revision when it moves: after this process commits,
45
+ * and when the database's write-ahead log changes under another process.
46
+ * Signals in one turn of the event loop coalesce into one read.
47
+ */
48
+ export declare function followWorkspace(store: Store, current: () => string, bus: LiveBus, options?: {
49
+ file?: string | null;
50
+ watchFile?: (path: string, listener: () => void) => FSWatcher | null;
51
+ }): WorkspaceFollower;
52
+ /** About every 30 s a room checks itself; a keep-alive goes between. */
53
+ export declare const SAFETY_NET_MS = 30000;
54
+ export declare const KEEP_ALIVE_MS = 15000;
55
+ /** What a page's stream may hold unsent before it is "behind". */
56
+ export declare const STREAM_LIMIT_BYTES: number;
57
+ /** One page's stream, bounded. Overflow is terminal, even below Node's drain threshold. */
58
+ export declare class LiveStream {
59
+ readonly response: ServerResponse;
60
+ private readonly limit;
61
+ private readonly onOverflow;
62
+ private ended;
63
+ constructor(response: ServerResponse, limit?: number, onOverflow?: () => void);
64
+ get open(): boolean;
65
+ private overflow;
66
+ private write;
67
+ send(event: string, data: unknown): void;
68
+ keepAlive(): void;
69
+ }
70
+ export type LiveViewer = {
71
+ name: string;
72
+ response: ServerResponse;
73
+ valid: () => boolean;
74
+ };
75
+ export type LiveRooms<K, V extends LiveViewer> = {
76
+ /** Keep this page's stream on the room until it closes; the first events say where it stands and who's here. */
77
+ join: (key: K, viewer: V) => void;
78
+ /** Every stream, ended (the server is closing). */
79
+ close: () => void;
80
+ size: () => number;
81
+ };
82
+ export type LiveRoomOptions = {
83
+ bus?: LiveBus | null;
84
+ safetyNetMs?: number;
85
+ keepAliveMs?: number;
86
+ limitBytes?: number;
87
+ };
88
+ /** Rooms of open pages that hear the bus. `presence` says who else is here, as each page should see it. */
89
+ export declare function createLiveRooms<K, V extends LiveViewer>(fingerprint: (key: K) => string | null, presence: (viewers: readonly V[], me: string) => unknown, options?: LiveRoomOptions): LiveRooms<K, V>;