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.
- package/CHANGELOG.md +21 -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/mcp-http.d.ts +1 -0
- package/dist/mcp-http.js +6 -4
- package/dist/mcp-oauth.d.ts +3 -0
- package/dist/mcp-oauth.js +8 -0
- package/dist/metrics.d.ts +3 -0
- package/dist/metrics.js +53 -1
- package/dist/operate.js +37 -24
- package/dist/public-access.d.ts +3 -1
- package/dist/public-access.js +5 -1
- 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 +31 -3
- package/dist/request-budget.js +50 -1
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +213 -100
- package/dist/server-telemetry.d.ts +86 -0
- package/dist/server-telemetry.js +263 -0
- package/dist/session-http.d.ts +5 -0
- package/dist/session-http.js +13 -0
- package/dist/session-server.d.ts +5 -0
- package/dist/session-server.js +2 -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/team-cli.js +28 -6
- package/dist/team-contract.d.ts +7 -0
- package/dist/team-contract.js +7 -0
- package/dist/team-http.d.ts +5 -0
- package/dist/team-http.js +19 -3
- package/dist/team-leads.d.ts +5 -1
- package/dist/team-leads.js +42 -4
- package/dist/team-runtime.d.ts +1 -1
- package/dist/team-runtime.js +3 -0
- package/dist/teams.d.ts +3 -0
- package/dist/teams.js +15 -0
- 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,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
|
+
}
|
package/dist/session-http.d.ts
CHANGED
|
@@ -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. */
|
package/dist/session-http.js
CHANGED
|
@@ -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.');
|
package/dist/session-server.d.ts
CHANGED
|
@@ -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>;
|
package/dist/session-server.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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",
|