@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
@@ -0,0 +1,126 @@
1
+ import { NotFoundError, ValidationError } from './errors.js';
2
+ export function createAutomationToolSurface(sdk) {
3
+ const view = async (automationId, runs = false) => {
4
+ const automation = await sdk.automations.get(automationId);
5
+ if (!automation)
6
+ throw new NotFoundError('Automation');
7
+ const [triggers, workflow, history] = await Promise.all([
8
+ sdk.triggers.list(automationId),
9
+ sdk.workflow.get(automationId),
10
+ runs === false ? undefined : sdk.runs.list(automationId, runs),
11
+ ]);
12
+ return { automation, triggers, workflow, ...(history ? { runs: history } : {}) };
13
+ };
14
+ const surface = {
15
+ async create(input) {
16
+ const { schedules = [], webhooks = [], workflow, ...automationInput } = input;
17
+ validateCreateIds(schedules, webhooks);
18
+ const staged = schedules.length > 0 || webhooks.length > 0 || workflow !== undefined;
19
+ const requestedEnabled = automationInput.enabled !== false;
20
+ const automation = await sdk.automations.create({
21
+ ...automationInput,
22
+ ...(staged ? { enabled: false } : {}),
23
+ });
24
+ try {
25
+ if (workflow)
26
+ await sdk.workflow.create(automation.id, workflow);
27
+ for (const schedule of schedules)
28
+ await sdk.triggers.schedule.create(automation.id, schedule);
29
+ for (const webhook of webhooks)
30
+ await sdk.triggers.webhook.create(automation.id, webhook);
31
+ if (staged && requestedEnabled)
32
+ await sdk.automations.update(automation.id, { enabled: true });
33
+ }
34
+ catch (error) {
35
+ throw disabledDraftError(automation.id, error);
36
+ }
37
+ return view(automation.id);
38
+ },
39
+ list: (query = {}) => sdk.automations.list(query),
40
+ get: view,
41
+ async update(automationId, input) {
42
+ requireChanges(input);
43
+ validateChanges(input.schedules, 'schedule');
44
+ validateChanges(input.webhooks, 'webhook');
45
+ const current = await sdk.automations.get(automationId);
46
+ if (!current)
47
+ throw new NotFoundError('Automation');
48
+ const changesResources = hasChanges(input.schedules) || hasChanges(input.webhooks) || Boolean(input.workflow);
49
+ const finalEnabled = input.patch?.enabled ?? current.enabled;
50
+ const { enabled: _enabled, ...metadata } = input.patch ?? {};
51
+ if (!changesResources) {
52
+ await sdk.automations.update(automationId, input.patch);
53
+ return view(automationId);
54
+ }
55
+ if (current.enabled)
56
+ await sdk.automations.update(automationId, { enabled: false });
57
+ try {
58
+ await applyTriggerChanges(automationId, input.schedules, sdk.triggers.schedule);
59
+ await applyTriggerChanges(automationId, input.webhooks, sdk.triggers.webhook);
60
+ await applyWorkflowChange(automationId, input.workflow, sdk.workflow);
61
+ await sdk.automations.update(automationId, { ...metadata, enabled: finalEnabled });
62
+ }
63
+ catch (error) {
64
+ throw disabledDraftError(automationId, error);
65
+ }
66
+ return view(automationId);
67
+ },
68
+ async delete(automationId) {
69
+ await sdk.automations.delete(automationId);
70
+ return { deleted: automationId };
71
+ },
72
+ runNow: (automationId, input = {}) => sdk.runs.runNow(automationId, input),
73
+ };
74
+ return Object.freeze(surface);
75
+ }
76
+ async function applyTriggerChanges(automationId, changes, operations) {
77
+ if (!changes)
78
+ return;
79
+ for (const triggerId of changes.delete ?? [])
80
+ await operations.delete(automationId, triggerId);
81
+ for (const item of changes.update ?? [])
82
+ await operations.update(automationId, item.id, item.patch);
83
+ for (const input of changes.create ?? [])
84
+ await operations.create(automationId, input);
85
+ }
86
+ async function applyWorkflowChange(automationId, change, workflow) {
87
+ if (!change)
88
+ return;
89
+ if ('create' in change)
90
+ await workflow.create(automationId, change.create);
91
+ else if ('update' in change)
92
+ await workflow.update(automationId, change.update);
93
+ else
94
+ await workflow.delete(automationId);
95
+ }
96
+ function requireChanges(input) {
97
+ if (!input.patch && !hasChanges(input.schedules) && !hasChanges(input.webhooks) && !input.workflow) {
98
+ throw new ValidationError('Automation update must include a change');
99
+ }
100
+ }
101
+ function hasChanges(changes) {
102
+ return Boolean(changes && [changes.create, changes.update, changes.delete]
103
+ .some((items) => items && items.length > 0));
104
+ }
105
+ function validateChanges(changes, label) {
106
+ if (!changes)
107
+ return;
108
+ const ids = [
109
+ ...changes.create?.flatMap(({ id }) => id ? [id] : []) ?? [],
110
+ ...changes.update?.map(({ id }) => id) ?? [],
111
+ ...changes.delete ?? [],
112
+ ];
113
+ if (new Set(ids).size !== ids.length) {
114
+ throw new ValidationError(`The same ${label} trigger cannot be changed twice`);
115
+ }
116
+ }
117
+ function validateCreateIds(schedules, webhooks) {
118
+ const ids = [...schedules, ...webhooks].flatMap(({ id }) => id ? [id] : []);
119
+ if (new Set(ids).size !== ids.length) {
120
+ throw new ValidationError('Trigger ids must be unique within an automation');
121
+ }
122
+ }
123
+ function disabledDraftError(automationId, error) {
124
+ const reason = error instanceof Error ? error.message : String(error);
125
+ return new Error(`Automation ${automationId} remains disabled because configuration failed: ${reason}`);
126
+ }
@@ -1,10 +1,5 @@
1
1
  import type { Json } from './contract.js';
2
2
  export type { Json } from './contract.js';
3
- /** The only authenticated identity Automations accepts from Core. */
4
- export interface AutomationTarget {
5
- userId: string;
6
- targetId: string;
7
- }
8
3
  interface StoredTriggerBase {
9
4
  userId: string;
10
5
  automationId: string;
@@ -13,9 +8,10 @@ interface StoredTriggerBase {
13
8
  }
14
9
  export interface StoredEventTrigger extends StoredTriggerBase {
15
10
  kind: 'event';
11
+ endpointId: string;
16
12
  source: string;
17
13
  event: string;
18
- secret: string;
14
+ secret: string | null;
19
15
  }
20
16
  export interface DueCronTrigger extends StoredTriggerBase {
21
17
  kind: 'cron';
@@ -32,6 +28,9 @@ export type RunInput = {
32
28
  } | {
33
29
  kind: 'cron';
34
30
  scheduledFor: string;
31
+ } | {
32
+ kind: 'manual';
33
+ payload: Json;
35
34
  };
36
35
  export type RunStatus = 'pending' | 'sent' | 'running' | 'completed' | 'failed';
37
36
  export interface AutomationRun {
@@ -51,27 +50,12 @@ export interface AutomationRun {
51
50
  output?: Json;
52
51
  error?: string;
53
52
  }
54
- export interface RunUpdate {
55
- status: 'running' | 'completed' | 'failed';
56
- startedAt?: string;
57
- finishedAt?: string;
58
- output?: Json;
59
- error?: string;
60
- }
61
53
  export interface AutomationStore {
62
- eventTriggers(target: AutomationTarget): Promise<StoredEventTrigger[]>;
54
+ eventTrigger(endpointId: string): Promise<StoredEventTrigger | null>;
63
55
  dueCronTriggers(now: Date): Promise<DueCronTrigger[]>;
64
56
  enqueueEvent(trigger: StoredEventTrigger, input: Extract<RunInput, {
65
57
  kind: 'event';
66
- }>, now: Date): Promise<AutomationRun | null>;
58
+ }>, now: Date, deliveryKey: string | null): Promise<AutomationRun | null>;
67
59
  enqueueCron(trigger: DueCronTrigger, nextRunAt: string, now: Date): Promise<AutomationRun | null>;
68
- pendingRuns(target: AutomationTarget): Promise<AutomationRun[]>;
69
- markSent(runId: string, target: AutomationTarget, now: Date): Promise<boolean>;
70
- updateRun(target: AutomationTarget, runId: string, update: RunUpdate): Promise<AutomationRun | null>;
71
- }
72
- /** The complete transport capability Automations needs from Core. */
73
- export interface AutomationTransport {
74
- isOnline(target: AutomationTarget): boolean;
75
- send(run: AutomationRun): Promise<boolean>;
76
60
  }
77
- export type AutomationLog = (event: 'event.rejected' | 'run.pending' | 'drain.started' | 'run.sent' | 'run.failed', details: Readonly<Record<string, string>>) => void;
61
+ export type AutomationLog = (event: 'event.rejected' | 'run.pending', details: Readonly<Record<string, string>>) => void;
@@ -1,6 +1,20 @@
1
1
  import type { Json, StoredEventTrigger } from './types.js';
2
2
  export declare function normalizeHeaders(headers: Record<string, string>): Record<string, string>;
3
- export declare function verifyEventSecret(secret: string, headers: Record<string, string>, body: Buffer): boolean;
3
+ export declare function verifyEventSecret(secret: string | null, headers: Record<string, string>, body: Buffer): boolean;
4
+ /**
5
+ * Generates and verifies the bearer capability embedded in a webhook URL.
6
+ * Supabase stores only the random endpoint id; possession also requires the
7
+ * service-held HMAC key, so a database read cannot mint working URLs.
8
+ */
9
+ export declare class WebhookEndpoints {
10
+ #private;
11
+ readonly origin: string;
12
+ constructor(origin: string, key: string | Buffer, randomId?: () => string);
13
+ createId(): string;
14
+ url(id: string): string;
15
+ resolve(token: string): string | null;
16
+ }
17
+ export declare function webhookDeliveryKey(headers: Record<string, string>, payload: Json): string | null;
4
18
  export declare function matchesEvent(trigger: Pick<StoredEventTrigger, 'source' | 'event'>, source: string, event: string): boolean;
5
19
  export declare function eventReferences(headers: Record<string, string>, payload: Json): {
6
20
  primary: {
@@ -1,6 +1,16 @@
1
1
  import crypto from 'node:crypto';
2
2
  const HMAC_HEADERS = ['x-webhook-signature', 'x-hub-signature-256', 'x-hub-signature'];
3
3
  const TOKEN_HEADERS = ['x-amalgm-webhook-secret', 'x-webhook-secret', 'x-gitlab-token'];
4
+ const DELIVERY_HEADERS = [
5
+ 'idempotency-key',
6
+ 'x-github-delivery',
7
+ 'x-gitlab-event-uuid',
8
+ 'x-linear-delivery',
9
+ 'svix-id',
10
+ 'webhook-id',
11
+ ];
12
+ const ENDPOINT_ID = /^[a-f0-9]{32}$/;
13
+ const MAX_DELIVERY_KEY_BYTES = 500;
4
14
  function equal(left, right) {
5
15
  const a = Buffer.from(left);
6
16
  const b = Buffer.from(right);
@@ -10,6 +20,8 @@ export function normalizeHeaders(headers) {
10
20
  return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
11
21
  }
12
22
  export function verifyEventSecret(secret, headers, body) {
23
+ if (secret === null)
24
+ return true;
13
25
  const normalized = normalizeHeaders(headers);
14
26
  for (const name of HMAC_HEADERS) {
15
27
  const supplied = normalized[name];
@@ -28,6 +40,63 @@ export function verifyEventSecret(secret, headers, body) {
28
40
  }
29
41
  return false;
30
42
  }
43
+ /**
44
+ * Generates and verifies the bearer capability embedded in a webhook URL.
45
+ * Supabase stores only the random endpoint id; possession also requires the
46
+ * service-held HMAC key, so a database read cannot mint working URLs.
47
+ */
48
+ export class WebhookEndpoints {
49
+ origin;
50
+ #key;
51
+ #randomId;
52
+ constructor(origin, key, randomId = endpointId) {
53
+ this.origin = webhookOrigin(origin);
54
+ this.#key = Buffer.isBuffer(key) ? Buffer.from(key) : Buffer.from(key, 'utf8');
55
+ if (this.#key.length < 32)
56
+ throw new Error('Webhook endpoint key must contain at least 32 bytes');
57
+ this.#randomId = randomId;
58
+ }
59
+ createId() {
60
+ const value = this.#randomId();
61
+ if (!ENDPOINT_ID.test(value))
62
+ throw new Error('Webhook endpoint id must be 128-bit lowercase hex');
63
+ return value;
64
+ }
65
+ url(id) {
66
+ if (!ENDPOINT_ID.test(id))
67
+ throw new Error('Invalid webhook endpoint id');
68
+ return `${this.origin}/e/${id}.${this.#signature(id)}`;
69
+ }
70
+ resolve(token) {
71
+ const separator = token.indexOf('.');
72
+ if (separator === -1 || token.indexOf('.', separator + 1) !== -1)
73
+ return null;
74
+ const id = token.slice(0, separator);
75
+ const supplied = token.slice(separator + 1);
76
+ if (!ENDPOINT_ID.test(id) || !supplied || !equal(supplied, this.#signature(id)))
77
+ return null;
78
+ return id;
79
+ }
80
+ #signature(id) {
81
+ return crypto.createHmac('sha256', this.#key).update(id).digest('base64url');
82
+ }
83
+ }
84
+ export function webhookDeliveryKey(headers, payload) {
85
+ const normalized = normalizeHeaders(headers);
86
+ for (const name of DELIVERY_HEADERS) {
87
+ const value = normalized[name]?.trim();
88
+ if (!value)
89
+ continue;
90
+ return boundedDeliveryKey(`${name}:${value}`);
91
+ }
92
+ if (normalized['stripe-signature']) {
93
+ const object = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
94
+ if (typeof object.id === 'string' && object.id.trim()) {
95
+ return boundedDeliveryKey(`stripe-event:${object.id.trim()}`);
96
+ }
97
+ }
98
+ return null;
99
+ }
31
100
  export function matchesEvent(trigger, source, event) {
32
101
  return (trigger.source === '*' || trigger.source === source)
33
102
  && (trigger.event === '*' || trigger.event === event);
@@ -58,3 +127,20 @@ export function eventReferences(headers, payload) {
58
127
  export function eventReference(headers, payload) {
59
128
  return eventReferences(headers, payload).primary;
60
129
  }
130
+ const endpointId = () => crypto.randomBytes(16).toString('hex');
131
+ function webhookOrigin(value) {
132
+ const parsed = new URL(value);
133
+ const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname.endsWith('.local');
134
+ if (parsed.protocol !== 'https:' && !(local && parsed.protocol === 'http:')) {
135
+ throw new Error('Webhook origin must use HTTPS');
136
+ }
137
+ if (parsed.pathname !== '/' || parsed.search || parsed.hash)
138
+ throw new Error('Webhook origin cannot include a path');
139
+ return parsed.toString().replace(/\/$/, '');
140
+ }
141
+ function boundedDeliveryKey(value) {
142
+ if (Buffer.byteLength(value) > MAX_DELIVERY_KEY_BYTES) {
143
+ throw Object.assign(new Error('Webhook delivery id is too long'), { status: 400 });
144
+ }
145
+ return value;
146
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@amalgm/automations",
3
- "version": "0.2.1",
4
- "description": "Amalgm's cloud automation SDK: Supabase-backed configuration, trigger admission, and run delivery.",
3
+ "version": "0.2.3",
4
+ "description": "Amalgm's automation SDK: durable trigger admission and target-machine execution.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
@@ -39,7 +39,7 @@
39
39
  "files": [
40
40
  "dist",
41
41
  "skills",
42
- "supabase",
42
+ "supabase/migrations",
43
43
  "AXIOMS.md",
44
44
  "PURPOSE.md",
45
45
  "README.md"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: automations
3
- description: Create, inspect, change, or delete Amalgm automations, schedules, workflows, and run history through the Automations MCP tools.
3
+ description: Create, inspect, change, delete, or manually run complete Amalgm automation definitions and inspect their run history through the Automations MCP tools.
4
4
  ---
5
5
 
6
6
  # Amalgm Automations
@@ -11,11 +11,8 @@ the agent adapter over the same hosted SDK used by the UI.
11
11
  ## Create a scheduled notification
12
12
 
13
13
  For a request such as “remind me to call my mom every minute for the next ten
14
- minutes,” create three resources for the current machine:
15
-
16
- 1. `amalgm_automations_create` with a clear name and the current machine target.
17
- 2. `amalgm_workflow_create` with a readable script summary and this compiled
18
- plan:
14
+ minutes,” make one `amalgm_automations_create` call containing the complete
15
+ definition. Include a readable workflow summary and this compiled plan:
19
16
 
20
17
  ```json
21
18
  {
@@ -30,23 +27,39 @@ minutes,” create three resources for the current machine:
30
27
  }
31
28
  ```
32
29
 
33
- 3. `amalgm_schedule_triggers_create` with cron `* * * * *`, the user's
34
- timezone, and `maxOccurrences: 10`.
30
+ Include one schedule with cron `* * * * *`, the user's timezone, and
31
+ `maxOccurrences: 10`. Omit `targetId` in a machine-bound session; never guess
32
+ an opaque target id.
35
33
 
36
- Create the automation disabled, attach its workflow and trigger, then enable it
37
- only after both exist. If setup fails, leave it disabled and explain which
38
- resource failed. Never replace a bounded occurrence count with an unbounded
39
- schedule plus a promise to clean it up later.
34
+ The tool stages multi-resource configuration disabled and enables it only after
35
+ setup succeeds. If setup fails, report the returned disabled draft id. Never
36
+ replace a bounded occurrence count with an unbounded schedule plus a promise to
37
+ clean it up later.
40
38
 
41
39
  ## Read and change
42
40
 
43
- Use list/get before changing an existing automation. Schedule and webhook
44
- triggers are separate resources. A workflow is zero-or-one per automation. Run
45
- history is read-only and remains after configuration deletion.
41
+ Use `amalgm_automations_list` or `amalgm_automations_get` before changing an
42
+ existing automation. Use `amalgm_automations_update` for grouped metadata,
43
+ schedule, webhook, or workflow changes, and `amalgm_automations_delete` only
44
+ after identifying the exact automation. Schedule and webhook triggers remain
45
+ distinct resources inside the definition. A workflow is zero-or-one per
46
+ automation. Run history is read-only, requested through `get`, and remains
47
+ after configuration deletion.
48
+
49
+ Use `amalgm_automations_run_now` when the user wants an enabled automation to
50
+ run immediately. Supply `idempotency_key` when a caller may retry the same
51
+ request. The result is a durable `pending` run, not proof that its selected
52
+ machine has finished it; use `amalgm_automations_get` with run history when the
53
+ user asks for the outcome.
54
+
55
+ Action discovery belongs to the Tools product, not Automations.
46
56
 
47
57
  `pending` means a run is durable and waiting for its selected machine. `sent`
48
58
  or `running` means that machine holds a lease. `completed` and `failed` are
49
59
  terminal. Do not infer delivery from the schedule alone; inspect runs when the
50
60
  user asks whether it actually happened.
51
61
 
52
- Webhook secrets are write-only. Never echo, log, or place them in a workflow.
62
+ Each webhook trigger returns one copyable `webhookUrl`; use that URL as the
63
+ provider destination. A separate provider signing secret is optional and
64
+ write-only. Never echo, log, or place either credential in a workflow. Rotate
65
+ the URL when its bearer capability may have been exposed.