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,49 @@
|
|
|
1
|
+
import type { Store } from './store.js';
|
|
2
|
+
import { type WorkIndexOptions, type WorkIndexPage } from './work-index.js';
|
|
3
|
+
import type { WorkSummaryAccess } from './work-summary.js';
|
|
4
|
+
/**
|
|
5
|
+
* Heavy, read-only database work for the server, run on worker threads (docs/server-performance.md).
|
|
6
|
+
* Each worker owns one read-only, query-only connection to the server's own database file, so the
|
|
7
|
+
* measured hot read (the work index behind `task list`) never holds the HTTP event loop. Callers
|
|
8
|
+
* authorize first and send only the already-admitted access; a worker never widens it, writes, or
|
|
9
|
+
* retries. Without a file-backed database (tests, the demo) nothing is attached and callers keep
|
|
10
|
+
* running the same builder in-process.
|
|
11
|
+
*/
|
|
12
|
+
export type ReadJob = {
|
|
13
|
+
kind: 'work-index-page';
|
|
14
|
+
now: string;
|
|
15
|
+
access: WorkSummaryAccess;
|
|
16
|
+
options: WorkIndexOptions;
|
|
17
|
+
};
|
|
18
|
+
export type ReadReply = {
|
|
19
|
+
id: number;
|
|
20
|
+
ok: true;
|
|
21
|
+
value: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
id: number;
|
|
24
|
+
ok: false;
|
|
25
|
+
error: {
|
|
26
|
+
name: string;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/** More queued reads than this fail at once instead of growing without bound. */
|
|
31
|
+
export declare const READ_QUEUE_MAX = 1000;
|
|
32
|
+
export declare class ReadExecutorError extends Error {
|
|
33
|
+
constructor(message: string);
|
|
34
|
+
}
|
|
35
|
+
export declare class ReadExecutor {
|
|
36
|
+
#private;
|
|
37
|
+
constructor(file: string, size?: number);
|
|
38
|
+
get size(): number;
|
|
39
|
+
/** workIndexPage(store, now, access, options) on a worker's read-only connection: same rows, same errors. */
|
|
40
|
+
workIndexPage(now: Date, access: WorkSummaryAccess, options: WorkIndexOptions): Promise<WorkIndexPage>;
|
|
41
|
+
/** Fails every queued and running read once, then stops the workers. */
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/** Half the cores, at most eight: the measured 20-engineer load needs over four (docs/server-performance.md),
|
|
45
|
+
* and the rest stay with the event loop and local agents. Workers start only when reads arrive. */
|
|
46
|
+
export declare function defaultReadWorkers(): number;
|
|
47
|
+
/** The server attaches its executor to its own Store; a command run on that Store reads through it. */
|
|
48
|
+
export declare function attachReadExecutor(store: Store, executor: ReadExecutor): () => void;
|
|
49
|
+
export declare function readExecutorOf(store: Store): ReadExecutor | null;
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Request budgets for person API tokens (v112): how many requests one `so_` token may make
|
|
3
|
-
* One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by
|
|
2
|
+
* Request budgets for person API tokens (v112): how many requests one `so_` token may make across all accepting routes.
|
|
3
|
+
* One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by those
|
|
4
4
|
* routes. A read token gets 120 requests in any sliding minute, an act token 30, and either 10,000 in a rolling day;
|
|
5
5
|
* an instance operator may override any of these for the installation or for one token in the console (step-up).
|
|
6
6
|
*
|
|
@@ -26,7 +26,7 @@ export declare const PER_DAY_MAX = 100000;
|
|
|
26
26
|
export declare const TRACKED_TOKENS = 1024;
|
|
27
27
|
export declare const FLUSH_MS = 5000;
|
|
28
28
|
/** Which limit a refusal hit: the body of a 429 names only this and Retry-After. */
|
|
29
|
-
export type BudgetLimit = "read-per-minute" | "act-per-minute" | "per-day";
|
|
29
|
+
export type BudgetLimit = "read-per-minute" | "act-per-minute" | "per-day" | "per-minute";
|
|
30
30
|
export type Admission = {
|
|
31
31
|
ok: true;
|
|
32
32
|
} | {
|
|
@@ -88,9 +88,37 @@ 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;
|
|
94
95
|
/** The tracked entry (now the most recently used), from memory or as last saved; the least recently used is saved and let go. */
|
|
95
96
|
private load;
|
|
96
97
|
}
|
|
98
|
+
/** Per-minute allowances for non-person-token identities and unauthenticated ingress. Password accounts get room
|
|
99
|
+
* for two-second follower polling plus ordinary commands; OAuth exchanges stay keyed by source address. */
|
|
100
|
+
export declare const SOURCE_BUDGET_DEFAULTS: Readonly<{
|
|
101
|
+
password: 120;
|
|
102
|
+
oauthToken: 30;
|
|
103
|
+
coordinator: 120;
|
|
104
|
+
teamsSource: 120;
|
|
105
|
+
teamsTenant: 600;
|
|
106
|
+
}>;
|
|
107
|
+
/**
|
|
108
|
+
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
109
|
+
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
110
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
111
|
+
*/
|
|
112
|
+
export declare class SourceAdmission {
|
|
113
|
+
private readonly perMinute;
|
|
114
|
+
private readonly clock;
|
|
115
|
+
private readonly tracked;
|
|
116
|
+
private readonly usage;
|
|
117
|
+
constructor(options: {
|
|
118
|
+
perMinute: number;
|
|
119
|
+
clock?: () => number;
|
|
120
|
+
tracked?: number;
|
|
121
|
+
});
|
|
122
|
+
get size(): number;
|
|
123
|
+
admit(source: string): Admission;
|
|
124
|
+
}
|
package/dist/request-budget.js
CHANGED
|
@@ -22,7 +22,7 @@ export const TRACKED_TOKENS = 1024;
|
|
|
22
22
|
export const FLUSH_MS = 5_000;
|
|
23
23
|
const MINUTE = 60_000, DAY = 86_400_000;
|
|
24
24
|
/** The words for a 429, in one line. */
|
|
25
|
-
export const limitWords = (limit, retryAfter) => `Request limit reached (${limit === "per-day" ? "requests per day" : limit === "read-per-minute" ? "read requests per minute" : "act requests per minute"}). Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.`;
|
|
25
|
+
export const limitWords = (limit, retryAfter) => `Request limit reached (${limit === "per-day" ? "requests per day" : limit === "read-per-minute" ? "read requests per minute" : limit === "act-per-minute" ? "act requests per minute" : "requests per minute"}). Try again in ${retryAfter} second${retryAfter === 1 ? "" : "s"}.`;
|
|
26
26
|
/** A limit typed into a form: a whole number within bounds, "" for none, or a problem. */
|
|
27
27
|
export function parseLimit(raw, max) {
|
|
28
28
|
const text = raw.trim();
|
|
@@ -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;
|
|
@@ -225,3 +231,46 @@ export class RequestBudget {
|
|
|
225
231
|
return usage;
|
|
226
232
|
}
|
|
227
233
|
}
|
|
234
|
+
/** Per-minute allowances for non-person-token identities and unauthenticated ingress. Password accounts get room
|
|
235
|
+
* for two-second follower polling plus ordinary commands; OAuth exchanges stay keyed by source address. */
|
|
236
|
+
export const SOURCE_BUDGET_DEFAULTS = Object.freeze({ password: 120, oauthToken: 30, coordinator: 120, teamsSource: 120, teamsTenant: 600 });
|
|
237
|
+
/**
|
|
238
|
+
* A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
|
|
239
|
+
* pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
|
|
240
|
+
* Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
|
|
241
|
+
*/
|
|
242
|
+
export class SourceAdmission {
|
|
243
|
+
perMinute;
|
|
244
|
+
clock;
|
|
245
|
+
tracked;
|
|
246
|
+
usage = new Map();
|
|
247
|
+
constructor(options) {
|
|
248
|
+
this.perMinute = Math.max(1, Math.min(PER_MINUTE_MAX, options.perMinute));
|
|
249
|
+
this.clock = options.clock ?? Date.now;
|
|
250
|
+
this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
|
|
251
|
+
}
|
|
252
|
+
get size() { return this.usage.size; }
|
|
253
|
+
admit(source) {
|
|
254
|
+
const now = this.clock();
|
|
255
|
+
let times = this.usage.get(source);
|
|
256
|
+
if (times === undefined) {
|
|
257
|
+
if (this.usage.size >= this.tracked) {
|
|
258
|
+
for (const [key, kept] of this.usage)
|
|
259
|
+
if (kept.every(at => at <= now - MINUTE))
|
|
260
|
+
this.usage.delete(key);
|
|
261
|
+
if (this.usage.size >= this.tracked)
|
|
262
|
+
this.usage.delete(this.usage.keys().next().value);
|
|
263
|
+
}
|
|
264
|
+
times = [];
|
|
265
|
+
}
|
|
266
|
+
this.usage.delete(source);
|
|
267
|
+
this.usage.set(source, times);
|
|
268
|
+
while (times.length > 0 && times[0] <= now - MINUTE)
|
|
269
|
+
times.shift();
|
|
270
|
+
if (times.length >= this.perMinute) {
|
|
271
|
+
return { ok: false, status: 429, limit: "per-minute", retryAfter: Math.max(1, Math.ceil((times[times.length - this.perMinute] + MINUTE - now) / 1000)) };
|
|
272
|
+
}
|
|
273
|
+
times.push(now);
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
}
|
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;
|