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
|
@@ -6,6 +6,12 @@ const {
|
|
|
6
6
|
replayGapAfter,
|
|
7
7
|
subscribeStateEvents,
|
|
8
8
|
} = require('./events');
|
|
9
|
+
const appResources = require('./app-resources');
|
|
10
|
+
|
|
11
|
+
function sendHandlerError(error, sendJson) {
|
|
12
|
+
const statusCode = Number(error?.statusCode) || 500;
|
|
13
|
+
sendJson(statusCode, { error: error?.message || 'internal error' });
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
function parsePositiveInt(value, fallback) {
|
|
11
17
|
const parsed = Number(value);
|
|
@@ -29,9 +35,70 @@ function sendSseControl(res, type, payload) {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
async function handleSnapshot(query, sendJson) {
|
|
38
|
+
// Restores persisted app registrations so their resources are readable
|
|
39
|
+
// even before the owning app has spoken this boot.
|
|
40
|
+
appResources.ensureAppResourcesLoaded();
|
|
32
41
|
sendJson(200, buildSnapshot(query.resources));
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
async function handleResourceRegister(body, sendJson) {
|
|
45
|
+
try {
|
|
46
|
+
sendJson(200, appResources.registerAppResource(body?.resource));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
sendHandlerError(error, sendJson);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleResourceUnregister(body, sendJson) {
|
|
53
|
+
try {
|
|
54
|
+
sendJson(200, appResources.unregisterAppResource(String(body?.resource || '')));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
sendHandlerError(error, sendJson);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function handleResourceEmit(body, sendJson) {
|
|
61
|
+
try {
|
|
62
|
+
const events = body?.events ?? body?.event;
|
|
63
|
+
if (events == null) {
|
|
64
|
+
sendJson(400, { error: 'body requires "events" (array) or "event" (single object)' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
sendJson(200, appResources.emitAppResourceEvents(body?.resource, events, { source: body?.source }));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
sendHandlerError(error, sendJson);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleDocOpen(body, sendJson) {
|
|
74
|
+
try {
|
|
75
|
+
sendJson(200, require('./docs').openDoc(body?.path));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
sendHandlerError(error, sendJson);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleDocUpdate(body, sendJson) {
|
|
82
|
+
try {
|
|
83
|
+
const updates = body?.updates ?? body?.update;
|
|
84
|
+
if (updates == null) {
|
|
85
|
+
sendJson(400, { error: 'body requires "updates" (array of base64 Yjs updates) or "update"' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
sendJson(200, require('./docs').applyDocUpdates(body?.path, updates));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
sendHandlerError(error, sendJson);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function handleResourceList(sendJson) {
|
|
95
|
+
try {
|
|
96
|
+
sendJson(200, { resources: appResources.listAppResources() });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
sendHandlerError(error, sendJson);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
35
102
|
async function handleEvents(query, sendJson) {
|
|
36
103
|
const after = parsePositiveInt(query.after, 0);
|
|
37
104
|
const limit = parsePositiveInt(query.limit, 1000);
|
|
@@ -48,6 +115,7 @@ async function handleEvents(query, sendJson) {
|
|
|
48
115
|
}
|
|
49
116
|
|
|
50
117
|
function handleStream(req, res, query) {
|
|
118
|
+
appResources.ensureAppResourcesLoaded();
|
|
51
119
|
const after = parsePositiveInt(query.after, 0);
|
|
52
120
|
|
|
53
121
|
res.writeHead(200, {
|
|
@@ -98,7 +166,13 @@ function handleStream(req, res, query) {
|
|
|
98
166
|
}
|
|
99
167
|
|
|
100
168
|
module.exports = {
|
|
169
|
+
handleDocOpen,
|
|
170
|
+
handleDocUpdate,
|
|
101
171
|
handleEvents,
|
|
172
|
+
handleResourceEmit,
|
|
173
|
+
handleResourceList,
|
|
174
|
+
handleResourceRegister,
|
|
175
|
+
handleResourceUnregister,
|
|
102
176
|
handleSnapshot,
|
|
103
177
|
handleStream,
|
|
104
178
|
};
|
|
@@ -1,96 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { currentSeq } = require('./events');
|
|
4
|
+
const registry = require('./registry');
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
require('./resources').registerBuiltinResources();
|
|
6
7
|
|
|
7
8
|
function normalizeResources(resources) {
|
|
8
|
-
const values = resources
|
|
9
|
+
const values = resources
|
|
10
|
+
? String(resources).split(',')
|
|
11
|
+
: registry.listDefaultResources();
|
|
9
12
|
const clean = values.map((value) => String(value || '').trim()).filter(Boolean);
|
|
10
|
-
return clean.length > 0 ? Array.from(new Set(clean)) :
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function automationGraph(cache) {
|
|
14
|
-
cache.automationGraph ||= require('../automations/store').listPublicAutomationGraph();
|
|
15
|
-
return cache.automationGraph;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function readResource(resource, cache) {
|
|
19
|
-
switch (resource) {
|
|
20
|
-
case 'automations':
|
|
21
|
-
return automationGraph(cache).automations;
|
|
22
|
-
case 'triggers':
|
|
23
|
-
return automationGraph(cache).triggers;
|
|
24
|
-
case 'automation_runs':
|
|
25
|
-
return require('../automations/store').listAutomationRuns({ limit: 300 });
|
|
26
|
-
case 'workflow_cell_runs':
|
|
27
|
-
return require('../automations/store').listWorkflowCellRuns({ limit: 300 });
|
|
28
|
-
case 'tasks':
|
|
29
|
-
return require('../tasks/store').loadTasks().tasks;
|
|
30
|
-
case 'task_runs':
|
|
31
|
-
return require('../tasks/store').listTaskRuns({ limit: 100 });
|
|
32
|
-
case 'event_runs':
|
|
33
|
-
return require('../events/store').listEventRuns({ limit: 100 });
|
|
34
|
-
case 'event_triggers':
|
|
35
|
-
return require('../events/store').loadEventTriggers().triggers;
|
|
36
|
-
case 'workflows':
|
|
37
|
-
return automationGraph(cache).workflows;
|
|
38
|
-
case 'agents': {
|
|
39
|
-
const { agentWithConfig } = require('../agent-config/store');
|
|
40
|
-
return require('../agents/store').getAllAgentsWithBuiltins()
|
|
41
|
-
.map((agent) => agentWithConfig(agent))
|
|
42
|
-
.filter(Boolean);
|
|
43
|
-
}
|
|
44
|
-
case 'agent_configs':
|
|
45
|
-
return require('../agent-config/store').listAgentConfigs();
|
|
46
|
-
case 'skills':
|
|
47
|
-
return require('../skills/store').listSkills();
|
|
48
|
-
case 'apps':
|
|
49
|
-
return require('../apps/store').loadApps().apps;
|
|
50
|
-
case 'ports':
|
|
51
|
-
return require('./ports').getCurrentPorts();
|
|
52
|
-
case 'toolbox':
|
|
53
|
-
cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
|
|
54
|
-
return cache.toolbox;
|
|
55
|
-
case 'tools':
|
|
56
|
-
cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
|
|
57
|
-
return cache.toolbox.tools;
|
|
58
|
-
case 'tool_actions':
|
|
59
|
-
cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
|
|
60
|
-
return cache.toolbox.toolActions;
|
|
61
|
-
case 'hooks':
|
|
62
|
-
return require('../agents/hooks').collectNativeHooks();
|
|
63
|
-
case 'projects':
|
|
64
|
-
case 'workspaces': {
|
|
65
|
-
const workspaceStore = require('../workspace/store');
|
|
66
|
-
return workspaceStore.listWorkspaces();
|
|
67
|
-
}
|
|
68
|
-
case 'project_context':
|
|
69
|
-
return require('../project-context/store').listProjectContexts();
|
|
70
|
-
case 'user_preferences':
|
|
71
|
-
return require('../lib/prefs').readUserPreferences();
|
|
72
|
-
case 'chat_payloads':
|
|
73
|
-
return require('../lib/chat-payloads').listChatPayloads();
|
|
74
|
-
case 'uploads':
|
|
75
|
-
return require('../fs/rest')._private.buildUploadsResource();
|
|
76
|
-
case 'mcp_connections':
|
|
77
|
-
return require('../mcp-connections/rest').buildConnectionsSnapshot();
|
|
78
|
-
case 'browser_profiles':
|
|
79
|
-
return require('../browser/store').listBrowserProfiles();
|
|
80
|
-
case 'browser_auth_bundles':
|
|
81
|
-
return require('../browser/store').listBrowserAuthBundles();
|
|
82
|
-
case 'browser_login_sessions':
|
|
83
|
-
return require('../browser/store').listBrowserLoginSessions();
|
|
84
|
-
case 'browser_surfaces':
|
|
85
|
-
// Surface-open requests are commands, not state: delivered via the
|
|
86
|
-
// event stream only, so snapshots always start empty.
|
|
87
|
-
return [];
|
|
88
|
-
default:
|
|
89
|
-
if (String(resource).startsWith('files:')) {
|
|
90
|
-
return require('../fs/rest')._private.buildFilesResource(resource);
|
|
91
|
-
}
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
13
|
+
return clean.length > 0 ? Array.from(new Set(clean)) : registry.listDefaultResources();
|
|
94
14
|
}
|
|
95
15
|
|
|
96
16
|
function buildSnapshot(resourcesInput) {
|
|
@@ -99,15 +19,15 @@ function buildSnapshot(resourcesInput) {
|
|
|
99
19
|
|
|
100
20
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
101
21
|
const beforeSeq = currentSeq();
|
|
102
|
-
// The
|
|
103
|
-
// landed mid-read, so reusing reads across attempts would serve the
|
|
104
|
-
// staleness the retry is trying to escape.
|
|
105
|
-
const
|
|
22
|
+
// The read context is per-attempt on purpose: a retry exists because a
|
|
23
|
+
// write landed mid-read, so reusing reads across attempts would serve the
|
|
24
|
+
// very staleness the retry is trying to escape.
|
|
25
|
+
const ctx = {};
|
|
106
26
|
const data = {};
|
|
107
27
|
for (const resource of resources) {
|
|
108
28
|
let value;
|
|
109
29
|
try {
|
|
110
|
-
value =
|
|
30
|
+
value = registry.readRegisteredResource(resource, ctx);
|
|
111
31
|
} catch (error) {
|
|
112
32
|
console.warn(`[LocalLive] Failed to read snapshot resource "${resource}":`, error?.message || error);
|
|
113
33
|
throw error;
|
|
@@ -128,4 +48,11 @@ function buildSnapshot(resourcesInput) {
|
|
|
128
48
|
return { seq: currentSeq(), stable: true, resources: {} };
|
|
129
49
|
}
|
|
130
50
|
|
|
131
|
-
module.exports = {
|
|
51
|
+
module.exports = {
|
|
52
|
+
// Kept for existing callers; now derived from the registry.
|
|
53
|
+
get DEFAULT_RESOURCES() {
|
|
54
|
+
return registry.listDefaultResources();
|
|
55
|
+
},
|
|
56
|
+
buildSnapshot,
|
|
57
|
+
normalizeResources,
|
|
58
|
+
};
|
|
@@ -361,3 +361,30 @@ test('automations tools list actions and compact run_now output', async () => {
|
|
|
361
361
|
assert.equal(payload.output, undefined);
|
|
362
362
|
assert.equal(payload.cellSummaries[0].name, 'quote');
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
test('workflow example published in tool descriptions compiles', () => {
|
|
366
|
+
const example = automationTools.WORKFLOW_EXAMPLE;
|
|
367
|
+
assert.equal(typeof example, 'string');
|
|
368
|
+
|
|
369
|
+
const validateTool = automationTools.find((tool) => tool.name === 'automations_validate');
|
|
370
|
+
assert.equal(validateTool.inputSchema.properties.workflowText.description.includes(example), true);
|
|
371
|
+
|
|
372
|
+
const ir = compileWorkflowText(example);
|
|
373
|
+
assert.equal(ir.cells.length, 2);
|
|
374
|
+
assert.equal(ir.cells[0].name, 'getData');
|
|
375
|
+
assert.equal(ir.cells[1].name, 'notify');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('workflow compiler rejects non-array cells with a shape-specific error', () => {
|
|
379
|
+
assert.throws(
|
|
380
|
+
() => compileWorkflowText(`export default workflow({
|
|
381
|
+
trigger: "*.*",
|
|
382
|
+
cells: { getData: code("getData", () => ({})) },
|
|
383
|
+
});`),
|
|
384
|
+
/`cells` must be an array of cell-builder calls.*got object/,
|
|
385
|
+
);
|
|
386
|
+
assert.throws(
|
|
387
|
+
() => compileWorkflowText('export default workflow({ trigger: "*.*" });'),
|
|
388
|
+
/Workflow needs at least one cell/,
|
|
389
|
+
);
|
|
390
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-app-resources-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
|
|
12
|
+
const { closeLocalDb } = require('../state/db');
|
|
13
|
+
const registry = require('../state/registry');
|
|
14
|
+
const appResources = require('../state/app-resources');
|
|
15
|
+
const { buildSnapshot } = require('../state/snapshot');
|
|
16
|
+
const { listEventsAfter, currentSeq, subscribeStateEvents } = require('../state/events');
|
|
17
|
+
|
|
18
|
+
test.after(() => {
|
|
19
|
+
closeLocalDb();
|
|
20
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('register → emit → snapshot: an app resource rides the same rails', () => {
|
|
24
|
+
const result = appResources.registerAppResource('app:demo:notes');
|
|
25
|
+
assert.equal(result.created, true);
|
|
26
|
+
assert.equal(registry.hasResource('app:demo:notes'), true);
|
|
27
|
+
|
|
28
|
+
// Registration is idempotent.
|
|
29
|
+
assert.equal(appResources.registerAppResource('app:demo:notes').created, false);
|
|
30
|
+
|
|
31
|
+
const live = [];
|
|
32
|
+
const unsubscribe = subscribeStateEvents((event) => live.push(event));
|
|
33
|
+
const before = currentSeq();
|
|
34
|
+
|
|
35
|
+
const emitted = appResources.emitAppResourceEvents('app:demo:notes', [
|
|
36
|
+
{ op: 'insert', id: 'n1', value: { id: 'n1', text: 'buy milk' } },
|
|
37
|
+
{ op: 'insert', id: 'n2', value: { id: 'n2', text: 'call mom' } },
|
|
38
|
+
{ op: 'update', id: 'n1', value: { id: 'n1', text: 'buy oat milk' } },
|
|
39
|
+
]);
|
|
40
|
+
unsubscribe();
|
|
41
|
+
|
|
42
|
+
assert.equal(emitted.events.length, 3);
|
|
43
|
+
assert.equal(emitted.seq, before + 3);
|
|
44
|
+
|
|
45
|
+
// Live subscribers saw each event, in order, on the shared sequence.
|
|
46
|
+
assert.deepEqual(live.map((e) => [e.resource, e.op, e.id]), [
|
|
47
|
+
['app:demo:notes', 'insert', 'n1'],
|
|
48
|
+
['app:demo:notes', 'insert', 'n2'],
|
|
49
|
+
['app:demo:notes', 'update', 'n1'],
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// The mirror is what snapshots serve.
|
|
53
|
+
const snapshot = buildSnapshot('app:demo:notes');
|
|
54
|
+
assert.deepEqual(snapshot.resources['app:demo:notes'], [
|
|
55
|
+
{ id: 'n1', text: 'buy oat milk' },
|
|
56
|
+
{ id: 'n2', text: 'call mom' },
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Events landed in the log for reconnect replay.
|
|
60
|
+
const replay = listEventsAfter(before);
|
|
61
|
+
assert.equal(replay.length, 3);
|
|
62
|
+
assert.equal(replay[0].source, 'app:demo:notes');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('delete and replace maintain the mirror', () => {
|
|
66
|
+
appResources.emitAppResourceEvents('app:demo:notes', { op: 'delete', id: 'n2' });
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
appResources.listAppResourceRows('app:demo:notes').map((row) => row.id),
|
|
69
|
+
['n1'],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
appResources.emitAppResourceEvents('app:demo:notes', {
|
|
73
|
+
op: 'replace',
|
|
74
|
+
value: [{ id: 'x1', text: 'fresh start' }],
|
|
75
|
+
});
|
|
76
|
+
assert.deepEqual(appResources.listAppResourceRows('app:demo:notes'), [
|
|
77
|
+
{ id: 'x1', text: 'fresh start' },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('registrations survive a registry restart (persisted + rewired)', () => {
|
|
82
|
+
// Simulate a fresh boot: the in-memory registration is gone, sqlite is not.
|
|
83
|
+
registry.unregisterResource('app:demo:notes');
|
|
84
|
+
assert.equal(registry.hasResource('app:demo:notes'), false);
|
|
85
|
+
|
|
86
|
+
// Force a reload pass the way rest.js does on first request.
|
|
87
|
+
const appResourcesPath = require.resolve('../state/app-resources');
|
|
88
|
+
delete require.cache[appResourcesPath];
|
|
89
|
+
const reloaded = require('../state/app-resources');
|
|
90
|
+
reloaded.ensureAppResourcesLoaded();
|
|
91
|
+
|
|
92
|
+
assert.equal(registry.hasResource('app:demo:notes'), true);
|
|
93
|
+
const snapshot = buildSnapshot('app:demo:notes');
|
|
94
|
+
assert.deepEqual(snapshot.resources['app:demo:notes'], [
|
|
95
|
+
{ id: 'x1', text: 'fresh start' },
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the door is namespaced and validated', () => {
|
|
100
|
+
// Built-in names cannot be claimed through the app door.
|
|
101
|
+
assert.throws(() => appResources.registerAppResource('skills'), /app:<appId>:<name>/);
|
|
102
|
+
assert.throws(() => appResources.registerAppResource('app:bad name:notes'), /app:<appId>:<name>/);
|
|
103
|
+
|
|
104
|
+
// Emitting to an unregistered resource 404s rather than auto-creating.
|
|
105
|
+
assert.throws(
|
|
106
|
+
() => appResources.emitAppResourceEvents('app:demo:ghost', { op: 'insert', id: '1', value: { id: '1' } }),
|
|
107
|
+
/not registered/,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Malformed events are rejected with shape-explicit errors.
|
|
111
|
+
assert.throws(
|
|
112
|
+
() => appResources.emitAppResourceEvents('app:demo:notes', { op: 'insert', value: { text: 'no id' } }),
|
|
113
|
+
/requires an id/,
|
|
114
|
+
);
|
|
115
|
+
assert.throws(
|
|
116
|
+
() => appResources.emitAppResourceEvents('app:demo:notes', { op: 'replace', value: 'nope' }),
|
|
117
|
+
/full array of rows/,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('unregister removes rows, frees the name, and tells listeners', () => {
|
|
122
|
+
const live = [];
|
|
123
|
+
const unsubscribe = subscribeStateEvents((event) => live.push(event));
|
|
124
|
+
const result = appResources.unregisterAppResource('app:demo:notes');
|
|
125
|
+
unsubscribe();
|
|
126
|
+
|
|
127
|
+
assert.equal(result.removed, true);
|
|
128
|
+
assert.equal(registry.hasResource('app:demo:notes'), false);
|
|
129
|
+
assert.equal(live.length, 1);
|
|
130
|
+
assert.equal(live[0].op, 'replace');
|
|
131
|
+
assert.deepEqual(live[0].value, []);
|
|
132
|
+
assert.equal(appResources.listAppResources().length, 0);
|
|
133
|
+
|
|
134
|
+
// Cascade cleared the mirror rows.
|
|
135
|
+
assert.deepEqual(appResources.listAppResourceRows('app:demo:notes'), []);
|
|
136
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-live-docs-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
|
|
12
|
+
const Y = require('yjs');
|
|
13
|
+
const { closeLocalDb } = require('../state/db');
|
|
14
|
+
const docs = require('../state/docs');
|
|
15
|
+
const { buildSnapshot } = require('../state/snapshot');
|
|
16
|
+
const { listEventsAfter, currentSeq } = require('../state/events');
|
|
17
|
+
|
|
18
|
+
const docDir = path.join(tempRoot, 'workspace');
|
|
19
|
+
fs.mkdirSync(docDir, { recursive: true });
|
|
20
|
+
|
|
21
|
+
function writeDoc(name, text) {
|
|
22
|
+
const target = path.join(docDir, name);
|
|
23
|
+
fs.writeFileSync(target, text, 'utf8');
|
|
24
|
+
return target;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clientFromState(stateBase64) {
|
|
32
|
+
const doc = new Y.Doc();
|
|
33
|
+
Y.applyUpdate(doc, new Uint8Array(Buffer.from(stateBase64, 'base64')));
|
|
34
|
+
return doc;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encodeLocalEdit(doc, mutate) {
|
|
38
|
+
let captured = null;
|
|
39
|
+
const listener = (update) => { captured = update; };
|
|
40
|
+
doc.on('update', listener);
|
|
41
|
+
mutate(doc.getText('content'));
|
|
42
|
+
doc.off('update', listener);
|
|
43
|
+
assert.ok(captured, 'edit should produce an update');
|
|
44
|
+
return Buffer.from(captured).toString('base64');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyEventsTo(doc, events) {
|
|
48
|
+
for (const event of events) {
|
|
49
|
+
if (event.patch?.yjs) {
|
|
50
|
+
Y.applyUpdate(doc, new Uint8Array(Buffer.from(event.patch.yjs, 'base64')));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test.after(() => {
|
|
56
|
+
docs.closeAllDocs();
|
|
57
|
+
closeLocalDb();
|
|
58
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('open seeds the document from disk and returns usable state', () => {
|
|
62
|
+
const target = writeDoc('seed.md', '# hello\n');
|
|
63
|
+
const opened = docs.openDoc(target);
|
|
64
|
+
|
|
65
|
+
assert.equal(opened.path, target);
|
|
66
|
+
assert.ok(opened.resource.startsWith('doc:'));
|
|
67
|
+
assert.equal(docs.pathFromDocResource(opened.resource), target);
|
|
68
|
+
|
|
69
|
+
const client = clientFromState(opened.state);
|
|
70
|
+
assert.equal(client.getText('content').toString(), '# hello\n');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('client updates apply, log as patch events, and flush back to disk', async () => {
|
|
74
|
+
const target = writeDoc('edit.md', 'hello world\n');
|
|
75
|
+
const opened = docs.openDoc(target);
|
|
76
|
+
const client = clientFromState(opened.state);
|
|
77
|
+
const before = currentSeq();
|
|
78
|
+
|
|
79
|
+
const update = encodeLocalEdit(client, (ytext) => ytext.insert(5, ','));
|
|
80
|
+
const result = docs.applyDocUpdates(target, [update]);
|
|
81
|
+
assert.equal(result.applied, 1);
|
|
82
|
+
assert.equal(docs.getDocText(target), 'hello, world\n');
|
|
83
|
+
|
|
84
|
+
// The event carries the update in the patch slot — no value payload.
|
|
85
|
+
const events = listEventsAfter(before).filter((e) => e.resource === opened.resource);
|
|
86
|
+
assert.equal(events.length, 1);
|
|
87
|
+
assert.equal(events[0].op, 'update');
|
|
88
|
+
assert.equal(events[0].id, target);
|
|
89
|
+
assert.ok(events[0].patch?.yjs);
|
|
90
|
+
assert.equal(events[0].value, undefined);
|
|
91
|
+
|
|
92
|
+
// Debounced write-back lands on disk.
|
|
93
|
+
await sleep(500);
|
|
94
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'hello, world\n');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('two clients editing concurrently converge to the same text', () => {
|
|
98
|
+
const target = writeDoc('merge.md', 'the middle\n');
|
|
99
|
+
const opened = docs.openDoc(target);
|
|
100
|
+
const clientA = clientFromState(opened.state);
|
|
101
|
+
const clientB = clientFromState(opened.state);
|
|
102
|
+
const before = currentSeq();
|
|
103
|
+
|
|
104
|
+
// Both edit from the SAME baseline, unaware of each other.
|
|
105
|
+
const updateA = encodeLocalEdit(clientA, (ytext) => ytext.insert(0, 'A: '));
|
|
106
|
+
const updateB = encodeLocalEdit(clientB, (ytext) => ytext.insert('the middle'.length, ' B'));
|
|
107
|
+
|
|
108
|
+
docs.applyDocUpdates(target, [updateA]);
|
|
109
|
+
docs.applyDocUpdates(target, [updateB]);
|
|
110
|
+
|
|
111
|
+
const engineText = docs.getDocText(target);
|
|
112
|
+
assert.equal(engineText, 'A: the middle B\n');
|
|
113
|
+
|
|
114
|
+
// Each client catches up from the relayed events and lands on the same text.
|
|
115
|
+
const events = listEventsAfter(before).filter((e) => e.resource === opened.resource);
|
|
116
|
+
applyEventsTo(clientA, events);
|
|
117
|
+
applyEventsTo(clientB, events);
|
|
118
|
+
assert.equal(clientA.getText('content').toString(), engineText);
|
|
119
|
+
assert.equal(clientB.getText('content').toString(), engineText);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('external disk edits reconcile into the live doc via the watcher', async () => {
|
|
123
|
+
const target = writeDoc('agent.md', 'agent line 1\n');
|
|
124
|
+
const opened = docs.openDoc(target);
|
|
125
|
+
const before = currentSeq();
|
|
126
|
+
|
|
127
|
+
// An "agent" writes the file directly, bypassing the doc API.
|
|
128
|
+
fs.writeFileSync(target, 'agent line 1\nagent line 2\n', 'utf8');
|
|
129
|
+
await sleep(700);
|
|
130
|
+
|
|
131
|
+
assert.equal(docs.getDocText(target), 'agent line 1\nagent line 2\n');
|
|
132
|
+
const events = listEventsAfter(before).filter((e) => e.resource === opened.resource);
|
|
133
|
+
assert.ok(events.length >= 1, 'external edit should emit at least one doc event');
|
|
134
|
+
assert.equal(events[events.length - 1].source, 'doc:disk');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('write-back does not echo into an event loop', async () => {
|
|
138
|
+
const target = writeDoc('loop.md', 'quiet\n');
|
|
139
|
+
const opened = docs.openDoc(target);
|
|
140
|
+
const client = clientFromState(opened.state);
|
|
141
|
+
|
|
142
|
+
const update = encodeLocalEdit(client, (ytext) => ytext.insert(0, 'still '));
|
|
143
|
+
docs.applyDocUpdates(target, [update]);
|
|
144
|
+
|
|
145
|
+
// Let the write-back land AND the watcher's reconcile window pass.
|
|
146
|
+
await sleep(900);
|
|
147
|
+
const settled = currentSeq();
|
|
148
|
+
await sleep(700);
|
|
149
|
+
const after = listEventsAfter(settled).filter((e) => e.resource === opened.resource);
|
|
150
|
+
assert.equal(after.length, 0, `write-back must not re-emit (got ${JSON.stringify(after)})`);
|
|
151
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'still quiet\n');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('documents survive close: persisted state + disk agree on reopen', () => {
|
|
155
|
+
const target = writeDoc('reopen.md', 'v1\n');
|
|
156
|
+
const opened = docs.openDoc(target);
|
|
157
|
+
const client = clientFromState(opened.state);
|
|
158
|
+
const update = encodeLocalEdit(client, (ytext) => ytext.insert(0, 'v2 over '));
|
|
159
|
+
docs.applyDocUpdates(target, [update]);
|
|
160
|
+
|
|
161
|
+
docs.closeAllDocs(); // flushes disk + state
|
|
162
|
+
|
|
163
|
+
const reopened = docs.openDoc(target);
|
|
164
|
+
const fresh = clientFromState(reopened.state);
|
|
165
|
+
assert.equal(fresh.getText('content').toString(), 'v2 over v1\n');
|
|
166
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'v2 over v1\n');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('snapshots serve open docs only and never break on bad names', () => {
|
|
170
|
+
const target = writeDoc('snap.md', 'snapshot me\n');
|
|
171
|
+
const opened = docs.openDoc(target);
|
|
172
|
+
|
|
173
|
+
const withDoc = buildSnapshot(opened.resource);
|
|
174
|
+
assert.equal(clientFromState(withDoc.resources[opened.resource].state)
|
|
175
|
+
.getText('content').toString(), 'snapshot me\n');
|
|
176
|
+
|
|
177
|
+
// Unopened doc → absent, garbage name → absent; snapshot itself stays fine.
|
|
178
|
+
const unopened = docs.docResourceName(path.join(docDir, 'never-opened.md'));
|
|
179
|
+
const garbage = buildSnapshot(`doc:!!!not-base64!!!,${unopened}`);
|
|
180
|
+
assert.deepEqual(garbage.resources, {});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('guards: binary, missing, oversized, and malformed inputs are rejected', () => {
|
|
184
|
+
assert.throws(() => docs.openDoc(writeDoc('image.png', 'not really')), /text files only/);
|
|
185
|
+
assert.throws(() => docs.openDoc(path.join(docDir, 'missing.md')), /not found/);
|
|
186
|
+
|
|
187
|
+
const big = writeDoc('big.md', 'x'.repeat(2 * 1024 * 1024 + 1));
|
|
188
|
+
assert.throws(() => docs.openDoc(big), /live document limit/);
|
|
189
|
+
|
|
190
|
+
const target = writeDoc('guarded.md', 'ok\n');
|
|
191
|
+
assert.throws(() => docs.applyDocUpdates(target, [123]), /base64-encoded/);
|
|
192
|
+
assert.throws(() => docs.applyDocUpdates(target, ['aGVsbG8=']), /invalid Yjs update/);
|
|
193
|
+
});
|