@amalgm/automations 0.2.1 → 0.2.2

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 (44) hide show
  1. package/AXIOMS.md +17 -1
  2. package/PURPOSE.md +11 -1
  3. package/README.md +5 -2
  4. package/dist/host/main.js +15 -4
  5. package/dist/host/server.d.ts +2 -0
  6. package/dist/host/server.js +25 -7
  7. package/dist/src/automations.d.ts +4 -7
  8. package/dist/src/automations.js +13 -65
  9. package/dist/src/client.d.ts +1 -0
  10. package/dist/src/client.js +3 -2
  11. package/dist/src/contract.d.ts +2 -29
  12. package/dist/src/events-http.js +4 -0
  13. package/dist/src/executor.d.ts +12 -3
  14. package/dist/src/executor.js +163 -44
  15. package/dist/src/http.js +4 -0
  16. package/dist/src/index.d.ts +4 -2
  17. package/dist/src/index.js +4 -2
  18. package/dist/src/machine-client.d.ts +1 -0
  19. package/dist/src/machine-client.js +2 -0
  20. package/dist/src/machine.d.ts +2 -1
  21. package/dist/src/machine.js +6 -1
  22. package/dist/src/mcp.d.ts +3 -0
  23. package/dist/src/mcp.js +53 -50
  24. package/dist/src/plan.d.ts +3 -0
  25. package/dist/src/plan.js +48 -0
  26. package/dist/src/run-contract.d.ts +46 -0
  27. package/dist/src/run-contract.js +1 -0
  28. package/dist/src/run-journal.d.ts +8 -0
  29. package/dist/src/run-journal.js +56 -0
  30. package/dist/src/runner.d.ts +14 -0
  31. package/dist/src/runner.js +70 -0
  32. package/dist/src/schema.d.ts +6 -6
  33. package/dist/src/schema.js +17 -2
  34. package/dist/src/supabase-crud/mappers.js +2 -0
  35. package/dist/src/supabase-crud/rows.d.ts +2 -0
  36. package/dist/src/supabase-machine.js +2 -0
  37. package/dist/src/supabase-store.d.ts +1 -4
  38. package/dist/src/supabase-store.js +0 -24
  39. package/dist/src/tool-surface.d.ts +46 -0
  40. package/dist/src/tool-surface.js +125 -0
  41. package/dist/src/types.d.ts +1 -16
  42. package/package.json +3 -3
  43. package/skills/automations/SKILL.md +20 -15
  44. package/supabase/migrations/20260830010000_durable_step_retries.sql +332 -0
package/AXIOMS.md CHANGED
@@ -49,8 +49,24 @@
49
49
  DPoP request URL behind the Fly proxy.
50
50
  20. Machine execution consumes the immutable workflow snapshot stored on the
51
51
  run. It never rediscovers or silently updates the automation definition.
52
- 21. A compiled workflow is a small declarative sequence of tool actions. The
52
+ 21. A compiled workflow is a small declarative sequence of one or more tool actions. The
53
53
  executor receives tool calling as a host capability and never embeds a
54
54
  Channels, Shell, CLI, or provider special case.
55
55
  22. Automations is a standalone hosted service. Gateway owns none of its API,
56
56
  scheduling, claim, execution, or persistence path.
57
+ 23. The durable run ledger is the offline queue. The platform never keeps a
58
+ second online-machine delivery buffer and never drops an unclaimed run.
59
+ 24. A run advances through its immutable plan in order. Every step transition
60
+ commits under the run's current lease before execution advances, and a
61
+ completed step is never intentionally invoked again.
62
+ 25. A machine may begin an action only after the service confirms its current
63
+ lease. It renews that lease while the action is running and stops advancing
64
+ when renewal fails.
65
+ 26. One run-step pair has one stable idempotency key. Step ids are unique in a
66
+ plan, and every action host receives that key and a cancellation signal.
67
+ 27. Only transient transport failures retry. They release the same run with a
68
+ bounded future retry time and retain completed step output; invalid plans,
69
+ missing actions, authorization failures, and other deterministic errors
70
+ are terminal.
71
+ 28. The selected target executes every action effect. The hosted service owns
72
+ only configuration, admission, leases, the step journal, and run history.
package/PURPOSE.md CHANGED
@@ -23,7 +23,17 @@ The product has two composable halves over that one state:
23
23
  write path. When a machine is offline, new runs remain pending; when it
24
24
  reconnects, Shell claims pending runs directly from the Automations service
25
25
  with its machine-bound DPoP identity and executes the persisted tool-action
26
- plan. Execution itself stays on the selected machine.
26
+ plan. Execution itself stays on the selected machine. The platform never
27
+ needs to know whether a machine is online: an unclaimed run is the complete
28
+ offline queue.
29
+
30
+ One run is one immutable plan plus one durable ordered step journal. The
31
+ machine records a step as running before invoking its action and records its
32
+ output before advancing. Reconnect resumes at the first step that is not
33
+ already complete. A transient network failure releases the same run back to
34
+ the queue with a bounded future retry time; a configuration, authorization, or
35
+ action error fails it. Stable per-step idempotency keys make an uncertain
36
+ network acknowledgement safe to repeat.
27
37
 
28
38
  Automations is its own hosted service and Fly machine. Its API and scheduler
29
39
  share the same SDK and Supabase authority; neither is composed into, proxied by,
package/README.md CHANGED
@@ -51,12 +51,15 @@ or webhook triggers and zero or one workflow. Supabase owns definitions,
51
51
  schedule clocks, immutable run snapshots, machine leases, and permanent run
52
52
  history. Webhook secrets are write-only.
53
53
 
54
- Compiled workflows use one small format:
54
+ Executable workflows use one small, non-empty format:
55
55
 
56
56
  ```ts
57
57
  type AutomationPlan = {
58
58
  version: 1;
59
- steps: Array<{ id: string; actionId: string; input: Json }>;
59
+ steps: [
60
+ { id: string; actionId: string; input: Json },
61
+ ...Array<{ id: string; actionId: string; input: Json }>,
62
+ ];
60
63
  };
61
64
  ```
62
65
 
package/dist/host/main.js CHANGED
@@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js';
2
2
  import { Automations } from '../src/automations.js';
3
3
  import { AutomationCrudService } from '../src/crud.js';
4
4
  import { createAutomationApi } from '../src/http.js';
5
+ import { createAutomationEventsApi } from '../src/events-http.js';
5
6
  import { createMachineRuns } from '../src/machine.js';
6
7
  import { createMachineRunsApi } from '../src/machine-http.js';
7
8
  import { SupabaseAutomationCrudRepository } from '../src/supabase-crud.js';
@@ -18,10 +19,7 @@ const authentication = createAutomationsAuthenticators({
18
19
  issuer: config.authorizationIssuer,
19
20
  supabase,
20
21
  });
21
- const delivery = new Automations(new SupabaseStore(supabase), {
22
- isOnline: () => false,
23
- send: async () => false,
24
- }, (event, details) => log(event, details));
22
+ const delivery = new Automations(new SupabaseStore(supabase), (event, details) => log(event, details));
25
23
  const controlApi = createAutomationApi({
26
24
  service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase)),
27
25
  authenticate: authentication.control,
@@ -31,10 +29,15 @@ const machineApi = createMachineRunsApi({
31
29
  authenticate: authentication.machine,
32
30
  runsFor: (principal) => createMachineRuns(machineRepository, principal),
33
31
  });
32
+ const eventsApi = createAutomationEventsApi({
33
+ delivery,
34
+ target: async (request) => eventTarget(request),
35
+ });
34
36
  const host = createAutomationsHost({
35
37
  publicOrigin: config.publicOrigin,
36
38
  controlApi,
37
39
  machineApi,
40
+ eventsApi,
38
41
  fireSchedules: () => delivery.fireDueCrons(),
39
42
  schedulerIntervalMs: config.schedulerIntervalMs,
40
43
  log,
@@ -46,3 +49,11 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
46
49
  function log(event, details = {}) {
47
50
  console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
48
51
  }
52
+ function eventTarget(request) {
53
+ const userId = request.headers.get('x-amalgm-user-id')?.trim() ?? '';
54
+ const targetId = request.headers.get('x-amalgm-target-id')?.trim() ?? '';
55
+ if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(userId) || !targetId) {
56
+ throw Object.assign(new Error('Webhook routing headers are required'), { status: 400 });
57
+ }
58
+ return { userId, targetId };
59
+ }
@@ -3,8 +3,10 @@ export declare function createAutomationsHost(options: {
3
3
  readonly publicOrigin: string;
4
4
  readonly controlApi: (request: Request) => Promise<Response>;
5
5
  readonly machineApi: (request: Request) => Promise<Response>;
6
+ readonly eventsApi?: (request: Request) => Promise<Response>;
6
7
  readonly fireSchedules: () => Promise<unknown>;
7
8
  readonly schedulerIntervalMs: number;
9
+ readonly maxRequestBodyBytes?: number;
8
10
  readonly log?: (event: string, details?: Readonly<Record<string, unknown>>) => void;
9
11
  }): {
10
12
  server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
@@ -17,14 +17,19 @@ export function createAutomationsHost(options) {
17
17
  try {
18
18
  if (incoming.url === '/healthz')
19
19
  return send(outgoing, Response.json({ ok: true }));
20
- const request = await webRequest(incoming, options.publicOrigin);
21
- const api = new URL(request.url).pathname.startsWith('/v1/machine/')
22
- ? options.machineApi : options.controlApi;
20
+ const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024);
21
+ const pathname = new URL(request.url).pathname;
22
+ const api = pathname === '/events' && options.eventsApi
23
+ ? options.eventsApi
24
+ : pathname.startsWith('/v1/machine/') ? options.machineApi : options.controlApi;
23
25
  await send(outgoing, await api(request));
24
26
  }
25
27
  catch (error) {
26
28
  log('request.failed', { error: safe(error) });
27
- await send(outgoing, Response.json({ error: 'Automations service failed' }, { status: 500 }));
29
+ const status = error instanceof HostRequestError ? error.status : 500;
30
+ await send(outgoing, Response.json({
31
+ error: status === 500 ? 'Automations service failed' : error instanceof Error ? error.message : String(error),
32
+ }, { status }));
28
33
  }
29
34
  });
30
35
  return {
@@ -36,10 +41,16 @@ export function createAutomationsHost(options) {
36
41
  },
37
42
  };
38
43
  }
39
- async function webRequest(request, publicOrigin) {
44
+ async function webRequest(request, publicOrigin, maximumBodyBytes) {
40
45
  const chunks = [];
41
- for await (const chunk of request)
42
- chunks.push(Buffer.from(chunk));
46
+ let size = 0;
47
+ for await (const value of request) {
48
+ const chunk = Buffer.from(value);
49
+ size += chunk.length;
50
+ if (size > maximumBodyBytes)
51
+ throw new HostRequestError(413, 'Request body is too large');
52
+ chunks.push(chunk);
53
+ }
43
54
  const body = Buffer.concat(chunks);
44
55
  return new Request(publicResourceRequestUrl(publicOrigin, request.url ?? '/'), {
45
56
  method: request.method ?? 'GET',
@@ -47,6 +58,13 @@ async function webRequest(request, publicOrigin) {
47
58
  ...(body.length ? { body } : {}),
48
59
  });
49
60
  }
61
+ class HostRequestError extends Error {
62
+ status;
63
+ constructor(status, message) {
64
+ super(message);
65
+ this.status = status;
66
+ }
67
+ }
50
68
  async function send(response, source) {
51
69
  response.writeHead(source.status, Object.fromEntries(source.headers));
52
70
  response.end(Buffer.from(await source.arrayBuffer()));
@@ -1,13 +1,13 @@
1
- import type { AutomationLog, AutomationRun, AutomationStore, AutomationTarget, AutomationTransport, Json, RunUpdate } from './types.js';
1
+ import type { AutomationLog, AutomationRun, AutomationStore, AutomationTarget, Json } from './types.js';
2
2
  export declare class EventRejectedError extends Error {
3
3
  constructor();
4
4
  }
5
+ /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
5
6
  export declare class Automations {
6
7
  #private;
7
8
  readonly store: AutomationStore;
8
- readonly transport: AutomationTransport;
9
9
  readonly log: AutomationLog;
10
- constructor(store: AutomationStore, transport: AutomationTransport, log?: AutomationLog);
10
+ constructor(store: AutomationStore, log?: AutomationLog);
11
11
  receiveEvent(input: {
12
12
  target: AutomationTarget;
13
13
  headers: Record<string, string>;
@@ -17,8 +17,5 @@ export declare class Automations {
17
17
  event?: string;
18
18
  now?: Date;
19
19
  }): Promise<AutomationRun[]>;
20
- fireDueCrons(now?: Date): Promise<AutomationRun[]>;
21
- targetOnline(target: AutomationTarget): Promise<number>;
22
- updateRun(target: AutomationTarget, runId: string, update: RunUpdate): Promise<AutomationRun | null>;
23
- drain(target: AutomationTarget): Promise<number>;
20
+ fireDueCrons(now?: Date, maximumRuns?: number): Promise<AutomationRun[]>;
24
21
  }
@@ -7,14 +7,12 @@ export class EventRejectedError extends Error {
7
7
  this.name = 'EventRejectedError';
8
8
  }
9
9
  }
10
+ /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
10
11
  export class Automations {
11
12
  store;
12
- transport;
13
13
  log;
14
- #drains = new Map();
15
- constructor(store, transport, log = noop) {
14
+ constructor(store, log = noop) {
16
15
  this.store = store;
17
- this.transport = transport;
18
16
  this.log = log;
19
17
  }
20
18
  async receiveEvent(input) {
@@ -39,15 +37,19 @@ export class Automations {
39
37
  const now = input.now || new Date();
40
38
  const runInput = { kind: 'event', ...reference, payload: input.payload };
41
39
  const runs = (await Promise.all(triggers.map((trigger) => (this.store.enqueueEvent(trigger, runInput, now))))).filter((run) => run !== null);
42
- await this.#drainCreated(runs);
40
+ this.#logPending(runs);
43
41
  return runs;
44
42
  }
45
- async fireDueCrons(now = new Date()) {
43
+ async fireDueCrons(now = new Date(), maximumRuns = 1_000) {
44
+ if (!Number.isInteger(maximumRuns) || maximumRuns < 1)
45
+ throw new Error('maximumRuns must be a positive integer');
46
46
  const created = [];
47
47
  for (const trigger of await this.store.dueCronTriggers(now)) {
48
48
  let scheduledFor = trigger.nextRunAt;
49
49
  let remaining = trigger.remainingOccurrences;
50
- while (new Date(scheduledFor) <= now && (remaining === undefined || remaining > 0)) {
50
+ while (created.length < maximumRuns
51
+ && new Date(scheduledFor) <= now
52
+ && (remaining === undefined || remaining > 0)) {
51
53
  const nextRunAt = nextCronAt(trigger.cron, trigger.timezone, scheduledFor);
52
54
  const run = await this.store.enqueueCron({ ...trigger, nextRunAt: scheduledFor }, nextRunAt, now);
53
55
  if (!run)
@@ -57,31 +59,13 @@ export class Automations {
57
59
  if (remaining !== undefined)
58
60
  remaining -= 1;
59
61
  }
62
+ if (created.length === maximumRuns)
63
+ break;
60
64
  }
61
- await this.#drainCreated(created);
65
+ this.#logPending(created);
62
66
  return created;
63
67
  }
64
- targetOnline(target) {
65
- return this.drain(target);
66
- }
67
- updateRun(target, runId, update) {
68
- if (update.output !== undefined)
69
- assertJson(update.output, 'Run output');
70
- return this.store.updateRun(target, runId, update);
71
- }
72
- drain(target) {
73
- const key = `${target.userId}:${target.targetId}`;
74
- const active = this.#drains.get(key);
75
- if (active)
76
- return active;
77
- const drain = this.#drain(target).finally(() => {
78
- if (this.#drains.get(key) === drain)
79
- this.#drains.delete(key);
80
- });
81
- this.#drains.set(key, drain);
82
- return drain;
83
- }
84
- async #drainCreated(runs) {
68
+ #logPending(runs) {
85
69
  for (const run of runs) {
86
70
  this.log('run.pending', {
87
71
  runId: run.id,
@@ -89,42 +73,6 @@ export class Automations {
89
73
  automationId: run.automationId,
90
74
  });
91
75
  }
92
- const targets = new Map(runs.map((run) => [
93
- `${run.userId}:${run.targetId}`,
94
- { userId: run.userId, targetId: run.targetId },
95
- ]));
96
- await Promise.all([...targets.values()].map((target) => this.drain(target)));
97
- }
98
- async #drain(target) {
99
- if (!this.transport.isOnline(target))
100
- return 0;
101
- this.log('drain.started', { targetId: target.targetId });
102
- let sent = 0;
103
- const failed = new Set();
104
- while (this.transport.isOnline(target)) {
105
- const runs = (await this.store.pendingRuns(target)).filter(({ id }) => !failed.has(id));
106
- if (runs.length === 0)
107
- return sent;
108
- for (const run of runs) {
109
- if (!this.transport.isOnline(target))
110
- return sent;
111
- try {
112
- if (!await this.transport.send(run)) {
113
- this.log('run.failed', { runId: run.id, targetId: target.targetId });
114
- failed.add(run.id);
115
- continue;
116
- }
117
- if (await this.store.markSent(run.id, target, new Date()))
118
- sent += 1;
119
- this.log('run.sent', { runId: run.id, targetId: target.targetId });
120
- }
121
- catch {
122
- this.log('run.failed', { runId: run.id, targetId: target.targetId });
123
- failed.add(run.id);
124
- }
125
- }
126
- }
127
- return sent;
128
76
  }
129
77
  }
130
78
  function assertJson(value, label) {
@@ -6,4 +6,5 @@ export declare function createAutomationClient(options: {
6
6
  authorization?: AutomationAuthorization;
7
7
  headers?: AutomationHeaders;
8
8
  fetch?: typeof globalThis.fetch;
9
+ requestTimeoutMs?: number;
9
10
  }): AutomationCrud;
@@ -1,7 +1,7 @@
1
1
  import { AutomationError } from './errors.js';
2
2
  export function createAutomationClient(options) {
3
3
  const baseUrl = options.baseUrl.replace(/\/$/, '');
4
- const request = createRequester(baseUrl, options.authorization, options.headers, options.fetch || globalThis.fetch);
4
+ const request = createRequester(baseUrl, options.authorization, options.headers, options.fetch || globalThis.fetch, options.requestTimeoutMs ?? 15_000);
5
5
  return {
6
6
  automations: {
7
7
  create: (input) => request('/v1/automations', 'POST', input),
@@ -39,7 +39,7 @@ export function createAutomationClient(options) {
39
39
  },
40
40
  };
41
41
  }
42
- function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
42
+ function createRequester(baseUrl, authorization, additionalHeaders, fetch, requestTimeoutMs) {
43
43
  return async (path, method, body, nullable = false) => {
44
44
  const url = `${baseUrl}${path}`;
45
45
  const token = await authorization?.();
@@ -52,6 +52,7 @@ function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
52
52
  ...await additionalHeaders?.(method, url),
53
53
  },
54
54
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
55
+ signal: AbortSignal.timeout(requestTimeoutMs),
55
56
  });
56
57
  if (response.status === 204)
57
58
  return undefined;
@@ -1,6 +1,8 @@
1
1
  export type Json = null | boolean | number | string | Json[] | {
2
2
  [key: string]: Json;
3
3
  };
4
+ import type { AutomationRun, ListRuns } from './run-contract.js';
5
+ export type { AutomationPlan, AutomationRun, AutomationRunJournal, AutomationStepRun, AutomationStepStatus, ListRuns, RunStatus, ToolActionStep, } from './run-contract.js';
4
6
  export type AutomationScope = 'automations:read' | 'automations:write' | 'runs:read' | 'runs:execute' | '*';
5
7
  /** Identity resolved by Amalgm before the SDK is bound to a caller. */
6
8
  export interface AutomationPrincipal {
@@ -84,15 +86,6 @@ export interface UpdateScheduleTrigger {
84
86
  /** Set a new finite budget, or null to make the schedule unbounded. */
85
87
  maxOccurrences?: number | null;
86
88
  }
87
- export interface ToolActionStep {
88
- id: string;
89
- actionId: string;
90
- input: Json;
91
- }
92
- export interface AutomationPlan {
93
- version: 1;
94
- steps: ToolActionStep[];
95
- }
96
89
  export interface CreateWebhookTrigger {
97
90
  id?: string;
98
91
  source?: string;
@@ -132,25 +125,6 @@ export interface UpdateWorkflow {
132
125
  allowlist?: Json | null;
133
126
  limits?: Json | null;
134
127
  }
135
- export type RunStatus = 'pending' | 'sent' | 'running' | 'completed' | 'failed';
136
- export interface AutomationRun {
137
- id: string;
138
- automationId: string;
139
- triggerId: string;
140
- workflowId: string;
141
- targetId: string;
142
- status: RunStatus;
143
- input: Json;
144
- createdAt: string;
145
- sentAt?: string;
146
- startedAt?: string;
147
- finishedAt?: string;
148
- output?: Json;
149
- error?: string;
150
- }
151
- export interface ListRuns extends PageQuery {
152
- status?: RunStatus;
153
- }
154
128
  export interface AutomationCrud {
155
129
  readonly automations: {
156
130
  create(input: CreateAutomation): Promise<Automation>;
@@ -190,4 +164,3 @@ export interface AutomationCrud {
190
164
  export interface AutomationCrudService {
191
165
  for(principal: AutomationPrincipal): AutomationCrud;
192
166
  }
193
- export {};
@@ -54,6 +54,10 @@ export function createAutomationEventsApi(config) {
54
54
  return json(401, { error: error.message });
55
55
  if (error instanceof EventHttpError)
56
56
  return json(error.status, { error: error.message });
57
+ const status = Number(error?.status);
58
+ if (Number.isInteger(status) && status >= 400 && status <= 499) {
59
+ return json(status, { error: error instanceof Error ? error.message : String(error) });
60
+ }
57
61
  return json(500, { error: error instanceof Error ? error.message : String(error) });
58
62
  }
59
63
  };
@@ -1,16 +1,25 @@
1
- import type { AutomationPlan, Json } from './contract.js';
1
+ import type { Json } from './contract.js';
2
2
  import type { ClaimedAutomationRun, MachineRuns } from './machine.js';
3
+ export { automationPlan } from './plan.js';
3
4
  export interface AutomationActionPort {
4
5
  call(input: Readonly<{
5
6
  actionId: string;
6
7
  payload: Json;
7
8
  idempotencyKey: string;
9
+ signal: AbortSignal;
8
10
  }>): Promise<Json>;
9
11
  }
12
+ export interface AutomationRunExecutorOptions {
13
+ readonly now?: () => Date;
14
+ readonly heartbeatMs?: number;
15
+ readonly maxAttempts?: number;
16
+ readonly retryDelayMs?: (attempt: number) => number;
17
+ }
10
18
  export declare class AutomationRunExecutor {
19
+ #private;
11
20
  private readonly runs;
12
21
  private readonly actions;
13
- constructor(runs: MachineRuns, actions: AutomationActionPort);
22
+ constructor(runs: MachineRuns, actions: AutomationActionPort, options?: AutomationRunExecutorOptions);
14
23
  execute(run: ClaimedAutomationRun): Promise<void>;
15
24
  }
16
- export declare function automationPlan(snapshot: Json): AutomationPlan;
25
+ export declare function transientFailure(error: unknown): boolean;