@amalgm/automations 0.3.1 → 0.4.0
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/AXIOMS.md +11 -0
- package/PURPOSE.md +18 -0
- package/README.md +34 -0
- package/dist/host/auth.js +4 -1
- package/dist/host/main.js +9 -1
- package/dist/host/notifications.d.ts +4 -0
- package/dist/host/notifications.js +19 -0
- package/dist/host/server.js +46 -7
- package/dist/src/cli/run.d.ts +1 -1
- package/dist/src/cli/run.js +12 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/machine-client.d.ts +3 -1
- package/dist/src/machine-client.js +6 -0
- package/dist/src/machine-event-stream.d.ts +8 -0
- package/dist/src/machine-event-stream.js +58 -0
- package/dist/src/machine-http.d.ts +2 -0
- package/dist/src/machine-http.js +8 -2
- package/dist/src/machine-notifications.d.ts +15 -0
- package/dist/src/machine-notifications.js +124 -0
- package/dist/src/machine.d.ts +1 -0
- package/dist/src/runner.d.ts +3 -1
- package/dist/src/runner.js +69 -46
- package/dist/src/supabase-machine.d.ts +1 -0
- package/dist/src/supabase-machine.js +3 -0
- package/package.json +2 -1
- package/skills/amalgm-automations/SKILL.md +148 -0
- package/skills/amalgm-automations/agents/openai.yaml +4 -0
- package/skills/amalgm-automations/references/command-contract.md +173 -0
- package/skills/amalgm-automations/references/setup-and-support.md +195 -0
- package/skills/amalgm-automations/references/workflow-plans.md +152 -0
- package/supabase/migrations/20260904020000_machine_run_notifications.sql +52 -0
- package/supabase/migrations/20260904030000_machine_notification_permissions.sql +4 -0
- package/skills/automations/SKILL.md +0 -141
package/AXIOMS.md
CHANGED
|
@@ -129,3 +129,14 @@
|
|
|
129
129
|
action or process results become an explicit byte count plus bounded JSON
|
|
130
130
|
preview before transport, so output can never strand a run outside the
|
|
131
131
|
ledger it is meant to update.
|
|
132
|
+
44. Agent onboarding delegates identity and runtime lifecycle to Shell and
|
|
133
|
+
proves Automations access with a successful read before declaring the
|
|
134
|
+
connection ready.
|
|
135
|
+
45. A machine claims work only after notification coverage is ready, on a wakeup,
|
|
136
|
+
or while draining previously discovered work; idle machines never poll.
|
|
137
|
+
46. Every run is committed before its wakeup, and every Fly host observes the
|
|
138
|
+
same private database notifications regardless of where admission occurred.
|
|
139
|
+
47. Retry and expired-lease wakeups follow the earliest durable eligibility time
|
|
140
|
+
for that user and machine; a notification is never proof of execution.
|
|
141
|
+
48. Losing upstream notification coverage closes downstream streams, and every
|
|
142
|
+
reconnect checks retained work before becoming idle again.
|
package/PURPOSE.md
CHANGED
|
@@ -27,6 +27,15 @@ The product has two composable halves over that one state:
|
|
|
27
27
|
needs to know whether a machine is online: an unclaimed run is the complete
|
|
28
28
|
offline queue.
|
|
29
29
|
|
|
30
|
+
The local worker is idle when there is no work. It opens one authenticated
|
|
31
|
+
HTTPS notification stream, claims retained runs when that stream becomes ready,
|
|
32
|
+
and drains work when notified. Committed Supabase changes wake every Fly host;
|
|
33
|
+
each host forwards only to the owning machine and schedules the next durable
|
|
34
|
+
retry or lease deadline. Notifications carry no workflow data and never replace
|
|
35
|
+
the run ledger. Reconnecting re-establishes notification coverage before checking
|
|
36
|
+
the ledger. Connection health and active execution leases have bounded timers;
|
|
37
|
+
idle machines have no periodic work-claim timer.
|
|
38
|
+
|
|
30
39
|
The agent CLI is the command-line projection of the same task-level command
|
|
31
40
|
surface as MCP: create, list, get, update, delete, and run-now. Each command
|
|
32
41
|
accepts the same JSON object as its corresponding MCP tool and returns the same
|
|
@@ -38,6 +47,15 @@ CLI sees only the local runtime admission token. The standalone adapter may be
|
|
|
38
47
|
composed over an already-bound SDK for tests and other hosts, but it never owns
|
|
39
48
|
automation lifecycle or authorization rules.
|
|
40
49
|
|
|
50
|
+
The portable Automations skill guides an agent from installation and Google
|
|
51
|
+
sign-in to a verified Automations connection, then back to the user's requested
|
|
52
|
+
work. Setup uses Shell's public commands and the existing browser approval;
|
|
53
|
+
the skill never becomes an authentication implementation. It reuses a working
|
|
54
|
+
connection, distinguishes account approval from runtime readiness, and helps
|
|
55
|
+
the user reach aayush@amalgm.ai with bounded, redacted diagnostic evidence when
|
|
56
|
+
the failing boundary cannot be repaired. The packaged skill is the source for
|
|
57
|
+
installed copies and public setup/support guidance.
|
|
58
|
+
|
|
41
59
|
The CLI is global configuration control, not directory-local state. The agent
|
|
42
60
|
or person creating an automation may invoke it from any directory; the selected
|
|
43
61
|
machine and the persisted workflow decide where effects occur later. A process
|
package/README.md
CHANGED
|
@@ -46,6 +46,11 @@ the API request.
|
|
|
46
46
|
- `@amalgm/automations/mcp`: agent tools over the same SDK.
|
|
47
47
|
- `@amalgm/automations/host`: standalone Fly service composition.
|
|
48
48
|
- `amalgm-automations`: CLI adapter.
|
|
49
|
+
- `skills/amalgm-automations`: portable agent skill for operating either the
|
|
50
|
+
MCP or global CLI surface, including installation, Google sign-in, runtime
|
|
51
|
+
readiness, troubleshooting, and support. The packaged skill is the source
|
|
52
|
+
for installed copies; its [setup and support reference](skills/amalgm-automations/references/setup-and-support.md)
|
|
53
|
+
supplies the public onboarding guidance.
|
|
49
54
|
|
|
50
55
|
## Agent CLI
|
|
51
56
|
|
|
@@ -198,6 +203,7 @@ The machine execution API is:
|
|
|
198
203
|
```text
|
|
199
204
|
POST /v1/machine/runs/claim
|
|
200
205
|
PATCH /v1/machine/runs/:runId
|
|
206
|
+
GET /v1/machine/runs/notifications
|
|
201
207
|
```
|
|
202
208
|
|
|
203
209
|
Public webhook admission uses the URL returned on each webhook trigger:
|
|
@@ -216,13 +222,41 @@ The target is never accepted in a machine request. It comes from the verified
|
|
|
216
222
|
access token. Run leases expire and are safely reclaimable; terminal updates
|
|
217
223
|
must present the active lease token.
|
|
218
224
|
|
|
225
|
+
The machine worker opens the authenticated HTTPS notification stream before
|
|
226
|
+
its first claim. `ready` and `wake` events cause it to drain retained work;
|
|
227
|
+
an idle worker has no polling interval. The stream sends a comment every
|
|
228
|
+
30 seconds to detect broken connections; comments never cause a claim or a
|
|
229
|
+
database read. Streams reconnect with fresh authorization at token expiry
|
|
230
|
+
(at most five minutes), and reconnect checks recover missed notifications.
|
|
231
|
+
|
|
232
|
+
Each Fly host subscribes to the same service-only Supabase Broadcast topic.
|
|
233
|
+
Run inserts and eligibility changes publish small owner/target wakeups from
|
|
234
|
+
the database transaction. Fly re-reads the earliest retry/lease deadline only
|
|
235
|
+
when a target connects, changes, or reaches that deadline. Losing the database
|
|
236
|
+
subscription closes the machine streams so they reconnect and catch up.
|
|
237
|
+
Cron scheduling remains hosted; no schedule timer runs on the user's machine.
|
|
238
|
+
|
|
239
|
+
Rollout order: apply the product migrations, deploy the Fly host, publish the
|
|
240
|
+
SDK, then release Shell with that exact SDK version. Shell wires
|
|
241
|
+
`notifications: runs.notifications` into `createAutomationMachineRunner`.
|
|
242
|
+
The polling option and `AMALGM_AUTOMATIONS_POLL_INTERVAL_MS` are removed.
|
|
243
|
+
Existing installed Shell releases keep their old behavior until updated.
|
|
244
|
+
|
|
219
245
|
## Verification
|
|
220
246
|
|
|
221
247
|
```bash
|
|
222
248
|
npm run verify
|
|
223
249
|
TEST_DATABASE_URL=postgres://... npm run test:supabase
|
|
250
|
+
npm run test:notifications
|
|
224
251
|
npm pack --dry-run
|
|
225
252
|
```
|
|
226
253
|
|
|
254
|
+
`test:supabase` requires an empty disposable Postgres database; its fixture
|
|
255
|
+
records the Supabase Broadcast boundary inside the same transaction.
|
|
256
|
+
`test:notifications` requires Node.js 22+, Docker, and Supabase CLI 2.26.9
|
|
257
|
+
(also pinned in CI). It starts and removes its own local Supabase project,
|
|
258
|
+
using real database Broadcast and two HTTP hosts to verify delivery, idle
|
|
259
|
+
behavior, offline catch-up, retry deadlines, and abandoned claims.
|
|
260
|
+
|
|
227
261
|
See [PURPOSE.md](./PURPOSE.md) and [AXIOMS.md](./AXIOMS.md) for the governing
|
|
228
262
|
ownership and behavior laws.
|
package/dist/host/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { authorizeMachineResource, } from '@amalgm/core/authorization';
|
|
3
|
-
import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, importJWK, jwtVerify, } from 'jose';
|
|
3
|
+
import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, decodeJwt, importJWK, jwtVerify, } from 'jose';
|
|
4
4
|
import { ForbiddenError } from '../src/errors.js';
|
|
5
5
|
export function createAutomationsAuthenticators(options) {
|
|
6
6
|
const jwks = createRemoteJWKSet(new URL(`${options.issuer}/.well-known/jwks.json`));
|
|
@@ -28,10 +28,13 @@ export function createAutomationsAuthenticators(options) {
|
|
|
28
28
|
},
|
|
29
29
|
machine: async (request) => {
|
|
30
30
|
const principal = await authorize(request, options.issuer, 'runs:execute', ports);
|
|
31
|
+
// The exact token above has already passed Core's signature/lifetime checks.
|
|
32
|
+
const claims = decodeJwt(request.headers.get('authorization').trim().split(/\s+/)[1]);
|
|
31
33
|
return {
|
|
32
34
|
userId: principal.userId,
|
|
33
35
|
computerId: principal.computerId,
|
|
34
36
|
scopes: automationScopes(principal.scopes),
|
|
37
|
+
authorizationExpiresAt: claims.exp * 1000,
|
|
35
38
|
};
|
|
36
39
|
},
|
|
37
40
|
});
|
package/dist/host/main.js
CHANGED
|
@@ -12,6 +12,8 @@ import { WebhookEndpoints } from '../src/webhook.js';
|
|
|
12
12
|
import { createAutomationsAuthenticators } from './auth.js';
|
|
13
13
|
import { automationsHostConfig } from './config.js';
|
|
14
14
|
import { createAutomationsHost } from './server.js';
|
|
15
|
+
import { createMachineRunNotifications } from '../src/machine-notifications.js';
|
|
16
|
+
import { subscribeToRunChanges } from './notifications.js';
|
|
15
17
|
const config = automationsHostConfig();
|
|
16
18
|
const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
17
19
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
@@ -27,9 +29,15 @@ const controlApi = createAutomationApi({
|
|
|
27
29
|
authenticate: authentication.control,
|
|
28
30
|
});
|
|
29
31
|
const machineRepository = new SupabaseMachineRunRepository(supabase);
|
|
32
|
+
const notifications = createMachineRunNotifications({
|
|
33
|
+
wakeDelay: (userId, computerId) => machineRepository.wakeDelay(userId, computerId),
|
|
34
|
+
log,
|
|
35
|
+
});
|
|
36
|
+
const closeNotifications = subscribeToRunChanges(supabase, notifications);
|
|
30
37
|
const machineApi = createMachineRunsApi({
|
|
31
38
|
authenticate: authentication.machine,
|
|
32
39
|
runsFor: (principal) => createMachineRuns(machineRepository, principal),
|
|
40
|
+
notifications,
|
|
33
41
|
});
|
|
34
42
|
const eventsApi = createAutomationEventsApi({
|
|
35
43
|
delivery,
|
|
@@ -46,7 +54,7 @@ const host = createAutomationsHost({
|
|
|
46
54
|
});
|
|
47
55
|
host.server.listen(config.port, '0.0.0.0', () => log('host.ready', { port: config.port }));
|
|
48
56
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
49
|
-
process.once(signal, () => void host.close().finally(() => process.exit(0)));
|
|
57
|
+
process.once(signal, () => void closeNotifications().then(() => host.close()).finally(() => process.exit(0)));
|
|
50
58
|
}
|
|
51
59
|
function log(event, details = {}) {
|
|
52
60
|
console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { MachineRunNotifications } from '../src/machine-notifications.js';
|
|
3
|
+
/** One private database subscription per Fly host, shared by its machine streams. */
|
|
4
|
+
export declare function subscribeToRunChanges(supabase: SupabaseClient, notifications: MachineRunNotifications): () => Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One private database subscription per Fly host, shared by its machine streams. */
|
|
2
|
+
export function subscribeToRunChanges(supabase, notifications) {
|
|
3
|
+
const channel = supabase.channel('amalgm:automations:runs', { config: { private: true } })
|
|
4
|
+
.on('broadcast', { event: 'changed' }, ({ payload }) => {
|
|
5
|
+
if (typeof payload?.userId === 'string' && typeof payload?.targetId === 'string') {
|
|
6
|
+
notifications.changed(payload.userId, payload.targetId);
|
|
7
|
+
}
|
|
8
|
+
})
|
|
9
|
+
.subscribe((status) => {
|
|
10
|
+
if (status === 'SUBSCRIBED')
|
|
11
|
+
notifications.connected();
|
|
12
|
+
else
|
|
13
|
+
notifications.disconnected();
|
|
14
|
+
});
|
|
15
|
+
return async () => {
|
|
16
|
+
notifications.close();
|
|
17
|
+
await supabase.removeChannel(channel);
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/host/server.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { once } from 'node:events';
|
|
2
3
|
import { publicResourceRequestUrl } from '@amalgm/core/authorization';
|
|
3
4
|
export function createAutomationsHost(options) {
|
|
4
5
|
const log = options.log ?? (() => { });
|
|
6
|
+
const requests = new Set();
|
|
5
7
|
let scheduling = null;
|
|
6
8
|
const tick = () => {
|
|
7
9
|
if (scheduling)
|
|
@@ -14,34 +16,48 @@ export function createAutomationsHost(options) {
|
|
|
14
16
|
timer.unref();
|
|
15
17
|
tick();
|
|
16
18
|
const server = createServer(async (incoming, outgoing) => {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
requests.add(controller);
|
|
21
|
+
const abort = () => controller.abort();
|
|
22
|
+
outgoing.once('close', abort);
|
|
17
23
|
try {
|
|
18
24
|
if (incoming.url === '/healthz')
|
|
19
|
-
return send(outgoing, Response.json({ ok: true }));
|
|
20
|
-
const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024);
|
|
25
|
+
return await send(outgoing, Response.json({ ok: true }), controller.signal);
|
|
26
|
+
const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024, controller.signal);
|
|
21
27
|
const pathname = new URL(request.url).pathname;
|
|
22
28
|
const api = pathname.startsWith('/e/') && options.eventsApi
|
|
23
29
|
? options.eventsApi
|
|
24
30
|
: pathname.startsWith('/v1/machine/') ? options.machineApi : options.controlApi;
|
|
25
|
-
await send(outgoing, await api(request));
|
|
31
|
+
await send(outgoing, await api(request), controller.signal);
|
|
26
32
|
}
|
|
27
33
|
catch (error) {
|
|
34
|
+
if (controller.signal.aborted)
|
|
35
|
+
return;
|
|
28
36
|
log('request.failed', { error: safe(error) });
|
|
37
|
+
if (outgoing.headersSent)
|
|
38
|
+
return outgoing.destroy();
|
|
29
39
|
const status = error instanceof HostRequestError ? error.status : 500;
|
|
30
40
|
await send(outgoing, Response.json({
|
|
31
41
|
error: status === 500 ? 'Automations service failed' : error instanceof Error ? error.message : String(error),
|
|
32
|
-
}, { status }));
|
|
42
|
+
}, { status }), controller.signal);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
outgoing.removeListener('close', abort);
|
|
46
|
+
requests.delete(controller);
|
|
33
47
|
}
|
|
34
48
|
});
|
|
35
49
|
return {
|
|
36
50
|
server,
|
|
37
51
|
async close() {
|
|
38
52
|
clearInterval(timer);
|
|
53
|
+
for (const request of requests)
|
|
54
|
+
request.abort();
|
|
39
55
|
await scheduling;
|
|
40
56
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
41
57
|
},
|
|
42
58
|
};
|
|
43
59
|
}
|
|
44
|
-
async function webRequest(request, publicOrigin, maximumBodyBytes) {
|
|
60
|
+
async function webRequest(request, publicOrigin, maximumBodyBytes, signal) {
|
|
45
61
|
const chunks = [];
|
|
46
62
|
let size = 0;
|
|
47
63
|
for await (const value of request) {
|
|
@@ -55,6 +71,7 @@ async function webRequest(request, publicOrigin, maximumBodyBytes) {
|
|
|
55
71
|
return new Request(publicResourceRequestUrl(publicOrigin, request.url ?? '/'), {
|
|
56
72
|
method: request.method ?? 'GET',
|
|
57
73
|
headers: request.headers,
|
|
74
|
+
signal,
|
|
58
75
|
...(body.length ? { body } : {}),
|
|
59
76
|
});
|
|
60
77
|
}
|
|
@@ -65,8 +82,30 @@ class HostRequestError extends Error {
|
|
|
65
82
|
this.status = status;
|
|
66
83
|
}
|
|
67
84
|
}
|
|
68
|
-
async function send(response, source) {
|
|
85
|
+
async function send(response, source, signal) {
|
|
69
86
|
response.writeHead(source.status, Object.fromEntries(source.headers));
|
|
70
|
-
response.
|
|
87
|
+
response.flushHeaders();
|
|
88
|
+
const reader = source.body?.getReader();
|
|
89
|
+
const abort = () => {
|
|
90
|
+
void reader?.cancel().catch(() => { });
|
|
91
|
+
response.destroy();
|
|
92
|
+
};
|
|
93
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
94
|
+
try {
|
|
95
|
+
signal.throwIfAborted();
|
|
96
|
+
while (reader) {
|
|
97
|
+
const { value, done } = await reader.read();
|
|
98
|
+
if (done)
|
|
99
|
+
break;
|
|
100
|
+
if (!response.write(value))
|
|
101
|
+
await once(response, 'drain', { signal });
|
|
102
|
+
}
|
|
103
|
+
response.end();
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
signal.removeEventListener('abort', abort);
|
|
107
|
+
await reader?.cancel().catch(() => { });
|
|
108
|
+
reader?.releaseLock();
|
|
109
|
+
}
|
|
71
110
|
}
|
|
72
111
|
const safe = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
package/dist/src/cli/run.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export interface AutomationCliIo extends AutomationCliInputPorts {
|
|
|
8
8
|
stdout?: Output;
|
|
9
9
|
stderr?: Output;
|
|
10
10
|
}
|
|
11
|
-
export declare const automationCliHelp = "amalgm automations \u2014 durable automation control for agents\n\nUsage:\n amalgm automations <command> [--input JSON | --file PATH | --stdin]\n amalgm-automations <command> [--input JSON | --file PATH | --stdin]\n\nCommands (identical to the Automations MCP task surface):\n create Create one complete definition\n list List the user's automations\n get Get one complete definition and optional run history\n update Apply grouped definition changes\n delete Delete current configuration; run history remains\n run-now Admit one durable manual run\n\nInput is one JSON object with the corresponding MCP tool's fields. Commands\nwithout options receive {}. Output is one JSON success or error envelope.\nUse --stdin when input contains credentials such as a webhook signing secret.\n\nWorkflow step lanes:\n version 1 action-only: {\"id\":\"...\",\"actionId\":\"product.action\",\"input\":{...}}\n version 2 action, command, or script steps:\n command {\"id\":\"...\",\"kind\":\"command\",\"command\":\"codex\",\"args\":[\"exec\",\"...\"],\"cwd\":\"/absolute/path\"}\n script {\"id\":\"...\",\"kind\":\"script\",\"runtime\":\"shell|node|python\",\"source\":\"...\",\"cwd\":\"/absolute/path\"}\n";
|
|
11
|
+
export declare const automationCliHelp = "amalgm automations \u2014 durable automation control for agents\n\nUsage:\n amalgm automations <command> [--input JSON | --file PATH | --stdin]\n amalgm-automations <command> [--input JSON | --file PATH | --stdin]\n\nCommands (identical to the Automations MCP task surface):\n create Create one complete definition\n list List the user's automations\n get Get one complete definition and optional run history\n update Apply grouped definition changes\n delete Delete current configuration; run history remains\n run-now Admit one durable manual run\n\nInput is one JSON object with the corresponding MCP tool's fields. Commands\nwithout options receive {}. Output is one JSON success or error envelope.\nUse --stdin when input contains credentials such as a webhook signing secret.\n\nSetup and support:\n Already connected? Reuse the running Amalgm Shell.\n First login: amalgm login --no-open\n Open the printed link, sign in with Google, and approve this computer.\n Keep the login process running; it hosts Shell after approval.\n Check: amalgm status --user EMAIL\n Resume a registered computer: amalgm run --user EMAIL\n Verify access: amalgm automations list --user EMAIL --input '{\"limit\":1}'\n No local installation? https://amalgm.ai/setup\n Support: aayush@amalgm.ai\n Never copy private connection credentials into a command or support email.\n\nWorkflow step lanes:\n version 1 action-only: {\"id\":\"...\",\"actionId\":\"product.action\",\"input\":{...}}\n version 2 action, command, or script steps:\n command {\"id\":\"...\",\"kind\":\"command\",\"command\":\"codex\",\"args\":[\"exec\",\"...\"],\"cwd\":\"/absolute/path\"}\n script {\"id\":\"...\",\"kind\":\"script\",\"runtime\":\"shell|node|python\",\"source\":\"...\",\"cwd\":\"/absolute/path\"}\n";
|
|
12
12
|
export declare function runAutomationCli(argv: string[], backend: AutomationCrud | AutomationCommands, io?: AutomationCliIo): Promise<number>;
|
|
13
13
|
export {};
|
package/dist/src/cli/run.js
CHANGED
|
@@ -20,6 +20,18 @@ Input is one JSON object with the corresponding MCP tool's fields. Commands
|
|
|
20
20
|
without options receive {}. Output is one JSON success or error envelope.
|
|
21
21
|
Use --stdin when input contains credentials such as a webhook signing secret.
|
|
22
22
|
|
|
23
|
+
Setup and support:
|
|
24
|
+
Already connected? Reuse the running Amalgm Shell.
|
|
25
|
+
First login: amalgm login --no-open
|
|
26
|
+
Open the printed link, sign in with Google, and approve this computer.
|
|
27
|
+
Keep the login process running; it hosts Shell after approval.
|
|
28
|
+
Check: amalgm status --user EMAIL
|
|
29
|
+
Resume a registered computer: amalgm run --user EMAIL
|
|
30
|
+
Verify access: amalgm automations list --user EMAIL --input '{"limit":1}'
|
|
31
|
+
No local installation? https://amalgm.ai/setup
|
|
32
|
+
Support: aayush@amalgm.ai
|
|
33
|
+
Never copy private connection credentials into a command or support email.
|
|
34
|
+
|
|
23
35
|
Workflow step lanes:
|
|
24
36
|
version 1 action-only: {"id":"...","actionId":"product.action","input":{...}}
|
|
25
37
|
version 2 action, command, or script steps:
|
package/dist/src/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { SupabaseMachineRunRepository, type MachineRpcClient } from './supabase-
|
|
|
19
19
|
export { createMachineRuns } from './machine.js';
|
|
20
20
|
export type { AutomationMachinePrincipal, ClaimedAutomationRun, MachineRunRepository, MachineRuns, MachineRunUpdate, } from './machine.js';
|
|
21
21
|
export { createMachineRunsApi } from './machine-http.js';
|
|
22
|
+
export { createMachineRunNotifications, type MachineRunNotifications } from './machine-notifications.js';
|
|
22
23
|
export { createMachineRunsClient, type AutomationRequestHeaders } from './machine-client.js';
|
|
23
24
|
export { AutomationRunExecutor, automationPlan, type AutomationActionPort, type AutomationProcessExecution, type AutomationProcessPort, type AutomationRunExecutorOptions, } from './executor.js';
|
|
24
25
|
export { createNodeAutomationProcessHost, type NodeAutomationProcessHostOptions, } from './node-process-host.js';
|
package/dist/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { SupabaseStore } from './supabase-store.js';
|
|
|
24
24
|
export { SupabaseMachineRunRepository } from './supabase-machine.js';
|
|
25
25
|
export { createMachineRuns } from './machine.js';
|
|
26
26
|
export { createMachineRunsApi } from './machine-http.js';
|
|
27
|
+
export { createMachineRunNotifications } from './machine-notifications.js';
|
|
27
28
|
export { createMachineRunsClient } from './machine-client.js';
|
|
28
29
|
export { AutomationRunExecutor, automationPlan, } from './executor.js';
|
|
29
30
|
export { createNodeAutomationProcessHost, } from './node-process-host.js';
|
|
@@ -5,4 +5,6 @@ export declare function createMachineRunsClient(options: {
|
|
|
5
5
|
readonly headers: AutomationRequestHeaders;
|
|
6
6
|
readonly fetch?: typeof globalThis.fetch;
|
|
7
7
|
readonly requestTimeoutMs?: number;
|
|
8
|
-
}): MachineRuns
|
|
8
|
+
}): MachineRuns & {
|
|
9
|
+
notifications(signal: AbortSignal): AsyncIterable<void>;
|
|
10
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AutomationError } from './errors.js';
|
|
2
|
+
import { machineEventStream } from './machine-event-stream.js';
|
|
2
3
|
export function createMachineRunsClient(options) {
|
|
3
4
|
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
4
5
|
const fetch = options.fetch ?? globalThis.fetch;
|
|
@@ -23,6 +24,11 @@ export function createMachineRunsClient(options) {
|
|
|
23
24
|
return payload;
|
|
24
25
|
};
|
|
25
26
|
return Object.freeze({
|
|
27
|
+
notifications(signal) {
|
|
28
|
+
return machineEventStream({
|
|
29
|
+
url: `${baseUrl}/v1/machine/runs/notifications`, headers: options.headers, fetch, signal,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
26
32
|
async claim(input = {}) {
|
|
27
33
|
const result = await request('/v1/machine/runs/claim', 'POST', input);
|
|
28
34
|
return result.runs;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AutomationRequestHeaders } from './machine-client.js';
|
|
2
|
+
/** A wakeup stream carries no run data; every reconnect gets fresh authorization. */
|
|
3
|
+
export declare function machineEventStream(options: {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly headers: AutomationRequestHeaders;
|
|
6
|
+
readonly fetch: typeof globalThis.fetch;
|
|
7
|
+
readonly signal: AbortSignal;
|
|
8
|
+
}): AsyncGenerator<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AutomationError } from './errors.js';
|
|
2
|
+
/** A wakeup stream carries no run data; every reconnect gets fresh authorization. */
|
|
3
|
+
export async function* machineEventStream(options) {
|
|
4
|
+
const connection = new AbortController();
|
|
5
|
+
const signal = AbortSignal.any([options.signal, connection.signal]);
|
|
6
|
+
let rejectAborted;
|
|
7
|
+
const aborted = new Promise((_, reject) => { rejectAborted = reject; });
|
|
8
|
+
const abort = () => rejectAborted(signal.reason);
|
|
9
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
10
|
+
let timeout = setTimeout(() => connection.abort(new Error('Notification connection timed out')), 15_000);
|
|
11
|
+
timeout.unref();
|
|
12
|
+
let reader;
|
|
13
|
+
try {
|
|
14
|
+
const headers = await Promise.race([
|
|
15
|
+
Promise.resolve().then(() => { signal.throwIfAborted(); return options.headers('GET', options.url); }),
|
|
16
|
+
aborted,
|
|
17
|
+
]);
|
|
18
|
+
signal.throwIfAborted();
|
|
19
|
+
const response = await options.fetch(options.url, {
|
|
20
|
+
headers: { ...headers, accept: 'text/event-stream' }, signal,
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
throw new AutomationError('notifications_unavailable', `Automations notifications returned ${response.status}`, response.status);
|
|
24
|
+
if (!response.headers.get('content-type')?.startsWith('text/event-stream') || !response.body) {
|
|
25
|
+
throw new Error('Expected an Automations notification stream');
|
|
26
|
+
}
|
|
27
|
+
reader = response.body.getReader();
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
let pending = '';
|
|
30
|
+
while (!signal.aborted) {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
timeout = setTimeout(() => connection.abort(new Error('Notification connection went silent')), 75_000);
|
|
33
|
+
timeout.unref();
|
|
34
|
+
const { value, done } = await reader.read();
|
|
35
|
+
if (done)
|
|
36
|
+
throw new Error('Automations notification connection closed');
|
|
37
|
+
pending += decoder.decode(value, { stream: true });
|
|
38
|
+
let boundary;
|
|
39
|
+
while ((boundary = /\r?\n\r?\n/.exec(pending))) {
|
|
40
|
+
const frame = pending.slice(0, boundary.index);
|
|
41
|
+
pending = pending.slice(boundary.index + boundary[0].length);
|
|
42
|
+
if (frame.length > 16_384)
|
|
43
|
+
throw new Error('Automations notification is too large');
|
|
44
|
+
if (/^event: ?(?:ready|wake)\r?$/m.test(frame))
|
|
45
|
+
yield;
|
|
46
|
+
}
|
|
47
|
+
if (pending.length > 16_384)
|
|
48
|
+
throw new Error('Automations notification is too large');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
signal.removeEventListener('abort', abort);
|
|
54
|
+
connection.abort();
|
|
55
|
+
await reader?.cancel().catch(() => { });
|
|
56
|
+
reader?.releaseLock();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AutomationMachinePrincipal, MachineRuns } from './machine.js';
|
|
2
|
+
import type { MachineRunNotifications } from './machine-notifications.js';
|
|
2
3
|
export declare function createMachineRunsApi(options: {
|
|
3
4
|
readonly authenticate: (request: Request) => Promise<AutomationMachinePrincipal>;
|
|
4
5
|
readonly runsFor: (principal: AutomationMachinePrincipal) => MachineRuns;
|
|
6
|
+
readonly notifications?: Pick<MachineRunNotifications, 'open'>;
|
|
5
7
|
}): (request: Request) => Promise<Response>;
|
package/dist/src/machine-http.js
CHANGED
|
@@ -7,7 +7,13 @@ export function createMachineRunsApi(options) {
|
|
|
7
7
|
if (parts[0] !== 'v1' || parts[1] !== 'machine' || parts[2] !== 'runs') {
|
|
8
8
|
return json(404, { error: 'Not found' });
|
|
9
9
|
}
|
|
10
|
-
const
|
|
10
|
+
const principal = await options.authenticate(request);
|
|
11
|
+
if (parts[3] === 'notifications' && parts.length === 4 && request.method === 'GET') {
|
|
12
|
+
if (!options.notifications)
|
|
13
|
+
throw new AutomationError('unavailable', 'Notifications unavailable', 503);
|
|
14
|
+
return options.notifications.open(principal, request.signal);
|
|
15
|
+
}
|
|
16
|
+
const runs = options.runsFor(principal);
|
|
11
17
|
if (parts[3] === 'claim' && parts.length === 4 && request.method === 'POST') {
|
|
12
18
|
return json(200, { runs: await runs.claim(await body(request)) });
|
|
13
19
|
}
|
|
@@ -18,7 +24,7 @@ export function createMachineRunsApi(options) {
|
|
|
18
24
|
}
|
|
19
25
|
catch (error) {
|
|
20
26
|
if (error instanceof AutomationError)
|
|
21
|
-
return json(error.code === 'validation' ? 400 : 403, {
|
|
27
|
+
return json(error.status ?? (error.code === 'validation' ? 400 : 403), {
|
|
22
28
|
error: error.message, code: error.code,
|
|
23
29
|
});
|
|
24
30
|
const code = error instanceof Error && 'code' in error ? String(error.code) : 'internal';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AutomationMachinePrincipal } from './machine.js';
|
|
2
|
+
/** Ephemeral wakeups only. Supabase remains the queue and the eligibility clock. */
|
|
3
|
+
export declare function createMachineRunNotifications(options: {
|
|
4
|
+
readonly wakeDelay: (userId: string, computerId: string) => Promise<number | null>;
|
|
5
|
+
readonly heartbeatMs?: number;
|
|
6
|
+
readonly maxStreamMs?: number;
|
|
7
|
+
readonly log?: (event: string, details: Readonly<Record<string, unknown>>) => void;
|
|
8
|
+
}): Readonly<{
|
|
9
|
+
connected(): void;
|
|
10
|
+
disconnected: () => void;
|
|
11
|
+
close: () => void;
|
|
12
|
+
changed(userId: string, computerId: string): void;
|
|
13
|
+
open(principal: AutomationMachinePrincipal, signal: AbortSignal): Response;
|
|
14
|
+
}>;
|
|
15
|
+
export type MachineRunNotifications = ReturnType<typeof createMachineRunNotifications>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { AutomationError } from './errors.js';
|
|
2
|
+
/** Ephemeral wakeups only. Supabase remains the queue and the eligibility clock. */
|
|
3
|
+
export function createMachineRunNotifications(options) {
|
|
4
|
+
const targets = new Map();
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
let connected = false;
|
|
7
|
+
const key = (userId, computerId) => JSON.stringify([userId, computerId]);
|
|
8
|
+
const refresh = async (target) => {
|
|
9
|
+
target.dirty = true;
|
|
10
|
+
if (target.refreshing)
|
|
11
|
+
return;
|
|
12
|
+
target.refreshing = true;
|
|
13
|
+
try {
|
|
14
|
+
while (target.dirty && target.listeners.size) {
|
|
15
|
+
target.dirty = false;
|
|
16
|
+
clearTimeout(target.timer);
|
|
17
|
+
const delay = await options.wakeDelay(target.userId, target.computerId);
|
|
18
|
+
if (target.dirty || !target.listeners.size)
|
|
19
|
+
continue;
|
|
20
|
+
if (delay === null)
|
|
21
|
+
continue;
|
|
22
|
+
if (!Number.isFinite(delay) || delay < 0)
|
|
23
|
+
throw new Error('Invalid machine wake delay');
|
|
24
|
+
if (delay === 0) {
|
|
25
|
+
for (const listener of target.listeners)
|
|
26
|
+
listener.send('wake');
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
target.timer = setTimeout(() => void refresh(target), Math.min(delay, 2_147_483_647));
|
|
30
|
+
target.timer.unref();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
options.log?.('automations.notifications.failed', { boundary: 'wake-deadline' });
|
|
36
|
+
for (const listener of [...target.listeners])
|
|
37
|
+
listener.close();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
target.refreshing = false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const disconnect = () => {
|
|
44
|
+
connected = false;
|
|
45
|
+
for (const target of targets.values()) {
|
|
46
|
+
for (const listener of [...target.listeners])
|
|
47
|
+
listener.close();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
connected() { connected = true; },
|
|
52
|
+
disconnected: disconnect,
|
|
53
|
+
close: disconnect,
|
|
54
|
+
changed(userId, computerId) {
|
|
55
|
+
const target = targets.get(key(userId, computerId));
|
|
56
|
+
if (target)
|
|
57
|
+
void refresh(target);
|
|
58
|
+
},
|
|
59
|
+
open(principal, signal) {
|
|
60
|
+
if (!connected)
|
|
61
|
+
throw new AutomationError('unavailable', 'Notifications are reconnecting', 503);
|
|
62
|
+
signal.throwIfAborted();
|
|
63
|
+
const targetKey = key(principal.userId, principal.computerId);
|
|
64
|
+
let target = targets.get(targetKey);
|
|
65
|
+
if (!target) {
|
|
66
|
+
target = { ...principal, listeners: new Set(), refreshing: false, dirty: false };
|
|
67
|
+
targets.set(targetKey, target);
|
|
68
|
+
}
|
|
69
|
+
const current = target;
|
|
70
|
+
let close = (_closeStream = true) => { };
|
|
71
|
+
const body = new ReadableStream({
|
|
72
|
+
start(controller) {
|
|
73
|
+
let closed = false;
|
|
74
|
+
let heartbeat;
|
|
75
|
+
let expiry;
|
|
76
|
+
const abort = () => close();
|
|
77
|
+
close = (closeStream = true) => {
|
|
78
|
+
if (closed)
|
|
79
|
+
return;
|
|
80
|
+
closed = true;
|
|
81
|
+
clearInterval(heartbeat);
|
|
82
|
+
clearTimeout(expiry);
|
|
83
|
+
signal.removeEventListener('abort', abort);
|
|
84
|
+
current.listeners.delete(listener);
|
|
85
|
+
if (!current.listeners.size) {
|
|
86
|
+
clearTimeout(current.timer);
|
|
87
|
+
targets.delete(targetKey);
|
|
88
|
+
}
|
|
89
|
+
if (closeStream)
|
|
90
|
+
controller.close();
|
|
91
|
+
};
|
|
92
|
+
const listener = {
|
|
93
|
+
send(event) {
|
|
94
|
+
if (closed)
|
|
95
|
+
return;
|
|
96
|
+
if ((controller.desiredSize ?? 0) <= 0)
|
|
97
|
+
return close();
|
|
98
|
+
controller.enqueue(encoder.encode(event === 'heartbeat'
|
|
99
|
+
? ': keepalive\n\n' : `event: ${event}\ndata: {}\n\n`));
|
|
100
|
+
},
|
|
101
|
+
close,
|
|
102
|
+
};
|
|
103
|
+
current.listeners.add(listener);
|
|
104
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
105
|
+
heartbeat = setInterval(() => listener.send('heartbeat'), options.heartbeatMs ?? 30_000);
|
|
106
|
+
heartbeat.unref();
|
|
107
|
+
const lifetime = Math.min(options.maxStreamMs ?? 300_000, (principal.authorizationExpiresAt ?? Infinity) - Date.now());
|
|
108
|
+
expiry = setTimeout(close, Math.max(0, lifetime));
|
|
109
|
+
expiry.unref();
|
|
110
|
+
listener.send('ready');
|
|
111
|
+
void refresh(current);
|
|
112
|
+
},
|
|
113
|
+
cancel() { close(false); },
|
|
114
|
+
}, { highWaterMark: 8 });
|
|
115
|
+
return new Response(body, {
|
|
116
|
+
headers: {
|
|
117
|
+
'content-type': 'text/event-stream',
|
|
118
|
+
'cache-control': 'no-store, no-transform',
|
|
119
|
+
'x-accel-buffering': 'no',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
package/dist/src/machine.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface AutomationMachinePrincipal {
|
|
|
4
4
|
readonly userId: string;
|
|
5
5
|
readonly computerId: string;
|
|
6
6
|
readonly scopes: readonly AutomationScope[];
|
|
7
|
+
readonly authorizationExpiresAt?: number;
|
|
7
8
|
}
|
|
8
9
|
export interface ClaimedAutomationRun extends AutomationRun {
|
|
9
10
|
readonly leaseToken: string;
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { ClaimedAutomationRun, MachineRuns } from './machine.js';
|
|
2
2
|
export interface AutomationMachineRunnerOptions {
|
|
3
3
|
readonly runs: MachineRuns;
|
|
4
|
+
readonly notifications: (signal: AbortSignal) => AsyncIterable<void>;
|
|
4
5
|
readonly execute: (run: ClaimedAutomationRun) => Promise<void>;
|
|
5
6
|
readonly batchSize?: number;
|
|
6
7
|
readonly leaseSeconds?: number;
|
|
7
|
-
readonly
|
|
8
|
+
readonly reconnectDelayMs?: number;
|
|
8
9
|
readonly maxBackoffMs?: number;
|
|
9
10
|
readonly log?: (event: string, details?: Readonly<Record<string, unknown>>) => void;
|
|
10
11
|
}
|
|
12
|
+
/** Listen first, then drain. There is no timer that asks an idle machine for work. */
|
|
11
13
|
export declare function createAutomationMachineRunner(options: AutomationMachineRunnerOptions): Readonly<{
|
|
12
14
|
start(): void;
|
|
13
15
|
close(): Promise<void>;
|