@amalgm/automations 0.3.2 → 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 +33 -1
- 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 +42 -7
- package/skills/amalgm-automations/agents/openai.yaml +2 -2
- package/skills/amalgm-automations/references/setup-and-support.md +195 -0
- package/supabase/migrations/20260904020000_machine_run_notifications.sql +52 -0
- package/supabase/migrations/20260904030000_machine_notification_permissions.sql +4 -0
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
|
@@ -47,7 +47,10 @@ the API request.
|
|
|
47
47
|
- `@amalgm/automations/host`: standalone Fly service composition.
|
|
48
48
|
- `amalgm-automations`: CLI adapter.
|
|
49
49
|
- `skills/amalgm-automations`: portable agent skill for operating either the
|
|
50
|
-
MCP or global CLI surface
|
|
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.
|
|
51
54
|
|
|
52
55
|
## Agent CLI
|
|
53
56
|
|
|
@@ -200,6 +203,7 @@ The machine execution API is:
|
|
|
200
203
|
```text
|
|
201
204
|
POST /v1/machine/runs/claim
|
|
202
205
|
PATCH /v1/machine/runs/:runId
|
|
206
|
+
GET /v1/machine/runs/notifications
|
|
203
207
|
```
|
|
204
208
|
|
|
205
209
|
Public webhook admission uses the URL returned on each webhook trigger:
|
|
@@ -218,13 +222,41 @@ The target is never accepted in a machine request. It comes from the verified
|
|
|
218
222
|
access token. Run leases expire and are safely reclaimable; terminal updates
|
|
219
223
|
must present the active lease token.
|
|
220
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
|
+
|
|
221
245
|
## Verification
|
|
222
246
|
|
|
223
247
|
```bash
|
|
224
248
|
npm run verify
|
|
225
249
|
TEST_DATABASE_URL=postgres://... npm run test:supabase
|
|
250
|
+
npm run test:notifications
|
|
226
251
|
npm pack --dry-run
|
|
227
252
|
```
|
|
228
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
|
+
|
|
229
261
|
See [PURPOSE.md](./PURPOSE.md) and [AXIOMS.md](./AXIOMS.md) for the governing
|
|
230
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>;
|
package/dist/src/runner.js
CHANGED
|
@@ -1,61 +1,84 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
/** Listen first, then drain. There is no timer that asks an idle machine for work. */
|
|
1
3
|
export function createAutomationMachineRunner(options) {
|
|
2
4
|
const batchSize = integer(options.batchSize ?? 8, 1, 20, 'batchSize');
|
|
3
5
|
const leaseSeconds = integer(options.leaseSeconds ?? 60, 30, 900, 'leaseSeconds');
|
|
4
|
-
const
|
|
5
|
-
const maxBackoffMs = integer(options.maxBackoffMs ??
|
|
6
|
-
let
|
|
7
|
-
let
|
|
8
|
-
let
|
|
9
|
-
let
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (
|
|
6
|
+
const reconnectDelayMs = integer(options.reconnectDelayMs ?? 1_000, 1, 60_000, 'reconnectDelayMs');
|
|
7
|
+
const maxBackoffMs = integer(options.maxBackoffMs ?? 30_000, reconnectDelayMs, 300_000, 'maxBackoffMs');
|
|
8
|
+
let lifetime = null;
|
|
9
|
+
let listening = null;
|
|
10
|
+
let draining = null;
|
|
11
|
+
let pending = false;
|
|
12
|
+
const wake = (connection, signal) => {
|
|
13
|
+
pending = true;
|
|
14
|
+
if (draining)
|
|
13
15
|
return;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
options.log?.('automations.claimed', { count: runs.length });
|
|
25
|
-
const settled = await Promise.allSettled(runs.map(options.execute));
|
|
26
|
-
settled.forEach((result) => {
|
|
27
|
-
if (result.status === 'rejected') {
|
|
28
|
-
options.log?.('automations.run.failed', { error: safe(result.reason) });
|
|
16
|
+
draining = (async () => {
|
|
17
|
+
while (pending && !signal.aborted) {
|
|
18
|
+
pending = false;
|
|
19
|
+
const runs = await options.runs.claim({ limit: batchSize, leaseSeconds });
|
|
20
|
+
if (runs.length)
|
|
21
|
+
options.log?.('automations.claimed', { count: runs.length });
|
|
22
|
+
const settled = await Promise.allSettled(runs.map(options.execute));
|
|
23
|
+
for (const result of settled) {
|
|
24
|
+
if (result.status === 'rejected')
|
|
25
|
+
options.log?.('automations.run.failed', { error: safe(result.reason) });
|
|
29
26
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
active = null;
|
|
41
|
-
schedule(nextDelay);
|
|
27
|
+
// Work may arrive while execution is busy, including a partial last batch.
|
|
28
|
+
pending ||= runs.length > 0;
|
|
29
|
+
}
|
|
30
|
+
})().catch((error) => {
|
|
31
|
+
options.log?.('automations.claim.failed', { error: safe(error) });
|
|
32
|
+
connection.abort(error);
|
|
33
|
+
}).finally(() => {
|
|
34
|
+
draining = null;
|
|
35
|
+
if (pending && !signal.aborted && !connection.signal.aborted)
|
|
36
|
+
wake(connection, signal);
|
|
42
37
|
});
|
|
43
|
-
|
|
38
|
+
};
|
|
39
|
+
const listen = async (signal) => {
|
|
40
|
+
let failures = 0;
|
|
41
|
+
while (!signal.aborted) {
|
|
42
|
+
const connection = new AbortController();
|
|
43
|
+
const connectedSignal = AbortSignal.any([signal, connection.signal]);
|
|
44
|
+
const startedAt = Date.now();
|
|
45
|
+
try {
|
|
46
|
+
for await (const _ of options.notifications(connectedSignal)) {
|
|
47
|
+
if (connectedSignal.aborted)
|
|
48
|
+
break;
|
|
49
|
+
wake(connection, signal);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (!signal.aborted)
|
|
54
|
+
options.log?.('automations.notifications.disconnected', { error: safe(error) });
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
connection.abort();
|
|
58
|
+
await draining;
|
|
59
|
+
}
|
|
60
|
+
if (signal.aborted)
|
|
61
|
+
break;
|
|
62
|
+
// A flapping stream must not reset its backoff merely by sending ready.
|
|
63
|
+
failures = Date.now() - startedAt >= 30_000 ? 0 : failures + 1;
|
|
64
|
+
const backoff = Math.min(maxBackoffMs, reconnectDelayMs * 2 ** Math.min(Math.max(0, failures - 1), 10));
|
|
65
|
+
await delay(backoff, undefined, { signal, ref: false }).catch(() => { });
|
|
66
|
+
}
|
|
44
67
|
};
|
|
45
68
|
return Object.freeze({
|
|
46
69
|
start() {
|
|
47
|
-
if (
|
|
70
|
+
if (lifetime)
|
|
48
71
|
return;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
lifetime = new AbortController();
|
|
73
|
+
pending = false;
|
|
74
|
+
listening = listen(lifetime.signal);
|
|
52
75
|
},
|
|
53
76
|
async close() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
lifetime?.abort();
|
|
78
|
+
await listening;
|
|
79
|
+
await draining;
|
|
80
|
+
lifetime = null;
|
|
81
|
+
listening = null;
|
|
59
82
|
},
|
|
60
83
|
});
|
|
61
84
|
}
|
|
@@ -11,6 +11,7 @@ export declare class SupabaseMachineRunRepository implements MachineRunRepositor
|
|
|
11
11
|
#private;
|
|
12
12
|
private readonly client;
|
|
13
13
|
constructor(client: MachineRpcClient);
|
|
14
|
+
wakeDelay(userId: string, computerId: string): Promise<number | null>;
|
|
14
15
|
claim(input: Parameters<MachineRunRepository['claim']>[0]): Promise<ClaimedAutomationRun[]>;
|
|
15
16
|
update(input: Parameters<MachineRunRepository['update']>[0]): Promise<ClaimedAutomationRun | null>;
|
|
16
17
|
}
|
|
@@ -4,6 +4,9 @@ export class SupabaseMachineRunRepository {
|
|
|
4
4
|
constructor(client) {
|
|
5
5
|
this.client = client;
|
|
6
6
|
}
|
|
7
|
+
wakeDelay(userId, computerId) {
|
|
8
|
+
return this.#call('amalgm_machine_wake_delay', { p_user_id: userId, p_target_id: computerId });
|
|
9
|
+
}
|
|
7
10
|
async claim(input) {
|
|
8
11
|
const rows = await this.#call('claim_amalgm_machine_runs', {
|
|
9
12
|
p_user_id: input.userId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amalgm/automations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Amalgm's automation SDK: durable trigger admission and target-machine execution.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"check": "tsx scripts/check-tree.ts && tsc -p tsconfig.json --noEmit",
|
|
50
50
|
"test": "tsx --test test/*.test.ts",
|
|
51
51
|
"test:supabase": "tsx --test test/integration/postgres.test.ts",
|
|
52
|
+
"test:notifications": "tsx scripts/test-notifications.ts",
|
|
52
53
|
"verify": "npm run check && npm test && npm run build && tsx scripts/check-cli-artifact.ts",
|
|
53
54
|
"release:check": "npm run verify && npm run test:supabase",
|
|
54
55
|
"prepack": "npm run build",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: amalgm-automations
|
|
3
|
-
description:
|
|
3
|
+
description: Set up, operate, and troubleshoot Amalgm Automations through MCP or the amalgm automations CLI. Use for installation, Google sign-in, scheduled or webhook workflows, Run Now, run history, and Automations support.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Amalgm Automations
|
|
@@ -10,6 +10,10 @@ workflow into one durable Amalgm automation and verify what actually ran.
|
|
|
10
10
|
Automations owns configuration and permanent run history; the selected Amalgm
|
|
11
11
|
machine executes the workflow.
|
|
12
12
|
|
|
13
|
+
Amalgm Automations is free. Guide the person through Google sign-in and computer
|
|
14
|
+
approval when needed; do not introduce a payment step. A native agent or other
|
|
15
|
+
external service still uses its own account and may have its own usage costs.
|
|
16
|
+
|
|
13
17
|
## Choose the available adapter
|
|
14
18
|
|
|
15
19
|
Prefer the six `amalgm_automations_*` MCP tools when they are callable. In a
|
|
@@ -28,11 +32,22 @@ amalgm_automations_run_now amalgm automations run-now
|
|
|
28
32
|
Both adapters accept the same JSON object and call the same SDK command
|
|
29
33
|
surface. Do not translate one into a different resource-level API.
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
Reuse a working connection. For an available MCP connection, a small `list`
|
|
36
|
+
request with `{"limit":1}` proves access; do not install or log in again. For
|
|
37
|
+
CLI use, check `amalgm automations --help` and use
|
|
38
|
+
`amalgm automations list --input '{"limit":1}'`. An empty successful list is
|
|
39
|
+
ready, not a setup failure. Respect an explicitly selected account.
|
|
40
|
+
|
|
41
|
+
If the user asks to install or sign in, or that read fails, read
|
|
42
|
+
[setup and support](references/setup-and-support.md). It covers the supported
|
|
43
|
+
install, browser approval, account selection, runtime restart, and recovery
|
|
44
|
+
commands. Carry setup through to a successful Automations read, then resume
|
|
45
|
+
the original request. Do not stop at “Shell must be running.”
|
|
46
|
+
|
|
47
|
+
Shell owns authentication. Never read, copy, or ask the user to supply a runtime
|
|
48
|
+
token, DPoP proof, device key, or machine credential. The public connection
|
|
49
|
+
commands obtain what they need; environment-variable credential setup is not
|
|
50
|
+
the user login flow.
|
|
36
51
|
|
|
37
52
|
Read [references/command-contract.md](references/command-contract.md) whenever
|
|
38
53
|
composing `create` or `update`, or when exact fields are uncertain. Read
|
|
@@ -92,22 +107,42 @@ not evidence that work completed.
|
|
|
92
107
|
returned run id must remain the same.
|
|
93
108
|
3. Poll `get` with `include_runs: true` or a `run_query` until the admitted run
|
|
94
109
|
becomes `completed` or `failed`. Match the exact run id; do not assume the
|
|
95
|
-
newest unrelated run is yours.
|
|
110
|
+
newest unrelated run is yours. Space out reads and back off while state is
|
|
111
|
+
unchanged; a rate-limit response is a reason to wait, not to submit again.
|
|
96
112
|
4. Inspect every step's status and bounded output. Report failure honestly,
|
|
97
113
|
including the failing step and service error.
|
|
98
114
|
|
|
99
115
|
`pending` means durable and awaiting its machine. `sent` or `running` means the
|
|
100
116
|
machine holds a lease. Only `completed` and `failed` are terminal.
|
|
101
117
|
|
|
118
|
+
Keep the user informed while waiting. If the machine stays unavailable or the
|
|
119
|
+
current session cannot keep waiting, report the exact run id and observed
|
|
120
|
+
nonterminal state; do not claim completion, create a replacement, or promise
|
|
121
|
+
background monitoring that has not been set up.
|
|
122
|
+
|
|
102
123
|
Diagnose at the failing boundary. Examples: `process_execution_unavailable`
|
|
103
124
|
means that machine lacks the process host; an executable-not-found error is a
|
|
104
125
|
machine PATH/install problem; `Installed agent has no model` is agent
|
|
105
126
|
configuration; an action-specific HTTP error belongs to that action service.
|
|
106
127
|
Do not relabel these as Run Now failures or patch the automation around them.
|
|
107
128
|
|
|
129
|
+
## Help when something goes wrong
|
|
130
|
+
|
|
131
|
+
Use [setup and support](references/setup-and-support.md) for login/runtime
|
|
132
|
+
failures, HTTP 429 or reported rate limits, and contacting support. Preserve
|
|
133
|
+
the actual error code, distinguish the failing service, and use bounded retries.
|
|
134
|
+
Do not invent minute/hour allowances or reset times that the service has not
|
|
135
|
+
reported.
|
|
136
|
+
|
|
137
|
+
For an unresolved issue, offer **Email Aayush at aayush@amalgm.ai** and prepare
|
|
138
|
+
a concise, redacted draft when helpful. Send only with the user's explicit
|
|
139
|
+
authorization and an available email tool; otherwise give them the draft.
|
|
140
|
+
|
|
108
141
|
## Report the outcome
|
|
109
142
|
|
|
110
143
|
Return the automation id, target choice if the user selected one, trigger
|
|
111
144
|
summary, workflow lanes, and—when execution was requested—the exact run id and
|
|
112
145
|
terminal result. Mention that run history is durable. Never claim a schedule
|
|
113
146
|
fired merely because configuration creation succeeded.
|
|
147
|
+
Include automation/run links when returned by the product; do not invent a
|
|
148
|
+
deep link or use a private webhook URL as a viewing link.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "Amalgm Automations"
|
|
3
|
-
short_description: "
|
|
4
|
-
default_prompt: "Use $amalgm-automations to create and verify an automation
|
|
3
|
+
short_description: "Set up, run, and troubleshoot Amalgm automations"
|
|
4
|
+
default_prompt: "Use $amalgm-automations to help me connect my account and create and verify an automation."
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Setup and support
|
|
2
|
+
|
|
3
|
+
Read this when installing, signing in, repairing a connection, or helping with
|
|
4
|
+
an error. Use Shell's commands for setup and Automations reads to verify access.
|
|
5
|
+
The agent handles the commands; the person chooses their Google account and
|
|
6
|
+
approves connecting their computer in the browser.
|
|
7
|
+
|
|
8
|
+
## Start with the connection you have
|
|
9
|
+
|
|
10
|
+
If Automations MCP already answers `list` with `{"limit":1}`, continue the task.
|
|
11
|
+
Do not require a local CLI for a working remote MCP connection. If that remote
|
|
12
|
+
connection requires authorization, use its actual sign-in/reconnect link. A
|
|
13
|
+
cloud agent cannot repair the user's computer by installing Shell on its own
|
|
14
|
+
temporary machine.
|
|
15
|
+
|
|
16
|
+
For local CLI work, use the existing `amalgm` executable or the exact Shell
|
|
17
|
+
launcher supplied by Amalgm's installer/app. The examples below use `amalgm`
|
|
18
|
+
as shorthand for that executable. Keep the same installation and account
|
|
19
|
+
throughout setup and verification.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
amalgm --version
|
|
23
|
+
amalgm --help
|
|
24
|
+
amalgm automations --help
|
|
25
|
+
amalgm automations list --input '{"limit":1}'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
When the user has chosen an account, add `--user` with that actual email to
|
|
29
|
+
Shell commands, including `amalgm automations ...`. An email selects a local
|
|
30
|
+
account; it does not authenticate it. `targetId` selects an execution computer,
|
|
31
|
+
not an account. Never substitute one for the other.
|
|
32
|
+
|
|
33
|
+
A successful Automations response is a JSON `result` envelope; an empty list
|
|
34
|
+
is valid. The six Automations commands use that envelope. Shell's `status`
|
|
35
|
+
returns a different JSON shape; `login` and `run` report progress as text.
|
|
36
|
+
|
|
37
|
+
## Install only when needed
|
|
38
|
+
|
|
39
|
+
Check for an existing app-managed installation before adding another copy.
|
|
40
|
+
If the desktop app already manages this computer, use its setup/connection
|
|
41
|
+
flow. A missing PATH entry is not proof that Amalgm is uninstalled.
|
|
42
|
+
|
|
43
|
+
On a fresh computer with Node.js and npm, the Shell package supplies the
|
|
44
|
+
global command. Check the published package's requirements before installing:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
node --version
|
|
48
|
+
npm view @amalgm/shell version engines --json
|
|
49
|
+
npm install --global @amalgm/shell
|
|
50
|
+
amalgm --version
|
|
51
|
+
amalgm automations --help
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Do not install `@amalgm/automations` alone as a substitute for Shell: its direct
|
|
55
|
+
CLI/MCP executables do not perform the user's Shell login. Do not change an
|
|
56
|
+
existing app's exact runtime binding or install a second runner to fix a
|
|
57
|
+
version mismatch. If the installed Shell lacks `automations`, report that
|
|
58
|
+
version and use its supported update path; do not invent `amalgm update`.
|
|
59
|
+
|
|
60
|
+
If Node/npm is unavailable or global installation is denied, use
|
|
61
|
+
[Amalgm computer setup](https://amalgm.ai/setup). The person signs in with
|
|
62
|
+
Google and the page supplies the current platform-specific install command.
|
|
63
|
+
That installer includes a private Node runtime and a one-time setup code.
|
|
64
|
+
Execute the command supplied by the trusted setup page when the user has
|
|
65
|
+
authorized connecting this computer. Do not invent a code, run a code-less
|
|
66
|
+
`install.sh`, or use sudo as an automatic workaround. Keep the code private
|
|
67
|
+
to this setup session.
|
|
68
|
+
|
|
69
|
+
The web installer places Shell under
|
|
70
|
+
`~/.amalgm/runtime-packages/shell/<installed-version>/bin/amalgm` on macOS/Linux.
|
|
71
|
+
If it is absent from PATH, use the exact launcher/version identified by the
|
|
72
|
+
installer or app, not whichever version directory happens to sort last.
|
|
73
|
+
|
|
74
|
+
## Sign in and connect the computer
|
|
75
|
+
|
|
76
|
+
For a first login through the CLI:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
amalgm login --no-open
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Run it in a persistent terminal/process session. Show the **verification URL
|
|
83
|
+
printed by that process** to the user as a clickable link; do not construct
|
|
84
|
+
the URL yourself. Omit `--no-open` only when the command should open the browser
|
|
85
|
+
on the same computer. The approval link and setup code are short-lived setup
|
|
86
|
+
information, not material for logs or support emails.
|
|
87
|
+
|
|
88
|
+
Tell the person: “Continue with Google, choose your Amalgm account, and approve
|
|
89
|
+
connecting this computer. I'll check the connection and continue afterward.”
|
|
90
|
+
Show the computer name when known. The browser owns sign-in and consent; do
|
|
91
|
+
not ask for passwords, provider tokens, or a credit card. Existing accounts
|
|
92
|
+
can use their existing supported sign-in method rather than creating a second
|
|
93
|
+
account just to use Google.
|
|
94
|
+
|
|
95
|
+
Keep that process alive while the person approves. Follow its progress until
|
|
96
|
+
`AMALGM_READY` appears or it reports an error. `login` continues hosting the
|
|
97
|
+
runtime after readiness; it is not expected to exit successfully. Do not kill
|
|
98
|
+
it after approval or use `--exit-after-ready` for real setup.
|
|
99
|
+
|
|
100
|
+
An already approved, unexpired code can be passed to
|
|
101
|
+
`amalgm login --setup-code CODE` if the original waiting process ended. Use
|
|
102
|
+
only the actual code from this setup attempt. If approval expired, finish the
|
|
103
|
+
old attempt and start a fresh login. If the person cancels, stop that attempt;
|
|
104
|
+
do not reopen approval until they choose to continue.
|
|
105
|
+
|
|
106
|
+
## Reuse or resume a registered computer
|
|
107
|
+
|
|
108
|
+
Once the account email is known, inspect it explicitly. Replace the example
|
|
109
|
+
email below with the user's actual selected account:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
amalgm status --user person@example.com
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Observed state | Next action |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `registration: "ready"`, `running: true` | Reuse this runtime; verify Automations access. |
|
|
118
|
+
| `registration: "ready"`, `running: false` | Run `amalgm run --user person@example.com` in a persistent session, then verify. |
|
|
119
|
+
| `registration: "absent"` or `"incomplete"` | Complete the supported login/setup flow for this account; preserve existing registration files. |
|
|
120
|
+
| Shell requests `--user` or lists several accounts | Ask which account to use if it is not already established. Supply that email consistently. |
|
|
121
|
+
| Status fails | Diagnose the actual error; a failed read is not proof that registration is absent. |
|
|
122
|
+
|
|
123
|
+
After login or resume, require both ready/running status and a successful read:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
amalgm automations list --user person@example.com --input '{"limit":1}'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Then return to the user's original automation request. This proves connection
|
|
130
|
+
and access, not that a future workflow ran or that every executable is installed.
|
|
131
|
+
MCP results can independently prove hosted access, but do not prove the selected
|
|
132
|
+
execution computer is online.
|
|
133
|
+
|
|
134
|
+
Use an existing app/installer-managed background runtime when possible. A
|
|
135
|
+
foreground `login`/`run` process must remain alive for local execution; do not
|
|
136
|
+
promise it survives terminal closure, logout, or reboot without evidence. If
|
|
137
|
+
the agent's process sessions cannot persist, use the app/setup-managed path or
|
|
138
|
+
explain the remaining requirement. An offline target leaves admitted runs
|
|
139
|
+
pending; it does not erase their history.
|
|
140
|
+
|
|
141
|
+
## Diagnose the failing boundary
|
|
142
|
+
|
|
143
|
+
Read the error's code, message, and status together; some Shell failures are
|
|
144
|
+
wrapped as `internal`. Keep automation and run ids when available. Try the
|
|
145
|
+
appropriate repair and verify again. If the same failure persists with no new
|
|
146
|
+
evidence, explain it and offer support rather than looping through login,
|
|
147
|
+
reinstallation, or repeated writes.
|
|
148
|
+
|
|
149
|
+
| Failure | What to do |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `runtime_unavailable`, connection refused, or Shell says it is not running | Inspect the selected account's status and use the resume/setup decision above. A standalone adapter asking for environment credentials should be replaced by the global `amalgm automations` path. |
|
|
152
|
+
| `runtime_unauthorized` | Retry the read once through the public Shell command, which obtains the current local connection. If it still fails, keep the error for support; never extract or replace tokens manually. |
|
|
153
|
+
| `runtime_transport` or `runtime_protocol` | Check local readiness, connectivity, and the installed version. A timeout or service outage is not evidence that the user needs a new account. |
|
|
154
|
+
| Wrong account, `forbidden`, or a target authorization error | Verify the chosen account and computer. Do not change ownership, guess another target, or treat every 403 as an expired login. |
|
|
155
|
+
| Missing/corrupt device key or an explicit recovery-required error | Explain key recovery and use `amalgm auth recover --user person@example.com` only when the user chooses recovery. It requires its own browser approval. Never delete identity files or silently re-register. After recovery, inspect status and resume if needed. |
|
|
156
|
+
| HTTP 429 or a reported rate limit | Honor a supplied retry delay/reset time and reduce request frequency. Limits are per minute/hour; do not invent numeric allowances or a reset time. If no delay is supplied, back off conservatively with a bounded retry, then report the unresolved limit. Never switch accounts to bypass it. |
|
|
157
|
+
| An installed native agent needs login, or its model/provider rejects work | Use that program/service's own supported login. Amalgm Google sign-in does not sign into Codex, Claude, or other providers. Do not add Amalgm credits as a guessed fix. |
|
|
158
|
+
| Missing executable, failed step, or invalid workflow | Read [workflow diagnoses](workflow-plans.md#common-boundary-diagnoses); repair the actual step/configuration without replacing the automation to hide the failure. |
|
|
159
|
+
|
|
160
|
+
For a failed mutation, inspect existing state before retrying. Reuse the same
|
|
161
|
+
Run Now input and idempotency key after an uncertain response. Preserve a
|
|
162
|
+
returned disabled draft id. A retry must not turn one requested automation or
|
|
163
|
+
run into several.
|
|
164
|
+
|
|
165
|
+
## Get support
|
|
166
|
+
|
|
167
|
+
Support is **Aayush, aayush@amalgm.ai**:
|
|
168
|
+
[Email Aayush](mailto:aayush@amalgm.ai?subject=Amalgm%20Automations%20help).
|
|
169
|
+
Offer this as soon as the user asks, or when the supported repair does not
|
|
170
|
+
resolve their problem. They do not need to complete a troubleshooting checklist
|
|
171
|
+
to contact a person.
|
|
172
|
+
|
|
173
|
+
Prepare a short email when helpful:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
To: aayush@amalgm.ai
|
|
177
|
+
Subject: Amalgm Automations — <short problem>
|
|
178
|
+
|
|
179
|
+
I was trying to: <intended result>
|
|
180
|
+
What happened: <failure and approximate time, including timezone>
|
|
181
|
+
Setup: <OS, agent/client, and Amalgm version if available>
|
|
182
|
+
Error: <code/status and a short redacted message>
|
|
183
|
+
Automation/run: <ids or viewing links, if available>
|
|
184
|
+
Already tried: <relevant checks or repair>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Use only relevant evidence. Do not attach raw runtime logs, browser cookies,
|
|
188
|
+
private auth files, setup links/codes, webhook URLs/signing secrets, or the
|
|
189
|
+
user's code, prompts, file paths, and workflow output without reviewing and
|
|
190
|
+
redacting them. Status output can contain private local paths; summarize its
|
|
191
|
+
registration/running fields instead of copying the entire object.
|
|
192
|
+
|
|
193
|
+
Send through an available email tool only if the user has explicitly authorized
|
|
194
|
+
the email; otherwise provide the draft and mail link. Do not invent a support
|
|
195
|
+
ticket, response-time promise, or claim that an email was sent.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- Commit the run and its private wakeup together. Every Fly host observes the
|
|
2
|
+
-- same change; local processes never subscribe with Supabase credentials.
|
|
3
|
+
CREATE OR REPLACE FUNCTION public.notify_amalgm_machine_run()
|
|
4
|
+
RETURNS trigger
|
|
5
|
+
LANGUAGE plpgsql
|
|
6
|
+
SECURITY DEFINER
|
|
7
|
+
SET search_path = ''
|
|
8
|
+
AS $$
|
|
9
|
+
BEGIN
|
|
10
|
+
IF TG_OP = 'UPDATE' AND
|
|
11
|
+
(OLD.status, OLD.retry_at, OLD.lease_expires_at) IS NOT DISTINCT FROM
|
|
12
|
+
(NEW.status, NEW.retry_at, NEW.lease_expires_at) THEN
|
|
13
|
+
RETURN NEW;
|
|
14
|
+
END IF;
|
|
15
|
+
PERFORM realtime.send(
|
|
16
|
+
jsonb_build_object('userId', NEW.user_id, 'targetId', NEW.target_id),
|
|
17
|
+
'changed', 'amalgm:automations:runs', true
|
|
18
|
+
);
|
|
19
|
+
RETURN NEW;
|
|
20
|
+
END;
|
|
21
|
+
$$;
|
|
22
|
+
|
|
23
|
+
REVOKE ALL ON FUNCTION public.notify_amalgm_machine_run() FROM PUBLIC;
|
|
24
|
+
CREATE TRIGGER amalgm_machine_run_notification
|
|
25
|
+
AFTER INSERT OR UPDATE ON public.amalgm_automation_runs
|
|
26
|
+
FOR EACH ROW EXECUTE FUNCTION public.notify_amalgm_machine_run();
|
|
27
|
+
|
|
28
|
+
-- Remain private even if a different product permits broad authenticated
|
|
29
|
+
-- Broadcast access. service_role is the only subscriber and bypasses RLS.
|
|
30
|
+
CREATE POLICY amalgm_run_notifications_service_only
|
|
31
|
+
ON realtime.messages AS RESTRICTIVE FOR ALL TO PUBLIC
|
|
32
|
+
USING (topic <> 'amalgm:automations:runs')
|
|
33
|
+
WITH CHECK (topic <> 'amalgm:automations:runs');
|
|
34
|
+
|
|
35
|
+
-- Use the database clock, exactly as claim_amalgm_machine_runs does. The host
|
|
36
|
+
-- sleeps until this deadline; no deadline means no work-check timer at all.
|
|
37
|
+
CREATE OR REPLACE FUNCTION public.amalgm_machine_wake_delay(p_user_id uuid, p_target_id text)
|
|
38
|
+
RETURNS double precision
|
|
39
|
+
LANGUAGE sql
|
|
40
|
+
SECURITY DEFINER
|
|
41
|
+
SET search_path = ''
|
|
42
|
+
AS $$
|
|
43
|
+
SELECT CASE WHEN count(*) = 0 THEN NULL ELSE
|
|
44
|
+
greatest(0, ceil(extract(epoch FROM (min(CASE WHEN status = 'pending'
|
|
45
|
+
THEN coalesce(retry_at, now()) ELSE coalesce(lease_expires_at, now()) END) - now())) * 1000))
|
|
46
|
+
END
|
|
47
|
+
FROM public.amalgm_automation_runs
|
|
48
|
+
WHERE user_id = p_user_id AND target_id = p_target_id
|
|
49
|
+
AND status IN ('pending', 'sent', 'running');
|
|
50
|
+
$$;
|
|
51
|
+
REVOKE ALL ON FUNCTION public.amalgm_machine_wake_delay(uuid, text) FROM PUBLIC;
|
|
52
|
+
GRANT EXECUTE ON FUNCTION public.amalgm_machine_wake_delay(uuid, text) TO service_role;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Supabase can grant EXECUTE directly through schema default privileges;
|
|
2
|
+
-- revoking PUBLIC alone does not remove those explicit grants.
|
|
3
|
+
REVOKE ALL ON FUNCTION public.notify_amalgm_machine_run() FROM anon, authenticated;
|
|
4
|
+
REVOKE ALL ON FUNCTION public.amalgm_machine_wake_delay(uuid, text) FROM anon, authenticated;
|