@amalgm/automations 0.2.2 → 0.2.4

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 +31 -23
  2. package/PURPOSE.md +16 -10
  3. package/README.md +30 -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 +18 -16
  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,57 @@
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.
28
+ Possession plus any configured provider proof admits that trigger; source
29
+ and event labels never form a second routing gate. Its random locator is not
30
+ sufficient without the hosted service's HMAC, and caller input never
31
+ supplies an owner or target.
32
+ 11. Optional provider signing secrets are persisted but write-only: normal
33
+ reads reveal only that a secret is configured.
34
+ 12. Run history is read-only in the control plane and always scoped to its
30
35
  automation's owner.
31
- 12. Storage records, Supabase RPCs, HTTP details, and MCP protocol details are
36
+ 13. Storage records, Supabase RPCs, HTTP details, and MCP protocol details are
32
37
  implementation concerns, not SDK concepts.
33
- 13. The control-plane SDK is the only configuration write path. The delivery
38
+ 14. The control-plane SDK is the only configuration write path. The delivery
34
39
  rail reads that configuration and writes only schedule clocks and run
35
40
  state; it never owns a second automation-definition API.
36
- 14. Admitting a run atomically verifies the current enabled configuration,
41
+ 15. Admitting a run atomically verifies the current enabled configuration,
37
42
  stores a secret-free snapshot, and, for a schedule, advances exactly the
38
43
  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
44
+ 16. Legacy local automation storage is not a compatibility authority. Engine
45
+ is deprecated read-only evidence; active callers use this SDK and its
46
+ standalone service, and no old store may run beside them.
47
+ 17. Event ingress returns success only after its admitted run is durable in
48
+ Supabase. A repeated explicit delivery id resolves to the same run.
49
+ 18. A finite schedule decrements its durable remaining occurrence count in the
45
50
  same transaction that admits a run; zero disables the trigger.
46
- 18. A machine receives work only by exclusively leasing runs whose persisted
51
+ 19. A machine receives work only by exclusively leasing runs whose persisted
47
52
  target equals the `computer_id` in its DPoP-bound access token.
48
- 19. The configured public origin, never forwarding headers, reconstructs the
53
+ 20. The configured public origin, never forwarding headers, reconstructs the
49
54
  DPoP request URL behind the Fly proxy.
50
- 20. Machine execution consumes the immutable workflow snapshot stored on the
55
+ 21. Machine execution consumes the immutable workflow snapshot stored on the
51
56
  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
57
+ 22. A compiled workflow is a small declarative sequence of one or more tool actions. The
53
58
  executor receives tool calling as a host capability and never embeds a
54
59
  Channels, Shell, CLI, or provider special case.
55
- 22. Automations is a standalone hosted service. Gateway owns none of its API,
60
+ 23. Automations is a standalone hosted service. Gateway owns none of its API,
56
61
  scheduling, claim, execution, or persistence path.
57
- 23. The durable run ledger is the offline queue. The platform never keeps a
62
+ 24. The durable run ledger is the offline queue. The platform never keeps a
58
63
  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
64
+ 25. A run advances through its immutable plan in order. Every step transition
60
65
  commits under the run's current lease before execution advances, and a
61
66
  completed step is never intentionally invoked again.
62
- 25. A machine may begin an action only after the service confirms its current
67
+ 26. A machine may begin an action only after the service confirms its current
63
68
  lease. It renews that lease while the action is running and stops advancing
64
69
  when renewal fails.
65
- 26. One run-step pair has one stable idempotency key. Step ids are unique in a
70
+ 27. One run-step pair has one stable idempotency key. Step ids are unique in a
66
71
  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
72
+ 28. Only transient transport failures retry. They release the same run with a
68
73
  bounded future retry time and retain completed step output; invalid plans,
69
74
  missing actions, authorization failures, and other deterministic errors
70
75
  are terminal.
71
- 28. The selected target executes every action effect. The hosted service owns
76
+ 29. The selected target executes every action effect. The hosted service owns
72
77
  only configuration, admission, leases, the step journal, and run history.
78
+ 30. Run Now is triggerless manual admission. It atomically snapshots the
79
+ current enabled automation and compiled workflow into the pending ledger;
80
+ 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,15 @@ 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. Provider source and event labels describe the
51
+ occurrence; they are not a second routing gate after the URL has selected the
52
+ trigger. The signed URL can be rotated without changing the automation, and its
53
+ usable bearer token cannot be reconstructed from the database alone.
49
54
 
50
55
  Amalgm supplies a resolved authenticated principal from its user session or
51
56
  HMAC-refresh flow; future API keys resolve to the same principal capability.
@@ -57,7 +62,8 @@ connectivity, and narrow host capabilities. The product receives identity and
57
62
  scopes — never raw credentials or Core storage — and neither side reaches into
58
63
  the other's storage or reimplements the other's decisions.
59
64
 
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.
65
+ This repository and its standalone Fly host are the active Automations
66
+ authority. Shell calls the published machine-claim and execution contracts
67
+ directly; the UI calls the hosted control plane through the published client.
68
+ Any remaining Engine automation code is deprecated migration/parity evidence,
69
+ 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,31 @@ 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
+ That URL selects the exact trigger. Provider source and event labels are
103
+ recorded as occurrence metadata, not used as another routing condition.
104
+ `Idempotency-Key` and common provider delivery-id headers deduplicate retries
105
+ against the same trigger. The caller never supplies a user or target id.
106
+
79
107
  The target is never accepted in a machine request. It comes from the verified
80
108
  access token. Run leases expire and are safely reclaimable; terminal updates
81
109
  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
  }
@@ -1,5 +1,5 @@
1
1
  import { nextCronAt } from './schedule.js';
2
- import { eventReferences, matchesEvent, verifyEventSecret } from './webhook.js';
2
+ import { eventReferences, verifyEventSecret } from './webhook.js';
3
3
  const noop = () => { };
4
4
  export class EventRejectedError extends Error {
5
5
  constructor() {
@@ -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,24 @@ 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
- let reference = references.primary;
30
- let triggers = authenticated.filter((trigger) => matchesEvent(trigger, reference.source, reference.event));
31
- if (triggers.length === 0 && references.fallback) {
32
- reference = references.fallback;
33
- triggers = authenticated.filter((trigger) => matchesEvent(trigger, reference.source, reference.event));
34
- }
35
- if (triggers.length === 0)
36
- return [];
36
+ const reference = references.primary;
37
37
  const now = input.now || new Date();
38
38
  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;
39
+ const run = await this.store.enqueueEvent(trigger, runInput, now, input.deliveryKey);
40
+ if (!run)
41
+ return { run: null, ...reference };
42
+ this.#logPending([run]);
43
+ return { run, ...reference };
42
44
  }
43
45
  async fireDueCrons(now = new Date(), maximumRuns = 1_000) {
44
46
  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);