toolroll 0.9.47 → 0.9.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/browser/workspace.js +16 -16
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -0
- package/dist/contracts/cli.d.ts +87 -0
- package/dist/contracts/cli.js +2 -0
- package/dist/contracts/health.d.ts +84 -0
- package/dist/contracts/health.js +11 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/flow-live.d.ts +5 -13
- package/dist/flow-live.js +11 -110
- package/dist/health-cli.d.ts +3 -0
- package/dist/health-cli.js +79 -0
- package/dist/live-bus.d.ts +89 -0
- package/dist/live-bus.js +321 -0
- package/dist/metrics.d.ts +3 -0
- package/dist/metrics.js +53 -1
- package/dist/operate.js +37 -24
- package/dist/read-executor-worker.d.ts +1 -0
- package/dist/read-executor-worker.js +26 -0
- package/dist/read-executor.d.ts +49 -0
- package/dist/read-executor.js +119 -0
- package/dist/request-budget.d.ts +1 -0
- package/dist/request-budget.js +6 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +28 -4
- package/dist/server-telemetry.d.ts +86 -0
- package/dist/server-telemetry.js +263 -0
- package/dist/sqlite-telemetry.d.ts +9 -0
- package/dist/sqlite-telemetry.js +98 -0
- package/dist/store.d.ts +6 -0
- package/dist/store.js +25 -3
- package/dist/surface.js +2 -1
- package/dist/task-live.d.ts +5 -14
- package/dist/task-live.js +13 -110
- package/dist/work-index.js +12 -1
- package/dist/workspace-revision.d.ts +2 -0
- package/dist/workspace-revision.js +3 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/request-budget.d.ts
CHANGED
|
@@ -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;
|
package/dist/request-budget.js
CHANGED
|
@@ -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
|
-
|
|
754
|
-
const
|
|
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
|
-
|
|
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;
|