@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.
Files changed (34) hide show
  1. package/AXIOMS.md +11 -0
  2. package/PURPOSE.md +18 -0
  3. package/README.md +34 -0
  4. package/dist/host/auth.js +4 -1
  5. package/dist/host/main.js +9 -1
  6. package/dist/host/notifications.d.ts +4 -0
  7. package/dist/host/notifications.js +19 -0
  8. package/dist/host/server.js +46 -7
  9. package/dist/src/cli/run.d.ts +1 -1
  10. package/dist/src/cli/run.js +12 -0
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/index.js +1 -0
  13. package/dist/src/machine-client.d.ts +3 -1
  14. package/dist/src/machine-client.js +6 -0
  15. package/dist/src/machine-event-stream.d.ts +8 -0
  16. package/dist/src/machine-event-stream.js +58 -0
  17. package/dist/src/machine-http.d.ts +2 -0
  18. package/dist/src/machine-http.js +8 -2
  19. package/dist/src/machine-notifications.d.ts +15 -0
  20. package/dist/src/machine-notifications.js +124 -0
  21. package/dist/src/machine.d.ts +1 -0
  22. package/dist/src/runner.d.ts +3 -1
  23. package/dist/src/runner.js +69 -46
  24. package/dist/src/supabase-machine.d.ts +1 -0
  25. package/dist/src/supabase-machine.js +3 -0
  26. package/package.json +2 -1
  27. package/skills/amalgm-automations/SKILL.md +148 -0
  28. package/skills/amalgm-automations/agents/openai.yaml +4 -0
  29. package/skills/amalgm-automations/references/command-contract.md +173 -0
  30. package/skills/amalgm-automations/references/setup-and-support.md +195 -0
  31. package/skills/amalgm-automations/references/workflow-plans.md +152 -0
  32. package/supabase/migrations/20260904020000_machine_run_notifications.sql +52 -0
  33. package/supabase/migrations/20260904030000_machine_notification_permissions.sql +4 -0
  34. 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
+ }
@@ -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.end(Buffer.from(await source.arrayBuffer()));
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);
@@ -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 {};
@@ -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:
@@ -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>;
@@ -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 runs = options.runsFor(await options.authenticate(request));
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
+ }
@@ -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;
@@ -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 pollIntervalMs?: number;
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>;