@amalgm/automations 0.2.1 → 0.2.3

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 (64) hide show
  1. package/AXIOMS.md +39 -17
  2. package/PURPOSE.md +26 -11
  3. package/README.md +33 -4
  4. package/dist/host/config.d.ts +2 -0
  5. package/dist/host/config.js +8 -0
  6. package/dist/host/main.js +10 -5
  7. package/dist/host/server.d.ts +2 -0
  8. package/dist/host/server.js +25 -7
  9. package/dist/src/automations.d.ts +15 -9
  10. package/dist/src/automations.js +33 -76
  11. package/dist/src/cli.d.ts +1 -1
  12. package/dist/src/cli.js +5 -2
  13. package/dist/src/client.d.ts +1 -0
  14. package/dist/src/client.js +4 -2
  15. package/dist/src/contract.d.ts +11 -31
  16. package/dist/src/crud/context.d.ts +3 -1
  17. package/dist/src/crud/context.js +2 -1
  18. package/dist/src/crud/repository.d.ts +17 -6
  19. package/dist/src/crud/runs.d.ts +1 -1
  20. package/dist/src/crud/runs.js +24 -1
  21. package/dist/src/crud/triggers.js +18 -9
  22. package/dist/src/crud.d.ts +4 -2
  23. package/dist/src/crud.js +5 -2
  24. package/dist/src/events-http.d.ts +3 -5
  25. package/dist/src/events-http.js +22 -26
  26. package/dist/src/executor.d.ts +12 -3
  27. package/dist/src/executor.js +163 -44
  28. package/dist/src/http.js +8 -1
  29. package/dist/src/index.d.ts +6 -4
  30. package/dist/src/index.js +6 -4
  31. package/dist/src/machine-client.d.ts +1 -0
  32. package/dist/src/machine-client.js +2 -0
  33. package/dist/src/machine.d.ts +2 -1
  34. package/dist/src/machine.js +6 -1
  35. package/dist/src/mcp.d.ts +3 -0
  36. package/dist/src/mcp.js +61 -50
  37. package/dist/src/plan.d.ts +3 -0
  38. package/dist/src/plan.js +48 -0
  39. package/dist/src/run-contract.d.ts +52 -0
  40. package/dist/src/run-contract.js +1 -0
  41. package/dist/src/run-journal.d.ts +8 -0
  42. package/dist/src/run-journal.js +56 -0
  43. package/dist/src/runner.d.ts +14 -0
  44. package/dist/src/runner.js +70 -0
  45. package/dist/src/schema.d.ts +31 -15
  46. package/dist/src/schema.js +27 -4
  47. package/dist/src/supabase-crud/mappers.d.ts +3 -2
  48. package/dist/src/supabase-crud/mappers.js +5 -0
  49. package/dist/src/supabase-crud/rows.d.ts +3 -0
  50. package/dist/src/supabase-crud/workflow-runs.d.ts +1 -1
  51. package/dist/src/supabase-crud/workflow-runs.js +10 -0
  52. package/dist/src/supabase-machine.js +2 -0
  53. package/dist/src/supabase-store.d.ts +3 -6
  54. package/dist/src/supabase-store.js +13 -34
  55. package/dist/src/tool-surface.d.ts +47 -0
  56. package/dist/src/tool-surface.js +126 -0
  57. package/dist/src/types.d.ts +8 -24
  58. package/dist/src/webhook.d.ts +15 -1
  59. package/dist/src/webhook.js +86 -0
  60. package/package.json +3 -3
  61. package/skills/automations/SKILL.md +29 -16
  62. package/supabase/migrations/20260830010000_durable_step_retries.sql +332 -0
  63. package/supabase/migrations/20260831010000_public_webhook_endpoints.sql +287 -0
  64. package/supabase/migrations/20260831020000_manual_run_admission.sql +68 -0
package/AXIOMS.md CHANGED
@@ -24,33 +24,55 @@
24
24
  9. Supabase is authoritative for automations, triggers, workflow source, and
25
25
  permanent run history. A definition edit or deletion never rewrites prior
26
26
  runs.
27
- 10. Webhook secrets are persisted but write-only: normal reads reveal only that
28
- a secret is configured.
29
- 11. Run history is read-only in the control plane and always scoped to its
27
+ 10. A webhook URL is a rotatable bearer capability for exactly one trigger. Its
28
+ random locator is not sufficient without the hosted service's HMAC, and
29
+ caller input never supplies an owner or target.
30
+ 11. Optional provider signing secrets are persisted but write-only: normal
31
+ reads reveal only that a secret is configured.
32
+ 12. Run history is read-only in the control plane and always scoped to its
30
33
  automation's owner.
31
- 12. Storage records, Supabase RPCs, HTTP details, and MCP protocol details are
34
+ 13. Storage records, Supabase RPCs, HTTP details, and MCP protocol details are
32
35
  implementation concerns, not SDK concepts.
33
- 13. The control-plane SDK is the only configuration write path. The delivery
36
+ 14. The control-plane SDK is the only configuration write path. The delivery
34
37
  rail reads that configuration and writes only schedule clocks and run
35
38
  state; it never owns a second automation-definition API.
36
- 14. Admitting a run atomically verifies the current enabled configuration,
39
+ 15. Admitting a run atomically verifies the current enabled configuration,
37
40
  stores a secret-free snapshot, and, for a schedule, advances exactly the
38
41
  firing instant that was claimed.
39
- 15. Legacy local automation storage is not a compatibility authority. Engine
40
- cutover migrates callers to this SDK and then deletes the old store.
41
- 16. Event ingress returns success only after every admitted run is durable in
42
- Supabase. Its recent-event list is a bounded, secret-free operational view,
43
- never a second event or run authority.
44
- 17. A finite schedule decrements its durable remaining occurrence count in the
42
+ 16. Legacy local automation storage is not a compatibility authority. Engine
43
+ is deprecated read-only evidence; active callers use this SDK and its
44
+ standalone service, and no old store may run beside them.
45
+ 17. Event ingress returns success only after its admitted run is durable in
46
+ Supabase. A repeated explicit delivery id resolves to the same run.
47
+ 18. A finite schedule decrements its durable remaining occurrence count in the
45
48
  same transaction that admits a run; zero disables the trigger.
46
- 18. A machine receives work only by exclusively leasing runs whose persisted
49
+ 19. A machine receives work only by exclusively leasing runs whose persisted
47
50
  target equals the `computer_id` in its DPoP-bound access token.
48
- 19. The configured public origin, never forwarding headers, reconstructs the
51
+ 20. The configured public origin, never forwarding headers, reconstructs the
49
52
  DPoP request URL behind the Fly proxy.
50
- 20. Machine execution consumes the immutable workflow snapshot stored on the
53
+ 21. Machine execution consumes the immutable workflow snapshot stored on the
51
54
  run. It never rediscovers or silently updates the automation definition.
52
- 21. A compiled workflow is a small declarative sequence of tool actions. The
55
+ 22. A compiled workflow is a small declarative sequence of one or more tool actions. The
53
56
  executor receives tool calling as a host capability and never embeds a
54
57
  Channels, Shell, CLI, or provider special case.
55
- 22. Automations is a standalone hosted service. Gateway owns none of its API,
58
+ 23. Automations is a standalone hosted service. Gateway owns none of its API,
56
59
  scheduling, claim, execution, or persistence path.
60
+ 24. The durable run ledger is the offline queue. The platform never keeps a
61
+ second online-machine delivery buffer and never drops an unclaimed run.
62
+ 25. A run advances through its immutable plan in order. Every step transition
63
+ commits under the run's current lease before execution advances, and a
64
+ completed step is never intentionally invoked again.
65
+ 26. A machine may begin an action only after the service confirms its current
66
+ lease. It renews that lease while the action is running and stops advancing
67
+ when renewal fails.
68
+ 27. One run-step pair has one stable idempotency key. Step ids are unique in a
69
+ plan, and every action host receives that key and a cancellation signal.
70
+ 28. Only transient transport failures retry. They release the same run with a
71
+ bounded future retry time and retain completed step output; invalid plans,
72
+ missing actions, authorization failures, and other deterministic errors
73
+ are terminal.
74
+ 29. The selected target executes every action effect. The hosted service owns
75
+ only configuration, admission, leases, the step journal, and run history.
76
+ 30. Run Now is triggerless manual admission. It atomically snapshots the
77
+ current enabled automation and compiled workflow into the pending ledger;
78
+ it never executes inline or changes a trigger's schedule state.
package/PURPOSE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ## Purpose
4
4
 
5
5
  Automations is the sole owner of Amalgm automation behavior and data. It lets an
6
- authenticated user create, inspect, change, and delete automation
6
+ authenticated user create, inspect, change, delete, and manually run automation
7
7
  configuration — an automation belongs to one user and one target, may own any
8
8
  number of scheduled and webhook triggers, and may own one workflow script — and
9
9
  have each trigger occurrence run on the selected existing Amalgm machine.
@@ -13,7 +13,7 @@ complete workflow, and permanent run history.
13
13
  The product has two composable halves over that one state:
14
14
 
15
15
  - **The configuration control plane** — one ergonomic, auth-bound SDK contract
16
- for automation CRUD and run-history reads. The HTTP API, CLI, MCP server, and
16
+ for automation CRUD, manual run admission, and run-history reads. The HTTP API, CLI, MCP server, and
17
17
  skill are adapters over that contract; they do not access Supabase directly
18
18
  or implement lifecycle rules.
19
19
  - **The delivery rail** — trigger admission, scheduling, and run state: the
@@ -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,
@@ -32,10 +42,14 @@ occurrence count in durable state, so “every minute for ten minutes” means t
32
42
  admitted runs and then an automatically disabled trigger — not a timer that a
33
43
  machine must remember.
34
44
 
35
- The event HTTP adapter is one thin door over that delivery rail. It bounds and
36
- parses the webhook body, resolves the authenticated target supplied by the
37
- host, and returns only after matching runs have been stored in Supabase. Its
38
- recent-event response is an ephemeral, secret-free operational projection.
45
+ The event HTTP adapter is one thin door over that delivery rail. Every webhook
46
+ trigger owns one opaque URL under `automations.amalgm.ai`; the URL resolves the
47
+ trigger, owner, and target, so a caller never supplies routing identity. The
48
+ adapter bounds and parses the body, optionally verifies the provider's signing
49
+ secret, deduplicates explicit provider delivery ids, and returns only after the
50
+ run has been stored in Supabase. The signed URL can be rotated without changing
51
+ the automation, and its usable bearer token cannot be reconstructed from the
52
+ database alone.
39
53
 
40
54
  Amalgm supplies a resolved authenticated principal from its user session or
41
55
  HMAC-refresh flow; future API keys resolve to the same principal capability.
@@ -47,7 +61,8 @@ connectivity, and narrow host capabilities. The product receives identity and
47
61
  scopes — never raw credentials or Core storage — and neither side reaches into
48
62
  the other's storage or reimplements the other's decisions.
49
63
 
50
- This extraction is complete only when every Automations surface in Engine calls
51
- this product service, product state has one writer, migrated behavior has parity
52
- tests, and the local `amalgm-mcp` automation implementation and storage are
53
- deleted rather than retained as a compatibility layer.
64
+ This repository and its standalone Fly host are the active Automations
65
+ authority. Shell calls the published machine-claim and execution contracts
66
+ directly; the UI calls the hosted control plane through the published client.
67
+ Any remaining Engine automation code is deprecated migration/parity evidence,
68
+ not a cutover dependency or compatibility authority.
package/README.md CHANGED
@@ -25,12 +25,19 @@ await sdk.triggers.schedule.create(automation.id, {
25
25
  timezone: 'America/Los_Angeles',
26
26
  maxOccurrences: 10,
27
27
  });
28
+
29
+ const run = await sdk.runs.runNow(automation.id, {
30
+ input: { requestedFrom: 'automation-page' },
31
+ idempotencyKey: crypto.randomUUID(),
32
+ });
28
33
  ```
29
34
 
30
35
  That schedule admits exactly ten durable runs, then disables itself. If the
31
36
  machine is offline, the runs remain pending. Shell later claims only work whose
32
37
  target matches its DPoP `computer_id`, executes the immutable tool-action plan,
33
- and commits the result.
38
+ and commits the result. Run Now returns the newly admitted `pending` run; it
39
+ uses that same machine execution path and never executes workflow effects in
40
+ the API request.
34
41
 
35
42
  ## Surfaces
36
43
 
@@ -49,14 +56,18 @@ or depend on Amalgm Gateway.
49
56
  An automation belongs to one user and target. It owns any number of schedule
50
57
  or webhook triggers and zero or one workflow. Supabase owns definitions,
51
58
  schedule clocks, immutable run snapshots, machine leases, and permanent run
52
- history. Webhook secrets are write-only.
59
+ history. Every webhook trigger has one rotatable URL; an optional provider
60
+ signing secret is write-only.
53
61
 
54
- Compiled workflows use one small format:
62
+ Executable workflows use one small, non-empty format:
55
63
 
56
64
  ```ts
57
65
  type AutomationPlan = {
58
66
  version: 1;
59
- steps: Array<{ id: string; actionId: string; input: Json }>;
67
+ steps: [
68
+ { id: string; actionId: string; input: Json },
69
+ ...Array<{ id: string; actionId: string; input: Json }>,
70
+ ];
60
71
  };
61
72
  ```
62
73
 
@@ -68,11 +79,29 @@ one action-calling capability, and each step receives the stable idempotency key
68
79
 
69
80
  The control API lives under `/v1/automations`. The machine execution API is:
70
81
 
82
+ ```text
83
+ POST /v1/automations/:automationId/runs
84
+ ```
85
+
86
+ The optional JSON body contains `input` and an `idempotencyKey`. Reusing the
87
+ key for the same automation returns the same admitted run.
88
+
89
+ The machine execution API is:
90
+
71
91
  ```text
72
92
  POST /v1/machine/runs/claim
73
93
  PATCH /v1/machine/runs/:runId
74
94
  ```
75
95
 
96
+ Public webhook admission uses the URL returned on each webhook trigger:
97
+
98
+ ```text
99
+ POST https://automations.amalgm.ai/e/<opaque-capability>
100
+ ```
101
+
102
+ `Idempotency-Key` and common provider delivery-id headers deduplicate retries
103
+ against the same trigger. The caller never supplies a user or target id.
104
+
76
105
  The target is never accepted in a machine request. It comes from the verified
77
106
  access token. Run leases expire and are safely reclaimable; terminal updates
78
107
  must present the active lease token.
@@ -1,6 +1,8 @@
1
1
  export interface AutomationsHostConfig {
2
2
  readonly port: number;
3
3
  readonly publicOrigin: string;
4
+ readonly webhookOrigin: string;
5
+ readonly webhookTokenKey: string;
4
6
  readonly supabaseUrl: string;
5
7
  readonly supabaseServiceRoleKey: string;
6
8
  readonly authorizationIssuer: string;
@@ -2,6 +2,8 @@ export function automationsHostConfig(env = process.env) {
2
2
  return Object.freeze({
3
3
  port: integer(env.PORT, 8080, 1, 65_535),
4
4
  publicOrigin: url(env.AMALGAM_PUBLIC_ORIGIN, 'AMALGAM_PUBLIC_ORIGIN'),
5
+ webhookOrigin: url(env.AUTOMATIONS_WEBHOOK_ORIGIN, 'AUTOMATIONS_WEBHOOK_ORIGIN'),
6
+ webhookTokenKey: secret(env.AUTOMATIONS_WEBHOOK_TOKEN_KEY, 'AUTOMATIONS_WEBHOOK_TOKEN_KEY'),
5
7
  supabaseUrl: url(env.SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL, 'SUPABASE_URL'),
6
8
  supabaseServiceRoleKey: required(env.SUPABASE_SERVICE_ROLE_KEY, 'SUPABASE_SERVICE_ROLE_KEY'),
7
9
  authorizationIssuer: url(env.AMALGM_AUTHORIZATION_ISSUER, 'AMALGM_AUTHORIZATION_ISSUER'),
@@ -13,6 +15,12 @@ function required(value, name) {
13
15
  throw new Error(`${name} is required`);
14
16
  return value.trim();
15
17
  }
18
+ function secret(value, name) {
19
+ const result = required(value, name);
20
+ if (Buffer.byteLength(result) < 32)
21
+ throw new Error(`${name} must contain at least 32 bytes`);
22
+ return result;
23
+ }
16
24
  function url(value, name) {
17
25
  const parsed = new URL(required(value, name));
18
26
  const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
package/dist/host/main.js CHANGED
@@ -2,11 +2,13 @@ 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';
8
9
  import { SupabaseMachineRunRepository } from '../src/supabase-machine.js';
9
10
  import { SupabaseStore } from '../src/supabase-store.js';
11
+ import { WebhookEndpoints } from '../src/webhook.js';
10
12
  import { createAutomationsAuthenticators } from './auth.js';
11
13
  import { automationsHostConfig } from './config.js';
12
14
  import { createAutomationsHost } from './server.js';
@@ -18,12 +20,10 @@ const authentication = createAutomationsAuthenticators({
18
20
  issuer: config.authorizationIssuer,
19
21
  supabase,
20
22
  });
21
- const delivery = new Automations(new SupabaseStore(supabase), {
22
- isOnline: () => false,
23
- send: async () => false,
24
- }, (event, details) => log(event, details));
23
+ const webhooks = new WebhookEndpoints(config.webhookOrigin, config.webhookTokenKey);
24
+ const delivery = new Automations(new SupabaseStore(supabase), (event, details) => log(event, details));
25
25
  const controlApi = createAutomationApi({
26
- service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase)),
26
+ service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase), () => new Date(), webhooks),
27
27
  authenticate: authentication.control,
28
28
  });
29
29
  const machineRepository = new SupabaseMachineRunRepository(supabase);
@@ -31,10 +31,15 @@ const machineApi = createMachineRunsApi({
31
31
  authenticate: authentication.machine,
32
32
  runsFor: (principal) => createMachineRuns(machineRepository, principal),
33
33
  });
34
+ const eventsApi = createAutomationEventsApi({
35
+ delivery,
36
+ endpoints: webhooks,
37
+ });
34
38
  const host = createAutomationsHost({
35
39
  publicOrigin: config.publicOrigin,
36
40
  controlApi,
37
41
  machineApi,
42
+ eventsApi,
38
43
  fireSchedules: () => delivery.fireDueCrons(),
39
44
  schedulerIntervalMs: config.schedulerIntervalMs,
40
45
  log,
@@ -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.startsWith('/e/') && 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,24 +1,30 @@
1
- import type { AutomationLog, AutomationRun, AutomationStore, AutomationTarget, AutomationTransport, Json, RunUpdate } from './types.js';
1
+ import type { AutomationLog, AutomationRun, AutomationStore, Json } from './types.js';
2
2
  export declare class EventRejectedError extends Error {
3
3
  constructor();
4
4
  }
5
+ export declare class EventEndpointNotFoundError extends Error {
6
+ constructor();
7
+ }
8
+ export interface EventAdmission {
9
+ run: AutomationRun | null;
10
+ source: string;
11
+ event: string;
12
+ }
13
+ /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
5
14
  export declare class Automations {
6
15
  #private;
7
16
  readonly store: AutomationStore;
8
- readonly transport: AutomationTransport;
9
17
  readonly log: AutomationLog;
10
- constructor(store: AutomationStore, transport: AutomationTransport, log?: AutomationLog);
18
+ constructor(store: AutomationStore, log?: AutomationLog);
11
19
  receiveEvent(input: {
12
- target: AutomationTarget;
20
+ endpointId: string;
13
21
  headers: Record<string, string>;
14
22
  body: Buffer;
15
23
  payload: Json;
24
+ deliveryKey: string | null;
16
25
  source?: string;
17
26
  event?: string;
18
27
  now?: Date;
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>;
28
+ }): Promise<EventAdmission>;
29
+ fireDueCrons(now?: Date, maximumRuns?: number): Promise<AutomationRun[]>;
24
30
  }
@@ -7,47 +7,58 @@ export class EventRejectedError extends Error {
7
7
  this.name = 'EventRejectedError';
8
8
  }
9
9
  }
10
+ export class EventEndpointNotFoundError extends Error {
11
+ constructor() {
12
+ super('Webhook endpoint was not found');
13
+ this.name = 'EventEndpointNotFoundError';
14
+ }
15
+ }
16
+ /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
10
17
  export class Automations {
11
18
  store;
12
- transport;
13
19
  log;
14
- #drains = new Map();
15
- constructor(store, transport, log = noop) {
20
+ constructor(store, log = noop) {
16
21
  this.store = store;
17
- this.transport = transport;
18
22
  this.log = log;
19
23
  }
20
24
  async receiveEvent(input) {
21
25
  assertJson(input.payload, 'Event payload');
22
- const candidates = await this.store.eventTriggers(input.target);
23
- const authenticated = candidates.filter((trigger) => (verifyEventSecret(trigger.secret, input.headers, input.body)));
24
- if (authenticated.length === 0) {
25
- this.log('event.rejected', { targetId: input.target.targetId });
26
+ const trigger = await this.store.eventTrigger(input.endpointId);
27
+ if (!trigger)
28
+ throw new EventEndpointNotFoundError();
29
+ if (!verifyEventSecret(trigger.secret, input.headers, input.body)) {
30
+ this.log('event.rejected', { triggerId: trigger.triggerId });
26
31
  throw new EventRejectedError();
27
32
  }
28
33
  const references = input.source && input.event
29
34
  ? { primary: { source: input.source, event: input.event } }
30
35
  : eventReferences(input.headers, input.payload);
31
36
  let reference = references.primary;
32
- let triggers = authenticated.filter((trigger) => matchesEvent(trigger, reference.source, reference.event));
33
- if (triggers.length === 0 && references.fallback) {
37
+ let matches = matchesEvent(trigger, reference.source, reference.event);
38
+ if (!matches && references.fallback) {
34
39
  reference = references.fallback;
35
- triggers = authenticated.filter((trigger) => matchesEvent(trigger, reference.source, reference.event));
40
+ matches = matchesEvent(trigger, reference.source, reference.event);
36
41
  }
37
- if (triggers.length === 0)
38
- return [];
42
+ if (!matches)
43
+ return { run: null, ...reference };
39
44
  const now = input.now || new Date();
40
45
  const runInput = { kind: 'event', ...reference, payload: input.payload };
41
- const runs = (await Promise.all(triggers.map((trigger) => (this.store.enqueueEvent(trigger, runInput, now))))).filter((run) => run !== null);
42
- await this.#drainCreated(runs);
43
- return runs;
46
+ const run = await this.store.enqueueEvent(trigger, runInput, now, input.deliveryKey);
47
+ if (!run)
48
+ return { run: null, ...reference };
49
+ this.#logPending([run]);
50
+ return { run, ...reference };
44
51
  }
45
- async fireDueCrons(now = new Date()) {
52
+ async fireDueCrons(now = new Date(), maximumRuns = 1_000) {
53
+ if (!Number.isInteger(maximumRuns) || maximumRuns < 1)
54
+ throw new Error('maximumRuns must be a positive integer');
46
55
  const created = [];
47
56
  for (const trigger of await this.store.dueCronTriggers(now)) {
48
57
  let scheduledFor = trigger.nextRunAt;
49
58
  let remaining = trigger.remainingOccurrences;
50
- while (new Date(scheduledFor) <= now && (remaining === undefined || remaining > 0)) {
59
+ while (created.length < maximumRuns
60
+ && new Date(scheduledFor) <= now
61
+ && (remaining === undefined || remaining > 0)) {
51
62
  const nextRunAt = nextCronAt(trigger.cron, trigger.timezone, scheduledFor);
52
63
  const run = await this.store.enqueueCron({ ...trigger, nextRunAt: scheduledFor }, nextRunAt, now);
53
64
  if (!run)
@@ -57,31 +68,13 @@ export class Automations {
57
68
  if (remaining !== undefined)
58
69
  remaining -= 1;
59
70
  }
71
+ if (created.length === maximumRuns)
72
+ break;
60
73
  }
61
- await this.#drainCreated(created);
74
+ this.#logPending(created);
62
75
  return created;
63
76
  }
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) {
77
+ #logPending(runs) {
85
78
  for (const run of runs) {
86
79
  this.log('run.pending', {
87
80
  runId: run.id,
@@ -89,42 +82,6 @@ export class Automations {
89
82
  automationId: run.automationId,
90
83
  });
91
84
  }
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
85
  }
129
86
  }
130
87
  function assertJson(value, label) {
package/dist/src/cli.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { AutomationCrud } from './contract.js';
2
2
  interface Output {
3
3
  write(chunk: string): unknown;
4
4
  }
5
- export declare const automationCliHelp = "Usage: amalgm-automations <resource> <action>\n\nautomations create|list|get|update|delete\ntriggers list | schedule <create|list|get|update|delete> | webhook <create|list|get|update|delete>\nworkflow create|get|update|delete\nruns list|get\n\nList filters:\n automations list [--target-id ID] [--enabled true|false] [--limit N] [--offset N]\n triggers schedule|webhook list AUTOMATION_ID [--limit N] [--offset N]\n runs list AUTOMATION_ID [--status STATUS] [--limit N] [--offset N]\n";
5
+ export declare const automationCliHelp = "Usage: amalgm-automations <resource> <action>\n\nautomations create|list|get|update|delete\ntriggers list | schedule <create|list|get|update|delete> | webhook <create|list|get|update|delete>\nworkflow create|get|update|delete\nruns run-now|list|get\n\nList filters:\n automations list [--target-id ID] [--enabled true|false] [--limit N] [--offset N]\n triggers schedule|webhook list AUTOMATION_ID [--limit N] [--offset N]\n runs list AUTOMATION_ID [--status STATUS] [--limit N] [--offset N]\n";
6
6
  export declare function runAutomationCli(argv: string[], sdk: AutomationCrud, output?: {
7
7
  stdout?: Output;
8
8
  stderr?: Output;
package/dist/src/cli.js CHANGED
@@ -4,7 +4,7 @@ export const automationCliHelp = `Usage: amalgm-automations <resource> <action>
4
4
  automations create|list|get|update|delete
5
5
  triggers list | schedule <create|list|get|update|delete> | webhook <create|list|get|update|delete>
6
6
  workflow create|get|update|delete
7
- runs list|get
7
+ runs run-now|list|get
8
8
 
9
9
  List filters:
10
10
  automations list [--target-id ID] [--enabled true|false] [--limit N] [--offset N]
@@ -109,11 +109,14 @@ async function workflowCommand(action, args, sdk) {
109
109
  }
110
110
  async function runsCommand(action, args, sdk) {
111
111
  const automationId = required(args, 0, 'automation id');
112
+ if (action === 'run-now') {
113
+ return sdk.runs.runNow(automationId, args[1] ? await jsonFile(args[1]) : {});
114
+ }
112
115
  if (action === 'list')
113
116
  return sdk.runs.list(automationId, runListOptions(args.slice(1)));
114
117
  if (action === 'get')
115
118
  return sdk.runs.get(automationId, required(args, 1, 'run id'));
116
- throw new Error('Usage: runs <list|get>');
119
+ throw new Error('Usage: runs <run-now|list|get>');
117
120
  }
118
121
  function automationListOptions(args) {
119
122
  const options = readOptions(args, new Set(['--target-id', '--enabled', '--limit', '--offset']));
@@ -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),
@@ -34,12 +34,13 @@ export function createAutomationClient(options) {
34
34
  delete: (automationId) => request(workflowPath(automationId), 'DELETE'),
35
35
  },
36
36
  runs: {
37
+ runNow: (automationId, input = {}) => request(runsPath(automationId), 'POST', input),
37
38
  list: (automationId, query = {}) => request(`${runsPath(automationId)}${queryString(query)}`, 'GET'),
38
39
  get: (automationId, runId) => request(`${runsPath(automationId)}/${part(runId)}`, 'GET', undefined, true),
39
40
  },
40
41
  };
41
42
  }
42
- function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
43
+ function createRequester(baseUrl, authorization, additionalHeaders, fetch, requestTimeoutMs) {
43
44
  return async (path, method, body, nullable = false) => {
44
45
  const url = `${baseUrl}${path}`;
45
46
  const token = await authorization?.();
@@ -52,6 +53,7 @@ function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
52
53
  ...await additionalHeaders?.(method, url),
53
54
  },
54
55
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
56
+ signal: AbortSignal.timeout(requestTimeoutMs),
55
57
  });
56
58
  if (response.status === 204)
57
59
  return undefined;