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
package/dist/task-live.d.ts
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type LiveRoomOptions, type LiveRooms, type LiveViewer } from "./live-bus.js";
|
|
2
2
|
import { type Store } from "./store.js";
|
|
3
|
-
export type TaskViewer =
|
|
4
|
-
name: string;
|
|
5
|
-
response: ServerResponse;
|
|
6
|
-
valid: () => boolean;
|
|
7
|
-
};
|
|
3
|
+
export type TaskViewer = LiveViewer;
|
|
8
4
|
/** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
|
|
9
5
|
export declare function taskFingerprint(store: Store, root: string, now: Date, livenessMs?: number): string | null;
|
|
10
|
-
export type TaskRooms =
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/** Every stream, ended (the server is closing). */
|
|
14
|
-
close: () => void;
|
|
15
|
-
size: () => number;
|
|
16
|
-
};
|
|
17
|
-
export declare function createTaskRooms(fingerprint: (root: string) => string | null, tickMs?: number): TaskRooms;
|
|
6
|
+
export type TaskRooms = LiveRooms<string, TaskViewer>;
|
|
7
|
+
/** Rooms keyed by the task family's root. With a bus, a write reaches every open page at once; without one, only the safety net. */
|
|
8
|
+
export declare function createTaskRooms(fingerprint: (root: string) => string | null, options?: LiveRoomOptions): TaskRooms;
|
package/dist/task-live.js
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
* same in all of them, and each shows who else has it open. The same design
|
|
4
4
|
* as the live canvas (flow-live.ts):
|
|
5
5
|
*
|
|
6
|
-
* - A change nudge.
|
|
7
|
-
* keyed by the task family's root
|
|
8
|
-
* versions' states, runs, decisions,
|
|
9
|
-
* did last, and whether each live
|
|
10
|
-
*
|
|
11
|
-
* usual way. The nudge carries no
|
|
6
|
+
* - A change nudge. When the live bus says the workspace was written
|
|
7
|
+
* (live-bus.ts), one indexed read keyed by the task family's root takes
|
|
8
|
+
* the family's fingerprint (its versions' states, runs, decisions,
|
|
9
|
+
* progress checkpoints, what the agent did last, and whether each live
|
|
10
|
+
* run's worker still answers); when it changed, every open page hears
|
|
11
|
+
* "change" and reads the task again the usual way. The nudge carries no
|
|
12
|
+
* task data. With nothing written, an open page costs no reads; a
|
|
13
|
+
* 30-second check heals a missed signal (and notices a lapsed worker).
|
|
12
14
|
* - Who's here. Everyone else with the task open, by name, one line per
|
|
13
15
|
* person however many pages they have. Memory only; it goes when the page
|
|
14
16
|
* closes or hides.
|
|
@@ -17,12 +19,9 @@
|
|
|
17
19
|
* keep one open reads itself on its usual beat.
|
|
18
20
|
*/
|
|
19
21
|
import { createHash } from "node:crypto";
|
|
22
|
+
import { createLiveRooms } from "./live-bus.js";
|
|
20
23
|
import { DEFAULT_LIVENESS_MS } from "./runner.js";
|
|
21
24
|
import { BUILT_IN } from "./store.js";
|
|
22
|
-
const TICK_MS = 1_000;
|
|
23
|
-
const CHECK_EVERY = 15;
|
|
24
|
-
const HEARTBEAT_EVERY = 15;
|
|
25
|
-
const PRESENCE_SETTLE_MS = 150;
|
|
26
25
|
/** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
|
|
27
26
|
export function taskFingerprint(store, root, now, livenessMs = DEFAULT_LIVENESS_MS) {
|
|
28
27
|
const row = store.handle.prepare(`WITH RECURSIVE family(ref, ext, depth) AS (
|
|
@@ -41,104 +40,8 @@ export function taskFingerprint(store, root, now, livenessMs = DEFAULT_LIVENESS_
|
|
|
41
40
|
WHERE EXISTS (SELECT 1 FROM family)`).get(root, new Date(now.getTime() - livenessMs).toISOString(), BUILT_IN);
|
|
42
41
|
return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
|
|
43
42
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return;
|
|
49
|
-
viewer.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
50
|
-
};
|
|
51
|
-
/** Everyone on the task but you, once each, by name. */
|
|
52
|
-
const others = (room, me) => [...new Set([...room.viewers].map(one => one.name).filter(name => name !== me))].sort((a, b) => a.localeCompare(b));
|
|
53
|
-
const announce = (room) => {
|
|
54
|
-
if (room.settling !== null)
|
|
55
|
-
clearTimeout(room.settling);
|
|
56
|
-
room.settling = setTimeout(() => {
|
|
57
|
-
room.settling = null;
|
|
58
|
-
for (const viewer of room.viewers)
|
|
59
|
-
write(viewer, "here", { people: others(room, viewer.name) });
|
|
60
|
-
}, PRESENCE_SETTLE_MS);
|
|
61
|
-
room.settling.unref();
|
|
62
|
-
};
|
|
63
|
-
const leave = (root, viewer) => {
|
|
64
|
-
const room = rooms.get(root);
|
|
65
|
-
if (room === undefined || !room.viewers.delete(viewer))
|
|
66
|
-
return;
|
|
67
|
-
if (!viewer.response.writableEnded)
|
|
68
|
-
viewer.response.end();
|
|
69
|
-
if (room.viewers.size > 0) {
|
|
70
|
-
announce(room);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (room.timer !== null)
|
|
74
|
-
clearInterval(room.timer);
|
|
75
|
-
if (room.settling !== null)
|
|
76
|
-
clearTimeout(room.settling);
|
|
77
|
-
rooms.delete(root);
|
|
78
|
-
};
|
|
79
|
-
const tick = (root, room) => {
|
|
80
|
-
room.ticks += 1;
|
|
81
|
-
if (room.ticks % CHECK_EVERY === 0) {
|
|
82
|
-
// A signed-out or narrowed account stops hearing about the task.
|
|
83
|
-
for (const viewer of [...room.viewers])
|
|
84
|
-
if (!viewer.valid()) {
|
|
85
|
-
write(viewer, "gone", {});
|
|
86
|
-
leave(root, viewer);
|
|
87
|
-
}
|
|
88
|
-
if (!rooms.has(root))
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
let now;
|
|
92
|
-
try {
|
|
93
|
-
now = fingerprint(root);
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return; /* a busy database: the next tick reads again */
|
|
97
|
-
}
|
|
98
|
-
if (now === null) {
|
|
99
|
-
for (const viewer of [...room.viewers]) {
|
|
100
|
-
write(viewer, "gone", {});
|
|
101
|
-
leave(root, viewer);
|
|
102
|
-
}
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
if (now !== room.last) {
|
|
106
|
-
room.last = now;
|
|
107
|
-
for (const viewer of room.viewers)
|
|
108
|
-
write(viewer, "change", { at: now });
|
|
109
|
-
}
|
|
110
|
-
else if (room.ticks % HEARTBEAT_EVERY === 0) {
|
|
111
|
-
for (const viewer of room.viewers)
|
|
112
|
-
if (!viewer.response.writableEnded)
|
|
113
|
-
viewer.response.write(": keep-alive\n\n");
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
return {
|
|
117
|
-
join(root, viewer) {
|
|
118
|
-
let room = rooms.get(root);
|
|
119
|
-
if (room === undefined) {
|
|
120
|
-
let last = null;
|
|
121
|
-
try {
|
|
122
|
-
last = fingerprint(root);
|
|
123
|
-
}
|
|
124
|
-
catch { /* the first tick reads it */ }
|
|
125
|
-
room = { viewers: new Set(), last, timer: null, ticks: 0, settling: null };
|
|
126
|
-
const created = room;
|
|
127
|
-
room.timer = setInterval(() => tick(root, created), tickMs);
|
|
128
|
-
room.timer.unref();
|
|
129
|
-
rooms.set(root, room);
|
|
130
|
-
}
|
|
131
|
-
room.viewers.add(viewer);
|
|
132
|
-
write(viewer, "change", { at: room.last });
|
|
133
|
-
write(viewer, "here", { people: others(room, viewer.name) });
|
|
134
|
-
announce(room);
|
|
135
|
-
viewer.response.once("close", () => leave(root, viewer));
|
|
136
|
-
},
|
|
137
|
-
close() {
|
|
138
|
-
for (const [root, room] of [...rooms])
|
|
139
|
-
for (const viewer of [...room.viewers])
|
|
140
|
-
leave(root, viewer);
|
|
141
|
-
},
|
|
142
|
-
size: () => [...rooms.values()].reduce((sum, room) => sum + room.viewers.size, 0),
|
|
143
|
-
};
|
|
43
|
+
/** Rooms keyed by the task family's root. With a bus, a write reaches every open page at once; without one, only the safety net. */
|
|
44
|
+
export function createTaskRooms(fingerprint, options = {}) {
|
|
45
|
+
// Everyone on the task but you, once each, by name.
|
|
46
|
+
return createLiveRooms(fingerprint, (viewers, me) => [...new Set(viewers.map(one => one.name).filter(name => name !== me))].sort((a, b) => a.localeCompare(b)), options);
|
|
144
47
|
}
|
package/dist/team-cli.js
CHANGED
|
@@ -41,6 +41,14 @@ existing local commands. Credentials stay in a private file, never in URLs or ar
|
|
|
41
41
|
Reads and attachment do not start model work. Server permissions remain authoritative.`;
|
|
42
42
|
export class UsageError extends Error {
|
|
43
43
|
}
|
|
44
|
+
/** Transport metadata stays local to the CLI; only an actual HTTP 429 permits a follower read retry. */
|
|
45
|
+
const RETRY_AFTER_MS = Symbol('retry-after-ms');
|
|
46
|
+
function retryDelay(header) {
|
|
47
|
+
const raw = header?.trim() ?? '';
|
|
48
|
+
const delay = /^\d+$/.test(raw) ? Number(raw) * 1000
|
|
49
|
+
: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), /.test(raw) ? Date.parse(raw) - Date.now() : NaN;
|
|
50
|
+
return Number.isFinite(delay) && delay >= 0 ? Math.max(1000, Math.min(300_000, delay)) : 5000;
|
|
51
|
+
}
|
|
44
52
|
const value = (flags, name) => typeof flags[name] === 'string' ? flags[name] : '';
|
|
45
53
|
const required = (flags, name) => { const result = value(flags, name); if (!result)
|
|
46
54
|
throw new UsageError(`--${name} is required.`); return result; };
|
|
@@ -244,13 +252,18 @@ async function responseBody(response) {
|
|
|
244
252
|
function failure(code, message, result) { return { version: 1, ok: false, code, message, ...(result === undefined ? {} : { result }) }; }
|
|
245
253
|
async function perform(profile, request, options) {
|
|
246
254
|
const reading = ['list', 'show'].includes(request.operation);
|
|
247
|
-
const
|
|
255
|
+
const receipt = { operation: request.operation, ...(request.args.conversationId ? { conversationId: request.args.conversationId } : {}), ...(request.args.requestId ? { requestId: request.args.requestId } : {}) };
|
|
256
|
+
const unconfirmed = () => failure(reading ? 'service-unavailable' : 'delivery-unconfirmed', reading ? 'The service response could not be read.' : 'The response could not be confirmed. Inspect saved messages before continuing; this request was not retried.', receipt);
|
|
248
257
|
try {
|
|
249
258
|
const response = await (options.fetch ?? fetch)(`${profile.origin}/api/team`, { method: 'POST', redirect: 'manual', credentials: 'omit', signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), headers: { authorization: bearerOf(profile), 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request) });
|
|
250
259
|
if (response.status >= 300 && response.status < 400) {
|
|
251
260
|
await response.body?.cancel();
|
|
252
261
|
return unconfirmed();
|
|
253
262
|
}
|
|
263
|
+
if (response.status === 429) {
|
|
264
|
+
await response.body?.cancel();
|
|
265
|
+
return { ...failure('rate-limited', reading ? 'Request limit reached. Try again shortly.' : 'Request limit reached. Inspect saved messages before continuing; this request was not retried.', receipt), [RETRY_AFTER_MS]: retryDelay(response.headers.get('retry-after')) };
|
|
266
|
+
}
|
|
254
267
|
const payload = await responseBody(response);
|
|
255
268
|
if (!record(payload) || payload.version !== 1 || typeof payload.ok !== 'boolean' || typeof payload.code !== 'string' || typeof payload.message !== 'string' || payload.snapshot !== undefined && !isSnapshot(payload.snapshot) || payload.ok && !response.ok)
|
|
256
269
|
return unconfirmed();
|
|
@@ -396,7 +409,7 @@ export async function maybeRunTeamCommand(argv, write, options = {}) {
|
|
|
396
409
|
if (!profile || !Object.hasOwn(saved.profiles, profileName))
|
|
397
410
|
throw new UsageError('No saved connection. Use connect with your individual sign-in first.');
|
|
398
411
|
secret = profile.token;
|
|
399
|
-
const call = (operation, args) => perform(profile, { operation, args }, options);
|
|
412
|
+
const call = (operation, args, signal) => perform(profile, { operation, args }, { ...options, ...(signal === undefined ? {} : { signal }) });
|
|
400
413
|
if (command === 'chat' || command === 'brief')
|
|
401
414
|
return await chat(command, flags, profile.account, call, emit, options, stderr);
|
|
402
415
|
let operation, args = {};
|
|
@@ -476,8 +489,8 @@ async function chat(command, flags, account, call, emit, options, stderr) {
|
|
|
476
489
|
throw new UsageError('--terms-digest requires --authorize.');
|
|
477
490
|
if (command === 'chat' && requestId && !flags.say)
|
|
478
491
|
throw new UsageError('Use brief --request-id to inspect a previous message.');
|
|
479
|
-
const show = async () => {
|
|
480
|
-
const reply = await call('show', { conversationId });
|
|
492
|
+
const show = async (signal) => {
|
|
493
|
+
const reply = await call('show', { conversationId }, signal);
|
|
481
494
|
if (reply.ok && (!reply.snapshot || reply.snapshot.selected?.id !== conversationId || leadId && reply.snapshot.selected.leadId !== leadId))
|
|
482
495
|
return failure('conversation-mismatch', 'The service returned a different conversation or lead. Nothing was sent.');
|
|
483
496
|
return reply;
|
|
@@ -577,22 +590,31 @@ async function chat(command, flags, account, call, emit, options, stderr) {
|
|
|
577
590
|
};
|
|
578
591
|
let pollResult = 0;
|
|
579
592
|
const poll = async () => {
|
|
593
|
+
let waitMs = 2_000;
|
|
580
594
|
while (!signal.aborted) {
|
|
581
595
|
try {
|
|
582
|
-
await (options.sleep ?? ((ms, abort) => delay(ms, undefined, { signal: abort })))(
|
|
596
|
+
await (options.sleep ?? ((ms, abort) => delay(ms, undefined, { signal: abort })))(waitMs, signal);
|
|
583
597
|
}
|
|
584
598
|
catch {
|
|
585
599
|
break;
|
|
586
600
|
}
|
|
587
601
|
if (signal.aborted)
|
|
588
602
|
break;
|
|
589
|
-
const next = await show();
|
|
603
|
+
const next = await show(signal);
|
|
604
|
+
if (signal.aborted)
|
|
605
|
+
break;
|
|
606
|
+
// Only this follower's read poll retries. Sends, authorizations and uncertain deliveries are never replayed.
|
|
607
|
+
if (!next.ok && next[RETRY_AFTER_MS] !== undefined) {
|
|
608
|
+
waitMs = next[RETRY_AFTER_MS];
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
590
611
|
if (!next.ok) {
|
|
591
612
|
emit(next);
|
|
592
613
|
pollResult = 1;
|
|
593
614
|
controller.abort();
|
|
594
615
|
break;
|
|
595
616
|
}
|
|
617
|
+
waitMs = 2_000;
|
|
596
618
|
current = next;
|
|
597
619
|
updated(next);
|
|
598
620
|
}
|
package/dist/team-contract.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { WorkIndexPage } from './work-index.js';
|
|
2
2
|
import type { BrowserActionCard } from './browser-workspace.js';
|
|
3
|
+
import type { Principal } from './operate-remote.js';
|
|
3
4
|
/** Shared central-team transport contract. Requests never establish actor identity. */
|
|
4
5
|
export type TeamActor = {
|
|
5
6
|
name: string;
|
|
6
7
|
generation: number;
|
|
8
|
+
/** Set when an `so_` API token signed in: its scope and project limit narrow every check, never widen one. */
|
|
9
|
+
principal?: Principal;
|
|
7
10
|
};
|
|
8
11
|
export type TeamRole = 'viewer' | 'contributor' | 'manager';
|
|
9
12
|
export type TeamLead = {
|
|
@@ -81,6 +84,10 @@ export type TeamSnapshot = {
|
|
|
81
84
|
};
|
|
82
85
|
export declare const TEAM_OPERATIONS: readonly ['list', 'show', 'create-lead', 'update-lead', 'create-conversation', 'member', 'send', 'edit', 'withdraw', 'read', 'authorize', 'follow', 'stop', 'transfer'];
|
|
83
86
|
export type TeamOperation = typeof TEAM_OPERATIONS[number];
|
|
87
|
+
/** Whether each operation writes anything (a read receipt included). Exhaustive, so a new operation can't default to read. */
|
|
88
|
+
export declare const TEAM_MUTATIONS: Readonly<Record<TeamOperation, boolean>>;
|
|
89
|
+
/** Whether this actor may run `operation`: a read-scope token never writes. */
|
|
90
|
+
export declare const teamScopeAllows: (actor: TeamActor, operation: TeamOperation) => boolean;
|
|
84
91
|
export type TeamRequest = {
|
|
85
92
|
operation: TeamOperation;
|
|
86
93
|
args: Record<string, unknown>;
|
package/dist/team-contract.js
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
1
|
export const TEAM_OPERATIONS = ['list', 'show', 'create-lead', 'update-lead', 'create-conversation', 'member', 'send', 'edit', 'withdraw', 'read', 'authorize', 'follow', 'stop', 'transfer'];
|
|
2
|
+
/** Whether each operation writes anything (a read receipt included). Exhaustive, so a new operation can't default to read. */
|
|
3
|
+
export const TEAM_MUTATIONS = {
|
|
4
|
+
list: false, show: false, 'create-lead': true, 'update-lead': true, 'create-conversation': true, member: true, send: true, edit: true,
|
|
5
|
+
withdraw: true, read: true, authorize: true, follow: true, stop: true, transfer: true,
|
|
6
|
+
};
|
|
7
|
+
/** Whether this actor may run `operation`: a read-scope token never writes. */
|
|
8
|
+
export const teamScopeAllows = (actor, operation) => !TEAM_MUTATIONS[operation] || actor.principal === undefined || actor.principal.scope === 'act';
|
package/dist/team-http.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type TeamActor, type TeamExecute } from './team-contract.js';
|
|
3
|
+
import { type Admission } from './request-budget.js';
|
|
3
4
|
export declare const TEAM_REQUEST_BYTES: number;
|
|
4
5
|
export type TeamHttpOptions = {
|
|
5
6
|
authenticate: (request: IncomingMessage) => TeamActor | null | Promise<TeamActor | null>;
|
|
@@ -9,6 +10,10 @@ export type TeamHttpOptions = {
|
|
|
9
10
|
execute: TeamExecute;
|
|
10
11
|
/** Current, authorized event cursor. null means the audience is no longer available. */
|
|
11
12
|
cursor?: (actor: TeamActor, conversationId?: string) => number | null;
|
|
13
|
+
/** Source admission before credential verification. Streams are charged once when opened. */
|
|
14
|
+
admit?: (request: IncomingMessage) => Admission;
|
|
15
|
+
/** Proved account/token admission, before reading the body or executing any operation. */
|
|
16
|
+
admitAuthenticated?: (request: IncomingMessage, actor: TeamActor) => Admission;
|
|
12
17
|
streamIntervalMs?: number;
|
|
13
18
|
streams?: Set<ServerResponse>;
|
|
14
19
|
};
|
package/dist/team-http.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { TEAM_OPERATIONS } from './team-contract.js';
|
|
1
|
+
import { TEAM_OPERATIONS, teamScopeAllows } from './team-contract.js';
|
|
2
|
+
import { limitWords } from './request-budget.js';
|
|
2
3
|
export const TEAM_REQUEST_BYTES = 64 * 1024;
|
|
3
4
|
const failure = (code, message) => ({ version: 1, ok: false, code, message });
|
|
4
|
-
function send(response, status, value) {
|
|
5
|
+
function send(response, status, value, headers = {}) {
|
|
5
6
|
if (response.destroyed || response.writableEnded)
|
|
6
7
|
return;
|
|
7
|
-
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
8
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
|
|
8
9
|
response.end(JSON.stringify(value));
|
|
9
10
|
}
|
|
10
11
|
async function body(request) {
|
|
@@ -40,6 +41,17 @@ export async function handleTeamHttp(request, response, options) {
|
|
|
40
41
|
const count = request.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'authorization').length;
|
|
41
42
|
if (count > 1)
|
|
42
43
|
return reject(400, 'ambiguous-credentials', 'Send one authorization header.');
|
|
44
|
+
const refuseAdmission = (admitted) => {
|
|
45
|
+
if (admitted.ok)
|
|
46
|
+
return false;
|
|
47
|
+
if (admitted.status === 503)
|
|
48
|
+
return reject(503, 'limits-unavailable', 'Request limits could not be checked; nothing ran. Try again shortly.');
|
|
49
|
+
send(response, 429, failure('rate-limited', limitWords(admitted.limit, admitted.retryAfter)), { 'retry-after': String(admitted.retryAfter) });
|
|
50
|
+
request.resume();
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
if (options.admit && refuseAdmission(options.admit(request)))
|
|
54
|
+
return true;
|
|
43
55
|
let actor;
|
|
44
56
|
try {
|
|
45
57
|
actor = await options.authenticate(request);
|
|
@@ -49,6 +61,8 @@ export async function handleTeamHttp(request, response, options) {
|
|
|
49
61
|
}
|
|
50
62
|
if (!actor)
|
|
51
63
|
return reject(401, 'unauthenticated', 'Sign in to continue.');
|
|
64
|
+
if (options.admitAuthenticated && refuseAdmission(options.admitAuthenticated(request, actor)))
|
|
65
|
+
return true;
|
|
52
66
|
const conversationId = url.searchParams.get('conversation') ?? undefined;
|
|
53
67
|
if (url.pathname === '/api/team/events') {
|
|
54
68
|
if (request.method !== 'GET')
|
|
@@ -133,6 +147,8 @@ export async function handleTeamHttp(request, response, options) {
|
|
|
133
147
|
return reject(405, 'method-not-allowed', 'Use GET or POST.');
|
|
134
148
|
if (!options.revalidate(request, actor))
|
|
135
149
|
return reject(401, 'unauthenticated', 'Sign in to continue.');
|
|
150
|
+
if (!teamScopeAllows(actor, input.operation))
|
|
151
|
+
return reject(403, 'read-only', 'Your token reads only. Use an act token for this.');
|
|
136
152
|
try {
|
|
137
153
|
const result = await options.execute(actor, input);
|
|
138
154
|
send(response, result.ok ? 200 : /forbidden|access|unauthor|member/.test(result.code) ? 403 : 409, result);
|
package/dist/team-leads.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Store } from './store.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type TeamActor, type TeamConversation, type TeamLead, type TeamRequest, type TeamResponse, type TeamRole, type TeamSnapshot } from './team-contract.js';
|
|
3
3
|
export type TeamAccess = {
|
|
4
4
|
conversation: TeamConversation;
|
|
5
5
|
lead: TeamLead;
|
|
@@ -25,7 +25,11 @@ export declare class TeamLeads {
|
|
|
25
25
|
readonly enrolled: () => readonly string[];
|
|
26
26
|
constructor(store: Store, enrolled: () => readonly string[]);
|
|
27
27
|
private get db();
|
|
28
|
+
/** The account as this actor may use it now. An API token's principal narrows it: a read token acts as a viewer, and a
|
|
29
|
+
* project-limited token sees only its projects. Every role and project check below reads this, never the raw row. */
|
|
28
30
|
private account;
|
|
31
|
+
/** Whether the actor may use `repo` now: their account's access, narrowed by any token limit. */
|
|
32
|
+
private reaches;
|
|
29
33
|
private leadRow;
|
|
30
34
|
private leadProjects;
|
|
31
35
|
private leadOf;
|
package/dist/team-leads.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { scanForSecrets } from './evidence.js';
|
|
3
3
|
import { ceilingDigestOf } from './principal.js';
|
|
4
4
|
import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from './project-access.js';
|
|
5
|
+
import { tokenProjects } from './api-tokens.js';
|
|
6
|
+
import { teamScopeAllows } from './team-contract.js';
|
|
5
7
|
class TeamRefusal extends Error {
|
|
6
8
|
code;
|
|
7
9
|
constructor(code, message) {
|
|
@@ -53,11 +55,22 @@ export class TeamLeads {
|
|
|
53
55
|
this.enrolled = enrolled;
|
|
54
56
|
}
|
|
55
57
|
get db() { return this.store.handle; }
|
|
58
|
+
/** The account as this actor may use it now. An API token's principal narrows it: a read token acts as a viewer, and a
|
|
59
|
+
* project-limited token sees only its projects. Every role and project check below reads this, never the raw row. */
|
|
56
60
|
account(actor) {
|
|
57
61
|
const account = this.store.accountOf(actor.name);
|
|
58
62
|
if (account === null || account.revokedAt !== null || account.generation !== actor.generation)
|
|
59
63
|
refuse('signed-out', 'Sign in again to use this conversation.');
|
|
60
|
-
|
|
64
|
+
const principal = actor.principal;
|
|
65
|
+
if (principal === undefined)
|
|
66
|
+
return account;
|
|
67
|
+
if (principal.account !== actor.name || principal.generation !== actor.generation)
|
|
68
|
+
refuse('signed-out', 'Sign in again to use this conversation.');
|
|
69
|
+
return { ...account, role: principal.scope === 'act' ? account.role : 'viewer', projects: tokenProjects(account.projects, principal.projects) };
|
|
70
|
+
}
|
|
71
|
+
/** Whether the actor may use `repo` now: their account's access, narrowed by any token limit. */
|
|
72
|
+
reaches(actor, repo) {
|
|
73
|
+
return this.store.accountCanAccess(actor.name, repo) && projectAccessAllows(this.account(actor).projects, repo);
|
|
61
74
|
}
|
|
62
75
|
leadRow(id) {
|
|
63
76
|
const row = this.db.prepare('SELECT * FROM team_lead WHERE id=?').get(id);
|
|
@@ -124,6 +137,28 @@ export class TeamLeads {
|
|
|
124
137
|
const { lead } = this.access(actor, conversationId);
|
|
125
138
|
return Number(this.db.prepare('SELECT COALESCE(MAX(id),0) AS n FROM team_event WHERE (conversation=? OR (conversation IS NULL AND lead=?))').get(conversationId, lead.id)?.['n'] ?? 0);
|
|
126
139
|
}
|
|
140
|
+
// A project-limited token hears only of the leads and conversations it can open, never activity beyond its projects.
|
|
141
|
+
if (actor.principal?.projects != null) {
|
|
142
|
+
const account = this.account(actor);
|
|
143
|
+
const leads = this.db.prepare('SELECT l.id FROM team_lead l JOIN team_lead_member m ON m.lead=l.id WHERE m.account=? AND m.active=1').all(actor.name).map(r => String(r['id']))
|
|
144
|
+
.filter(id => this.leadProjects(id).every(p => projectAccessAllows(account.projects, p) && this.enrolled().includes(p)));
|
|
145
|
+
const conversations = this.db.prepare('SELECT conversation FROM team_participant WHERE account=? AND active=1').all(actor.name).map(r => String(r['conversation']))
|
|
146
|
+
.filter(id => { try {
|
|
147
|
+
this.access(actor, id);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (e instanceof TeamRefusal)
|
|
152
|
+
return false;
|
|
153
|
+
throw e;
|
|
154
|
+
} });
|
|
155
|
+
let n = 0;
|
|
156
|
+
for (const id of leads)
|
|
157
|
+
n = Math.max(n, Number(this.db.prepare('SELECT COALESCE(MAX(id),0) AS n FROM team_event WHERE lead=? AND conversation IS NULL').get(id)?.['n'] ?? 0));
|
|
158
|
+
for (const id of conversations)
|
|
159
|
+
n = Math.max(n, Number(this.db.prepare('SELECT COALESCE(MAX(id),0) AS n FROM team_event WHERE conversation=?').get(id)?.['n'] ?? 0));
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
127
162
|
return Number(this.db.prepare(`SELECT COALESCE(MAX(e.id),0) AS n FROM team_event e JOIN team_lead_member m ON m.lead=e.lead AND m.account=? AND m.active=1
|
|
128
163
|
WHERE e.conversation IS NULL OR EXISTS(SELECT 1 FROM team_participant p JOIN team_conversation c ON c.id=p.conversation WHERE p.conversation=e.conversation AND p.account=? AND p.active=1 AND (c.visibility='team' OR c.created_by=?))`).get(actor.name, actor.name, actor.name)?.['n'] ?? 0);
|
|
129
164
|
}
|
|
@@ -159,9 +194,12 @@ export class TeamLeads {
|
|
|
159
194
|
try {
|
|
160
195
|
const result = this.store.transact(() => {
|
|
161
196
|
this.account(actor);
|
|
197
|
+
if (!teamScopeAllows(actor, request.operation))
|
|
198
|
+
refuse('read-only', 'Your token reads only. Use an act token for this.');
|
|
162
199
|
const requestId = request.args['requestId'];
|
|
163
200
|
const key = requestId === undefined ? null : str(requestId, 'Request ID', 128);
|
|
164
|
-
|
|
201
|
+
// A token's scope and project limit are part of what was asked: a narrower token never replays a wider one's receipt.
|
|
202
|
+
const digest = hash(stable(actor.principal === undefined ? request : { request, scope: actor.principal.scope, projects: actor.principal.projects }));
|
|
165
203
|
if (key) {
|
|
166
204
|
const seen = this.db.prepare('SELECT payload_hash,response_json FROM team_request WHERE account=? AND generation=? AND request_id=?').get(actor.name, actor.generation, key);
|
|
167
205
|
if (seen) {
|
|
@@ -527,7 +565,7 @@ export class TeamLeads {
|
|
|
527
565
|
if (ancestry.problem !== null)
|
|
528
566
|
return false;
|
|
529
567
|
const ref = this.store.lookupRef(ancestry.chain[ancestry.chain.length - 1] ?? taskId);
|
|
530
|
-
if (!ref || ref.repo === null || !lead.projects.includes(ref.repo) || !this.
|
|
568
|
+
if (!ref || ref.repo === null || !lead.projects.includes(ref.repo) || !this.reaches(actor, ref.repo))
|
|
531
569
|
return false;
|
|
532
570
|
if (conversationId) {
|
|
533
571
|
const access = this.access(actor, conversationId, 'contributor');
|
|
@@ -546,7 +584,7 @@ export class TeamLeads {
|
|
|
546
584
|
if (ancestry.problem !== null)
|
|
547
585
|
refuse('invalid-task', 'Task history needs inspection before ownership can change.');
|
|
548
586
|
const ref = this.store.lookupRef(ancestry.chain[ancestry.chain.length - 1] ?? taskId);
|
|
549
|
-
if (!ref || ref.repo === null || !target.projects.includes(ref.repo) || !this.
|
|
587
|
+
if (!ref || ref.repo === null || !target.projects.includes(ref.repo) || !this.reaches(actor, ref.repo))
|
|
550
588
|
refuse('forbidden', 'The receiving lead must have access to this task’s project.');
|
|
551
589
|
const old = this.taskOwner(taskId), expected = integer(a['expectedRevision']);
|
|
552
590
|
if ((old?.revision ?? 0) !== expected)
|
package/dist/team-runtime.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Store } from './store.js';
|
|
2
2
|
import type { WorkspaceRevision } from './workspace-revision.js';
|
|
3
3
|
import { TeamLeads } from './team-leads.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type TeamActor, type TeamExecute } from './team-contract.js';
|
|
5
5
|
import { type MateTurnInput } from './mate.js';
|
|
6
6
|
import { type TeamChatProviderResolver } from './team-chat-authorization.js';
|
|
7
7
|
export type TeamRuntimeOptions = {
|
package/dist/team-runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { deliverTeamUpdates, startTeamUpdates } from './team-updates.js';
|
|
5
5
|
import { workIndexPage } from './work-index.js';
|
|
6
6
|
import { TeamLeads } from './team-leads.js';
|
|
7
|
+
import { teamScopeAllows } from './team-contract.js';
|
|
7
8
|
import { verifyApproverStanding } from './principal.js';
|
|
8
9
|
import { credentialKeyOf, isDirectChatProvider, priceForConfig, subscriptionCredentialKey, mateWorstCaseForPrice } from './converse.js';
|
|
9
10
|
import { runMateTurn } from './mate.js';
|
|
@@ -188,6 +189,8 @@ export function createTeamRuntime(options) {
|
|
|
188
189
|
}
|
|
189
190
|
const execute = async (actor, request) => {
|
|
190
191
|
try {
|
|
192
|
+
if (!teamScopeAllows(actor, request.operation))
|
|
193
|
+
return response(false, 'read-only', 'Your token reads only. Use an act token for this.');
|
|
191
194
|
if (request.operation === 'authorize')
|
|
192
195
|
return authorize(actor, request.args);
|
|
193
196
|
if (request.operation === 'send' || request.operation === 'follow' && request.args['enabled'] === true) {
|
package/dist/teams.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { chatObject as object } from "./chat-delivery.js";
|
|
3
3
|
import { type TeamsChatOptions } from "./teams-chat.js";
|
|
4
4
|
import type { Store } from "./store.js";
|
|
5
|
+
import { type Admission } from "./request-budget.js";
|
|
5
6
|
export declare const TEAMS_ACTIVITY_BYTES: number;
|
|
6
7
|
export declare const TEAMS_MESSAGES_PATH = "/teams/messages";
|
|
7
8
|
/** The receiver: prove the Bot Framework token for this app, save the activity, acknowledge. Nothing runs here. */
|
|
@@ -10,6 +11,8 @@ export declare function handleTeamsHttp(request: IncomingMessage, response: Serv
|
|
|
10
11
|
dir: string;
|
|
11
12
|
fetcher?: typeof fetch;
|
|
12
13
|
clock?: () => Date;
|
|
14
|
+
admitSource: (request: IncomingMessage) => Admission;
|
|
15
|
+
admitTenant: (tenant: string) => Admission;
|
|
13
16
|
}): Promise<boolean>;
|
|
14
17
|
/** The worker-side loop: a lease per installation, one pass a second. */
|
|
15
18
|
export declare function followTeams(options: Omit<TeamsChatOptions, "identity" | "api" | "owner" | "current"> & {
|
package/dist/teams.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ChatState } from "./chat-delivery-state.js";
|
|
|
8
8
|
import { chatObject as object } from "./chat-delivery.js";
|
|
9
9
|
import { loadTeamsCredentials, teamsAccessToken, teamsApi, verifyTeamsToken, TeamsError } from "./teams-api.js";
|
|
10
10
|
import { deliverTeamsPart, planTeamsNotifications, planTeamsRooms, processTeamsEvent, receiveTeams } from "./teams-chat.js";
|
|
11
|
+
import { limitWords } from "./request-budget.js";
|
|
11
12
|
export const TEAMS_ACTIVITY_BYTES = 64 * 1024;
|
|
12
13
|
export const TEAMS_MESSAGES_PATH = "/teams/messages";
|
|
13
14
|
/** The receiver: prove the Bot Framework token for this app, save the activity, acknowledge. Nothing runs here. */
|
|
@@ -18,12 +19,26 @@ export async function handleTeamsHttp(request, response, options) {
|
|
|
18
19
|
const reply = (status, body = "{}") => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(body); request.resume(); return true; };
|
|
19
20
|
if (request.method !== "POST")
|
|
20
21
|
return reply(405);
|
|
22
|
+
const refuseAdmission = (admitted) => {
|
|
23
|
+
if (admitted.ok)
|
|
24
|
+
return false;
|
|
25
|
+
if (admitted.status === 429)
|
|
26
|
+
response.setHeader("retry-after", String(admitted.retryAfter));
|
|
27
|
+
return reply(admitted.status, JSON.stringify({ error: admitted.status === 429 ? "rate-limited" : "limits-unavailable",
|
|
28
|
+
message: admitted.status === 429 ? limitWords(admitted.limit, admitted.retryAfter) : "Request limits could not be checked; nothing was saved." }));
|
|
29
|
+
};
|
|
30
|
+
// Source admission precedes JWT verification (including key fetches). Only proved app credentials consume
|
|
31
|
+
// the configured tenant's allowance; an untrusted activity cannot select or evade that allowance.
|
|
32
|
+
if (refuseAdmission(options.admitSource(request)))
|
|
33
|
+
return true;
|
|
21
34
|
const credentials = loadTeamsCredentials(options.dir);
|
|
22
35
|
if (credentials === null)
|
|
23
36
|
return reply(404);
|
|
24
37
|
const claims = await verifyTeamsToken(request.headers.authorization, credentials.app, options.fetcher ?? fetch);
|
|
25
38
|
if (claims === null)
|
|
26
39
|
return reply(401);
|
|
40
|
+
if (refuseAdmission(options.admitTenant(credentials.tenant)))
|
|
41
|
+
return true;
|
|
27
42
|
if (Number(request.headers["content-length"] ?? 0) > TEAMS_ACTIVITY_BYTES)
|
|
28
43
|
return reply(413);
|
|
29
44
|
let raw = "";
|
package/dist/work-index.js
CHANGED
|
@@ -242,6 +242,17 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
|
|
|
242
242
|
FROM claimed c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
|
|
243
243
|
)`;
|
|
244
244
|
const registered = new WeakMap();
|
|
245
|
+
const statements = new WeakMap();
|
|
246
|
+
/** The page query is long and has one text per view filter: compile each once per connection. */
|
|
247
|
+
function prepared(store, sql) {
|
|
248
|
+
let cache = statements.get(store.handle);
|
|
249
|
+
if (cache === undefined)
|
|
250
|
+
statements.set(store.handle, cache = new Map());
|
|
251
|
+
let statement = cache.get(sql);
|
|
252
|
+
if (statement === undefined)
|
|
253
|
+
cache.set(sql, statement = store.handle.prepare(sql));
|
|
254
|
+
return statement;
|
|
255
|
+
}
|
|
245
256
|
/** Connection-local pure parsing, with exactly the authoritative stored-scope
|
|
246
257
|
* validator. The callback cannot query, inspect files or grant authority. */
|
|
247
258
|
function registerValidators(store) {
|
|
@@ -358,7 +369,7 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
358
369
|
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(WORK_INDEX_MAX_LIMIT, Math.floor(options.limit))) : WORK_INDEX_PAGE_LIMIT;
|
|
359
370
|
const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
|
|
360
371
|
const filter = view === 'needs-you' ? 'needs=1' : view === 'running' ? 'family_running=1' : view === 'completed' ? "code='complete'" : '1';
|
|
361
|
-
const rows = store
|
|
372
|
+
const rows = prepared(store, `${projection}, selected_page AS MATERIALIZED (
|
|
362
373
|
SELECT * FROM ranked WHERE ${filter} AND ($cursorRoot=0 OR
|
|
363
374
|
${ORDER}>$cursorRank OR (${ORDER}=$cursorRank AND (sort_at<$cursorAt OR (sort_at=$cursorAt AND root_ref<$cursorRoot))))
|
|
364
375
|
ORDER BY ${ORDER},sort_at DESC,root_ref DESC LIMIT $limit
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Store } from './store.js';
|
|
2
|
+
/** The service_cursor row the revision triggers move (live-bus.ts listens for it). */
|
|
3
|
+
export declare const WORKSPACE_REVISION_KEY = "workspace-content:v1";
|
|
2
4
|
export type WorkspaceRevision = {
|
|
3
5
|
current(): string;
|
|
4
6
|
expiresAt(now: Date): number;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* metadata only: it never grants access or certifies task/process state. */
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { DEFAULT_LIVENESS_MS } from './runner.js';
|
|
5
|
-
|
|
5
|
+
/** The service_cursor row the revision triggers move (live-bus.ts listens for it). */
|
|
6
|
+
export const WORKSPACE_REVISION_KEY = 'workspace-content:v1';
|
|
7
|
+
const KEY = WORKSPACE_REVISION_KEY;
|
|
6
8
|
const PREFIX = 'workspace_revision_v1_';
|
|
7
9
|
const MAX_AGE_MS = 60_000;
|
|
8
10
|
const OMIT = new Set(['schema_version', 'service_cursor', 'wake', 'notification_delivery',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.49",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|