toolroll 0.9.50 → 0.9.52

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/backup.d.ts +8 -0
  3. package/dist/backup.js +10 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +1 -1
  6. package/dist/browser-workspace.d.ts +1 -0
  7. package/dist/database-reclaim.d.ts +4 -0
  8. package/dist/database-reclaim.js +13 -0
  9. package/dist/demo.d.ts +1 -1
  10. package/dist/demo.js +1 -1
  11. package/dist/desktop-update.d.ts +1 -1
  12. package/dist/live-bus.d.ts +3 -3
  13. package/dist/live-bus.js +3 -3
  14. package/dist/mcp-oauth.d.ts +1 -1
  15. package/dist/mcp-oauth.js +3 -2
  16. package/dist/monitoring.js +32 -8
  17. package/dist/notification-delivery.d.ts +19 -0
  18. package/dist/notification-delivery.js +37 -0
  19. package/dist/operate-remote-arguments.js +1 -1
  20. package/dist/operate-remote.js +2 -2
  21. package/dist/operate.d.ts +1 -1
  22. package/dist/operate.js +54 -120
  23. package/dist/process-recovery-settlement.js +1 -0
  24. package/dist/request-budget.d.ts +5 -2
  25. package/dist/request-budget.js +7 -3
  26. package/dist/retention.js +6 -5
  27. package/dist/scope.d.ts +1 -0
  28. package/dist/scope.js +19 -9
  29. package/dist/serve.js +15 -10
  30. package/dist/server/people-tokens.js +1 -2
  31. package/dist/server/remote.js +19 -4
  32. package/dist/server/settings.js +3 -1
  33. package/dist/server/shared.d.ts +1 -0
  34. package/dist/server/shared.js +2 -0
  35. package/dist/sign-in-guard.d.ts +41 -16
  36. package/dist/sign-in-guard.js +147 -24
  37. package/dist/source-key.d.ts +2 -0
  38. package/dist/source-key.js +49 -0
  39. package/dist/store.d.ts +38 -43
  40. package/dist/store.js +255 -134
  41. package/dist/surface.js +1 -2
  42. package/dist/team-http.d.ts +3 -0
  43. package/dist/team-http.js +3 -0
  44. package/dist/toolroll-update.d.ts +14 -3
  45. package/dist/toolroll-update.js +105 -14
  46. package/dist/webhooks.d.ts +10 -22
  47. package/dist/webhooks.js +43 -81
  48. package/dist/work-index.js +2 -2
  49. package/dist/workspace-revision.d.ts +28 -1
  50. package/dist/workspace-revision.js +276 -49
  51. package/package.json +1 -3
@@ -1,19 +1,275 @@
1
- /** Durable invalidation for authenticated workspace reads. This is cache
2
- * metadata only: it never grants access or certifies task/process state. */
3
- import { createHash } from 'node:crypto';
4
1
  import { DEFAULT_LIVENESS_MS } from './runner.js';
5
- /** The service_cursor row the revision triggers move (live-bus.ts listens for it). */
2
+ /** The service_cursor row every meaningful commit moves (live-bus.ts listens for it). */
6
3
  export const WORKSPACE_REVISION_KEY = 'workspace-content:v1';
7
4
  const KEY = WORKSPACE_REVISION_KEY;
8
- const PREFIX = 'workspace_revision_v1_';
9
5
  const MAX_AGE_MS = 60_000;
10
- const OMIT = new Set(['schema_version', 'service_cursor', 'wake', 'notification_delivery',
6
+ const OMIT = new Set(['schema_version', 'service_cursor', 'wake', 'notification_delivery', 'sqlite_sequence',
11
7
  'team_read', 'team_request', 'push_delivery', 'telegram_update', 'telegram_outbound_message', 'telegram_retry', 'bridge_lease']);
12
8
  const QUIET = {
13
9
  runner: ['heartbeat_at'], claim: ['heartbeat_at', 'expires_at'], watch_lease: ['heartbeat_at', 'expires_at'],
14
10
  watch_episode: ['ticks'], provider_readiness: ['observed_at'],
15
11
  };
16
- const identifier = (name) => `"${name.replaceAll('"', '""')}"`;
12
+ /** A quiet beat that revives what a view already rendered as gone: the rows a beat may move, read before and after.
13
+ * A runner beat after its liveness window (or backwards) is a revival; a lease renewed at or past its expiry is one. */
14
+ const REVIVAL = {
15
+ runner: { rows: 'SELECT rowid AS id, heartbeat_at AS h, NULL AS e FROM runner',
16
+ revived: (before, after) => after.h < before.h || Date.parse(after.h) >= Date.parse(before.h) + DEFAULT_LIVENESS_MS },
17
+ claim: { rows: 'SELECT rowid AS id, heartbeat_at AS h, expires_at AS e FROM claim WHERE released_at IS NULL', revived: (before, after) => before.e !== null && after.h >= before.e },
18
+ watch_lease: { rows: 'SELECT rowid AS id, heartbeat_at AS h, expires_at AS e FROM watch_lease', revived: (before, after) => before.e !== null && after.h >= before.e },
19
+ };
20
+ const BUMP = `INSERT INTO service_cursor(key,value,updated_at) VALUES ('${KEY}',1,strftime('%Y-%m-%dT%H:%M:%fZ','now'))
21
+ ON CONFLICT(key) DO UPDATE SET value=service_cursor.value+1,updated_at=excluded.updated_at`;
22
+ const NAME = String.raw `(?:"((?:[^"]|"")+)"|\[([^\]]+)\]|([A-Za-z_][\w$]*))`;
23
+ const TABLE = new RegExp(String.raw `^(?:${NAME}\s*\.\s*)?${NAME}`);
24
+ function tableAt(text) {
25
+ const match = TABLE.exec(text);
26
+ if (match === null)
27
+ return null;
28
+ const schema = match[1] ?? match[2] ?? match[3] ?? null;
29
+ const table = match[4] ?? match[5] ?? match[6];
30
+ return { schema: schema?.toLowerCase() ?? null, table: table.replaceAll('""', '"').toLowerCase(), rest: text.slice(match[0].length) };
31
+ }
32
+ /** The columns an UPDATE's SET clause assigns, or null when it can't be read (then the write counts). */
33
+ function assigned(rest) {
34
+ const set = /^\s+SET\s+/i.exec(rest);
35
+ if (set === null)
36
+ return null;
37
+ const columns = [];
38
+ let depth = 0, quote = null, start = set[0].length;
39
+ const take = (end) => {
40
+ const lhs = rest.slice(start, end).split('=')[0].trim();
41
+ const names = lhs.startsWith('(') ? lhs.slice(1, -1).split(',') : [lhs];
42
+ for (const one of names) {
43
+ const name = tableAt(one.trim());
44
+ if (name === null)
45
+ return false;
46
+ columns.push(name.table);
47
+ }
48
+ return true;
49
+ };
50
+ for (let i = start; i < rest.length; i++) {
51
+ const c = rest[i];
52
+ if (quote !== null) {
53
+ if (c === quote)
54
+ quote = null;
55
+ continue;
56
+ }
57
+ if (c === "'" || c === '"' || c === '`') {
58
+ quote = c;
59
+ continue;
60
+ }
61
+ if (c === '(')
62
+ depth++;
63
+ else if (c === ')')
64
+ depth--;
65
+ else if (depth === 0 && c === ',') {
66
+ if (!take(i))
67
+ return null;
68
+ start = i + 1;
69
+ }
70
+ else if (depth === 0 && /^\s(?:WHERE|FROM|RETURNING|ORDER|LIMIT)\b/i.test(rest.slice(i, i + 11))) {
71
+ return take(i) ? columns : null;
72
+ }
73
+ }
74
+ return take(rest.length) ? columns : null;
75
+ }
76
+ export function statementEffect(sql) {
77
+ const text = sql.replace(/^(?:\s|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)+/, '');
78
+ if (/^BEGIN\b/i.test(text))
79
+ return { kind: 'begin' };
80
+ if (/^(?:COMMIT|END)\b/i.test(text))
81
+ return { kind: 'commit' };
82
+ let match = /^ROLLBACK(?:\s+TRANSACTION)?\s+TO\s+(?:SAVEPOINT\s+)?(\S+?)\s*;?\s*$/i.exec(text);
83
+ if (match !== null)
84
+ return { kind: 'rollback-to', name: match[1].toLowerCase() };
85
+ if (/^ROLLBACK\b/i.test(text))
86
+ return { kind: 'rollback' };
87
+ match = /^SAVEPOINT\s+(\S+?)\s*;?\s*$/i.exec(text);
88
+ if (match !== null)
89
+ return { kind: 'savepoint', name: match[1].toLowerCase() };
90
+ match = /^RELEASE\s+(?:SAVEPOINT\s+)?(\S+?)\s*;?\s*$/i.exec(text);
91
+ if (match !== null)
92
+ return { kind: 'release', name: match[1].toLowerCase() };
93
+ // WITH … is only ever a prefix to one data statement here; its CTEs cannot write.
94
+ const body = /^WITH\b/i.test(text) ? text.replace(/^WITH\b[\s\S]*?(?=\b(?:INSERT|REPLACE|UPDATE|DELETE)\b)/i, '') : text;
95
+ match = /^(?:(?:INSERT|REPLACE)(?:\s+OR\s+\w+)?\s+INTO|DELETE\s+FROM)\s+/i.exec(body);
96
+ const update = match === null ? /^UPDATE(?:\s+OR\s+\w+)?\s+/i.exec(body) : null;
97
+ const at = match ?? update;
98
+ if (at === null)
99
+ return { kind: 'none' };
100
+ const target = tableAt(body.slice(at[0].length));
101
+ if (target === null)
102
+ return { kind: 'write', table: '', quiet: false };
103
+ if (target.schema === 'temp' || OMIT.has(target.table) || target.table.startsWith('memory_search'))
104
+ return { kind: 'none' };
105
+ const quietColumns = QUIET[target.table];
106
+ if (update === null || quietColumns === undefined)
107
+ return { kind: 'write', table: target.table, quiet: false };
108
+ const columns = assigned(target.rest);
109
+ return { kind: 'write', table: target.table, quiet: columns !== null && columns.every(column => quietColumns.includes(column)) };
110
+ }
111
+ /** How many times this connection has moved the revision; read through any wrapper around it. */
112
+ const BUMPS = Symbol('workspace revision bumps');
113
+ /** Wrap one connection so each committed meaningful write moves the workspace revision exactly once. Native
114
+ * extensions (scalar functions, isTransaction) pass through; a test spy on prepare/exec replaces only the facade's. */
115
+ export function trackWorkspaceWrites(db) {
116
+ const state = { bumps: 0 };
117
+ const effects = new Map();
118
+ const effectOf = (sql) => {
119
+ let effect = effects.get(sql);
120
+ if (effect === undefined) {
121
+ effect = statementEffect(sql);
122
+ if (effects.size >= 2048)
123
+ effects.clear();
124
+ effects.set(sql, effect);
125
+ }
126
+ return effect;
127
+ };
128
+ let open = false, dirty = false, bySavepoint = false;
129
+ let savepoints = [];
130
+ const native = db;
131
+ const inTransaction = () => typeof native.isTransaction === 'boolean' ? native.isTransaction : open;
132
+ let totals = null;
133
+ const total = () => Number((totals ??= db.prepare('SELECT total_changes() AS n')).get()?.['n'] ?? 0);
134
+ const bump = () => {
135
+ try {
136
+ db.exec(BUMP);
137
+ state.bumps++;
138
+ }
139
+ catch { /* a file without the cursor (an old shape opened bare) has no revision to move */ }
140
+ };
141
+ const reset = () => { open = false; dirty = false; bySavepoint = false; savepoints = []; };
142
+ /** A meaningful write changed a row: its transaction's commit moves the revision, or this autocommit does now. */
143
+ const wrote = () => { if (inTransaction())
144
+ dirty = true;
145
+ else
146
+ bump(); };
147
+ const control = (effect, run) => {
148
+ switch (effect.kind) {
149
+ case 'begin':
150
+ run();
151
+ reset();
152
+ open = true;
153
+ return;
154
+ case 'commit':
155
+ if (dirty) {
156
+ bump();
157
+ dirty = false;
158
+ }
159
+ run();
160
+ reset();
161
+ return;
162
+ case 'rollback':
163
+ try {
164
+ run();
165
+ }
166
+ finally {
167
+ reset();
168
+ }
169
+ return;
170
+ case 'savepoint':
171
+ if (!inTransaction()) {
172
+ reset();
173
+ bySavepoint = true;
174
+ }
175
+ run();
176
+ open = true;
177
+ savepoints.push({ name: effect.name, dirty });
178
+ return;
179
+ case 'rollback-to': {
180
+ run();
181
+ const at = savepoints.map(one => one.name).lastIndexOf(effect.name);
182
+ if (at >= 0) {
183
+ dirty = savepoints[at].dirty;
184
+ savepoints.length = at + 1;
185
+ }
186
+ return;
187
+ }
188
+ case 'release': {
189
+ const at = savepoints.map(one => one.name).lastIndexOf(effect.name);
190
+ // Releasing the savepoint that began the transaction commits it.
191
+ if (at === 0 && bySavepoint && dirty) {
192
+ bump();
193
+ dirty = false;
194
+ }
195
+ run();
196
+ if (at >= 0)
197
+ savepoints.length = at;
198
+ if (!inTransaction())
199
+ reset();
200
+ return;
201
+ }
202
+ default: run();
203
+ }
204
+ };
205
+ /** Run one data statement and report whether it changed the workspace. */
206
+ const measured = (effect, body) => {
207
+ if (effect.kind !== 'write')
208
+ return body();
209
+ const revival = effect.quiet ? REVIVAL[effect.table] : undefined;
210
+ const beats = () => new Map(db.prepare(revival.rows).all()
211
+ .map(row => [Number(row['id']), { h: String(row['h']), e: row['e'] == null ? null : String(row['e']) }]));
212
+ const before = revival === undefined ? null : beats();
213
+ const changes = total();
214
+ const result = body();
215
+ if (total() === changes)
216
+ return result;
217
+ if (!effect.quiet)
218
+ wrote();
219
+ else if (before !== null) {
220
+ for (const [id, after] of beats()) {
221
+ const was = before.get(id);
222
+ if (was !== undefined && was.h !== after.h && revival.revived(was, after)) {
223
+ wrote();
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ return result;
229
+ };
230
+ const methods = {
231
+ exec(sql) {
232
+ let effect = effectOf(sql);
233
+ // A script of several statements is read by its first; past a quiet first one, any row it changes counts.
234
+ if (effect.kind === 'none' && /;\s*\S/.test(sql))
235
+ effect = { kind: 'write', table: '', quiet: false };
236
+ if (effect.kind === 'write' || effect.kind === 'none')
237
+ measured(effect, () => db.exec(sql));
238
+ else
239
+ control(effect, () => db.exec(sql));
240
+ },
241
+ prepare(sql) {
242
+ const statement = db.prepare(sql), effect = effectOf(sql);
243
+ if (effect.kind === 'none')
244
+ return statement;
245
+ const run = (body) => effect.kind === 'write' ? measured(effect, body) : (control(effect, () => { body(); }), undefined);
246
+ return passThrough(statement, {
247
+ run: (...params) => effect.kind === 'write' ? measured(effect, () => statement.run(...params)) : runControl(effect, control, () => statement.run(...params)),
248
+ get: (...params) => run(() => statement.get(...params)),
249
+ all: (...params) => run(() => statement.all(...params)),
250
+ });
251
+ },
252
+ close() { db.close(); },
253
+ };
254
+ return passThrough(db, Object.assign(methods, { [BUMPS]: () => state.bumps }));
255
+ }
256
+ function runControl(effect, control, body) {
257
+ let result = { changes: 0 };
258
+ control(effect, () => { result = body(); });
259
+ return result;
260
+ }
261
+ function passThrough(inner, methods) {
262
+ return new Proxy(methods, {
263
+ get(target, property) {
264
+ if (Object.hasOwn(target, property))
265
+ return Reflect.get(target, property);
266
+ const value = Reflect.get(inner, property, inner);
267
+ return typeof value === 'function' ? value.bind(inner) : value;
268
+ },
269
+ has: (target, property) => Reflect.has(target, property) || Reflect.has(inner, property),
270
+ getPrototypeOf: () => Object.getPrototypeOf(inner),
271
+ });
272
+ }
17
273
  /** Per-server response metadata. Reads preserve insertion order; writes at
18
274
  * capacity discard the oldest key before storing the supplied validator. */
19
275
  export class WorkspaceValidatorCache {
@@ -27,46 +283,13 @@ export class WorkspaceValidatorCache {
27
283
  }
28
284
  export function prepareWorkspaceRevision(store) {
29
285
  const db = store.raw();
30
- const bump = `INSERT INTO service_cursor(key,value,updated_at) VALUES ('${KEY}',1,strftime('%Y-%m-%dT%H:%M:%fZ','now'))
31
- ON CONFLICT(key) DO UPDATE SET value=service_cursor.value+1,updated_at=excluded.updated_at;`;
32
- const wanted = new Map();
33
- // Virtual tables (the FTS5 memory index) cannot carry triggers, and their
34
- // shadow tables are derived views rebuilt from the stores of record.
35
- const tables = db.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql NOT LIKE 'CREATE VIRTUAL%' AND name NOT LIKE 'memory_search%'").all();
36
- for (const row of tables) {
37
- const table = String(row['name']);
38
- if (OMIT.has(table))
39
- continue;
40
- const columns = db.prepare(`PRAGMA table_info(${identifier(table)})`).all().map(column => String(column['name']));
41
- const meaningful = columns.filter(column => !QUIET[table]?.includes(column));
42
- const changes = meaningful.map(column => `OLD.${identifier(column)} IS NOT NEW.${identifier(column)}`);
43
- // A routine beat is quiet, but a revival after a liveness/lease gap must
44
- // invalidate an already-rendered "offline" view immediately.
45
- if (table === 'runner')
46
- changes.push(`NEW.heartbeat_at < OLD.heartbeat_at OR NEW.heartbeat_at >= strftime('%Y-%m-%dT%H:%M:%fZ',OLD.heartbeat_at,'+${DEFAULT_LIVENESS_MS / 1000} seconds')`);
47
- if (table === 'claim' || table === 'watch_lease')
48
- changes.push('NEW.heartbeat_at >= OLD.expires_at');
49
- for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
50
- const guard = event === 'UPDATE' ? ` WHEN ${changes.join(' OR ')}` : '';
51
- const body = `AFTER ${event} ON ${identifier(table)}${guard} BEGIN ${bump} END`;
52
- const digest = createHash('sha256').update(body).digest('hex').slice(0, 16);
53
- wanted.set(`${PREFIX}${table}_${event.toLowerCase()}_${digest}`, body);
54
- }
55
- }
56
- // Only DDL and one metadata row are written here; no task/history scan or
57
- // backfill holds the writer. Existing installations perform no DDL writes.
58
- store.transact(() => {
59
- const existing = db.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name LIKE ?").all(`${PREFIX}%`);
60
- const installed = new Set(existing.map(row => String(row['name'])).filter(name => name.startsWith(PREFIX)));
61
- for (const name of installed)
62
- if (!wanted.has(name))
63
- db.exec(`DROP TRIGGER ${identifier(name)}`);
64
- for (const [name, body] of wanted)
65
- if (!installed.has(name))
66
- db.exec(`CREATE TRIGGER ${identifier(name)} ${body}`);
67
- db.prepare('INSERT OR IGNORE INTO service_cursor(key,value,updated_at) VALUES (?,0,?)').run(KEY, new Date().toISOString());
68
- });
69
- const current = db.prepare('SELECT CAST(value AS TEXT) AS value FROM service_cursor WHERE key = ?');
286
+ db.prepare('INSERT OR IGNORE INTO service_cursor(key,value,updated_at) VALUES (?,0,?)').run(KEY, new Date().toISOString());
287
+ const cursor = db.prepare('SELECT CAST(value AS TEXT) AS value FROM service_cursor WHERE key = ?');
288
+ // Another connection's commit moves data_version; this connection's own moves its bump count. Neither moved: the
289
+ // revision read last is still the revision.
290
+ const dataVersion = db.prepare('PRAGMA data_version');
291
+ const counted = db[BUMPS];
292
+ let seen = null;
70
293
  const boundary = db.prepare(`SELECT MIN(at) AS at FROM (
71
294
  SELECT MIN(expires_at) AS at FROM claim WHERE released_at IS NULL AND expires_at > ?
72
295
  UNION ALL SELECT MIN(until) FROM hold WHERE until > ?
@@ -79,10 +302,14 @@ export function prepareWorkspaceRevision(store) {
79
302
  )`);
80
303
  return {
81
304
  current() {
82
- const value = current.get(KEY)?.['value'];
305
+ const data = dataVersion.get()?.['data_version'], bumps = counted?.() ?? -1;
306
+ if (seen !== null && counted !== undefined && seen.data === data && seen.bumps === bumps)
307
+ return seen.revision;
308
+ const value = cursor.get(KEY)?.['value'];
83
309
  if (typeof value !== 'string' || !/^\d+$/.test(value))
84
310
  throw new Error('Workspace revision is unavailable');
85
- return `v1:${value}`;
311
+ seen = { data, bumps, revision: `v1:${value}` };
312
+ return seen.revision;
86
313
  },
87
314
  expiresAt(now) {
88
315
  const iso = now.toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.50",
3
+ "version": "0.9.52",
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"
@@ -70,8 +70,6 @@
70
70
  "@types/react-dom": "19.3.0",
71
71
  "esbuild": "0.28.2",
72
72
  "happy-dom": "20.14.5",
73
- "paper": "0.12.18",
74
- "simple-icons": "16.34.0",
75
73
  "tailwindcss": "4.3.3",
76
74
  "tsx": "4.23.15",
77
75
  "typescript": "7.0.2",