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,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;
package/dist/serve.js CHANGED
@@ -44,6 +44,7 @@ import { addRoutine, removeRoutine, runRoutine } from "./teammate-desk.js";
44
44
  import { sendTeammateWeeklies, undoCall } from "./teammate-week.js";
45
45
  import { answerTeammateQuestion } from "./teammate-work.js";
46
46
  import { createFlowRooms, flowFingerprint } from "./flow-live.js";
47
+ import { createLiveBus, followWorkspace } from "./live-bus.js";
47
48
  import { createTaskRooms, taskFingerprint } from "./task-live.js";
48
49
  import { runActivityOf } from "./task-activity.js";
49
50
  import { disconnectGoogle, finishGoogleConsent, GOOGLE_CALLBACK, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "./google-mail.js";
@@ -242,6 +243,7 @@ import { createIntegrationMonitor } from "./integrations.js";
242
243
  import { BACKUP_CSS, backupHtml } from "./backup-ui.js";
243
244
  import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder } from "./backup.js";
244
245
  import { prometheusMetrics } from "./metrics.js";
246
+ import { healthWords, instrumentRequest } from "./server-telemetry.js";
245
247
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
246
248
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
247
249
  import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
@@ -291,6 +293,7 @@ import { installMethod } from "./install-method.js";
291
293
  import { PACKAGE_VERSION } from "./version.js";
292
294
  import { updateNoticeWords } from "./update-notice.js";
293
295
  import { whenHtml } from "./when-html.js";
296
+ import { ReadExecutor, attachReadExecutor, defaultReadWorkers } from "./read-executor.js";
294
297
  const SESSION_COOKIE = "standing-orders_session";
295
298
  /** Stands in for "no project" in a ceiling: no folder resolves to it, so it admits nothing. */
296
299
  const NO_PROJECT = "/\0no-project";
@@ -460,6 +463,11 @@ export function createDecisionServer(options) {
460
463
  }
461
464
  }
462
465
  const clock = options.clock ?? (() => new Date());
466
+ // Heavy reads (the work index behind `task list`) run on read-only worker connections, off the request loop.
467
+ const readFile = store.isDemo() ? null : store.databaseFile();
468
+ const readWorkers = options.readWorkers ?? (process.env["VITEST"] !== undefined ? 0 : defaultReadWorkers());
469
+ const reads = readFile === null || readWorkers < 1 ? null : new ReadExecutor(readFile, readWorkers);
470
+ const detachReads = reads === null ? () => { } : attachReadExecutor(store, reads);
463
471
  /** Task checkouts, for Settings → Storage: the pool's own root, or the folder beside the database. */
464
472
  const storagePool = (databaseFile) => new WorktreePool(store, { root: options.poolRoot ?? join(dirname(databaseFile), "worktrees") });
465
473
  const install = installMethod(options.installBin);
@@ -750,8 +758,11 @@ export function createDecisionServer(options) {
750
758
  /** One-click connections on their way: the service's sign-in page and back, 15 minutes at most. */
751
759
  const connectVisits = new Map();
752
760
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
753
- const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow));
754
- const taskRooms = createTaskRooms(root => taskFingerprint(store, root, clock()));
761
+ // Live views push on write (live-bus.ts): the revision triggers move, the rooms hear it.
762
+ const liveBus = createLiveBus();
763
+ const liveFollower = followWorkspace(store, () => workspaceRevision.current(), liveBus, { file: store.databaseFile() });
764
+ const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow), { bus: liveBus });
765
+ const taskRooms = createTaskRooms(root => taskFingerprint(store, root, clock()), { bus: liveBus });
755
766
  const teamChatProvider = () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; };
756
767
  const team = createTeamRuntime({ store, repos: codingProjects, evidenceRoot, clock, workspaceRevision,
757
768
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
@@ -841,6 +852,7 @@ export function createDecisionServer(options) {
841
852
  /** The task behind a resource, for the ceiling check; null = no ref (visible). */
842
853
  const taskRepoOf = (taskRef) => store.refForId(taskRef)?.repo ?? null;
843
854
  const server = createServer((request, response) => {
855
+ instrumentRequest(store.telemetry, request, response);
844
856
  void handle(request, response).catch(error => {
845
857
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query.
846
858
  logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
@@ -857,6 +869,8 @@ export function createDecisionServer(options) {
857
869
  }
858
870
  });
859
871
  });
872
+ server.once('listening', () => store.telemetry.start());
873
+ server.once('close', () => store.telemetry.stop());
860
874
  // --public-url (arc 3): validated to EXACTLY an https origin. Its host
861
875
  // joins the allowed set, its origin authorizes POSTs, and cookies turn
862
876
  // Secure. Anything malformed refuses at startup, loudly.
@@ -2000,7 +2014,7 @@ export function createDecisionServer(options) {
2000
2014
  url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
2001
2015
  !url.pathname.startsWith("/chat/demo/") &&
2002
2016
  !/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
2003
- !url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
2017
+ !url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/health" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
2004
2018
  !(url.pathname === "/board" && url.searchParams.get("scope") === "all");
2005
2019
  if (needsProject)
2006
2020
  return redirect(response, `/projects?return=${encodeURIComponent(safeReturn(url.pathname + url.search))}`);
@@ -4226,6 +4240,14 @@ export function createDecisionServer(options) {
4226
4240
  return sendScreen(response, 200, screen("Data", `<p><a href="/settings">Settings</a></p><h1>Data</h1>${dataExportHtml(who.session.csrf, { problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4227
4241
  }
4228
4242
  // v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
4243
+ if (url.pathname === "/health") {
4244
+ if (!store.isInstanceOperator(who.name))
4245
+ return refuse(response, who, 403, "An instance operator reads server health.", "/");
4246
+ const health = store.telemetry.snapshot();
4247
+ return request.headers.accept?.includes('application/json')
4248
+ ? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
4249
+ : respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
4250
+ }
4229
4251
  if (url.pathname === "/metrics") {
4230
4252
  if (!store.isInstanceOperator(who.name))
4231
4253
  return refuse(response, who, 403, "An instance operator reads metrics.", "/");
@@ -12559,7 +12581,9 @@ export function createDecisionServer(options) {
12559
12581
  chatStreams.clear();
12560
12582
  flowRooms.close();
12561
12583
  taskRooms.close();
12562
- void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop())]).then(() => closeServer(callback)).catch(error => {
12584
+ detachReads();
12585
+ liveFollower.close();
12586
+ void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop()), bounded(reads?.close())]).then(() => closeServer(callback)).catch(error => {
12563
12587
  if (callback)
12564
12588
  callback(error instanceof Error ? error : Error('Coding session shutdown failed.'));
12565
12589
  else
@@ -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
+ }
@@ -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;