toolroll 0.9.51 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/backup.d.ts +8 -0
- package/dist/backup.js +10 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +1 -1
- package/dist/browser-workspace.d.ts +1 -0
- package/dist/database-reclaim.d.ts +4 -0
- package/dist/database-reclaim.js +13 -0
- package/dist/demo.d.ts +1 -1
- package/dist/demo.js +1 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/live-bus.d.ts +3 -3
- package/dist/live-bus.js +3 -3
- package/dist/monitoring.js +32 -8
- package/dist/notification-delivery.d.ts +19 -0
- package/dist/notification-delivery.js +37 -0
- package/dist/operate-remote-arguments.js +1 -1
- package/dist/operate-remote.js +2 -2
- package/dist/operate.js +49 -119
- package/dist/process-recovery-settlement.js +1 -0
- package/dist/retention.js +6 -5
- package/dist/serve.js +1 -1
- package/dist/server/settings.js +3 -1
- package/dist/server/shared.d.ts +1 -0
- package/dist/server/shared.js +2 -0
- package/dist/store.d.ts +38 -43
- package/dist/store.js +255 -134
- package/dist/surface.js +1 -2
- package/dist/toolroll-update.d.ts +14 -3
- package/dist/toolroll-update.js +105 -14
- package/dist/webhooks.d.ts +10 -22
- package/dist/webhooks.js +43 -81
- package/dist/work-index.js +2 -2
- package/dist/workspace-revision.d.ts +28 -1
- package/dist/workspace-revision.js +276 -49
- 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
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|