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.
@@ -0,0 +1,98 @@
1
+ const states = new WeakMap();
2
+ /** One observation for the entire bounded BEGIN retry, including a failed acquisition. */
3
+ export function measureWriteWait(db, body) {
4
+ const state = states.get(db);
5
+ if (!state || state.measuringWait)
6
+ return body();
7
+ const started = state.telemetry.clock();
8
+ state.measuringWait = true;
9
+ try {
10
+ return body();
11
+ }
12
+ finally {
13
+ state.measuringWait = false;
14
+ state.telemetry.writeWait.observe((state.telemetry.clock() - started) / 1000);
15
+ }
16
+ }
17
+ /** Preserve SQLite calls, timeouts and transaction semantics. No SQL or parameters leave this wrapper.
18
+ * Native SQLite does not expose busy-handler time for autocommit writes: report their whole duration separately.
19
+ * Runtime explicit IMMEDIATE/EXCLUSIVE transactions report acquisition and hold; startup migrations are excluded.
20
+ */
21
+ export function instrumentDatabase(db, telemetry) {
22
+ const state = { telemetry, measuringWait: false };
23
+ let heldAt = null;
24
+ const elapsed = (at) => (telemetry.clock() - at) / 1000;
25
+ const operation = (sql) => sql.replace(/^(?:\s|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)+/, '').trim();
26
+ const writing = (sql) => /^(?:INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER)\b/i.test(sql) || /^WITH\b[\s\S]*\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i.test(sql);
27
+ const standalone = (write, body) => {
28
+ if (!write || heldAt !== null)
29
+ return body();
30
+ const at = telemetry.clock();
31
+ try {
32
+ return body();
33
+ }
34
+ finally {
35
+ telemetry.writeStatement.observe(elapsed(at));
36
+ }
37
+ };
38
+ const finish = () => { if (heldAt !== null) {
39
+ telemetry.writeHold.observe(elapsed(heldAt));
40
+ heldAt = null;
41
+ } };
42
+ const methods = {
43
+ exec(sql) {
44
+ const op = operation(sql);
45
+ if (/^BEGIN\s+(?:IMMEDIATE|EXCLUSIVE)\b/i.test(op)) {
46
+ const at = telemetry.clock();
47
+ try {
48
+ db.exec(sql);
49
+ heldAt = telemetry.clock();
50
+ }
51
+ finally {
52
+ if (!state.measuringWait)
53
+ telemetry.writeWait.observe(elapsed(at));
54
+ }
55
+ }
56
+ else if (/^(?:COMMIT|END|ROLLBACK(?!\s+TO))\b/i.test(op)) {
57
+ db.exec(sql);
58
+ finish();
59
+ }
60
+ else
61
+ standalone(writing(op), () => db.exec(sql));
62
+ },
63
+ prepare(sql) {
64
+ const statement = db.prepare(sql), write = writing(operation(sql));
65
+ const methods = {
66
+ run: (...params) => standalone(write, () => statement.run(...params)),
67
+ get: (...params) => standalone(write, () => statement.get(...params)),
68
+ all: (...params) => standalone(write, () => statement.all(...params)),
69
+ };
70
+ return preservingNative(statement, methods);
71
+ },
72
+ close() { try {
73
+ db.close();
74
+ }
75
+ finally {
76
+ finish();
77
+ } },
78
+ };
79
+ // Store.handle also exposes native extensions (for example scalar functions used by work summaries).
80
+ // Preserve their receivers and getters instead of narrowing the live connection to the minimal test interface.
81
+ const wrapped = preservingNative(db, methods);
82
+ states.set(wrapped, state);
83
+ return wrapped;
84
+ }
85
+ function preservingNative(native, methods) {
86
+ // Overrides belong to the facade, so a test spy or caller replacing prepare/run never mutates the
87
+ // native method that the timed implementation calls (and cannot recurse back into itself).
88
+ return new Proxy(methods, {
89
+ get(target, property) {
90
+ if (Object.hasOwn(target, property))
91
+ return Reflect.get(target, property);
92
+ const value = Reflect.get(native, property, native);
93
+ return typeof value === 'function' ? value.bind(native) : value;
94
+ },
95
+ has: (target, property) => Reflect.has(target, property) || Reflect.has(native, property),
96
+ getPrototypeOf: () => Object.getPrototypeOf(native),
97
+ });
98
+ }
package/dist/store.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ServerTelemetry } from "./server-telemetry.js";
1
2
  import type { DecisionOption } from "./contracts/decision.js";
2
3
  import { type SavedToolAction, type SavedToolRule } from "./contracts/store-json.js";
3
4
  import type { FlowCardChange, FlowCardFields } from "./contracts/flow-card.js";
@@ -1939,6 +1940,7 @@ export declare class ReviewBindingError extends Error {
1939
1940
  }
1940
1941
  export declare class Store {
1941
1942
  #private;
1943
+ readonly telemetry: ServerTelemetry;
1942
1944
  private readonly db;
1943
1945
  constructor(db: Database);
1944
1946
  /** TESTS ONLY: raw database access for migration fixtures. Production
@@ -2139,6 +2141,10 @@ export declare class Store {
2139
2141
  * themselves when called alone.
2140
2142
  */
2141
2143
  transact<T>(body: () => T): T;
2144
+ private readonly commitListeners;
2145
+ /** Hear each outer transact() commit (live-bus.ts). Listeners run synchronously
2146
+ * after COMMIT and must not write; the return value stops listening. */
2147
+ onCommit(listener: () => void): () => void;
2142
2148
  /**
2143
2149
  * A named SAVEPOINT for a multi-statement primitive that must roll back
2144
2150
  * WHOLESALE on a mid-body error, EVEN when nested inside an outer
package/dist/store.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { chatSchema, chatTables } from "./contracts/chat-tables.js";
2
+ import { ServerTelemetry } from "./server-telemetry.js";
3
+ import { instrumentDatabase, measureWriteWait } from "./sqlite-telemetry.js";
2
4
  import { parseStoreColumn, readStoreColumn, readStoreStringifiedList, readStoreTextList } from "./contracts/store-json.js";
3
5
  import { assessmentFromSavedEvidence, verificationEvidence } from "./verification-evidence.js";
4
6
  import { LEARNING_SCHEMA, queueLearning } from "./project-learning.js";
@@ -3849,6 +3851,9 @@ export function isDatabaseBusy(error) {
3849
3851
  * wait, which is still counted in nominal sleep.
3850
3852
  */
3851
3853
  function beginWriteWithin(db, budgetMs) {
3854
+ measureWriteWait(db, () => beginWriteAttempt(db, budgetMs));
3855
+ }
3856
+ function beginWriteAttempt(db, budgetMs) {
3852
3857
  const deadline = performance.now() + budgetMs;
3853
3858
  db.exec(`PRAGMA busy_timeout = ${WRITE_WAIT_SLICE_MS}`);
3854
3859
  try {
@@ -7349,9 +7354,11 @@ export class ReviewBindingError extends Error {
7349
7354
  constructor(message) { super(message); this.name = "ReviewBindingError"; }
7350
7355
  }
7351
7356
  export class Store {
7357
+ telemetry;
7352
7358
  db;
7353
7359
  constructor(db) {
7354
- this.db = db;
7360
+ this.telemetry = new ServerTelemetry();
7361
+ this.db = instrumentDatabase(db, this.telemetry);
7355
7362
  }
7356
7363
  /** TESTS ONLY: raw database access for migration fixtures. Production
7357
7364
  * code never calls this — the typed methods are the API. */
@@ -7967,10 +7974,10 @@ export class Store {
7967
7974
  return body();
7968
7975
  beginWriteWithin(this.db, CONCURRENT_WRITER_WAIT_MS);
7969
7976
  this.transacting = true;
7977
+ let result;
7970
7978
  try {
7971
- const result = body();
7979
+ result = body();
7972
7980
  this.db.exec("COMMIT");
7973
- return result;
7974
7981
  }
7975
7982
  catch (error) {
7976
7983
  this.db.exec("ROLLBACK");
@@ -7979,6 +7986,21 @@ export class Store {
7979
7986
  finally {
7980
7987
  this.transacting = false;
7981
7988
  }
7989
+ // Only after the outer COMMIT: a rolled-back or nested body tells no one.
7990
+ for (const listener of this.commitListeners) {
7991
+ try {
7992
+ listener();
7993
+ }
7994
+ catch { /* a listener never fails the write */ }
7995
+ }
7996
+ return result;
7997
+ }
7998
+ commitListeners = new Set();
7999
+ /** Hear each outer transact() commit (live-bus.ts). Listeners run synchronously
8000
+ * after COMMIT and must not write; the return value stops listening. */
8001
+ onCommit(listener) {
8002
+ this.commitListeners.add(listener);
8003
+ return () => { this.commitListeners.delete(listener); };
7982
8004
  }
7983
8005
  /**
7984
8006
  * A named SAVEPOINT for a multi-statement primitive that must roll back
package/dist/surface.js CHANGED
@@ -129,6 +129,7 @@ const GUIDE_ROWS = [
129
129
  ...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
130
130
  // ---- the queue (agent surface) ----
131
131
  { invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
132
+ { invocation: 'health', synopsis: 'recent server latency, event-loop delay, SQLite writes and live streams from the saved connection; instance operators only', audience: 'agent', agentMayInvoke: true, mutation: 'none', flags: [jsonFlag, { name: 'profile', takesValue: true, meaning: 'saved server connection' }] },
132
133
  { invocation: "integrations", synopsis: "every integration as Connected, Not set up or Broken, with its last success and error, what uses it and what to do; --saved skips new checks; never a secret", audience: "agent", agentMayInvoke: true, mutation: "none",
133
134
  flags: [jsonFlag, dbFlag, { name: "saved", takesValue: false, meaning: "show the last checks without checking again" }] },
134
135
  { invocation: "ready", synopsis: "what could be dispatched right now (rows carry reservedFor)", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
@@ -446,7 +447,7 @@ const REMOTE_NO = [
446
447
  "conversation read", "conversation follow", "conversation stop",
447
448
  "session capabilities", "session list", "session show", "session changes", "session start", "session send", "session stop", "session resume", "session recover",
448
449
  // This machine's checkout, saved profile, credentials and catch-up.
449
- "project show", "project use", "brief", "lead token", "lead say",
450
+ "project show", "project use", "brief", "lead token", "lead say", "health",
450
451
  "assignment show", "assignment updates", "assignment claim", "assignment check", "assignment brief", "assignment inbox", "assignment ack",
451
452
  // Workers, the loop and the services that run here.
452
453
  "claim", "heartbeat", "release", "reap", "tick", "build", "reconcile",
@@ -1,17 +1,8 @@
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
- export type TaskViewer = {
4
- name: string;
5
- response: ServerResponse;
6
- valid: () => boolean;
7
- };
3
+ export type TaskViewer = LiveViewer;
8
4
  /** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
9
5
  export declare function taskFingerprint(store: Store, root: string, now: Date, livenessMs?: number): string | null;
10
- export type TaskRooms = {
11
- /** Keep this page's stream on the task family until it closes; the first events say where it stands and who's here. */
12
- join: (root: string, viewer: TaskViewer) => void;
13
- /** Every stream, ended (the server is closing). */
14
- close: () => void;
15
- size: () => number;
16
- };
17
- export declare function createTaskRooms(fingerprint: (root: string) => string | null, tickMs?: number): TaskRooms;
6
+ export type TaskRooms = LiveRooms<string, TaskViewer>;
7
+ /** Rooms keyed by the task family's root. With a bus, a write reaches every open page at once; without one, only the safety net. */
8
+ export declare function createTaskRooms(fingerprint: (root: string) => string | null, options?: LiveRoomOptions): TaskRooms;
package/dist/task-live.js CHANGED
@@ -3,12 +3,14 @@
3
3
  * same in all of them, and each shows who else has it open. The same design
4
4
  * as the live canvas (flow-live.ts):
5
5
  *
6
- * - A change nudge. While anyone has a task open, one indexed read a second,
7
- * keyed by the task family's root, takes the family's fingerprint (its
8
- * versions' states, runs, decisions, progress checkpoints, what the agent
9
- * did last, and whether each live run's worker still answers); when it
10
- * changes, every open page hears "change" and reads the task again the
11
- * usual way. The nudge carries no task data.
6
+ * - A change nudge. When the live bus says the workspace was written
7
+ * (live-bus.ts), one indexed read keyed by the task family's root takes
8
+ * the family's fingerprint (its versions' states, runs, decisions,
9
+ * progress checkpoints, what the agent did last, and whether each live
10
+ * run's worker still answers); when it changed, every open page hears
11
+ * "change" and reads the task again the usual way. The nudge carries no
12
+ * task data. With nothing written, an open page costs no reads; a
13
+ * 30-second check heals a missed signal (and notices a lapsed worker).
12
14
  * - Who's here. Everyone else with the task open, by name, one line per
13
15
  * person however many pages they have. Memory only; it goes when the page
14
16
  * closes or hides.
@@ -17,12 +19,9 @@
17
19
  * keep one open reads itself on its usual beat.
18
20
  */
19
21
  import { createHash } from "node:crypto";
22
+ import { createLiveRooms } from "./live-bus.js";
20
23
  import { DEFAULT_LIVENESS_MS } from "./runner.js";
21
24
  import { BUILT_IN } from "./store.js";
22
- const TICK_MS = 1_000;
23
- const CHECK_EVERY = 15;
24
- const HEARTBEAT_EVERY = 15;
25
- const PRESENCE_SETTLE_MS = 150;
26
25
  /** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
27
26
  export function taskFingerprint(store, root, now, livenessMs = DEFAULT_LIVENESS_MS) {
28
27
  const row = store.handle.prepare(`WITH RECURSIVE family(ref, ext, depth) AS (
@@ -41,104 +40,8 @@ export function taskFingerprint(store, root, now, livenessMs = DEFAULT_LIVENESS_
41
40
  WHERE EXISTS (SELECT 1 FROM family)`).get(root, new Date(now.getTime() - livenessMs).toISOString(), BUILT_IN);
42
41
  return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
43
42
  }
44
- export function createTaskRooms(fingerprint, tickMs = TICK_MS) {
45
- const rooms = new Map();
46
- const write = (viewer, event, data) => {
47
- if (viewer.response.writableEnded || viewer.response.destroyed)
48
- return;
49
- viewer.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
50
- };
51
- /** Everyone on the task but you, once each, by name. */
52
- const others = (room, me) => [...new Set([...room.viewers].map(one => one.name).filter(name => name !== me))].sort((a, b) => a.localeCompare(b));
53
- const announce = (room) => {
54
- if (room.settling !== null)
55
- clearTimeout(room.settling);
56
- room.settling = setTimeout(() => {
57
- room.settling = null;
58
- for (const viewer of room.viewers)
59
- write(viewer, "here", { people: others(room, viewer.name) });
60
- }, PRESENCE_SETTLE_MS);
61
- room.settling.unref();
62
- };
63
- const leave = (root, viewer) => {
64
- const room = rooms.get(root);
65
- if (room === undefined || !room.viewers.delete(viewer))
66
- return;
67
- if (!viewer.response.writableEnded)
68
- viewer.response.end();
69
- if (room.viewers.size > 0) {
70
- announce(room);
71
- return;
72
- }
73
- if (room.timer !== null)
74
- clearInterval(room.timer);
75
- if (room.settling !== null)
76
- clearTimeout(room.settling);
77
- rooms.delete(root);
78
- };
79
- const tick = (root, room) => {
80
- room.ticks += 1;
81
- if (room.ticks % CHECK_EVERY === 0) {
82
- // A signed-out or narrowed account stops hearing about the task.
83
- for (const viewer of [...room.viewers])
84
- if (!viewer.valid()) {
85
- write(viewer, "gone", {});
86
- leave(root, viewer);
87
- }
88
- if (!rooms.has(root))
89
- return;
90
- }
91
- let now;
92
- try {
93
- now = fingerprint(root);
94
- }
95
- catch {
96
- return; /* a busy database: the next tick reads again */
97
- }
98
- if (now === null) {
99
- for (const viewer of [...room.viewers]) {
100
- write(viewer, "gone", {});
101
- leave(root, viewer);
102
- }
103
- return;
104
- }
105
- if (now !== room.last) {
106
- room.last = now;
107
- for (const viewer of room.viewers)
108
- write(viewer, "change", { at: now });
109
- }
110
- else if (room.ticks % HEARTBEAT_EVERY === 0) {
111
- for (const viewer of room.viewers)
112
- if (!viewer.response.writableEnded)
113
- viewer.response.write(": keep-alive\n\n");
114
- }
115
- };
116
- return {
117
- join(root, viewer) {
118
- let room = rooms.get(root);
119
- if (room === undefined) {
120
- let last = null;
121
- try {
122
- last = fingerprint(root);
123
- }
124
- catch { /* the first tick reads it */ }
125
- room = { viewers: new Set(), last, timer: null, ticks: 0, settling: null };
126
- const created = room;
127
- room.timer = setInterval(() => tick(root, created), tickMs);
128
- room.timer.unref();
129
- rooms.set(root, room);
130
- }
131
- room.viewers.add(viewer);
132
- write(viewer, "change", { at: room.last });
133
- write(viewer, "here", { people: others(room, viewer.name) });
134
- announce(room);
135
- viewer.response.once("close", () => leave(root, viewer));
136
- },
137
- close() {
138
- for (const [root, room] of [...rooms])
139
- for (const viewer of [...room.viewers])
140
- leave(root, viewer);
141
- },
142
- size: () => [...rooms.values()].reduce((sum, room) => sum + room.viewers.size, 0),
143
- };
43
+ /** Rooms keyed by the task family's root. With a bus, a write reaches every open page at once; without one, only the safety net. */
44
+ export function createTaskRooms(fingerprint, options = {}) {
45
+ // Everyone on the task but you, once each, by name.
46
+ return createLiveRooms(fingerprint, (viewers, me) => [...new Set(viewers.map(one => one.name).filter(name => name !== me))].sort((a, b) => a.localeCompare(b)), options);
144
47
  }
@@ -242,6 +242,17 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
242
242
  FROM claimed c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
243
243
  )`;
244
244
  const registered = new WeakMap();
245
+ const statements = new WeakMap();
246
+ /** The page query is long and has one text per view filter: compile each once per connection. */
247
+ function prepared(store, sql) {
248
+ let cache = statements.get(store.handle);
249
+ if (cache === undefined)
250
+ statements.set(store.handle, cache = new Map());
251
+ let statement = cache.get(sql);
252
+ if (statement === undefined)
253
+ cache.set(sql, statement = store.handle.prepare(sql));
254
+ return statement;
255
+ }
245
256
  /** Connection-local pure parsing, with exactly the authoritative stored-scope
246
257
  * validator. The callback cannot query, inspect files or grant authority. */
247
258
  function registerValidators(store) {
@@ -358,7 +369,7 @@ export function workIndexPage(store, now, access, options = {}) {
358
369
  const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(WORK_INDEX_MAX_LIMIT, Math.floor(options.limit))) : WORK_INDEX_PAGE_LIMIT;
359
370
  const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
360
371
  const filter = view === 'needs-you' ? 'needs=1' : view === 'running' ? 'family_running=1' : view === 'completed' ? "code='complete'" : '1';
361
- const rows = store.handle.prepare(`${projection}, selected_page AS MATERIALIZED (
372
+ const rows = prepared(store, `${projection}, selected_page AS MATERIALIZED (
362
373
  SELECT * FROM ranked WHERE ${filter} AND ($cursorRoot=0 OR
363
374
  ${ORDER}>$cursorRank OR (${ORDER}=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
364
375
  ORDER BY ${ORDER},sort_at DESC,root_ref DESC LIMIT $limit
@@ -1,4 +1,6 @@
1
1
  import type { Store } from './store.js';
2
+ /** The service_cursor row the revision triggers move (live-bus.ts listens for it). */
3
+ export declare const WORKSPACE_REVISION_KEY = "workspace-content:v1";
2
4
  export type WorkspaceRevision = {
3
5
  current(): string;
4
6
  expiresAt(now: Date): number;
@@ -2,7 +2,9 @@
2
2
  * metadata only: it never grants access or certifies task/process state. */
3
3
  import { createHash } from 'node:crypto';
4
4
  import { DEFAULT_LIVENESS_MS } from './runner.js';
5
- const KEY = 'workspace-content:v1';
5
+ /** The service_cursor row the revision triggers move (live-bus.ts listens for it). */
6
+ export const WORKSPACE_REVISION_KEY = 'workspace-content:v1';
7
+ const KEY = WORKSPACE_REVISION_KEY;
6
8
  const PREFIX = 'workspace_revision_v1_';
7
9
  const MAX_AGE_MS = 60_000;
8
10
  const OMIT = new Set(['schema_version', 'service_cursor', 'wake', 'notification_delivery',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.47",
3
+ "version": "0.9.48",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"