toolroll 0.9.46 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/activity-line.d.ts +26 -0
  3. package/dist/activity-line.js +47 -0
  4. package/dist/browser/workspace.js +25 -25
  5. package/dist/browser-workspace.d.ts +18 -1
  6. package/dist/browser-workspace.js +9 -3
  7. package/dist/builder.js +4 -2
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +4 -0
  10. package/dist/contracts/cli.d.ts +87 -0
  11. package/dist/contracts/cli.js +2 -0
  12. package/dist/contracts/health.d.ts +84 -0
  13. package/dist/contracts/health.js +11 -0
  14. package/dist/flow-card-task.d.ts +2 -0
  15. package/dist/flow-card-task.js +57 -0
  16. package/dist/flow-live.d.ts +7 -15
  17. package/dist/flow-live.js +77 -115
  18. package/dist/flows-ui.js +15 -1
  19. package/dist/health-cli.d.ts +3 -0
  20. package/dist/health-cli.js +79 -0
  21. package/dist/live-bus.d.ts +89 -0
  22. package/dist/live-bus.js +321 -0
  23. package/dist/live.d.ts +14 -0
  24. package/dist/live.js +71 -22
  25. package/dist/metrics.d.ts +3 -0
  26. package/dist/metrics.js +53 -1
  27. package/dist/operate.js +43 -25
  28. package/dist/planner.js +4 -3
  29. package/dist/read-executor-worker.d.ts +1 -0
  30. package/dist/read-executor-worker.js +26 -0
  31. package/dist/read-executor.d.ts +49 -0
  32. package/dist/read-executor.js +119 -0
  33. package/dist/request-budget.d.ts +1 -0
  34. package/dist/request-budget.js +6 -0
  35. package/dist/serve.d.ts +7 -0
  36. package/dist/serve.js +118 -42
  37. package/dist/server-telemetry.d.ts +86 -0
  38. package/dist/server-telemetry.js +263 -0
  39. package/dist/setup-guide.d.ts +2 -2
  40. package/dist/sqlite-telemetry.d.ts +9 -0
  41. package/dist/sqlite-telemetry.js +98 -0
  42. package/dist/store.d.ts +22 -0
  43. package/dist/store.js +59 -3
  44. package/dist/surface.js +2 -1
  45. package/dist/task-activity.d.ts +27 -0
  46. package/dist/task-activity.js +53 -0
  47. package/dist/task-live.d.ts +8 -0
  48. package/dist/task-live.js +47 -0
  49. package/dist/work-index.js +12 -1
  50. package/dist/workspace-revision.d.ts +2 -0
  51. package/dist/workspace-revision.js +3 -1
  52. package/package.json +1 -1
package/dist/planner.js CHANGED
@@ -31,6 +31,7 @@ import { TOKEN_ENVS as TELEGRAM_TOKEN_ENVS } from "./telegram.js";
31
31
  import { evidenceRoot, mailboxName, planFileName, quarantineMailboxes, readMailbox, storeEvidence, writeEvidenceFile, } from "./evidence.js";
32
32
  import { MARKER as LEASE_MARKER } from "./worktree.js";
33
33
  import { openLiveLog } from "./live.js";
34
+ import { withRunActivity } from "./task-activity.js";
34
35
  import { proveTreeUntouched, snapshotIgnored } from "./tree-proof.js";
35
36
  import { CLAUDE_LIMITS, ACCEPTANCE_LIMITS } from "./scope.js";
36
37
  import { normalizeStructuredJson, storeStructuredAttempt, validationErrorsJson, STRUCTURED_REPAIR_ATTEMPTS, STRUCTURED_REPAIR_MAX_TURNS, STRUCTURED_REPAIR_TIMEOUT_MS, } from "./structured-output.js";
@@ -590,7 +591,7 @@ export async function plan(store, request) {
590
591
  }
591
592
  // The live window (peek): the same transcript file the builder keeps,
592
593
  // so `toolroll peek` and the run page can watch this session too.
593
- const liveLog = openLiveLog(root, request.runId);
594
+ const liveLog = withRunActivity(openLiveLog(root, request.runId), store, request.runId, clock);
594
595
  // The durable run row, not a caller's duplicate options, is the route
595
596
  // every correction inherits.
596
597
  const provider = logicalRun.provider;
@@ -730,7 +731,7 @@ export async function plan(store, request) {
730
731
  ...(agent === undefined ? {} : { runner: agent }),
731
732
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
732
733
  clock,
733
- ...(liveLog === null ? {} : { onStreamEvent: (event) => liveLog.observe(event) }),
734
+ onStreamEvent: (event) => liveLog.observe(event),
734
735
  });
735
736
  const firstProof = await proveAfterInvocation();
736
737
  if (firstProof !== null)
@@ -883,7 +884,7 @@ export async function plan(store, request) {
883
884
  ...(agent === undefined ? {} : { runner: agent }),
884
885
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
885
886
  clock,
886
- ...(liveLog === null ? {} : { onStreamEvent: (event) => liveLog.observe(event) }),
887
+ onStreamEvent: (event) => liveLog.observe(event),
887
888
  });
888
889
  }
889
890
  catch (error) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { openStoreReadOnly } from './store.js';
3
+ import { workIndexPage } from './work-index.js';
4
+ /** One read-only, query-only connection per worker (read-executor.ts). Jobs arrive one at a time. */
5
+ let store = null;
6
+ function connection() {
7
+ if (store !== null)
8
+ return store;
9
+ const opened = openStoreReadOnly(String(workerData.file));
10
+ if (opened === null)
11
+ throw new Error('The read worker could not open the database read-only at the current schema.');
12
+ opened.handle.exec('PRAGMA query_only=1');
13
+ return store = opened;
14
+ }
15
+ parentPort.on('message', ({ id, job }) => {
16
+ let reply;
17
+ try {
18
+ if (job.kind !== 'work-index-page')
19
+ throw new Error('Unknown read.');
20
+ reply = { id, ok: true, value: workIndexPage(connection(), new Date(job.now), job.access, job.options) };
21
+ }
22
+ catch (error) {
23
+ reply = { id, ok: false, error: { name: error instanceof Error ? error.name : 'Error', message: error instanceof Error ? error.message : String(error) } };
24
+ }
25
+ parentPort.postMessage(reply);
26
+ });
@@ -0,0 +1,49 @@
1
+ import type { Store } from './store.js';
2
+ import { type WorkIndexOptions, type WorkIndexPage } from './work-index.js';
3
+ import type { WorkSummaryAccess } from './work-summary.js';
4
+ /**
5
+ * Heavy, read-only database work for the server, run on worker threads (docs/server-performance.md).
6
+ * Each worker owns one read-only, query-only connection to the server's own database file, so the
7
+ * measured hot read (the work index behind `task list`) never holds the HTTP event loop. Callers
8
+ * authorize first and send only the already-admitted access; a worker never widens it, writes, or
9
+ * retries. Without a file-backed database (tests, the demo) nothing is attached and callers keep
10
+ * running the same builder in-process.
11
+ */
12
+ export type ReadJob = {
13
+ kind: 'work-index-page';
14
+ now: string;
15
+ access: WorkSummaryAccess;
16
+ options: WorkIndexOptions;
17
+ };
18
+ export type ReadReply = {
19
+ id: number;
20
+ ok: true;
21
+ value: unknown;
22
+ } | {
23
+ id: number;
24
+ ok: false;
25
+ error: {
26
+ name: string;
27
+ message: string;
28
+ };
29
+ };
30
+ /** More queued reads than this fail at once instead of growing without bound. */
31
+ export declare const READ_QUEUE_MAX = 1000;
32
+ export declare class ReadExecutorError extends Error {
33
+ constructor(message: string);
34
+ }
35
+ export declare class ReadExecutor {
36
+ #private;
37
+ constructor(file: string, size?: number);
38
+ get size(): number;
39
+ /** workIndexPage(store, now, access, options) on a worker's read-only connection: same rows, same errors. */
40
+ workIndexPage(now: Date, access: WorkSummaryAccess, options: WorkIndexOptions): Promise<WorkIndexPage>;
41
+ /** Fails every queued and running read once, then stops the workers. */
42
+ close(): Promise<void>;
43
+ }
44
+ /** Half the cores, at most eight: the measured 20-engineer load needs over four (docs/server-performance.md),
45
+ * and the rest stay with the event loop and local agents. Workers start only when reads arrive. */
46
+ export declare function defaultReadWorkers(): number;
47
+ /** The server attaches its executor to its own Store; a command run on that Store reads through it. */
48
+ export declare function attachReadExecutor(store: Store, executor: ReadExecutor): () => void;
49
+ export declare function readExecutorOf(store: Store): ReadExecutor | null;
@@ -0,0 +1,119 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { availableParallelism } from 'node:os';
3
+ import { WorkIndexCursorError } from './work-index.js';
4
+ /** More queued reads than this fail at once instead of growing without bound. */
5
+ export const READ_QUEUE_MAX = 1_000;
6
+ export class ReadExecutorError extends Error {
7
+ constructor(message) { super(message); this.name = 'ReadExecutorError'; }
8
+ }
9
+ export class ReadExecutor {
10
+ #file;
11
+ #size;
12
+ #slots = [];
13
+ #queue = [];
14
+ #next = 1;
15
+ #closed = false;
16
+ constructor(file, size = defaultReadWorkers()) {
17
+ this.#file = file;
18
+ this.#size = Math.max(1, Math.floor(size));
19
+ }
20
+ get size() { return this.#size; }
21
+ /** workIndexPage(store, now, access, options) on a worker's read-only connection: same rows, same errors. */
22
+ async workIndexPage(now, access, options) {
23
+ try {
24
+ return await this.#run({ kind: 'work-index-page', now: now.toISOString(), access, options });
25
+ }
26
+ catch (error) {
27
+ if (error instanceof Error && error.name === 'WorkIndexCursorError')
28
+ throw new WorkIndexCursorError();
29
+ throw error;
30
+ }
31
+ }
32
+ #run(job) {
33
+ if (this.#closed)
34
+ return Promise.reject(new ReadExecutorError('The server is closing.'));
35
+ if (this.#queue.length >= READ_QUEUE_MAX)
36
+ return Promise.reject(new ReadExecutorError('The server is too busy to read this now; try again shortly.'));
37
+ return new Promise((resolve, reject) => {
38
+ this.#queue.push({ id: this.#next++, job, resolve, reject });
39
+ this.#pump();
40
+ });
41
+ }
42
+ #pump() {
43
+ while (this.#queue.length > 0) {
44
+ let slot = this.#slots.find(one => one.busy === null);
45
+ if (slot === undefined && this.#slots.length < this.#size)
46
+ slot = this.#spawn();
47
+ if (slot === undefined)
48
+ return;
49
+ const pending = this.#queue.shift();
50
+ slot.busy = pending;
51
+ slot.worker.postMessage({ id: pending.id, job: pending.job });
52
+ }
53
+ }
54
+ #spawn() {
55
+ // The installed server runs the built sibling; tests run the TypeScript source through tsx.
56
+ const worker = import.meta.url.endsWith('.ts')
57
+ ? new Worker(`import('tsx/esm/api').then(tsx => { tsx.register(); return import(${JSON.stringify(new URL('./read-executor-worker.ts', import.meta.url).href)}); });`, { eval: true, workerData: { file: this.#file } })
58
+ : new Worker(new URL('./read-executor-worker.js', import.meta.url), { workerData: { file: this.#file } });
59
+ const slot = { worker, busy: null };
60
+ this.#slots.push(slot);
61
+ worker.on('message', (reply) => {
62
+ const pending = slot.busy;
63
+ if (pending === null || pending.id !== reply.id)
64
+ return;
65
+ slot.busy = null;
66
+ if (reply.ok)
67
+ pending.resolve(reply.value);
68
+ else
69
+ pending.reject(Object.assign(new Error(reply.error.message), { name: reply.error.name }));
70
+ this.#pump();
71
+ });
72
+ // A crashed worker fails only the read it held; the next read starts a fresh one.
73
+ const lost = (error) => {
74
+ const at = this.#slots.indexOf(slot);
75
+ if (at < 0)
76
+ return;
77
+ this.#slots.splice(at, 1);
78
+ const pending = slot.busy;
79
+ slot.busy = null;
80
+ pending?.reject(error);
81
+ if (!this.#closed)
82
+ this.#pump();
83
+ };
84
+ worker.on('error', error => lost(error instanceof Error ? error : new ReadExecutorError(String(error))));
85
+ worker.on('exit', code => lost(new ReadExecutorError(`The read worker stopped (exit ${code}).`)));
86
+ worker.unref();
87
+ return slot;
88
+ }
89
+ /** Fails every queued and running read once, then stops the workers. */
90
+ async close() {
91
+ if (this.#closed)
92
+ return;
93
+ this.#closed = true;
94
+ const stopped = new ReadExecutorError('The server is closing.');
95
+ for (const pending of this.#queue.splice(0))
96
+ pending.reject(stopped);
97
+ const slots = this.#slots.splice(0);
98
+ for (const slot of slots) {
99
+ slot.busy?.reject(stopped);
100
+ slot.busy = null;
101
+ }
102
+ await Promise.all(slots.map(slot => slot.worker.terminate().catch(() => 0)));
103
+ }
104
+ }
105
+ /** Half the cores, at most eight: the measured 20-engineer load needs over four (docs/server-performance.md),
106
+ * and the rest stay with the event loop and local agents. Workers start only when reads arrive. */
107
+ export function defaultReadWorkers() {
108
+ return Math.max(1, Math.min(8, Math.floor(availableParallelism() / 2)));
109
+ }
110
+ const attached = new WeakMap();
111
+ /** The server attaches its executor to its own Store; a command run on that Store reads through it. */
112
+ export function attachReadExecutor(store, executor) {
113
+ attached.set(store, executor);
114
+ return () => { if (attached.get(store) === executor)
115
+ attached.delete(store); };
116
+ }
117
+ export function readExecutorOf(store) {
118
+ return attached.get(store) ?? null;
119
+ }
@@ -88,6 +88,7 @@ export declare class RequestBudget {
88
88
  * read. Anything unreadable refuses with 503 and nothing runs.
89
89
  */
90
90
  admit(tokenId: string, route: BudgetRoute): Admission;
91
+ private check;
91
92
  /** Save every changed entry. A server calls this on a timer and when it closes. */
92
93
  flush(): void;
93
94
  private save;
@@ -123,6 +123,12 @@ export class RequestBudget {
123
123
  * read. Anything unreadable refuses with 503 and nothing runs.
124
124
  */
125
125
  admit(tokenId, route) {
126
+ const result = this.check(tokenId, route);
127
+ if (!result.ok)
128
+ this.store.telemetry.budgetRefused(route, result.status === 429 ? result.limit : 'unavailable');
129
+ return result;
130
+ }
131
+ check(tokenId, route) {
126
132
  try {
127
133
  const now = this.clock();
128
134
  const token = this.store.apiTokenSecret(tokenId)?.row;
package/dist/serve.d.ts CHANGED
@@ -33,6 +33,8 @@ export type ServeOptions = {
33
33
  installBin?: string;
34
34
  /** Native coding workspace injection for isolated integration tests. */
35
35
  codingWorkspace?: CodingWorkspace;
36
+ /** Read-only worker connections for heavy reads (read-executor.ts); 0 reads in-process. Default: off under tests. */
37
+ readWorkers?: number;
36
38
  store: Store;
37
39
  evidenceRoot: string;
38
40
  clock?: () => Date;
@@ -274,6 +276,11 @@ type Chrome = {
274
276
  scope?: "all" | "project" | "board-all";
275
277
  /** The saturated inbox count — never a sum of unbounded list reads. */
276
278
  inboxCount: number;
279
+ /** The project the Tasks count covers: the request's own project view
280
+ * (the open project, or /work's ?project=), null for every admitted project. */
281
+ inboxProject?: string | null;
282
+ /** The Tasks badge's words, naming what it covers: "3 need you in shop". */
283
+ inboxLabel?: string;
277
284
  inboxSaturated: boolean;
278
285
  settings: boolean;
279
286
  /** This database is a demo sandbox: banner every page, spend fenced. */