@amalgm/automations 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AXIOMS.md +37 -16
  2. package/PURPOSE.md +14 -1
  3. package/README.md +5 -2
  4. package/dist/host/config.d.ts +1 -0
  5. package/dist/host/config.js +1 -0
  6. package/dist/host/main.js +16 -4
  7. package/dist/host/server.d.ts +3 -0
  8. package/dist/host/server.js +27 -8
  9. package/dist/src/automations.d.ts +4 -7
  10. package/dist/src/automations.js +13 -65
  11. package/dist/src/client.d.ts +1 -0
  12. package/dist/src/client.js +3 -2
  13. package/dist/src/contract.d.ts +4 -30
  14. package/dist/src/crud/automations.js +3 -3
  15. package/dist/src/crud/context.d.ts +1 -0
  16. package/dist/src/crud/context.js +10 -1
  17. package/dist/src/events-http.js +4 -0
  18. package/dist/src/executor.d.ts +12 -3
  19. package/dist/src/executor.js +163 -44
  20. package/dist/src/http.js +4 -0
  21. package/dist/src/index.d.ts +4 -2
  22. package/dist/src/index.js +4 -2
  23. package/dist/src/machine-client.d.ts +1 -0
  24. package/dist/src/machine-client.js +2 -0
  25. package/dist/src/machine.d.ts +2 -1
  26. package/dist/src/machine.js +6 -1
  27. package/dist/src/mcp.d.ts +3 -0
  28. package/dist/src/mcp.js +53 -50
  29. package/dist/src/plan.d.ts +3 -0
  30. package/dist/src/plan.js +48 -0
  31. package/dist/src/run-contract.d.ts +46 -0
  32. package/dist/src/run-contract.js +1 -0
  33. package/dist/src/run-journal.d.ts +8 -0
  34. package/dist/src/run-journal.js +56 -0
  35. package/dist/src/runner.d.ts +14 -0
  36. package/dist/src/runner.js +70 -0
  37. package/dist/src/schema.d.ts +46 -46
  38. package/dist/src/schema.js +18 -3
  39. package/dist/src/supabase-crud/mappers.js +2 -0
  40. package/dist/src/supabase-crud/rows.d.ts +2 -0
  41. package/dist/src/supabase-machine.js +2 -0
  42. package/dist/src/supabase-store.d.ts +1 -4
  43. package/dist/src/supabase-store.js +0 -24
  44. package/dist/src/tool-surface.d.ts +46 -0
  45. package/dist/src/tool-surface.js +125 -0
  46. package/dist/src/types.d.ts +1 -16
  47. package/package.json +5 -5
  48. package/skills/automations/SKILL.md +20 -15
  49. package/supabase/migrations/20260830010000_durable_step_retries.sql +332 -0
@@ -0,0 +1,125 @@
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
+ };
73
+ return Object.freeze(surface);
74
+ }
75
+ async function applyTriggerChanges(automationId, changes, operations) {
76
+ if (!changes)
77
+ return;
78
+ for (const triggerId of changes.delete ?? [])
79
+ await operations.delete(automationId, triggerId);
80
+ for (const item of changes.update ?? [])
81
+ await operations.update(automationId, item.id, item.patch);
82
+ for (const input of changes.create ?? [])
83
+ await operations.create(automationId, input);
84
+ }
85
+ async function applyWorkflowChange(automationId, change, workflow) {
86
+ if (!change)
87
+ return;
88
+ if ('create' in change)
89
+ await workflow.create(automationId, change.create);
90
+ else if ('update' in change)
91
+ await workflow.update(automationId, change.update);
92
+ else
93
+ await workflow.delete(automationId);
94
+ }
95
+ function requireChanges(input) {
96
+ if (!input.patch && !hasChanges(input.schedules) && !hasChanges(input.webhooks) && !input.workflow) {
97
+ throw new ValidationError('Automation update must include a change');
98
+ }
99
+ }
100
+ function hasChanges(changes) {
101
+ return Boolean(changes && [changes.create, changes.update, changes.delete]
102
+ .some((items) => items && items.length > 0));
103
+ }
104
+ function validateChanges(changes, label) {
105
+ if (!changes)
106
+ return;
107
+ const ids = [
108
+ ...changes.create?.flatMap(({ id }) => id ? [id] : []) ?? [],
109
+ ...changes.update?.map(({ id }) => id) ?? [],
110
+ ...changes.delete ?? [],
111
+ ];
112
+ if (new Set(ids).size !== ids.length) {
113
+ throw new ValidationError(`The same ${label} trigger cannot be changed twice`);
114
+ }
115
+ }
116
+ function validateCreateIds(schedules, webhooks) {
117
+ const ids = [...schedules, ...webhooks].flatMap(({ id }) => id ? [id] : []);
118
+ if (new Set(ids).size !== ids.length) {
119
+ throw new ValidationError('Trigger ids must be unique within an automation');
120
+ }
121
+ }
122
+ function disabledDraftError(automationId, error) {
123
+ const reason = error instanceof Error ? error.message : String(error);
124
+ return new Error(`Automation ${automationId} remains disabled because configuration failed: ${reason}`);
125
+ }
@@ -51,13 +51,6 @@ export interface AutomationRun {
51
51
  output?: Json;
52
52
  error?: string;
53
53
  }
54
- export interface RunUpdate {
55
- status: 'running' | 'completed' | 'failed';
56
- startedAt?: string;
57
- finishedAt?: string;
58
- output?: Json;
59
- error?: string;
60
- }
61
54
  export interface AutomationStore {
62
55
  eventTriggers(target: AutomationTarget): Promise<StoredEventTrigger[]>;
63
56
  dueCronTriggers(now: Date): Promise<DueCronTrigger[]>;
@@ -65,13 +58,5 @@ export interface AutomationStore {
65
58
  kind: 'event';
66
59
  }>, now: Date): Promise<AutomationRun | null>;
67
60
  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
61
  }
77
- export type AutomationLog = (event: 'event.rejected' | 'run.pending' | 'drain.started' | 'run.sent' | 'run.failed', details: Readonly<Record<string, string>>) => void;
62
+ export type AutomationLog = (event: 'event.rejected' | 'run.pending', details: Readonly<Record<string, string>>) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@amalgm/automations",
3
- "version": "0.2.0",
4
- "description": "Amalgm's cloud automation SDK: Supabase-backed configuration, trigger admission, and run delivery.",
3
+ "version": "0.2.2",
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"
@@ -58,7 +58,7 @@
58
58
  "node": ">=20"
59
59
  },
60
60
  "dependencies": {
61
- "@amalgm/core": "0.4.2",
61
+ "@amalgm/core": "0.4.4",
62
62
  "@modelcontextprotocol/sdk": "^1.30.0",
63
63
  "@supabase/supabase-js": "2.57.4",
64
64
  "cron-parser": "^5.4.0",
@@ -70,6 +70,6 @@
70
70
  "@types/pg": "^8.20.1",
71
71
  "pg": "^8.16.3",
72
72
  "tsx": "^4.23.1",
73
- "typescript": "^7.0.2"
73
+ "typescript": "5.9.3"
74
74
  }
75
75
  }
@@ -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, or delete 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,19 +27,27 @@ 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
+ Action discovery belongs to the Tools product, not Automations. Manual
50
+ execution is not advertised until the delivery SDK supports it.
46
51
 
47
52
  `pending` means a run is durable and waiting for its selected machine. `sent`
48
53
  or `running` means that machine holds a lease. `completed` and `failed` are
@@ -0,0 +1,332 @@
1
+ -- One Supabase run row is the queue, lease, durable step journal, and history.
2
+ -- The target machine is the only executor.
3
+
4
+ ALTER TABLE public.amalgm_automation_runs
5
+ ADD COLUMN IF NOT EXISTS retry_at timestamptz;
6
+
7
+ DROP FUNCTION IF EXISTS list_amalgm_pending_runs(uuid, text);
8
+ DROP FUNCTION IF EXISTS mark_amalgm_run_sent(uuid, uuid, text, timestamptz);
9
+ DROP FUNCTION IF EXISTS update_amalgm_run(uuid, uuid, text, jsonb);
10
+
11
+ ALTER TABLE public.amalgm_automation_runs
12
+ DROP CONSTRAINT IF EXISTS amalgm_automation_run_retry_state;
13
+ ALTER TABLE public.amalgm_automation_runs
14
+ ADD CONSTRAINT amalgm_automation_run_retry_state CHECK (
15
+ status = 'pending' OR retry_at IS NULL
16
+ );
17
+
18
+ DROP INDEX IF EXISTS public.amalgm_automation_runs_claimable;
19
+ CREATE INDEX amalgm_automation_runs_claimable
20
+ ON public.amalgm_automation_runs(user_id, target_id, status, retry_at, lease_expires_at, created_at, id)
21
+ WHERE status IN ('pending', 'sent', 'running');
22
+
23
+ CREATE OR REPLACE FUNCTION list_amalgm_event_triggers(p_user_id uuid, p_target_id text)
24
+ RETURNS TABLE (
25
+ user_id uuid,
26
+ automation_id text,
27
+ trigger_id text,
28
+ target_id text,
29
+ source text,
30
+ event text,
31
+ secret text
32
+ )
33
+ LANGUAGE sql
34
+ SECURITY DEFINER
35
+ SET search_path = public
36
+ AS $$
37
+ SELECT t.user_id, t.automation_id, t.id, a.target_id, t.source, t.event, t.secret
38
+ FROM public.amalgm_automation_triggers t
39
+ JOIN public.amalgm_automations a
40
+ ON a.user_id = t.user_id AND a.id = t.automation_id
41
+ JOIN public.amalgm_automation_workflows w
42
+ ON w.user_id = a.user_id AND w.automation_id = a.id
43
+ WHERE t.kind = 'webhook' AND t.user_id = p_user_id
44
+ AND a.target_id = p_target_id AND t.enabled AND a.enabled
45
+ AND jsonb_typeof(w.compiled) = 'object'
46
+ AND w.compiled->>'version' = '1'
47
+ AND jsonb_typeof(w.compiled->'steps') = 'array'
48
+ AND jsonb_array_length(
49
+ CASE WHEN jsonb_typeof(w.compiled->'steps') = 'array'
50
+ THEN w.compiled->'steps' ELSE '[]'::jsonb END
51
+ ) BETWEEN 1 AND 100;
52
+ $$;
53
+
54
+ DROP FUNCTION IF EXISTS list_due_amalgm_crons(timestamptz);
55
+ CREATE FUNCTION list_due_amalgm_crons(p_now timestamptz)
56
+ RETURNS TABLE (
57
+ user_id uuid,
58
+ automation_id text,
59
+ trigger_id text,
60
+ target_id text,
61
+ cron text,
62
+ timezone text,
63
+ next_run_at timestamptz,
64
+ remaining_occurrences integer
65
+ )
66
+ LANGUAGE sql
67
+ SECURITY DEFINER
68
+ SET search_path = public
69
+ AS $$
70
+ SELECT t.user_id, t.automation_id, t.id, a.target_id,
71
+ t.cron, t.timezone, t.next_run_at, t.remaining_occurrences
72
+ FROM public.amalgm_automation_triggers t
73
+ JOIN public.amalgm_automations a
74
+ ON a.user_id = t.user_id AND a.id = t.automation_id
75
+ JOIN public.amalgm_automation_workflows w
76
+ ON w.user_id = a.user_id AND w.automation_id = a.id
77
+ WHERE t.kind = 'schedule' AND t.next_run_at <= p_now
78
+ AND (t.remaining_occurrences IS NULL OR t.remaining_occurrences > 0)
79
+ AND t.enabled AND a.enabled
80
+ AND jsonb_typeof(w.compiled) = 'object'
81
+ AND w.compiled->>'version' = '1'
82
+ AND jsonb_typeof(w.compiled->'steps') = 'array'
83
+ AND jsonb_array_length(
84
+ CASE WHEN jsonb_typeof(w.compiled->'steps') = 'array'
85
+ THEN w.compiled->'steps' ELSE '[]'::jsonb END
86
+ ) BETWEEN 1 AND 100
87
+ ORDER BY t.next_run_at, t.user_id, t.automation_id, t.id;
88
+ $$;
89
+
90
+ -- Re-check the executable plan inside the same transaction that admits work.
91
+ -- Discovery is advisory; this function is the authority at the race boundary.
92
+ CREATE OR REPLACE FUNCTION enqueue_amalgm_run(
93
+ p_kind text,
94
+ p_user_id uuid,
95
+ p_automation_id text,
96
+ p_trigger_id text,
97
+ p_target_id text,
98
+ p_expected_next_run_at timestamptz,
99
+ p_next_run_at timestamptz,
100
+ p_input jsonb,
101
+ p_now timestamptz
102
+ ) RETURNS SETOF public.amalgm_automation_runs
103
+ LANGUAGE plpgsql
104
+ SECURITY DEFINER
105
+ SET search_path = public
106
+ AS $$
107
+ DECLARE
108
+ v_inserted integer := 0;
109
+ BEGIN
110
+ IF p_kind = 'cron' THEN
111
+ PERFORM 1
112
+ FROM public.amalgm_automation_triggers t
113
+ JOIN public.amalgm_automations a
114
+ ON a.user_id = t.user_id AND a.id = t.automation_id
115
+ WHERE t.user_id = p_user_id AND t.automation_id = p_automation_id
116
+ AND t.id = p_trigger_id AND a.target_id = p_target_id
117
+ AND t.kind = 'schedule' AND t.next_run_at = p_expected_next_run_at
118
+ AND (t.remaining_occurrences IS NULL OR t.remaining_occurrences > 0)
119
+ AND t.enabled AND a.enabled
120
+ FOR UPDATE OF t;
121
+ ELSIF p_kind = 'event' THEN
122
+ PERFORM 1
123
+ FROM public.amalgm_automation_triggers t
124
+ JOIN public.amalgm_automations a
125
+ ON a.user_id = t.user_id AND a.id = t.automation_id
126
+ WHERE t.user_id = p_user_id AND t.automation_id = p_automation_id
127
+ AND t.id = p_trigger_id AND a.target_id = p_target_id
128
+ AND t.kind = 'webhook' AND t.enabled AND a.enabled;
129
+ ELSE
130
+ RAISE EXCEPTION 'Invalid automation trigger kind';
131
+ END IF;
132
+ IF NOT FOUND THEN RETURN; END IF;
133
+
134
+ RETURN QUERY
135
+ INSERT INTO public.amalgm_automation_runs (
136
+ user_id, target_id, automation_id, trigger_id, workflow_id,
137
+ automation_payload, input, status, created_at
138
+ )
139
+ SELECT a.user_id, a.target_id, a.id, t.id, w.id,
140
+ jsonb_strip_nulls(jsonb_build_object(
141
+ 'id', a.id, 'targetId', a.target_id, 'name', a.name,
142
+ 'description', a.description, 'enabled', a.enabled,
143
+ 'trigger', jsonb_strip_nulls(jsonb_build_object(
144
+ 'id', t.id,
145
+ 'kind', CASE WHEN t.kind = 'schedule' THEN 'cron' ELSE 'event' END,
146
+ 'enabled', t.enabled, 'cron', t.cron, 'timezone', t.timezone,
147
+ 'source', t.source, 'event', t.event
148
+ )),
149
+ 'workflow', jsonb_strip_nulls(jsonb_build_object(
150
+ 'id', w.id, 'name', w.name, 'script', w.script,
151
+ 'compiled', w.compiled, 'allowlist', w.allowlist, 'limits', w.limits
152
+ ))
153
+ )),
154
+ p_input, 'pending', p_now
155
+ FROM public.amalgm_automations a
156
+ JOIN public.amalgm_automation_triggers t
157
+ ON t.user_id = a.user_id AND t.automation_id = a.id
158
+ JOIN public.amalgm_automation_workflows w
159
+ ON w.user_id = a.user_id AND w.automation_id = a.id
160
+ WHERE a.user_id = p_user_id AND a.id = p_automation_id AND t.id = p_trigger_id
161
+ AND a.target_id = p_target_id AND t.enabled AND a.enabled
162
+ AND jsonb_typeof(w.compiled) = 'object'
163
+ AND w.compiled->>'version' = '1'
164
+ AND jsonb_typeof(w.compiled->'steps') = 'array'
165
+ AND jsonb_array_length(
166
+ CASE WHEN jsonb_typeof(w.compiled->'steps') = 'array'
167
+ THEN w.compiled->'steps' ELSE '[]'::jsonb END
168
+ ) BETWEEN 1 AND 100
169
+ RETURNING *;
170
+ GET DIAGNOSTICS v_inserted = ROW_COUNT;
171
+
172
+ IF p_kind = 'cron' AND v_inserted = 1 THEN
173
+ UPDATE public.amalgm_automation_triggers SET
174
+ next_run_at = p_next_run_at,
175
+ remaining_occurrences = CASE WHEN remaining_occurrences IS NULL
176
+ THEN NULL ELSE remaining_occurrences - 1 END,
177
+ enabled = CASE WHEN remaining_occurrences = 1 THEN false ELSE enabled END,
178
+ updated_at = p_now
179
+ WHERE user_id = p_user_id AND automation_id = p_automation_id
180
+ AND id = p_trigger_id AND kind = 'schedule'
181
+ AND next_run_at = p_expected_next_run_at;
182
+ END IF;
183
+ END;
184
+ $$;
185
+
186
+ CREATE OR REPLACE FUNCTION claim_amalgm_machine_runs(
187
+ p_user_id uuid,
188
+ p_target_id text,
189
+ p_limit integer,
190
+ p_lease_seconds integer
191
+ ) RETURNS SETOF public.amalgm_automation_runs
192
+ LANGUAGE plpgsql
193
+ SECURITY DEFINER
194
+ SET search_path = public
195
+ AS $$
196
+ BEGIN
197
+ UPDATE public.amalgm_automation_runs SET
198
+ status = 'failed', error = COALESCE(error, 'Machine execution attempts exhausted'),
199
+ finished_at = now(), lease_token = NULL, lease_expires_at = NULL, retry_at = NULL
200
+ WHERE user_id = p_user_id AND target_id = p_target_id AND attempts >= 5
201
+ AND (
202
+ (status = 'pending' AND COALESCE(retry_at, '-infinity'::timestamptz) <= now())
203
+ OR (status IN ('sent', 'running') AND COALESCE(lease_expires_at, '-infinity'::timestamptz) <= now())
204
+ );
205
+
206
+ RETURN QUERY
207
+ WITH candidates AS (
208
+ SELECT id
209
+ FROM public.amalgm_automation_runs
210
+ WHERE user_id = p_user_id AND target_id = p_target_id AND attempts < 5
211
+ AND (
212
+ (status = 'pending' AND COALESCE(retry_at, '-infinity'::timestamptz) <= now())
213
+ OR (status IN ('sent', 'running') AND COALESCE(lease_expires_at, '-infinity'::timestamptz) <= now())
214
+ )
215
+ ORDER BY created_at, id
216
+ FOR UPDATE SKIP LOCKED
217
+ LIMIT LEAST(GREATEST(p_limit, 1), 20)
218
+ )
219
+ UPDATE public.amalgm_automation_runs r SET
220
+ status = 'sent', sent_at = COALESCE(sent_at, now()),
221
+ lease_token = gen_random_uuid(),
222
+ lease_expires_at = now() + make_interval(secs => LEAST(GREATEST(p_lease_seconds, 30), 900)),
223
+ retry_at = NULL,
224
+ attempts = attempts + 1
225
+ FROM candidates
226
+ WHERE r.id = candidates.id
227
+ RETURNING r.*;
228
+ END;
229
+ $$;
230
+
231
+ CREATE OR REPLACE FUNCTION update_amalgm_claimed_run(
232
+ p_user_id uuid,
233
+ p_target_id text,
234
+ p_run_id uuid,
235
+ p_lease_token uuid,
236
+ p_update jsonb,
237
+ p_now timestamptz
238
+ ) RETURNS SETOF public.amalgm_automation_runs
239
+ LANGUAGE plpgsql
240
+ SECURITY DEFINER
241
+ SET search_path = public
242
+ AS $$
243
+ DECLARE
244
+ v_status text := p_update->>'status';
245
+ BEGIN
246
+ IF v_status NOT IN ('pending', 'running', 'completed', 'failed') THEN
247
+ RAISE EXCEPTION 'Invalid run status';
248
+ END IF;
249
+ IF p_update ? 'retryAt' AND v_status <> 'pending' THEN
250
+ RAISE EXCEPTION 'Only a pending run may have retryAt';
251
+ END IF;
252
+
253
+ RETURN QUERY
254
+ UPDATE public.amalgm_automation_runs SET
255
+ status = CASE WHEN v_status = 'pending' AND attempts >= 5 THEN 'failed' ELSE v_status END,
256
+ started_at = CASE WHEN v_status = 'running' THEN COALESCE(started_at, p_now) ELSE started_at END,
257
+ finished_at = CASE
258
+ WHEN v_status IN ('completed', 'failed') OR (v_status = 'pending' AND attempts >= 5) THEN p_now
259
+ WHEN v_status = 'pending' THEN NULL
260
+ ELSE finished_at
261
+ END,
262
+ output = CASE WHEN p_update ? 'output' THEN p_update->'output' ELSE output END,
263
+ error = CASE
264
+ WHEN p_update ? 'error' THEN left(p_update->>'error', 1000)
265
+ WHEN v_status = 'running' THEN NULL
266
+ ELSE error
267
+ END,
268
+ retry_at = CASE
269
+ WHEN v_status = 'pending' AND attempts < 5 AND p_update ? 'retryAt'
270
+ THEN (p_update->>'retryAt')::timestamptz
271
+ ELSE NULL
272
+ END,
273
+ lease_token = CASE WHEN v_status = 'running' THEN lease_token ELSE NULL END,
274
+ lease_expires_at = CASE WHEN v_status = 'running' THEN p_now + interval '60 seconds' ELSE NULL END
275
+ WHERE id = p_run_id AND user_id = p_user_id AND target_id = p_target_id
276
+ AND status IN ('sent', 'running') AND lease_token = p_lease_token
277
+ AND lease_expires_at > p_now
278
+ RETURNING *;
279
+ END;
280
+ $$;
281
+
282
+ CREATE OR REPLACE FUNCTION list_amalgm_runs(
283
+ p_user_id uuid,
284
+ p_automation_id text,
285
+ p_status text,
286
+ p_limit integer,
287
+ p_offset integer
288
+ )
289
+ RETURNS jsonb
290
+ LANGUAGE sql
291
+ SECURITY DEFINER
292
+ SET search_path = public
293
+ AS $$
294
+ WITH filtered AS (
295
+ SELECT id, automation_id, trigger_id, workflow_id, target_id, status, attempts,
296
+ retry_at, input, created_at, sent_at, started_at, finished_at, output, error
297
+ FROM public.amalgm_automation_runs
298
+ WHERE user_id = p_user_id AND automation_id = p_automation_id
299
+ AND (p_status IS NULL OR status = p_status)
300
+ ), page_rows AS (
301
+ SELECT * FROM filtered ORDER BY created_at DESC, id DESC LIMIT p_limit OFFSET p_offset
302
+ )
303
+ SELECT jsonb_build_object(
304
+ 'items', COALESCE((SELECT jsonb_agg(to_jsonb(page_rows)) FROM page_rows), '[]'::jsonb),
305
+ 'total', (SELECT count(*) FROM filtered)
306
+ );
307
+ $$;
308
+
309
+ CREATE OR REPLACE FUNCTION get_amalgm_run(p_user_id uuid, p_automation_id text, p_run_id text)
310
+ RETURNS jsonb
311
+ LANGUAGE sql
312
+ SECURITY DEFINER
313
+ SET search_path = public
314
+ AS $$
315
+ SELECT to_jsonb(r) - 'user_id' - 'automation_payload' - 'lease_token' - 'lease_expires_at'
316
+ FROM public.amalgm_automation_runs r
317
+ WHERE r.user_id = p_user_id AND r.automation_id = p_automation_id AND r.id::text = p_run_id;
318
+ $$;
319
+
320
+ REVOKE ALL ON FUNCTION claim_amalgm_machine_runs(uuid, text, integer, integer) FROM PUBLIC;
321
+ REVOKE ALL ON FUNCTION update_amalgm_claimed_run(uuid, text, uuid, uuid, jsonb, timestamptz) FROM PUBLIC;
322
+ REVOKE ALL ON FUNCTION list_due_amalgm_crons(timestamptz) FROM PUBLIC;
323
+ REVOKE ALL ON FUNCTION list_amalgm_event_triggers(uuid, text) FROM PUBLIC;
324
+ REVOKE ALL ON FUNCTION list_amalgm_runs(uuid, text, text, integer, integer) FROM PUBLIC;
325
+ REVOKE ALL ON FUNCTION get_amalgm_run(uuid, text, text) FROM PUBLIC;
326
+
327
+ GRANT EXECUTE ON FUNCTION claim_amalgm_machine_runs(uuid, text, integer, integer) TO service_role;
328
+ GRANT EXECUTE ON FUNCTION update_amalgm_claimed_run(uuid, text, uuid, uuid, jsonb, timestamptz) TO service_role;
329
+ GRANT EXECUTE ON FUNCTION list_due_amalgm_crons(timestamptz) TO service_role;
330
+ GRANT EXECUTE ON FUNCTION list_amalgm_event_triggers(uuid, text) TO service_role;
331
+ GRANT EXECUTE ON FUNCTION list_amalgm_runs(uuid, text, text, integer, integer) TO service_role;
332
+ GRANT EXECUTE ON FUNCTION get_amalgm_run(uuid, text, text) TO service_role;