toolroll 0.9.47 → 0.9.49

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 (60) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/browser/workspace.js +16 -16
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.js +4 -0
  5. package/dist/contracts/cli.d.ts +87 -0
  6. package/dist/contracts/cli.js +2 -0
  7. package/dist/contracts/health.d.ts +84 -0
  8. package/dist/contracts/health.js +11 -0
  9. package/dist/desktop-update.d.ts +1 -1
  10. package/dist/flow-live.d.ts +5 -13
  11. package/dist/flow-live.js +11 -110
  12. package/dist/health-cli.d.ts +3 -0
  13. package/dist/health-cli.js +79 -0
  14. package/dist/live-bus.d.ts +89 -0
  15. package/dist/live-bus.js +321 -0
  16. package/dist/mcp-http.d.ts +1 -0
  17. package/dist/mcp-http.js +6 -4
  18. package/dist/mcp-oauth.d.ts +3 -0
  19. package/dist/mcp-oauth.js +8 -0
  20. package/dist/metrics.d.ts +3 -0
  21. package/dist/metrics.js +53 -1
  22. package/dist/operate.js +37 -24
  23. package/dist/public-access.d.ts +3 -1
  24. package/dist/public-access.js +5 -1
  25. package/dist/read-executor-worker.d.ts +1 -0
  26. package/dist/read-executor-worker.js +26 -0
  27. package/dist/read-executor.d.ts +49 -0
  28. package/dist/read-executor.js +119 -0
  29. package/dist/request-budget.d.ts +31 -3
  30. package/dist/request-budget.js +50 -1
  31. package/dist/serve.d.ts +2 -0
  32. package/dist/serve.js +213 -100
  33. package/dist/server-telemetry.d.ts +86 -0
  34. package/dist/server-telemetry.js +263 -0
  35. package/dist/session-http.d.ts +5 -0
  36. package/dist/session-http.js +13 -0
  37. package/dist/session-server.d.ts +5 -0
  38. package/dist/session-server.js +2 -0
  39. package/dist/sqlite-telemetry.d.ts +9 -0
  40. package/dist/sqlite-telemetry.js +98 -0
  41. package/dist/store.d.ts +6 -0
  42. package/dist/store.js +25 -3
  43. package/dist/surface.js +2 -1
  44. package/dist/task-live.d.ts +5 -14
  45. package/dist/task-live.js +13 -110
  46. package/dist/team-cli.js +28 -6
  47. package/dist/team-contract.d.ts +7 -0
  48. package/dist/team-contract.js +7 -0
  49. package/dist/team-http.d.ts +5 -0
  50. package/dist/team-http.js +19 -3
  51. package/dist/team-leads.d.ts +5 -1
  52. package/dist/team-leads.js +42 -4
  53. package/dist/team-runtime.d.ts +1 -1
  54. package/dist/team-runtime.js +3 -0
  55. package/dist/teams.d.ts +3 -0
  56. package/dist/teams.js +15 -0
  57. package/dist/work-index.js +12 -1
  58. package/dist/workspace-revision.d.ts +2 -0
  59. package/dist/workspace-revision.js +3 -1
  60. package/package.json +1 -1
@@ -0,0 +1,86 @@
1
+ import type { BudgetLimit } from './request-budget.js';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ export declare const TELEMETRY_WINDOW_MS = 300000;
4
+ export declare const LATENCY_BUCKETS: number[];
5
+ export declare const ROUTE_FAMILIES: readonly ['cli', 'mcp', 'team', 'team-stream', 'chat', 'chat-stream', 'flows', 'flow-stream', 'tasks', 'results', 'decisions', 'sessions', 'settings', 'projects', 'assets', 'auth', 'metrics', 'health', 'console', 'other'];
6
+ export type RouteFamily = typeof ROUTE_FAMILIES[number];
7
+ export type StreamFamily = 'team-stream' | 'chat-stream' | 'flow-stream' | 'other';
8
+ export declare const STREAM_FAMILIES: readonly StreamFamily[];
9
+ export declare const QUEUE_BUCKETS: number[];
10
+ /** Only literals from the allowlist can become labels, including for unknown or malformed paths. */
11
+ export declare function routeFamily(raw: string): RouteFamily;
12
+ type Counts = {
13
+ bins: number[];
14
+ count: number;
15
+ sum: number;
16
+ max: number;
17
+ };
18
+ export type LatencySummary = {
19
+ count: number;
20
+ p50Ms: number | null;
21
+ p99Ms: number | null;
22
+ maxMs: number | null;
23
+ totalMs: number;
24
+ };
25
+ /** Lifetime histogram plus a ring of ten-second slices. Cost and memory do not grow with traffic. */
26
+ export declare class TimingHistogram {
27
+ private readonly clock;
28
+ readonly total: Counts;
29
+ private readonly slices;
30
+ constructor(clock: () => number);
31
+ observe(seconds: number, weight?: number): void;
32
+ private prune;
33
+ recent(): LatencySummary;
34
+ }
35
+ export type HealthSnapshot = {
36
+ version: 1;
37
+ windowSeconds: number;
38
+ uptimeSeconds: number;
39
+ requests: LatencySummary;
40
+ routes: Partial<Record<RouteFamily, LatencySummary>>;
41
+ eventLoop: LatencySummary;
42
+ writes: {
43
+ wait: LatencySummary;
44
+ hold: LatencySummary;
45
+ statement: LatencySummary;
46
+ };
47
+ streams: {
48
+ open: number;
49
+ queuedBytes: number;
50
+ maxQueuedBytes: number;
51
+ };
52
+ budgetRefusals: number;
53
+ };
54
+ export declare class ServerTelemetry {
55
+ readonly clock: () => number;
56
+ readonly requests: TimingHistogram;
57
+ readonly routes: Map<"assets" | "auth" | "chat" | "chat-stream" | "cli" | "console" | "decisions" | "flow-stream" | "flows" | "health" | "mcp" | "metrics" | "other" | "projects" | "results" | "sessions" | "settings" | "tasks" | "team" | "team-stream", TimingHistogram>;
58
+ readonly eventLoop: TimingHistogram;
59
+ readonly writeWait: TimingHistogram;
60
+ readonly writeHold: TimingHistogram;
61
+ readonly writeStatement: TimingHistogram;
62
+ readonly refusals: Map<string, number>;
63
+ private readonly recentRefusals;
64
+ private readonly streams;
65
+ private monitor;
66
+ private timer;
67
+ private readonly started;
68
+ constructor(clock?: () => number);
69
+ start(): void;
70
+ stop(): void;
71
+ /**
72
+ * Native nanosecond samples, folded into bounded buckets once a second, never one retained object per tick.
73
+ * Each raw sample includes the sampling timer's own interval, so it is subtracted: an idle loop reads near zero.
74
+ */
75
+ sampleEventLoop(): void;
76
+ budgetRefused(route: 'api' | 'mcp', reason: BudgetLimit | 'unavailable'): void;
77
+ private pruneRefusals;
78
+ observeRequest(family: RouteFamily, milliseconds: number): void;
79
+ openStream(response: ServerResponse, family: StreamFamily): void;
80
+ streamQueues(family?: StreamFamily): number[];
81
+ snapshot(): HealthSnapshot;
82
+ }
83
+ /** SSE latency is time to opening headers; a live view's lifetime is not request work. */
84
+ export declare function instrumentRequest(telemetry: ServerTelemetry, request: IncomingMessage, response: ServerResponse): void;
85
+ export declare function healthWords(health: HealthSnapshot): string;
86
+ export {};
@@ -0,0 +1,263 @@
1
+ /** Bounded, in-memory performance measurements. Never retain URLs, SQL, or identities. */
2
+ import { monitorEventLoopDelay } from 'node:perf_hooks';
3
+ export const TELEMETRY_WINDOW_MS = 300_000;
4
+ const SLICE_MS = 10_000;
5
+ /** monitorEventLoopDelay samples by sleeping this long, so every raw sample includes it. */
6
+ const EVENT_LOOP_RESOLUTION_MS = 20;
7
+ export const LATENCY_BUCKETS = [0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.015, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15, 30, 60];
8
+ export const ROUTE_FAMILIES = ['cli', 'mcp', 'team', 'team-stream', 'chat', 'chat-stream', 'flows', 'flow-stream', 'tasks', 'results', 'decisions', 'sessions', 'settings', 'projects', 'assets', 'auth', 'metrics', 'health', 'console', 'other'];
9
+ export const STREAM_FAMILIES = ['team-stream', 'chat-stream', 'flow-stream', 'other'];
10
+ export const QUEUE_BUCKETS = [0, 1024, 4096, 16384, 65536, 262144, 1048576];
11
+ /** Only literals from the allowlist can become labels, including for unknown or malformed paths. */
12
+ export function routeFamily(raw) {
13
+ let path;
14
+ try {
15
+ path = new URL(raw, 'http://localhost').pathname;
16
+ }
17
+ catch {
18
+ return 'other';
19
+ }
20
+ if (path === '/api/cli')
21
+ return 'cli';
22
+ if (path === '/mcp' || path.startsWith('/mcp/'))
23
+ return 'mcp';
24
+ if (path === '/api/team/events')
25
+ return 'team-stream';
26
+ if (path === '/api/team')
27
+ return 'team';
28
+ if (path === '/chat/stream')
29
+ return 'chat-stream';
30
+ if (/^\/flows\/[^/]+\/live$/.test(path))
31
+ return 'flow-stream';
32
+ if (path === '/metrics')
33
+ return 'metrics';
34
+ if (path === '/health')
35
+ return 'health';
36
+ const first = path.split('/')[1];
37
+ switch (first) {
38
+ case 'chat':
39
+ case 'lead': return 'chat';
40
+ case 'flows':
41
+ case 'recipes': return 'flows';
42
+ case 't':
43
+ case 'tasks':
44
+ case 'queue': return 'tasks';
45
+ case 'r':
46
+ case 'runs':
47
+ case 'review': return 'results';
48
+ case 'd':
49
+ case 'i': return 'decisions';
50
+ case 'code':
51
+ case 'session': return 'sessions';
52
+ case 'settings':
53
+ case 'people':
54
+ case 'spend': return 'settings';
55
+ case 'projects': return 'projects';
56
+ case 'assets': return 'assets';
57
+ case 'login':
58
+ case 'logout':
59
+ case 'auth': return 'auth';
60
+ case '':
61
+ case 'work':
62
+ case 'inbox':
63
+ case 'board':
64
+ case 'fleet':
65
+ case 'menu':
66
+ case 'done': return 'console';
67
+ default: return 'other';
68
+ }
69
+ }
70
+ const counts = () => ({ bins: Array.from({ length: LATENCY_BUCKETS.length + 1 }, () => 0), count: 0, sum: 0, max: 0 });
71
+ const summary = (value) => {
72
+ const percentile = (fraction) => {
73
+ if (value.count === 0)
74
+ return null;
75
+ const rank = Math.ceil(value.count * fraction);
76
+ let n = 0;
77
+ for (const [i, count] of value.bins.entries()) {
78
+ n += count;
79
+ if (n >= rank)
80
+ return Math.min(LATENCY_BUCKETS[i] ?? value.max, value.max) * 1000;
81
+ }
82
+ return value.max * 1000;
83
+ };
84
+ return { count: value.count, p50Ms: percentile(0.5), p99Ms: percentile(0.99), maxMs: value.count ? value.max * 1000 : null, totalMs: value.sum * 1000 };
85
+ };
86
+ /** Lifetime histogram plus a ring of ten-second slices. Cost and memory do not grow with traffic. */
87
+ export class TimingHistogram {
88
+ clock;
89
+ total = counts();
90
+ slices = new Map();
91
+ constructor(clock) {
92
+ this.clock = clock;
93
+ }
94
+ observe(seconds, weight = 1) {
95
+ if (!Number.isFinite(seconds) || seconds < 0 || weight <= 0)
96
+ return;
97
+ const at = Math.floor(this.clock() / SLICE_MS);
98
+ this.prune(at);
99
+ let slice = this.slices.get(at);
100
+ if (!slice) {
101
+ slice = counts();
102
+ this.slices.set(at, slice);
103
+ }
104
+ let bin = LATENCY_BUCKETS.findIndex(bound => seconds <= bound);
105
+ if (bin < 0)
106
+ bin = LATENCY_BUCKETS.length;
107
+ for (const value of [this.total, slice]) {
108
+ value.bins[bin] += weight;
109
+ value.count += weight;
110
+ value.sum += seconds * weight;
111
+ value.max = Math.max(value.max, seconds);
112
+ }
113
+ }
114
+ prune(at) {
115
+ for (const key of this.slices.keys())
116
+ if (key <= at - TELEMETRY_WINDOW_MS / SLICE_MS)
117
+ this.slices.delete(key);
118
+ }
119
+ recent() {
120
+ this.prune(Math.floor(this.clock() / SLICE_MS));
121
+ const merged = counts();
122
+ for (const value of this.slices.values()) {
123
+ value.bins.forEach((n, i) => { merged.bins[i] += n; });
124
+ merged.count += value.count;
125
+ merged.sum += value.sum;
126
+ merged.max = Math.max(merged.max, value.max);
127
+ }
128
+ return summary(merged);
129
+ }
130
+ }
131
+ export class ServerTelemetry {
132
+ clock;
133
+ requests;
134
+ routes = new Map();
135
+ eventLoop;
136
+ writeWait;
137
+ writeHold;
138
+ writeStatement;
139
+ refusals = new Map();
140
+ recentRefusals = new Map();
141
+ streams = new Map();
142
+ monitor;
143
+ timer;
144
+ started;
145
+ constructor(clock = () => performance.now()) {
146
+ this.clock = clock;
147
+ this.started = clock();
148
+ this.requests = new TimingHistogram(clock);
149
+ this.eventLoop = new TimingHistogram(clock);
150
+ this.writeWait = new TimingHistogram(clock);
151
+ this.writeHold = new TimingHistogram(clock);
152
+ this.writeStatement = new TimingHistogram(clock);
153
+ for (const family of ROUTE_FAMILIES)
154
+ this.routes.set(family, new TimingHistogram(clock));
155
+ }
156
+ start() {
157
+ if (this.monitor)
158
+ return;
159
+ this.monitor = monitorEventLoopDelay({ resolution: EVENT_LOOP_RESOLUTION_MS });
160
+ this.monitor.enable();
161
+ this.timer = setInterval(() => this.sampleEventLoop(), 1000);
162
+ this.timer.unref();
163
+ }
164
+ stop() {
165
+ if (this.timer)
166
+ clearInterval(this.timer);
167
+ this.sampleEventLoop();
168
+ this.monitor?.disable();
169
+ this.monitor = undefined;
170
+ this.timer = undefined;
171
+ }
172
+ /**
173
+ * Native nanosecond samples, folded into bounded buckets once a second, never one retained object per tick.
174
+ * Each raw sample includes the sampling timer's own interval, so it is subtracted: an idle loop reads near zero.
175
+ */
176
+ sampleEventLoop() {
177
+ const h = this.monitor;
178
+ if (!h || !h.count)
179
+ return;
180
+ const count = h.count, steps = Math.min(count, 1000);
181
+ let previous = 0;
182
+ for (let i = 1; i <= steps; i++) {
183
+ const rank = Math.floor(i * count / steps);
184
+ this.eventLoop.observe(Math.max(0, h.percentile(100 * rank / count) / 1e6 - EVENT_LOOP_RESOLUTION_MS) / 1000, rank - previous);
185
+ previous = rank;
186
+ }
187
+ h.reset();
188
+ }
189
+ budgetRefused(route, reason) {
190
+ const key = `${route}:${reason}`;
191
+ this.refusals.set(key, (this.refusals.get(key) ?? 0) + 1);
192
+ const at = Math.floor(this.clock() / SLICE_MS);
193
+ this.pruneRefusals(at);
194
+ this.recentRefusals.set(at, (this.recentRefusals.get(at) ?? 0) + 1);
195
+ }
196
+ pruneRefusals(at) {
197
+ for (const key of this.recentRefusals.keys())
198
+ if (key <= at - TELEMETRY_WINDOW_MS / SLICE_MS)
199
+ this.recentRefusals.delete(key);
200
+ }
201
+ observeRequest(family, milliseconds) {
202
+ this.requests.observe(milliseconds / 1000);
203
+ this.routes.get(family).observe(milliseconds / 1000);
204
+ }
205
+ openStream(response, family) {
206
+ this.streams.set(response, family);
207
+ const closed = () => this.streams.delete(response);
208
+ response.once('close', closed);
209
+ response.once('finish', closed);
210
+ }
211
+ streamQueues(family) {
212
+ return [...this.streams].filter(([response, kind]) => !response.destroyed && !response.writableEnded && (family === undefined || family === kind)).map(([response]) => response.writableLength);
213
+ }
214
+ snapshot() {
215
+ this.sampleEventLoop();
216
+ this.pruneRefusals(Math.floor(this.clock() / SLICE_MS));
217
+ const queues = this.streamQueues();
218
+ return {
219
+ version: 1, windowSeconds: TELEMETRY_WINDOW_MS / 1000, uptimeSeconds: (this.clock() - this.started) / 1000,
220
+ requests: this.requests.recent(), routes: Object.fromEntries([...this.routes].map(([route, histogram]) => [route, histogram.recent()]).filter(([, value]) => value.count > 0)),
221
+ eventLoop: this.eventLoop.recent(), writes: { wait: this.writeWait.recent(), hold: this.writeHold.recent(), statement: this.writeStatement.recent() },
222
+ streams: { open: queues.length, queuedBytes: queues.reduce((sum, bytes) => sum + bytes, 0), maxQueuedBytes: queues.reduce((max, bytes) => Math.max(max, bytes), 0) },
223
+ budgetRefusals: [...this.recentRefusals.values()].reduce((sum, n) => sum + n, 0),
224
+ };
225
+ }
226
+ }
227
+ /** SSE latency is time to opening headers; a live view's lifetime is not request work. */
228
+ export function instrumentRequest(telemetry, request, response) {
229
+ const family = routeFamily(request.url ?? '/'), started = telemetry.clock();
230
+ let recorded = false;
231
+ const record = () => { if (!recorded) {
232
+ recorded = true;
233
+ telemetry.observeRequest(family, telemetry.clock() - started);
234
+ } };
235
+ const writeHead = response.writeHead;
236
+ response.writeHead = function (...args) {
237
+ const result = Reflect.apply(writeHead, this, args);
238
+ const headers = typeof args[1] === 'object' ? args[1] : args[2];
239
+ const contentType = this.getHeader('content-type') ?? (headers && !Array.isArray(headers) ? Object.entries(headers).find(([name]) => name.toLowerCase() === 'content-type')?.[1] : undefined);
240
+ if (String(contentType).startsWith('text/event-stream') && !recorded) {
241
+ record();
242
+ telemetry.openStream(this, STREAM_FAMILIES.includes(family) ? family : 'other');
243
+ }
244
+ return result;
245
+ };
246
+ response.once('finish', record);
247
+ response.once('close', record);
248
+ }
249
+ export function healthWords(health) {
250
+ const ms = (n) => n === null ? 'no samples' : `${n.toFixed(2)} ms`;
251
+ const timing = (value) => value.count ? `p50 ${ms(value.p50Ms)}, p99 ${ms(value.p99Ms)} (${value.count} samples)` : 'no samples yet';
252
+ return [
253
+ `Server health — last 5 minutes (up ${Math.floor(health.uptimeSeconds)} seconds; approximate percentiles)`,
254
+ `Requests: ${timing(health.requests)}.`,
255
+ ...Object.entries(health.routes).map(([route, value]) => ` ${route}: ${timing(value)}.`),
256
+ `Event-loop delay: ${timing(health.eventLoop)}; 20 ms sampling interval.`,
257
+ `SQLite write-lock waits: ${timing(health.writes.wait)}.`,
258
+ `SQLite time holding a write transaction: ${timing(health.writes.hold)}.`,
259
+ `SQLite standalone writes, including lock waits: ${timing(health.writes.statement)}.`,
260
+ `Live streams: ${health.streams.open} open; ${health.streams.queuedBytes} bytes waiting to send, largest queue ${health.streams.maxQueuedBytes} bytes.`,
261
+ `Request-budget refusals: ${health.budgetRefusals}.`,
262
+ ].join('\n');
263
+ }
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { type SessionOperation, type SessionRequests, type SessionResponse } from './session-contract.js';
3
+ import { type Admission } from './request-budget.js';
3
4
  export type SessionHttpActor = {
4
5
  name: string;
5
6
  generation: number;
@@ -13,6 +14,10 @@ export type SessionHttpOptions = {
13
14
  /** The existing live owner's operation. Null means no owner is ready.
14
15
  * The service rechecks actor generation and project access at execution. */
15
16
  execute: SessionHttpExecute | null;
17
+ /** The bearer request's budget by source (request-budget.ts): charged once, before the credential is checked or a body read. */
18
+ admit?: (request: IncomingMessage) => Admission;
19
+ /** Account admission after successful authentication, before any body read or owner call. */
20
+ admitAuthenticated?: (request: IncomingMessage, actor: SessionHttpActor) => Admission;
16
21
  };
17
22
  /** Handle this API family before the browser's form parser or login redirects.
18
23
  * This adapter dispatches at most once. It owns no catalog, database or provider. */
@@ -1,4 +1,5 @@
1
1
  import { SESSION_REQUEST_BYTES, SESSION_RESPONSE_BYTES, isSessionResponse, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
2
+ import { limitWords } from './request-budget.js';
2
3
  function readBody(request) {
3
4
  return new Promise(resolve => {
4
5
  let size = 0;
@@ -99,6 +100,16 @@ export async function handleSessionHttp(request, response, options) {
99
100
  if (authorizationCount !== 1 || !/^Bearer (.+):(.+)$/.test(request.headers.authorization ?? '')) {
100
101
  return reject(401, 'unauthenticated', 'Current operator bearer credentials are required.');
101
102
  }
103
+ const refuseAdmission = (admitted) => {
104
+ if (admitted.ok)
105
+ return false;
106
+ if (admitted.status === 503)
107
+ return reject(503, 'limits-unavailable', 'Request limits could not be checked; nothing ran. Try again shortly.');
108
+ response.setHeader('retry-after', String(admitted.retryAfter));
109
+ return reject(429, 'rate-limited', limitWords(admitted.limit, admitted.retryAfter));
110
+ };
111
+ if (options.admit && refuseAdmission(options.admit(request)))
112
+ return true;
102
113
  let actor;
103
114
  try {
104
115
  actor = await options.authenticate(request);
@@ -109,6 +120,8 @@ export async function handleSessionHttp(request, response, options) {
109
120
  if (actor === null || typeof actor.name !== 'string' || !actor.name.trim() || !Number.isSafeInteger(actor.generation) || actor.generation < 1) {
110
121
  return reject(401, 'unauthenticated', 'Current operator bearer credentials are required.');
111
122
  }
123
+ if (options.admitAuthenticated && refuseAdmission(options.admitAuthenticated(request, actor)))
124
+ return true;
112
125
  const media = request.headers['content-type']?.split(';')[0]?.trim().toLowerCase();
113
126
  if (media !== 'application/json')
114
127
  return reject(415, 'unsupported-media-type', 'Send the session request as application/json.');
@@ -1,6 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Store } from './store.js';
3
3
  import type { CodingWorkspace } from './coding-workspace.js';
4
+ import { type SessionHttpActor } from './session-http.js';
5
+ import type { Admission } from './request-budget.js';
4
6
  /** Bind the transport to createDecisionServer's existing catalog owner and
5
7
  * live project admission. No cookie, coordinator token or caller-supplied actor
6
8
  * can acquire installation-wide native coding authority through this adapter. */
@@ -9,4 +11,7 @@ export declare function createSessionEndpoint(options: {
9
11
  workspace: CodingWorkspace | null;
10
12
  projects: () => readonly string[];
11
13
  projectAllowed: (repo: string) => boolean;
14
+ /** The request's source budget (request-budget.ts), charged once before the password is checked. */
15
+ admit?: (request: IncomingMessage) => Admission;
16
+ admitAuthenticated?: (request: IncomingMessage, actor: SessionHttpActor) => Admission;
12
17
  }): (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
@@ -21,6 +21,8 @@ export function createSessionEndpoint(options) {
21
21
  },
22
22
  });
23
23
  return (request, response) => handleSessionHttp(request, response, {
24
+ ...(options.admit === undefined ? {} : { admit: options.admit }),
25
+ ...(options.admitAuthenticated === undefined ? {} : { admitAuthenticated: options.admitAuthenticated }),
24
26
  authenticate: request => {
25
27
  const bearer = /^Bearer (.+):(.+)$/.exec(request.headers.authorization ?? '');
26
28
  if (!bearer)
@@ -0,0 +1,9 @@
1
+ import type { Database } from './store.js';
2
+ import type { ServerTelemetry } from './server-telemetry.js';
3
+ /** One observation for the entire bounded BEGIN retry, including a failed acquisition. */
4
+ export declare function measureWriteWait<T>(db: Database, body: () => T): T;
5
+ /** Preserve SQLite calls, timeouts and transaction semantics. No SQL or parameters leave this wrapper.
6
+ * Native SQLite does not expose busy-handler time for autocommit writes: report their whole duration separately.
7
+ * Runtime explicit IMMEDIATE/EXCLUSIVE transactions report acquisition and hold; startup migrations are excluded.
8
+ */
9
+ export declare function instrumentDatabase(db: Database, telemetry: ServerTelemetry): Database;
@@ -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",