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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amalgm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.150",
|
|
4
4
|
"description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"opencode-ai": "^1.17.6",
|
|
47
47
|
"tsx": "^4.21.0",
|
|
48
48
|
"ws": "^8.21.0",
|
|
49
|
+
"yjs": "^13.6.31",
|
|
49
50
|
"zod": "^4.4.3"
|
|
50
51
|
}
|
|
51
52
|
}
|
|
@@ -19,15 +19,27 @@ const TRIGGER_DESCRIPTION = [
|
|
|
19
19
|
'For a manual-only automation, still create one trigger with `enabled: false`; `automations_run_now` can run the workflow without waiting for that trigger to fire.',
|
|
20
20
|
].join('\n\n');
|
|
21
21
|
|
|
22
|
+
const WORKFLOW_EXAMPLE = `export default workflow({
|
|
23
|
+
trigger: "*.*",
|
|
24
|
+
cells: [
|
|
25
|
+
code("getData", () => ({ msg: "hello" })),
|
|
26
|
+
tool("notify", "notifications.notify_user", (ctx) => ({
|
|
27
|
+
message: ctx.cells.getData.output.msg,
|
|
28
|
+
})),
|
|
29
|
+
],
|
|
30
|
+
});`;
|
|
31
|
+
|
|
22
32
|
const WORKFLOW_DESCRIPTION = [
|
|
23
33
|
'Source-of-truth workflow script. Use `export default workflow({ trigger, allowlist, limits, cells })`.',
|
|
24
34
|
'The script is the workflow. A trigger decides when it runs; cells decide what happens.',
|
|
35
|
+
'`cells` is an ARRAY of cell-builder calls. The builders `code`, `cli`, `http`, `tool`, and `agent` are globals (do not destructure or import them), and each takes the cell name as its first argument. Complete example:',
|
|
36
|
+
'```js\n' + WORKFLOW_EXAMPLE + '\n```',
|
|
25
37
|
'Workflow `trigger` refs are `source.event` strings such as `github.push`; `github.*` means any GitHub event; `*.push` means a push from any source; `*.*` means any source and event.',
|
|
26
|
-
'
|
|
38
|
+
'Cell builders: `code(name, fn)`, gated local `cli(name, config)`, gated `http(name, config)`, and `tool(name, "tool.action", args)`.',
|
|
27
39
|
'Agents are tools too: use `agent(name, agentIdOrName, { prompt, run_in_background: true })` or `tool(name, "agents.talk_to_agent", args)`.',
|
|
28
40
|
'Every function receives one ctx object: `{ automation, trigger, workflow, event, payload, headers, previous, outputs, cells, secrets, stop }`.',
|
|
29
41
|
'Cell outputs are available as `ctx.cells.cellName.output`, `ctx.cells.cellName.field` for object outputs, `ctx.outputs.cellName`, and top-level `ctx.cellName`.',
|
|
30
|
-
'
|
|
42
|
+
'To see a known-good script, call `automations_get` on any existing automation and read its `workflowText`.',
|
|
31
43
|
].join('\n\n');
|
|
32
44
|
|
|
33
45
|
function compactAutomation(automation, includeWorkflowText = true) {
|
|
@@ -470,3 +482,5 @@ module.exports = [
|
|
|
470
482
|
},
|
|
471
483
|
},
|
|
472
484
|
];
|
|
485
|
+
|
|
486
|
+
module.exports.WORKFLOW_EXAMPLE = WORKFLOW_EXAMPLE;
|
|
@@ -789,7 +789,10 @@ module.exports = {
|
|
|
789
789
|
handleSearch,
|
|
790
790
|
handleWrite,
|
|
791
791
|
_private: {
|
|
792
|
+
base64UrlDecode,
|
|
793
|
+
base64UrlEncode,
|
|
792
794
|
buildFilesResource,
|
|
795
|
+
classifyFile,
|
|
793
796
|
expandHome,
|
|
794
797
|
buildUploadsResource,
|
|
795
798
|
configuredRootPaths,
|
|
@@ -797,5 +800,6 @@ module.exports = {
|
|
|
797
800
|
pathFromFilesResource,
|
|
798
801
|
publishFilesystemChange,
|
|
799
802
|
resolveSafePath,
|
|
803
|
+
resolveSafePathSync,
|
|
800
804
|
},
|
|
801
805
|
};
|
|
@@ -3,20 +3,50 @@
|
|
|
3
3
|
const stateRest = require('../../state/rest');
|
|
4
4
|
|
|
5
5
|
async function handleStateRoutes(ctx) {
|
|
6
|
-
if (ctx.method
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
if (ctx.method === 'GET') {
|
|
7
|
+
if (ctx.pathname === '/state/snapshot') {
|
|
8
|
+
stateRest.handleSnapshot(ctx.getQuery(), ctx.sendJson);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (ctx.pathname === '/state/events') {
|
|
12
|
+
stateRest.handleEvents(ctx.getQuery(), ctx.sendJson);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if (ctx.pathname === '/state/stream') {
|
|
16
|
+
stateRest.handleStream(ctx.req, ctx.res, ctx.getQuery());
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (ctx.pathname === '/state/resources') {
|
|
20
|
+
stateRest.handleResourceList(ctx.sendJson);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
15
24
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
|
|
26
|
+
if (ctx.method === 'POST') {
|
|
27
|
+
if (ctx.pathname === '/state/resources/register') {
|
|
28
|
+
await stateRest.handleResourceRegister(await ctx.readJsonBody(), ctx.sendJson);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (ctx.pathname === '/state/resources/unregister') {
|
|
32
|
+
await stateRest.handleResourceUnregister(await ctx.readJsonBody(), ctx.sendJson);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (ctx.pathname === '/state/resources/emit') {
|
|
36
|
+
await stateRest.handleResourceEmit(await ctx.readJsonBody(), ctx.sendJson);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (ctx.pathname === '/state/docs/open') {
|
|
40
|
+
await stateRest.handleDocOpen(await ctx.readJsonBody(), ctx.sendJson);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (ctx.pathname === '/state/docs/update') {
|
|
44
|
+
await stateRest.handleDocUpdate(await ctx.readJsonBody(), ctx.sendJson);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
19
48
|
}
|
|
49
|
+
|
|
20
50
|
return false;
|
|
21
51
|
}
|
|
22
52
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-registered resources: the HTTP door onto the local-live rails.
|
|
5
|
+
*
|
|
6
|
+
* Apps run out-of-process, so the engine cannot call a read() inside them.
|
|
7
|
+
* Instead the engine keeps the mirror: an app registers a namespaced name
|
|
8
|
+
* (`app:<appId>:<name>`), pushes changes as events, and the engine stores the
|
|
9
|
+
* current rows in sqlite. The registry's read() for an app resource reads
|
|
10
|
+
* that mirror, so snapshots/replays/resets behave exactly like built-ins.
|
|
11
|
+
*
|
|
12
|
+
* Registrations persist across restarts; ensureAppResourcesLoaded() re-wires
|
|
13
|
+
* them into the in-memory registry on first use.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { openLocalDb } = require('./db');
|
|
17
|
+
const registry = require('./registry');
|
|
18
|
+
const { insertStateEvent, publishStateEvent } = require('./events');
|
|
19
|
+
|
|
20
|
+
const APP_RESOURCE_NAME_RE = /^app:[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_.-]*$/i;
|
|
21
|
+
const MAX_EVENTS_PER_EMIT = 500;
|
|
22
|
+
const VALID_OPS = new Set(['insert', 'update', 'delete', 'replace']);
|
|
23
|
+
|
|
24
|
+
let loaded = false;
|
|
25
|
+
|
|
26
|
+
function ensureSchema(database) {
|
|
27
|
+
database.exec(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS app_resources (
|
|
29
|
+
name TEXT PRIMARY KEY,
|
|
30
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
31
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS app_resource_rows (
|
|
35
|
+
resource TEXT NOT NULL,
|
|
36
|
+
id TEXT NOT NULL,
|
|
37
|
+
value_json TEXT NOT NULL,
|
|
38
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
39
|
+
PRIMARY KEY (resource, id),
|
|
40
|
+
FOREIGN KEY (resource) REFERENCES app_resources(name) ON DELETE CASCADE
|
|
41
|
+
);
|
|
42
|
+
`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isValidAppResourceName(name) {
|
|
46
|
+
return typeof name === 'string' && APP_RESOURCE_NAME_RE.test(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listAppResourceRows(name) {
|
|
50
|
+
const rows = openLocalDb()
|
|
51
|
+
.prepare('SELECT value_json FROM app_resource_rows WHERE resource = ? ORDER BY id ASC')
|
|
52
|
+
.all(name);
|
|
53
|
+
const values = [];
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
try {
|
|
56
|
+
values.push(JSON.parse(row.value_json));
|
|
57
|
+
} catch {
|
|
58
|
+
// A torn row should not take the whole resource down.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return values;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wireIntoRegistry(name) {
|
|
65
|
+
if (registry.hasResource(name)) return;
|
|
66
|
+
registry.registerResource(name, {
|
|
67
|
+
read: () => listAppResourceRows(name),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ensureAppResourcesLoaded() {
|
|
72
|
+
if (loaded) return;
|
|
73
|
+
const database = openLocalDb();
|
|
74
|
+
ensureSchema(database);
|
|
75
|
+
const names = database.prepare('SELECT name FROM app_resources ORDER BY name ASC').all();
|
|
76
|
+
for (const { name } of names) wireIntoRegistry(name);
|
|
77
|
+
loaded = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function registerAppResource(name) {
|
|
81
|
+
if (!isValidAppResourceName(name)) {
|
|
82
|
+
throw invalid(`resource name must look like "app:<appId>:<name>" (got ${JSON.stringify(name)})`);
|
|
83
|
+
}
|
|
84
|
+
ensureAppResourcesLoaded();
|
|
85
|
+
const database = openLocalDb();
|
|
86
|
+
const inserted = database
|
|
87
|
+
.prepare('INSERT OR IGNORE INTO app_resources (name) VALUES (?)')
|
|
88
|
+
.run(name).changes > 0;
|
|
89
|
+
wireIntoRegistry(name);
|
|
90
|
+
return { resource: name, created: inserted };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unregisterAppResource(name) {
|
|
94
|
+
ensureAppResourcesLoaded();
|
|
95
|
+
const database = openLocalDb();
|
|
96
|
+
const existed = database.prepare('DELETE FROM app_resources WHERE name = ?').run(name).changes > 0;
|
|
97
|
+
if (!existed) return { resource: name, removed: false };
|
|
98
|
+
registry.unregisterResource(name);
|
|
99
|
+
// Tell connected screens the resource is gone (empty), through the log so
|
|
100
|
+
// replay/reset semantics hold.
|
|
101
|
+
const event = database.transaction(() =>
|
|
102
|
+
insertStateEvent(database, { resource: name, op: 'replace', value: [], source: 'app:unregister' }),
|
|
103
|
+
)();
|
|
104
|
+
publishStateEvent(event);
|
|
105
|
+
return { resource: name, removed: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function invalid(message) {
|
|
109
|
+
const error = new Error(message);
|
|
110
|
+
error.statusCode = 400;
|
|
111
|
+
return error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeEmitEvent(name, input, index) {
|
|
115
|
+
if (!input || typeof input !== 'object') {
|
|
116
|
+
throw invalid(`events[${index}] must be an object`);
|
|
117
|
+
}
|
|
118
|
+
const op = input.op;
|
|
119
|
+
if (!VALID_OPS.has(op)) {
|
|
120
|
+
throw invalid(`events[${index}].op must be one of insert|update|delete|replace`);
|
|
121
|
+
}
|
|
122
|
+
if (op === 'replace') {
|
|
123
|
+
if (!Array.isArray(input.value)) {
|
|
124
|
+
throw invalid(`events[${index}]: replace requires value to be the full array of rows`);
|
|
125
|
+
}
|
|
126
|
+
for (const row of input.value) {
|
|
127
|
+
if (!row || typeof row !== 'object' || row.id == null) {
|
|
128
|
+
throw invalid(`events[${index}]: every replace row needs an id`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { op, value: input.value };
|
|
132
|
+
}
|
|
133
|
+
const id = input.id ?? input.value?.id;
|
|
134
|
+
if (id == null || String(id) === '') {
|
|
135
|
+
throw invalid(`events[${index}]: ${op} requires an id`);
|
|
136
|
+
}
|
|
137
|
+
if (op !== 'delete' && (!input.value || typeof input.value !== 'object')) {
|
|
138
|
+
throw invalid(`events[${index}]: ${op} requires value (the row object)`);
|
|
139
|
+
}
|
|
140
|
+
return { op, id: String(id), value: input.value };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function applyMirror(database, name, event) {
|
|
144
|
+
if (event.op === 'replace') {
|
|
145
|
+
database.prepare('DELETE FROM app_resource_rows WHERE resource = ?').run(name);
|
|
146
|
+
const upsert = database.prepare(`
|
|
147
|
+
INSERT INTO app_resource_rows (resource, id, value_json, updated_at)
|
|
148
|
+
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
149
|
+
ON CONFLICT(resource, id) DO UPDATE SET
|
|
150
|
+
value_json = excluded.value_json,
|
|
151
|
+
updated_at = excluded.updated_at
|
|
152
|
+
`);
|
|
153
|
+
for (const row of event.value) upsert.run(name, String(row.id), JSON.stringify(row));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.op === 'delete') {
|
|
157
|
+
database.prepare('DELETE FROM app_resource_rows WHERE resource = ? AND id = ?').run(name, event.id);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
database.prepare(`
|
|
161
|
+
INSERT INTO app_resource_rows (resource, id, value_json, updated_at)
|
|
162
|
+
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
163
|
+
ON CONFLICT(resource, id) DO UPDATE SET
|
|
164
|
+
value_json = excluded.value_json,
|
|
165
|
+
updated_at = excluded.updated_at
|
|
166
|
+
`).run(name, event.id, JSON.stringify(event.value));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Apply a batch of app events: mirror rows and the event log update in one
|
|
171
|
+
* transaction, then publish to live subscribers.
|
|
172
|
+
*/
|
|
173
|
+
function emitAppResourceEvents(name, eventsInput, options = {}) {
|
|
174
|
+
ensureAppResourcesLoaded();
|
|
175
|
+
if (!isValidAppResourceName(name)) {
|
|
176
|
+
throw invalid(`resource name must look like "app:<appId>:<name>" (got ${JSON.stringify(name)})`);
|
|
177
|
+
}
|
|
178
|
+
const database = openLocalDb();
|
|
179
|
+
const registered = database.prepare('SELECT 1 FROM app_resources WHERE name = ?').get(name);
|
|
180
|
+
if (!registered) {
|
|
181
|
+
const error = new Error(`resource "${name}" is not registered; call /state/resources/register first`);
|
|
182
|
+
error.statusCode = 404;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const inputs = Array.isArray(eventsInput) ? eventsInput : [eventsInput];
|
|
187
|
+
if (inputs.length === 0) return { events: [], seq: null };
|
|
188
|
+
if (inputs.length > MAX_EVENTS_PER_EMIT) {
|
|
189
|
+
throw invalid(`at most ${MAX_EVENTS_PER_EMIT} events per call (got ${inputs.length}); send a replace instead`);
|
|
190
|
+
}
|
|
191
|
+
const normalized = inputs.map((input, index) => normalizeEmitEvent(name, input, index));
|
|
192
|
+
|
|
193
|
+
const source = typeof options.source === 'string' && options.source ? options.source : name;
|
|
194
|
+
const inserted = database.transaction(() => {
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const event of normalized) {
|
|
197
|
+
applyMirror(database, name, event);
|
|
198
|
+
out.push(insertStateEvent(database, {
|
|
199
|
+
resource: name,
|
|
200
|
+
op: event.op,
|
|
201
|
+
id: event.id,
|
|
202
|
+
value: event.value,
|
|
203
|
+
source,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
})();
|
|
208
|
+
|
|
209
|
+
for (const event of inserted) publishStateEvent(event);
|
|
210
|
+
return {
|
|
211
|
+
events: inserted.map((event) => ({ seq: event.seq, op: event.op, id: event.id ?? null })),
|
|
212
|
+
seq: inserted.length > 0 ? inserted[inserted.length - 1].seq : null,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function listAppResources() {
|
|
217
|
+
ensureAppResourcesLoaded();
|
|
218
|
+
const database = openLocalDb();
|
|
219
|
+
return database.prepare(`
|
|
220
|
+
SELECT
|
|
221
|
+
r.name,
|
|
222
|
+
r.created_at,
|
|
223
|
+
r.updated_at,
|
|
224
|
+
COUNT(rows.id) AS row_count
|
|
225
|
+
FROM app_resources r
|
|
226
|
+
LEFT JOIN app_resource_rows rows ON rows.resource = r.name
|
|
227
|
+
GROUP BY r.name
|
|
228
|
+
ORDER BY r.name ASC
|
|
229
|
+
`).all().map((row) => ({
|
|
230
|
+
resource: row.name,
|
|
231
|
+
rowCount: Number(row.row_count || 0),
|
|
232
|
+
createdAt: row.created_at,
|
|
233
|
+
updatedAt: row.updated_at,
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
emitAppResourceEvents,
|
|
239
|
+
ensureAppResourcesLoaded,
|
|
240
|
+
isValidAppResourceName,
|
|
241
|
+
listAppResourceRows,
|
|
242
|
+
listAppResources,
|
|
243
|
+
registerAppResource,
|
|
244
|
+
unregisterAppResource,
|
|
245
|
+
};
|