@amalgm/automations 0.2.2 → 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 (46) hide show
  1. package/AXIOMS.md +29 -23
  2. package/PURPOSE.md +15 -10
  3. package/README.md +28 -2
  4. package/dist/host/config.d.ts +2 -0
  5. package/dist/host/config.js +8 -0
  6. package/dist/host/main.js +4 -10
  7. package/dist/host/server.js +1 -1
  8. package/dist/src/automations.d.ts +12 -3
  9. package/dist/src/automations.js +21 -12
  10. package/dist/src/cli.d.ts +1 -1
  11. package/dist/src/cli.js +5 -2
  12. package/dist/src/client.js +1 -0
  13. package/dist/src/contract.d.ts +11 -4
  14. package/dist/src/crud/context.d.ts +3 -1
  15. package/dist/src/crud/context.js +2 -1
  16. package/dist/src/crud/repository.d.ts +17 -6
  17. package/dist/src/crud/runs.d.ts +1 -1
  18. package/dist/src/crud/runs.js +24 -1
  19. package/dist/src/crud/triggers.js +18 -9
  20. package/dist/src/crud.d.ts +4 -2
  21. package/dist/src/crud.js +5 -2
  22. package/dist/src/events-http.d.ts +3 -5
  23. package/dist/src/events-http.js +18 -26
  24. package/dist/src/http.js +4 -1
  25. package/dist/src/index.d.ts +4 -4
  26. package/dist/src/index.js +2 -2
  27. package/dist/src/mcp.js +9 -1
  28. package/dist/src/run-contract.d.ts +6 -0
  29. package/dist/src/schema.d.ts +29 -13
  30. package/dist/src/schema.js +10 -2
  31. package/dist/src/supabase-crud/mappers.d.ts +3 -2
  32. package/dist/src/supabase-crud/mappers.js +3 -0
  33. package/dist/src/supabase-crud/rows.d.ts +1 -0
  34. package/dist/src/supabase-crud/workflow-runs.d.ts +1 -1
  35. package/dist/src/supabase-crud/workflow-runs.js +10 -0
  36. package/dist/src/supabase-store.d.ts +3 -3
  37. package/dist/src/supabase-store.js +13 -10
  38. package/dist/src/tool-surface.d.ts +1 -0
  39. package/dist/src/tool-surface.js +1 -0
  40. package/dist/src/types.d.ts +7 -8
  41. package/dist/src/webhook.d.ts +15 -1
  42. package/dist/src/webhook.js +86 -0
  43. package/package.json +1 -1
  44. package/skills/automations/SKILL.md +12 -4
  45. package/supabase/migrations/20260831010000_public_webhook_endpoints.sql +287 -0
  46. package/supabase/migrations/20260831020000_manual_run_admission.sql +68 -0
package/AXIOMS.md CHANGED
@@ -24,49 +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 one or more 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.
57
- 23. The durable run ledger is the offline queue. The platform never keeps a
60
+ 24. The durable run ledger is the offline queue. The platform never keeps a
58
61
  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
62
+ 25. A run advances through its immutable plan in order. Every step transition
60
63
  commits under the run's current lease before execution advances, and a
61
64
  completed step is never intentionally invoked again.
62
- 25. A machine may begin an action only after the service confirms its current
65
+ 26. A machine may begin an action only after the service confirms its current
63
66
  lease. It renews that lease while the action is running and stops advancing
64
67
  when renewal fails.
65
- 26. One run-step pair has one stable idempotency key. Step ids are unique in a
68
+ 27. One run-step pair has one stable idempotency key. Step ids are unique in a
66
69
  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
70
+ 28. Only transient transport failures retry. They release the same run with a
68
71
  bounded future retry time and retain completed step output; invalid plans,
69
72
  missing actions, authorization failures, and other deterministic errors
70
73
  are terminal.
71
- 28. The selected target executes every action effect. The hosted service owns
74
+ 29. The selected target executes every action effect. The hosted service owns
72
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
@@ -42,10 +42,14 @@ occurrence count in durable state, so “every minute for ten minutes” means t
42
42
  admitted runs and then an automatically disabled trigger — not a timer that a
43
43
  machine must remember.
44
44
 
45
- The event HTTP adapter is one thin door over that delivery rail. It bounds and
46
- parses the webhook body, resolves the authenticated target supplied by the
47
- host, and returns only after matching runs have been stored in Supabase. Its
48
- 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.
49
53
 
50
54
  Amalgm supplies a resolved authenticated principal from its user session or
51
55
  HMAC-refresh flow; future API keys resolve to the same principal capability.
@@ -57,7 +61,8 @@ connectivity, and narrow host capabilities. The product receives identity and
57
61
  scopes — never raw credentials or Core storage — and neither side reaches into
58
62
  the other's storage or reimplements the other's decisions.
59
63
 
60
- This extraction is complete only when every Automations surface in Engine calls
61
- this product service, product state has one writer, migrated behavior has parity
62
- tests, and the local `amalgm-mcp` automation implementation and storage are
63
- 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,7 +56,8 @@ 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
62
  Executable workflows use one small, non-empty format:
55
63
 
@@ -71,11 +79,29 @@ one action-calling capability, and each step receives the stable idempotency key
71
79
 
72
80
  The control API lives under `/v1/automations`. The machine execution API is:
73
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
+
74
91
  ```text
75
92
  POST /v1/machine/runs/claim
76
93
  PATCH /v1/machine/runs/:runId
77
94
  ```
78
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
+
79
105
  The target is never accepted in a machine request. It comes from the verified
80
106
  access token. Run leases expire and are safely reclaimable; terminal updates
81
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
@@ -8,6 +8,7 @@ import { createMachineRunsApi } from '../src/machine-http.js';
8
8
  import { SupabaseAutomationCrudRepository } from '../src/supabase-crud.js';
9
9
  import { SupabaseMachineRunRepository } from '../src/supabase-machine.js';
10
10
  import { SupabaseStore } from '../src/supabase-store.js';
11
+ import { WebhookEndpoints } from '../src/webhook.js';
11
12
  import { createAutomationsAuthenticators } from './auth.js';
12
13
  import { automationsHostConfig } from './config.js';
13
14
  import { createAutomationsHost } from './server.js';
@@ -19,9 +20,10 @@ const authentication = createAutomationsAuthenticators({
19
20
  issuer: config.authorizationIssuer,
20
21
  supabase,
21
22
  });
23
+ const webhooks = new WebhookEndpoints(config.webhookOrigin, config.webhookTokenKey);
22
24
  const delivery = new Automations(new SupabaseStore(supabase), (event, details) => log(event, details));
23
25
  const controlApi = createAutomationApi({
24
- service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase)),
26
+ service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase), () => new Date(), webhooks),
25
27
  authenticate: authentication.control,
26
28
  });
27
29
  const machineRepository = new SupabaseMachineRunRepository(supabase);
@@ -31,7 +33,7 @@ const machineApi = createMachineRunsApi({
31
33
  });
32
34
  const eventsApi = createAutomationEventsApi({
33
35
  delivery,
34
- target: async (request) => eventTarget(request),
36
+ endpoints: webhooks,
35
37
  });
36
38
  const host = createAutomationsHost({
37
39
  publicOrigin: config.publicOrigin,
@@ -49,11 +51,3 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
49
51
  function log(event, details = {}) {
50
52
  console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
51
53
  }
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
- }
@@ -19,7 +19,7 @@ export function createAutomationsHost(options) {
19
19
  return send(outgoing, Response.json({ ok: true }));
20
20
  const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024);
21
21
  const pathname = new URL(request.url).pathname;
22
- const api = pathname === '/events' && options.eventsApi
22
+ const api = pathname.startsWith('/e/') && options.eventsApi
23
23
  ? options.eventsApi
24
24
  : pathname.startsWith('/v1/machine/') ? options.machineApi : options.controlApi;
25
25
  await send(outgoing, await api(request));
@@ -1,7 +1,15 @@
1
- import type { AutomationLog, AutomationRun, AutomationStore, AutomationTarget, Json } 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
+ }
5
13
  /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
6
14
  export declare class Automations {
7
15
  #private;
@@ -9,13 +17,14 @@ export declare class Automations {
9
17
  readonly log: AutomationLog;
10
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[]>;
28
+ }): Promise<EventAdmission>;
20
29
  fireDueCrons(now?: Date, maximumRuns?: number): Promise<AutomationRun[]>;
21
30
  }
@@ -7,6 +7,12 @@ 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
+ }
10
16
  /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
11
17
  export class Automations {
12
18
  store;
@@ -17,28 +23,31 @@ export class Automations {
17
23
  }
18
24
  async receiveEvent(input) {
19
25
  assertJson(input.payload, 'Event payload');
20
- const candidates = await this.store.eventTriggers(input.target);
21
- const authenticated = candidates.filter((trigger) => (verifyEventSecret(trigger.secret, input.headers, input.body)));
22
- if (authenticated.length === 0) {
23
- 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 });
24
31
  throw new EventRejectedError();
25
32
  }
26
33
  const references = input.source && input.event
27
34
  ? { primary: { source: input.source, event: input.event } }
28
35
  : eventReferences(input.headers, input.payload);
29
36
  let reference = references.primary;
30
- let triggers = authenticated.filter((trigger) => matchesEvent(trigger, reference.source, reference.event));
31
- if (triggers.length === 0 && references.fallback) {
37
+ let matches = matchesEvent(trigger, reference.source, reference.event);
38
+ if (!matches && references.fallback) {
32
39
  reference = references.fallback;
33
- triggers = authenticated.filter((trigger) => matchesEvent(trigger, reference.source, reference.event));
40
+ matches = matchesEvent(trigger, reference.source, reference.event);
34
41
  }
35
- if (triggers.length === 0)
36
- return [];
42
+ if (!matches)
43
+ return { run: null, ...reference };
37
44
  const now = input.now || new Date();
38
45
  const runInput = { kind: 'event', ...reference, payload: input.payload };
39
- const runs = (await Promise.all(triggers.map((trigger) => (this.store.enqueueEvent(trigger, runInput, now))))).filter((run) => run !== null);
40
- this.#logPending(runs);
41
- 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 };
42
51
  }
43
52
  async fireDueCrons(now = new Date(), maximumRuns = 1_000) {
44
53
  if (!Number.isInteger(maximumRuns) || maximumRuns < 1)
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']));
@@ -34,6 +34,7 @@ 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
  },
@@ -1,8 +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
+ import type { AutomationRun, ListRuns, RunAutomationNow } from './run-contract.js';
5
+ export type { AutomationPlan, AutomationRun, AutomationRunJournal, AutomationStepRun, AutomationStepStatus, ListRuns, RunAutomationNow, RunStatus, ToolActionStep, } from './run-contract.js';
6
6
  export type AutomationScope = 'automations:read' | 'automations:write' | 'runs:read' | 'runs:execute' | '*';
7
7
  /** Identity resolved by Amalgm before the SDK is bound to a caller. */
8
8
  export interface AutomationPrincipal {
@@ -68,6 +68,8 @@ export interface WebhookTrigger extends TriggerBase {
68
68
  kind: 'webhook';
69
69
  source: string;
70
70
  event: string;
71
+ /** Bearer-capability URL. Treat it as a credential and rotate it if exposed. */
72
+ webhookUrl: string;
71
73
  secretConfigured: boolean;
72
74
  }
73
75
  export type Trigger = ScheduleTrigger | WebhookTrigger;
@@ -90,13 +92,17 @@ export interface CreateWebhookTrigger {
90
92
  id?: string;
91
93
  source?: string;
92
94
  event?: string;
93
- secret: string;
95
+ /** Optional provider signing secret; the generated webhook URL authenticates admission. */
96
+ secret?: string;
94
97
  enabled?: boolean;
95
98
  }
96
99
  export interface UpdateWebhookTrigger {
97
100
  source?: string;
98
101
  event?: string;
99
- secret?: string;
102
+ /** Set null to remove provider-signature verification. */
103
+ secret?: string | null;
104
+ /** Invalidates the existing URL and returns its replacement. */
105
+ rotateUrl?: boolean;
100
106
  enabled?: boolean;
101
107
  }
102
108
  export interface Workflow {
@@ -157,6 +163,7 @@ export interface AutomationCrud {
157
163
  delete(automationId: string): Promise<void>;
158
164
  };
159
165
  readonly runs: {
166
+ runNow(automationId: string, input?: RunAutomationNow): Promise<AutomationRun>;
160
167
  list(automationId: string, query?: ListRuns): Promise<Page<AutomationRun>>;
161
168
  get(automationId: string, runId: string): Promise<AutomationRun | null>;
162
169
  };
@@ -1,14 +1,16 @@
1
1
  import type { AutomationPrincipal } from '../contract.js';
2
2
  import type { AutomationCrudRepository } from './repository.js';
3
+ import type { WebhookEndpoints } from '../webhook.js';
3
4
  export interface CrudContext {
4
5
  repository: AutomationCrudRepository;
5
6
  principal: AutomationPrincipal;
6
7
  clock: () => Date;
8
+ webhooks: WebhookEndpoints;
7
9
  read(): void;
8
10
  write(): void;
9
11
  runsRead(): void;
10
12
  exists(automationId: string): Promise<void>;
11
13
  }
12
- export declare function createContext(repository: AutomationCrudRepository, principal: AutomationPrincipal, clock: () => Date): CrudContext;
14
+ export declare function createContext(repository: AutomationCrudRepository, principal: AutomationPrincipal, clock: () => Date, webhooks: WebhookEndpoints): CrudContext;
13
15
  export declare function assertTarget(principal: AutomationPrincipal, targetId: string): void;
14
16
  export declare function resolveTarget(principal: AutomationPrincipal, targetId?: string): string;
@@ -1,11 +1,12 @@
1
1
  import { ForbiddenError, NotFoundError, ValidationError } from '../errors.js';
2
2
  import { id, requiredText } from '../validation.js';
3
- export function createContext(repository, principal, clock) {
3
+ export function createContext(repository, principal, clock, webhooks) {
4
4
  assertPrincipal(principal);
5
5
  return {
6
6
  repository,
7
7
  principal,
8
8
  clock,
9
+ webhooks,
9
10
  read: () => assertScope(principal, 'automations:read'),
10
11
  write: () => assertScope(principal, 'automations:write'),
11
12
  runsRead: () => assertScope(principal, 'runs:read'),
@@ -1,4 +1,4 @@
1
- import type { Automation, AutomationRun, CreateAutomation, CreateScheduleTrigger, CreateWebhookTrigger, CreateWorkflow, ListAutomations, ListRuns, Page, PageQuery, ScheduleTrigger, Trigger, UpdateAutomation, UpdateScheduleTrigger, UpdateWebhookTrigger, UpdateWorkflow, WebhookTrigger, Workflow } from '../contract.js';
1
+ import type { Automation, AutomationRun, CreateAutomation, CreateScheduleTrigger, CreateWebhookTrigger, CreateWorkflow, Json, ListAutomations, ListRuns, Page, PageQuery, ScheduleTrigger, UpdateAutomation, UpdateScheduleTrigger, UpdateWebhookTrigger, UpdateWorkflow, WebhookTrigger, Workflow } from '../contract.js';
2
2
  export type StoredScheduleCreate = Omit<Required<CreateScheduleTrigger>, 'maxOccurrences'> & {
3
3
  maxOccurrences: number | null;
4
4
  nextRunAt: string;
@@ -7,6 +7,16 @@ export type StoredScheduleUpdate = UpdateScheduleTrigger & {
7
7
  nextRunAt?: string;
8
8
  remainingOccurrences?: number | null;
9
9
  };
10
+ export type WebhookTriggerRecord = Omit<WebhookTrigger, 'webhookUrl'> & {
11
+ endpointId: string;
12
+ };
13
+ export type StoredWebhookCreate = Omit<Required<CreateWebhookTrigger>, 'secret'> & {
14
+ secret: string | null;
15
+ endpointId: string;
16
+ };
17
+ export type StoredWebhookUpdate = Omit<UpdateWebhookTrigger, 'rotateUrl'> & {
18
+ endpointId?: string;
19
+ };
10
20
  export type NormalizedListAutomations = Required<PageQuery> & Omit<ListAutomations, keyof PageQuery>;
11
21
  export type NormalizedListRuns = Required<PageQuery> & Omit<ListRuns, keyof PageQuery>;
12
22
  export interface AutomationCrudRepository {
@@ -20,16 +30,17 @@ export interface AutomationCrudRepository {
20
30
  getScheduleTrigger(userId: string, automationId: string, triggerId: string): Promise<ScheduleTrigger | null>;
21
31
  updateScheduleTrigger(userId: string, automationId: string, triggerId: string, patch: StoredScheduleUpdate): Promise<ScheduleTrigger | null>;
22
32
  deleteScheduleTrigger(userId: string, automationId: string, triggerId: string): Promise<boolean>;
23
- createWebhookTrigger(userId: string, automationId: string, input: Required<CreateWebhookTrigger>): Promise<WebhookTrigger>;
24
- listWebhookTriggers(userId: string, automationId: string, query: Required<PageQuery>): Promise<Page<WebhookTrigger>>;
25
- getWebhookTrigger(userId: string, automationId: string, triggerId: string): Promise<WebhookTrigger | null>;
26
- updateWebhookTrigger(userId: string, automationId: string, triggerId: string, patch: UpdateWebhookTrigger): Promise<WebhookTrigger | null>;
33
+ createWebhookTrigger(userId: string, automationId: string, input: StoredWebhookCreate): Promise<WebhookTriggerRecord>;
34
+ listWebhookTriggers(userId: string, automationId: string, query: Required<PageQuery>): Promise<Page<WebhookTriggerRecord>>;
35
+ getWebhookTrigger(userId: string, automationId: string, triggerId: string): Promise<WebhookTriggerRecord | null>;
36
+ updateWebhookTrigger(userId: string, automationId: string, triggerId: string, patch: StoredWebhookUpdate): Promise<WebhookTriggerRecord | null>;
27
37
  deleteWebhookTrigger(userId: string, automationId: string, triggerId: string): Promise<boolean>;
28
- listTriggers(userId: string, automationId: string): Promise<Trigger[]>;
38
+ listTriggers(userId: string, automationId: string): Promise<Array<ScheduleTrigger | WebhookTriggerRecord>>;
29
39
  createWorkflow(userId: string, automationId: string, input: Required<CreateWorkflow>): Promise<Workflow>;
30
40
  getWorkflow(userId: string, automationId: string): Promise<Workflow | null>;
31
41
  updateWorkflow(userId: string, automationId: string, patch: UpdateWorkflow): Promise<Workflow | null>;
32
42
  deleteWorkflow(userId: string, automationId: string): Promise<boolean>;
33
43
  listRuns(userId: string, automationId: string, query: NormalizedListRuns): Promise<Page<AutomationRun>>;
34
44
  getRun(userId: string, automationId: string, runId: string): Promise<AutomationRun | null>;
45
+ runAutomationNow(userId: string, automationId: string, input: Json, idempotencyKey: string | null, now: Date): Promise<AutomationRun | null>;
35
46
  }
@@ -1,3 +1,3 @@
1
1
  import type { AutomationCrud } from '../contract.js';
2
- import type { CrudContext } from './context.js';
2
+ import { type CrudContext } from './context.js';
3
3
  export declare function runOperations(context: CrudContext): AutomationCrud['runs'];
@@ -1,8 +1,31 @@
1
- import { parseListRuns } from '../schema.js';
1
+ import { AutomationError, NotFoundError, ValidationError } from '../errors.js';
2
+ import { compiledAutomationPlan } from '../plan.js';
3
+ import { parseListRuns, parseRunAutomationNow } from '../schema.js';
2
4
  import { id, page } from '../validation.js';
5
+ import { assertTarget } from './context.js';
3
6
  export function runOperations(context) {
4
7
  const { principal, repository } = context;
5
8
  return {
9
+ runNow: async (automationId, input = {}) => {
10
+ context.write();
11
+ const automationKey = id(automationId, 'Automation id');
12
+ const parsed = parseRunAutomationNow(input);
13
+ const automation = await repository.getAutomation(principal.userId, automationKey);
14
+ if (!automation)
15
+ throw new NotFoundError('Automation');
16
+ assertTarget(principal, automation.targetId);
17
+ if (!automation.enabled)
18
+ throw new ValidationError('Automation must be enabled before it can run');
19
+ const workflow = await repository.getWorkflow(principal.userId, automationKey);
20
+ if (!workflow?.compiled)
21
+ throw new ValidationError('Automation needs a compiled workflow before it can run');
22
+ compiledAutomationPlan(workflow.compiled);
23
+ const run = await repository.runAutomationNow(principal.userId, automationKey, { kind: 'manual', payload: parsed.input ?? {} }, parsed.idempotencyKey ?? null, context.clock());
24
+ if (!run) {
25
+ throw new AutomationError('conflict', 'Automation changed before the run was admitted; inspect it and try again');
26
+ }
27
+ return run;
28
+ },
6
29
  list: async (automationId, query = {}) => {
7
30
  context.runsRead();
8
31
  const parsed = parseListRuns(query);