amalgm 0.1.149 → 0.1.150
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/package.json +2 -1
- package/runtime/scripts/amalgm-mcp/automations/tools.js +16 -2
- package/runtime/scripts/amalgm-mcp/fs/rest.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +42 -12
- package/runtime/scripts/amalgm-mcp/state/app-resources.js +245 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +391 -0
- package/runtime/scripts/amalgm-mcp/state/registry.js +105 -0
- package/runtime/scripts/amalgm-mcp/state/resources.js +162 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +74 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +19 -92
- package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/state-app-resources.test.js +136 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/state-registry.test.js +95 -0
- package/runtime/scripts/amalgm-mcp/workflows/compiler.js +5 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live documents: Yjs-backed text files on the local-live rails (wave 2.5).
|
|
5
|
+
*
|
|
6
|
+
* A document is a text file (markdown, code — anything in the fs layer's
|
|
7
|
+
* text-extension set) whose contents are held in a Y.Doc while open. Edits
|
|
8
|
+
* travel as Yjs updates in the event log's `patch` slot; the file on disk
|
|
9
|
+
* remains the artifact (debounced write-back), so everything that reads the
|
|
10
|
+
* file — agents, exports, git — keeps working unchanged.
|
|
11
|
+
*
|
|
12
|
+
* Wire contract:
|
|
13
|
+
* POST /state/docs/open {path} → {resource, path, state, seq}
|
|
14
|
+
* POST /state/docs/update {path, updates:[b64]} → {seq, applied}
|
|
15
|
+
* events: {resource: "doc:<base64url(path)>", op: "update", id: path,
|
|
16
|
+
* patch: {yjs: "<base64 update>"}}
|
|
17
|
+
* snapshot read: {path, state, updatedAt} for OPEN docs only — the open
|
|
18
|
+
* endpoint is the bootstrap; snapshots never load documents.
|
|
19
|
+
*
|
|
20
|
+
* Failure isolation is a hard requirement: this module is only ever reached
|
|
21
|
+
* through its own routes and the `doc:` registry read (which catches all
|
|
22
|
+
* errors). If yjs is unavailable, routes answer 501 and nothing else in the
|
|
23
|
+
* realtime layer is affected.
|
|
24
|
+
*
|
|
25
|
+
* External writers (agents editing the file directly) are folded in by a
|
|
26
|
+
* per-document directory watcher: on file change, disk text is diffed into
|
|
27
|
+
* the Y.Doc as a splice, which emits a normal update event. The cycle is
|
|
28
|
+
* loop-safe because reconcile only acts when disk content changed past the
|
|
29
|
+
* last text we read or wrote (entry.lastDiskText) — our own write-backs and
|
|
30
|
+
* stale watcher events can never splice, so they can never re-emit.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const { openLocalDb } = require('./db');
|
|
36
|
+
const { appendStateEvent, currentSeq } = require('./events');
|
|
37
|
+
|
|
38
|
+
const DOC_RESOURCE_PREFIX = 'doc:';
|
|
39
|
+
const TEXT_KEY = 'content';
|
|
40
|
+
const MAX_DOC_BYTES = Number.parseInt(process.env.AMALGM_DOC_MAX_BYTES || '', 10) || 2 * 1024 * 1024;
|
|
41
|
+
const MAX_OPEN_DOCS = 64;
|
|
42
|
+
const DISK_WRITE_DEBOUNCE_MS = 300;
|
|
43
|
+
const WATCH_RECONCILE_DEBOUNCE_MS = 200;
|
|
44
|
+
const MAX_UPDATES_PER_CALL = 200;
|
|
45
|
+
|
|
46
|
+
// path -> { doc, resource, watcher, reconcileTimer, diskWriteTimer,
|
|
47
|
+
// lastAccess, suppressDiskWriteOnce }
|
|
48
|
+
const openDocs = new Map();
|
|
49
|
+
|
|
50
|
+
function fsPrivate() {
|
|
51
|
+
return require('../fs/rest')._private;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadYjs() {
|
|
55
|
+
try {
|
|
56
|
+
return require('yjs');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
throw Object.assign(
|
|
60
|
+
new Error(`live documents require the yjs package: ${message}`),
|
|
61
|
+
{ statusCode: 501 },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function invalid(message, statusCode = 400) {
|
|
67
|
+
return Object.assign(new Error(message), { statusCode });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureSchema(database) {
|
|
71
|
+
database.exec(`
|
|
72
|
+
CREATE TABLE IF NOT EXISTS doc_states (
|
|
73
|
+
path TEXT PRIMARY KEY,
|
|
74
|
+
state BLOB NOT NULL,
|
|
75
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
76
|
+
);
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function docResourceName(absolutePath) {
|
|
81
|
+
return `${DOC_RESOURCE_PREFIX}${fsPrivate().base64UrlEncode(absolutePath)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pathFromDocResource(resource) {
|
|
85
|
+
if (typeof resource !== 'string' || !resource.startsWith(DOC_RESOURCE_PREFIX)) return null;
|
|
86
|
+
try {
|
|
87
|
+
return fsPrivate().base64UrlDecode(resource.slice(DOC_RESOURCE_PREFIX.length));
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveDocPath(pathInput) {
|
|
94
|
+
const resolved = fsPrivate().resolveSafePathSync(pathInput);
|
|
95
|
+
const { isText } = fsPrivate().classifyFile(resolved);
|
|
96
|
+
if (!isText) {
|
|
97
|
+
throw invalid(`live documents support text files only (got ${path.extname(resolved) || 'no extension'})`);
|
|
98
|
+
}
|
|
99
|
+
const stats = fs.statSync(resolved);
|
|
100
|
+
if (!stats.isFile()) throw invalid('path is not a file');
|
|
101
|
+
if (stats.size > MAX_DOC_BYTES) {
|
|
102
|
+
throw invalid(`file exceeds the ${MAX_DOC_BYTES}-byte live document limit`, 413);
|
|
103
|
+
}
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Splice `nextText` into ytext as a minimal edit (common prefix/suffix kept),
|
|
109
|
+
* backing off surrogate-pair boundaries so we never split a character.
|
|
110
|
+
*/
|
|
111
|
+
function spliceTextInto(Y, doc, ytext, nextText, origin) {
|
|
112
|
+
const current = ytext.toString();
|
|
113
|
+
if (current === nextText) return false;
|
|
114
|
+
|
|
115
|
+
let start = 0;
|
|
116
|
+
const maxStart = Math.min(current.length, nextText.length);
|
|
117
|
+
while (start < maxStart && current[start] === nextText[start]) start += 1;
|
|
118
|
+
if (start > 0 && isLowSurrogate(current.charCodeAt(start))) start -= 1;
|
|
119
|
+
|
|
120
|
+
let endCurrent = current.length;
|
|
121
|
+
let endNext = nextText.length;
|
|
122
|
+
while (endCurrent > start && endNext > start && current[endCurrent - 1] === nextText[endNext - 1]) {
|
|
123
|
+
endCurrent -= 1;
|
|
124
|
+
endNext -= 1;
|
|
125
|
+
}
|
|
126
|
+
if (endCurrent < current.length && isLowSurrogate(current.charCodeAt(endCurrent))) {
|
|
127
|
+
endCurrent += 1;
|
|
128
|
+
endNext += 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
doc.transact(() => {
|
|
132
|
+
if (endCurrent > start) ytext.delete(start, endCurrent - start);
|
|
133
|
+
if (endNext > start) ytext.insert(start, nextText.slice(start, endNext));
|
|
134
|
+
}, origin);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isLowSurrogate(code) {
|
|
139
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function persistDocState(Y, entry) {
|
|
143
|
+
const database = openLocalDb();
|
|
144
|
+
ensureSchema(database);
|
|
145
|
+
database.prepare(`
|
|
146
|
+
INSERT INTO doc_states (path, state, updated_at)
|
|
147
|
+
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
148
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
149
|
+
state = excluded.state,
|
|
150
|
+
updated_at = excluded.updated_at
|
|
151
|
+
`).run(entry.path, Buffer.from(Y.encodeStateAsUpdate(entry.doc)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function scheduleDiskWrite(Y, entry) {
|
|
155
|
+
if (entry.diskWriteTimer) return;
|
|
156
|
+
entry.diskWriteTimer = setTimeout(() => {
|
|
157
|
+
entry.diskWriteTimer = null;
|
|
158
|
+
flushDocToDisk(Y, entry);
|
|
159
|
+
}, DISK_WRITE_DEBOUNCE_MS);
|
|
160
|
+
if (typeof entry.diskWriteTimer.unref === 'function') entry.diskWriteTimer.unref();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function flushDocToDisk(Y, entry) {
|
|
164
|
+
try {
|
|
165
|
+
const text = entry.doc.getText(TEXT_KEY).toString();
|
|
166
|
+
const onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
167
|
+
if (onDisk !== text) fs.writeFileSync(entry.path, text, 'utf8');
|
|
168
|
+
entry.lastDiskText = text;
|
|
169
|
+
persistDocState(Y, entry);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.warn(`[LiveDoc] Failed to flush "${entry.path}" to disk:`, error?.message || error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reconcileFromDisk(Y, entry) {
|
|
176
|
+
try {
|
|
177
|
+
const onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
178
|
+
// Only fold in disk content that actually CHANGED since we last read or
|
|
179
|
+
// wrote it. Without this, a watcher event firing while the doc is ahead
|
|
180
|
+
// of disk (write-back still debounced) would "reconcile" the stale disk
|
|
181
|
+
// text back over live edits.
|
|
182
|
+
if (onDisk === entry.lastDiskText) return;
|
|
183
|
+
entry.lastDiskText = onDisk;
|
|
184
|
+
const ytext = entry.doc.getText(TEXT_KEY);
|
|
185
|
+
// origin 'disk' keeps the update handler from writing the same bytes back.
|
|
186
|
+
spliceTextInto(Y, entry.doc, ytext, onDisk, 'disk');
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// File deleted or unreadable — the doc simply stops following the disk.
|
|
189
|
+
console.warn(`[LiveDoc] Reconcile from disk failed for "${entry.path}":`, error?.message || error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function watchDocFile(Y, entry) {
|
|
194
|
+
const dir = path.dirname(entry.path);
|
|
195
|
+
const base = path.basename(entry.path);
|
|
196
|
+
try {
|
|
197
|
+
entry.watcher = fs.watch(dir, { persistent: false }, (eventType, filename) => {
|
|
198
|
+
// Some platforms omit filename; reconcile is content-compare based and
|
|
199
|
+
// idempotent, so over-firing is safe.
|
|
200
|
+
if (filename && filename !== base) return;
|
|
201
|
+
if (entry.reconcileTimer) return;
|
|
202
|
+
entry.reconcileTimer = setTimeout(() => {
|
|
203
|
+
entry.reconcileTimer = null;
|
|
204
|
+
reconcileFromDisk(Y, entry);
|
|
205
|
+
}, WATCH_RECONCILE_DEBOUNCE_MS);
|
|
206
|
+
if (typeof entry.reconcileTimer.unref === 'function') entry.reconcileTimer.unref();
|
|
207
|
+
});
|
|
208
|
+
entry.watcher.on('error', () => {
|
|
209
|
+
try { entry.watcher.close(); } catch {}
|
|
210
|
+
entry.watcher = null;
|
|
211
|
+
});
|
|
212
|
+
if (typeof entry.watcher.unref === 'function') entry.watcher.unref();
|
|
213
|
+
} catch {
|
|
214
|
+
// Watching is best-effort; external edits still reconcile on next open.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function closeDocEntry(Y, entry) {
|
|
219
|
+
if (entry.diskWriteTimer) {
|
|
220
|
+
clearTimeout(entry.diskWriteTimer);
|
|
221
|
+
entry.diskWriteTimer = null;
|
|
222
|
+
}
|
|
223
|
+
if (entry.reconcileTimer) {
|
|
224
|
+
clearTimeout(entry.reconcileTimer);
|
|
225
|
+
entry.reconcileTimer = null;
|
|
226
|
+
}
|
|
227
|
+
flushDocToDisk(Y, entry);
|
|
228
|
+
if (entry.watcher) {
|
|
229
|
+
try { entry.watcher.close(); } catch {}
|
|
230
|
+
entry.watcher = null;
|
|
231
|
+
}
|
|
232
|
+
entry.doc.destroy();
|
|
233
|
+
openDocs.delete(entry.path);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function evictIfNeeded(Y) {
|
|
237
|
+
while (openDocs.size > MAX_OPEN_DOCS) {
|
|
238
|
+
let oldest = null;
|
|
239
|
+
for (const entry of openDocs.values()) {
|
|
240
|
+
if (!oldest || entry.lastAccess < oldest.lastAccess) oldest = entry;
|
|
241
|
+
}
|
|
242
|
+
if (!oldest) return;
|
|
243
|
+
closeDocEntry(Y, oldest);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function loadDocEntry(pathInput) {
|
|
248
|
+
const Y = loadYjs();
|
|
249
|
+
const resolved = resolveDocPath(pathInput);
|
|
250
|
+
const existing = openDocs.get(resolved);
|
|
251
|
+
if (existing) {
|
|
252
|
+
existing.lastAccess = Date.now();
|
|
253
|
+
return existing;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const database = openLocalDb();
|
|
257
|
+
ensureSchema(database);
|
|
258
|
+
|
|
259
|
+
const doc = new Y.Doc();
|
|
260
|
+
const entry = {
|
|
261
|
+
path: resolved,
|
|
262
|
+
resource: docResourceName(resolved),
|
|
263
|
+
doc,
|
|
264
|
+
watcher: null,
|
|
265
|
+
reconcileTimer: null,
|
|
266
|
+
diskWriteTimer: null,
|
|
267
|
+
lastAccess: Date.now(),
|
|
268
|
+
// The disk text as of our last read/write; reconcile skips anything that
|
|
269
|
+
// hasn't changed past it (see reconcileFromDisk).
|
|
270
|
+
lastDiskText: null,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const persisted = database.prepare('SELECT state FROM doc_states WHERE path = ?').get(resolved);
|
|
274
|
+
if (persisted?.state) {
|
|
275
|
+
try {
|
|
276
|
+
Y.applyUpdate(doc, new Uint8Array(persisted.state), 'persisted');
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.warn(`[LiveDoc] Discarding corrupt persisted state for "${resolved}":`, error?.message || error);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Every applied change — client update, disk reconcile, or seed — flows
|
|
283
|
+
// through this one pipeline: event out, then (except disk-origin) disk out.
|
|
284
|
+
doc.on('update', (update, origin) => {
|
|
285
|
+
appendStateEvent({
|
|
286
|
+
resource: entry.resource,
|
|
287
|
+
op: 'update',
|
|
288
|
+
id: entry.path,
|
|
289
|
+
patch: { yjs: Buffer.from(update).toString('base64') },
|
|
290
|
+
source: origin === 'disk' ? 'doc:disk' : 'doc:update',
|
|
291
|
+
});
|
|
292
|
+
if (origin !== 'disk') scheduleDiskWrite(Y, entry);
|
|
293
|
+
else persistDocState(Y, entry);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// The disk is the source of truth at open: seeds new docs, and folds in
|
|
297
|
+
// anything written while the doc was closed (agents, git, other tools).
|
|
298
|
+
reconcileFromDisk(Y, entry);
|
|
299
|
+
|
|
300
|
+
openDocs.set(resolved, entry);
|
|
301
|
+
watchDocFile(Y, entry);
|
|
302
|
+
evictIfNeeded(Y);
|
|
303
|
+
return entry;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function openDoc(pathInput) {
|
|
307
|
+
const Y = loadYjs();
|
|
308
|
+
const entry = loadDocEntry(pathInput);
|
|
309
|
+
return {
|
|
310
|
+
resource: entry.resource,
|
|
311
|
+
path: entry.path,
|
|
312
|
+
state: Buffer.from(Y.encodeStateAsUpdate(entry.doc)).toString('base64'),
|
|
313
|
+
seq: currentSeq(),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function applyDocUpdates(pathInput, updatesInput) {
|
|
318
|
+
const Y = loadYjs();
|
|
319
|
+
const inputs = Array.isArray(updatesInput) ? updatesInput : [updatesInput];
|
|
320
|
+
if (inputs.length === 0) return { applied: 0, seq: currentSeq() };
|
|
321
|
+
if (inputs.length > MAX_UPDATES_PER_CALL) {
|
|
322
|
+
throw invalid(`at most ${MAX_UPDATES_PER_CALL} updates per call (got ${inputs.length})`);
|
|
323
|
+
}
|
|
324
|
+
const updates = inputs.map((value, index) => {
|
|
325
|
+
if (typeof value !== 'string' || !value) {
|
|
326
|
+
throw invalid(`updates[${index}] must be a base64-encoded Yjs update`);
|
|
327
|
+
}
|
|
328
|
+
return Buffer.from(value, 'base64');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const entry = loadDocEntry(pathInput);
|
|
332
|
+
entry.lastAccess = Date.now();
|
|
333
|
+
for (const update of updates) {
|
|
334
|
+
try {
|
|
335
|
+
Y.applyUpdate(entry.doc, new Uint8Array(update), 'client');
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw invalid(`invalid Yjs update: ${error?.message || error}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { applied: updates.length, seq: currentSeq() };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Registry read for `doc:` resources. Serves only documents that are already
|
|
345
|
+
* open — the open endpoint is the bootstrap — and never throws: a failure
|
|
346
|
+
* here must not take down snapshots of unrelated resources.
|
|
347
|
+
*/
|
|
348
|
+
function readDocResource(resource) {
|
|
349
|
+
try {
|
|
350
|
+
const target = pathFromDocResource(resource);
|
|
351
|
+
if (!target) return undefined;
|
|
352
|
+
const entry = openDocs.get(target);
|
|
353
|
+
if (!entry) return undefined;
|
|
354
|
+
const Y = loadYjs();
|
|
355
|
+
return {
|
|
356
|
+
path: entry.path,
|
|
357
|
+
state: Buffer.from(Y.encodeStateAsUpdate(entry.doc)).toString('base64'),
|
|
358
|
+
updatedAt: new Date(entry.lastAccess).toISOString(),
|
|
359
|
+
};
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.warn(`[LiveDoc] Snapshot read failed for "${resource}":`, error?.message || error);
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getDocText(pathInput) {
|
|
367
|
+
const entry = loadDocEntry(pathInput);
|
|
368
|
+
return entry.doc.getText(TEXT_KEY).toString();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Test/shutdown helper: flush and close every open document. */
|
|
372
|
+
function closeAllDocs() {
|
|
373
|
+
let Y;
|
|
374
|
+
try {
|
|
375
|
+
Y = loadYjs();
|
|
376
|
+
} catch {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
for (const entry of Array.from(openDocs.values())) closeDocEntry(Y, entry);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
module.exports = {
|
|
383
|
+
DOC_RESOURCE_PREFIX,
|
|
384
|
+
applyDocUpdates,
|
|
385
|
+
closeAllDocs,
|
|
386
|
+
docResourceName,
|
|
387
|
+
getDocText,
|
|
388
|
+
openDoc,
|
|
389
|
+
pathFromDocResource,
|
|
390
|
+
readDocResource,
|
|
391
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The resource registry: the sign-up sheet for the local-live realtime system.
|
|
5
|
+
*
|
|
6
|
+
* A resource is a named, subscribable list of rows. Registering one takes two
|
|
7
|
+
* things — a name and a read function — and buys everything the realtime
|
|
8
|
+
* layer does: inclusion in snapshots, live event delivery, reconnect replay,
|
|
9
|
+
* and reset semantics. The registry never sees the underlying storage; `read`
|
|
10
|
+
* is the only bridge.
|
|
11
|
+
*
|
|
12
|
+
* registerResource('skills', {
|
|
13
|
+
* read: () => require('../skills/store').listSkills(),
|
|
14
|
+
* snapshotDefault: true,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* Spec fields:
|
|
18
|
+
* read(ctx) required. Returns the current rows (or any snapshot
|
|
19
|
+
* value). `ctx` is a per-snapshot-attempt cache object so
|
|
20
|
+
* resources sharing an expensive read (e.g. the automation
|
|
21
|
+
* graph) compute it once per attempt. Must not be reused
|
|
22
|
+
* across attempts — a retry exists because a write landed
|
|
23
|
+
* mid-read.
|
|
24
|
+
* snapshotDefault optional. Include this resource when a snapshot is
|
|
25
|
+
* requested with no explicit resource list. Defaults false.
|
|
26
|
+
*
|
|
27
|
+
* Prefix registrations serve families of dynamic names (e.g. `files:<path>`):
|
|
28
|
+
* the spec's read receives the full resource name as its second argument.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const resources = new Map();
|
|
32
|
+
const prefixes = new Map();
|
|
33
|
+
|
|
34
|
+
function assertSpec(name, spec) {
|
|
35
|
+
if (!name || typeof name !== 'string') {
|
|
36
|
+
throw new Error('registerResource requires a non-empty string name');
|
|
37
|
+
}
|
|
38
|
+
if (!spec || typeof spec.read !== 'function') {
|
|
39
|
+
throw new Error(`resource "${name}" requires a read() function`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function registerResource(name, spec) {
|
|
44
|
+
assertSpec(name, spec);
|
|
45
|
+
if (resources.has(name)) {
|
|
46
|
+
throw new Error(`resource "${name}" is already registered`);
|
|
47
|
+
}
|
|
48
|
+
resources.set(name, { snapshotDefault: false, ...spec });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function registerResourcePrefix(prefix, spec) {
|
|
52
|
+
assertSpec(prefix, spec);
|
|
53
|
+
if (prefixes.has(prefix)) {
|
|
54
|
+
throw new Error(`resource prefix "${prefix}" is already registered`);
|
|
55
|
+
}
|
|
56
|
+
prefixes.set(prefix, { snapshotDefault: false, ...spec });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unregisterResource(name) {
|
|
60
|
+
return resources.delete(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getResourceSpec(name) {
|
|
64
|
+
const direct = resources.get(name);
|
|
65
|
+
if (direct) return direct;
|
|
66
|
+
for (const [prefix, spec] of prefixes) {
|
|
67
|
+
if (name.startsWith(prefix)) return spec;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasResource(name) {
|
|
73
|
+
return getResourceSpec(String(name || '')) !== undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readRegisteredResource(name, ctx) {
|
|
77
|
+
const spec = getResourceSpec(name);
|
|
78
|
+
if (!spec) return undefined;
|
|
79
|
+
return spec.read(ctx, name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Registration order is preserved: the default snapshot lists resources in
|
|
83
|
+
// the order they signed up, which keeps wire output stable across releases.
|
|
84
|
+
function listDefaultResources() {
|
|
85
|
+
const names = [];
|
|
86
|
+
for (const [name, spec] of resources) {
|
|
87
|
+
if (spec.snapshotDefault) names.push(name);
|
|
88
|
+
}
|
|
89
|
+
return names;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function listRegisteredResources() {
|
|
93
|
+
return Array.from(resources.keys());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
getResourceSpec,
|
|
98
|
+
hasResource,
|
|
99
|
+
listDefaultResources,
|
|
100
|
+
listRegisteredResources,
|
|
101
|
+
readRegisteredResource,
|
|
102
|
+
registerResource,
|
|
103
|
+
registerResourcePrefix,
|
|
104
|
+
unregisterResource,
|
|
105
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in resource registrations. These are the first names on the registry's
|
|
5
|
+
* sign-up sheet — each one was previously a hand-written case in a switch in
|
|
6
|
+
* snapshot.js. Requires stay inside read() so opening the registry never pulls
|
|
7
|
+
* in a store the caller doesn't ask for.
|
|
8
|
+
*
|
|
9
|
+
* Registration order defines the default snapshot's resource order — append
|
|
10
|
+
* new defaults at the end.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
registerResource,
|
|
15
|
+
registerResourcePrefix,
|
|
16
|
+
} = require('./registry');
|
|
17
|
+
|
|
18
|
+
// The automation graph is one read shared by three resources; ctx keeps it
|
|
19
|
+
// single-pass per snapshot attempt.
|
|
20
|
+
function automationGraph(ctx) {
|
|
21
|
+
ctx.automationGraph ||= require('../automations/store').listPublicAutomationGraph();
|
|
22
|
+
return ctx.automationGraph;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toolboxCatalog(ctx) {
|
|
26
|
+
ctx.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
|
|
27
|
+
return ctx.toolbox;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function registerBuiltinResources() {
|
|
31
|
+
registerResource('automations', {
|
|
32
|
+
snapshotDefault: true,
|
|
33
|
+
read: (ctx) => automationGraph(ctx).automations,
|
|
34
|
+
});
|
|
35
|
+
registerResource('triggers', {
|
|
36
|
+
snapshotDefault: true,
|
|
37
|
+
read: (ctx) => automationGraph(ctx).triggers,
|
|
38
|
+
});
|
|
39
|
+
registerResource('workflows', {
|
|
40
|
+
snapshotDefault: true,
|
|
41
|
+
read: (ctx) => automationGraph(ctx).workflows,
|
|
42
|
+
});
|
|
43
|
+
registerResource('automation_runs', {
|
|
44
|
+
snapshotDefault: true,
|
|
45
|
+
read: () => require('../automations/store').listAutomationRuns({ limit: 300 }),
|
|
46
|
+
});
|
|
47
|
+
registerResource('workflow_cell_runs', {
|
|
48
|
+
snapshotDefault: true,
|
|
49
|
+
read: () => require('../automations/store').listWorkflowCellRuns({ limit: 300 }),
|
|
50
|
+
});
|
|
51
|
+
registerResource('agents', {
|
|
52
|
+
snapshotDefault: true,
|
|
53
|
+
read: () => {
|
|
54
|
+
const { agentWithConfig } = require('../agent-config/store');
|
|
55
|
+
return require('../agents/store').getAllAgentsWithBuiltins()
|
|
56
|
+
.map((agent) => agentWithConfig(agent))
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
registerResource('agent_configs', {
|
|
61
|
+
snapshotDefault: true,
|
|
62
|
+
read: () => require('../agent-config/store').listAgentConfigs(),
|
|
63
|
+
});
|
|
64
|
+
registerResource('skills', {
|
|
65
|
+
snapshotDefault: true,
|
|
66
|
+
read: () => require('../skills/store').listSkills(),
|
|
67
|
+
});
|
|
68
|
+
registerResource('apps', {
|
|
69
|
+
snapshotDefault: true,
|
|
70
|
+
read: () => require('../apps/store').loadApps().apps,
|
|
71
|
+
});
|
|
72
|
+
registerResource('ports', {
|
|
73
|
+
snapshotDefault: true,
|
|
74
|
+
read: () => require('./ports').getCurrentPorts(),
|
|
75
|
+
});
|
|
76
|
+
registerResource('toolbox', {
|
|
77
|
+
snapshotDefault: true,
|
|
78
|
+
read: (ctx) => toolboxCatalog(ctx),
|
|
79
|
+
});
|
|
80
|
+
registerResource('tools', {
|
|
81
|
+
snapshotDefault: true,
|
|
82
|
+
read: (ctx) => toolboxCatalog(ctx).tools,
|
|
83
|
+
});
|
|
84
|
+
registerResource('tool_actions', {
|
|
85
|
+
snapshotDefault: true,
|
|
86
|
+
read: (ctx) => toolboxCatalog(ctx).toolActions,
|
|
87
|
+
});
|
|
88
|
+
registerResource('hooks', {
|
|
89
|
+
snapshotDefault: true,
|
|
90
|
+
read: () => require('../agents/hooks').collectNativeHooks(),
|
|
91
|
+
});
|
|
92
|
+
registerResource('projects', {
|
|
93
|
+
snapshotDefault: true,
|
|
94
|
+
read: () => require('../workspace/store').listWorkspaces(),
|
|
95
|
+
});
|
|
96
|
+
registerResource('workspaces', {
|
|
97
|
+
snapshotDefault: true,
|
|
98
|
+
read: () => require('../workspace/store').listWorkspaces(),
|
|
99
|
+
});
|
|
100
|
+
registerResource('project_context', {
|
|
101
|
+
snapshotDefault: true,
|
|
102
|
+
read: () => require('../project-context/store').listProjectContexts(),
|
|
103
|
+
});
|
|
104
|
+
registerResource('user_preferences', {
|
|
105
|
+
snapshotDefault: true,
|
|
106
|
+
read: () => require('../lib/prefs').readUserPreferences(),
|
|
107
|
+
});
|
|
108
|
+
registerResource('chat_payloads', {
|
|
109
|
+
snapshotDefault: true,
|
|
110
|
+
read: () => require('../lib/chat-payloads').listChatPayloads(),
|
|
111
|
+
});
|
|
112
|
+
registerResource('mcp_connections', {
|
|
113
|
+
snapshotDefault: true,
|
|
114
|
+
read: () => require('../mcp-connections/rest').buildConnectionsSnapshot(),
|
|
115
|
+
});
|
|
116
|
+
registerResource('browser_profiles', {
|
|
117
|
+
snapshotDefault: true,
|
|
118
|
+
read: () => require('../browser/store').listBrowserProfiles(),
|
|
119
|
+
});
|
|
120
|
+
registerResource('browser_auth_bundles', {
|
|
121
|
+
snapshotDefault: true,
|
|
122
|
+
read: () => require('../browser/store').listBrowserAuthBundles(),
|
|
123
|
+
});
|
|
124
|
+
registerResource('browser_login_sessions', {
|
|
125
|
+
snapshotDefault: true,
|
|
126
|
+
read: () => require('../browser/store').listBrowserLoginSessions(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Subscribable but not part of the default snapshot set.
|
|
130
|
+
registerResource('tasks', {
|
|
131
|
+
read: () => require('../tasks/store').loadTasks().tasks,
|
|
132
|
+
});
|
|
133
|
+
registerResource('task_runs', {
|
|
134
|
+
read: () => require('../tasks/store').listTaskRuns({ limit: 100 }),
|
|
135
|
+
});
|
|
136
|
+
registerResource('event_runs', {
|
|
137
|
+
read: () => require('../events/store').listEventRuns({ limit: 100 }),
|
|
138
|
+
});
|
|
139
|
+
registerResource('event_triggers', {
|
|
140
|
+
read: () => require('../events/store').loadEventTriggers().triggers,
|
|
141
|
+
});
|
|
142
|
+
registerResource('uploads', {
|
|
143
|
+
read: () => require('../fs/rest')._private.buildUploadsResource(),
|
|
144
|
+
});
|
|
145
|
+
registerResource('browser_surfaces', {
|
|
146
|
+
// Surface-open requests are commands, not state: delivered via the
|
|
147
|
+
// event stream only, so snapshots always start empty.
|
|
148
|
+
read: () => [],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
registerResourcePrefix('files:', {
|
|
152
|
+
read: (ctx, name) => require('../fs/rest')._private.buildFilesResource(name),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Live documents (wave 2.5). readDocResource never throws and serves only
|
|
156
|
+
// already-open docs, so a broken document cannot take down a snapshot.
|
|
157
|
+
registerResourcePrefix('doc:', {
|
|
158
|
+
read: (ctx, name) => require('./docs').readDocResource(name),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { registerBuiltinResources };
|