@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
@@ -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 {
@@ -52,11 +51,11 @@ export interface AutomationRun {
52
51
  error?: string;
53
52
  }
54
53
  export interface AutomationStore {
55
- eventTriggers(target: AutomationTarget): Promise<StoredEventTrigger[]>;
54
+ eventTrigger(endpointId: string): Promise<StoredEventTrigger | null>;
56
55
  dueCronTriggers(now: Date): Promise<DueCronTrigger[]>;
57
56
  enqueueEvent(trigger: StoredEventTrigger, input: Extract<RunInput, {
58
57
  kind: 'event';
59
- }>, now: Date): Promise<AutomationRun | null>;
58
+ }>, now: Date, deliveryKey: string | null): Promise<AutomationRun | null>;
60
59
  enqueueCron(trigger: DueCronTrigger, nextRunAt: string, now: Date): Promise<AutomationRun | null>;
61
60
  }
62
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@amalgm/automations",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Amalgm's automation SDK: durable trigger admission and target-machine execution.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: automations
3
- description: Create, inspect, change, or delete complete Amalgm automation definitions and inspect their 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
@@ -46,12 +46,20 @@ distinct resources inside the definition. A workflow is zero-or-one per
46
46
  automation. Run history is read-only, requested through `get`, and remains
47
47
  after configuration deletion.
48
48
 
49
- Action discovery belongs to the Tools product, not Automations. Manual
50
- execution is not advertised until the delivery SDK supports it.
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.
51
56
 
52
57
  `pending` means a run is durable and waiting for its selected machine. `sent`
53
58
  or `running` means that machine holds a lease. `completed` and `failed` are
54
59
  terminal. Do not infer delivery from the schedule alone; inspect runs when the
55
60
  user asks whether it actually happened.
56
61
 
57
- 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.
@@ -0,0 +1,287 @@
1
+ -- One public webhook URL resolves exactly one trigger. The endpoint locator is
2
+ -- not itself a credential: the hosted service adds and verifies its HMAC.
3
+
4
+ ALTER TABLE public.amalgm_automation_triggers
5
+ ADD COLUMN IF NOT EXISTS endpoint_id text;
6
+
7
+ UPDATE public.amalgm_automation_triggers
8
+ SET endpoint_id = encode(gen_random_bytes(16), 'hex')
9
+ WHERE kind = 'webhook' AND endpoint_id IS NULL;
10
+
11
+ CREATE UNIQUE INDEX IF NOT EXISTS amalgm_webhook_endpoint_id
12
+ ON public.amalgm_automation_triggers(endpoint_id)
13
+ WHERE endpoint_id IS NOT NULL;
14
+
15
+ DO $$
16
+ DECLARE
17
+ v_constraint text;
18
+ BEGIN
19
+ SELECT c.conname INTO v_constraint
20
+ FROM pg_constraint c
21
+ WHERE c.conrelid = 'public.amalgm_automation_triggers'::regclass
22
+ AND c.contype = 'c'
23
+ AND pg_get_constraintdef(c.oid) LIKE '%length(secret)%'
24
+ LIMIT 1;
25
+ IF v_constraint IS NOT NULL THEN
26
+ EXECUTE format('ALTER TABLE public.amalgm_automation_triggers DROP CONSTRAINT %I', v_constraint);
27
+ END IF;
28
+ END;
29
+ $$;
30
+
31
+ ALTER TABLE public.amalgm_automation_triggers
32
+ DROP CONSTRAINT IF EXISTS amalgm_automation_trigger_shape;
33
+ ALTER TABLE public.amalgm_automation_triggers
34
+ ADD CONSTRAINT amalgm_automation_trigger_shape CHECK (
35
+ (kind = 'schedule' AND cron IS NOT NULL AND timezone IS NOT NULL
36
+ AND next_run_at IS NOT NULL AND source IS NULL AND event IS NULL
37
+ AND secret IS NULL AND endpoint_id IS NULL)
38
+ OR
39
+ (kind = 'webhook' AND cron IS NULL AND timezone IS NULL
40
+ AND next_run_at IS NULL AND source IS NOT NULL AND event IS NOT NULL
41
+ AND (secret IS NULL OR length(secret) >= 16)
42
+ AND endpoint_id ~ '^[a-f0-9]{32}$')
43
+ );
44
+
45
+ ALTER TABLE public.amalgm_automation_runs
46
+ ADD COLUMN IF NOT EXISTS delivery_key text;
47
+
48
+ ALTER TABLE public.amalgm_automation_runs
49
+ DROP CONSTRAINT IF EXISTS amalgm_automation_run_delivery_key;
50
+ ALTER TABLE public.amalgm_automation_runs
51
+ ADD CONSTRAINT amalgm_automation_run_delivery_key CHECK (
52
+ delivery_key IS NULL OR length(delivery_key) BETWEEN 1 AND 500
53
+ );
54
+
55
+ CREATE UNIQUE INDEX IF NOT EXISTS amalgm_automation_webhook_delivery
56
+ ON public.amalgm_automation_runs(user_id, automation_id, trigger_id, delivery_key)
57
+ WHERE delivery_key IS NOT NULL;
58
+
59
+ CREATE OR REPLACE FUNCTION public.amalgm_trigger_json(p_trigger public.amalgm_automation_triggers)
60
+ RETURNS jsonb
61
+ LANGUAGE sql
62
+ IMMUTABLE
63
+ SET search_path = public
64
+ AS $$
65
+ SELECT jsonb_strip_nulls(jsonb_build_object(
66
+ 'id', p_trigger.id,
67
+ 'automation_id', p_trigger.automation_id,
68
+ 'kind', p_trigger.kind,
69
+ 'enabled', p_trigger.enabled,
70
+ 'cron', p_trigger.cron,
71
+ 'timezone', p_trigger.timezone,
72
+ 'next_run_at', p_trigger.next_run_at,
73
+ 'max_occurrences', p_trigger.max_occurrences,
74
+ 'remaining_occurrences', p_trigger.remaining_occurrences,
75
+ 'source', p_trigger.source,
76
+ 'event', p_trigger.event,
77
+ 'endpoint_id', p_trigger.endpoint_id,
78
+ 'secret_configured', p_trigger.secret IS NOT NULL,
79
+ 'created_at', p_trigger.created_at,
80
+ 'updated_at', p_trigger.updated_at
81
+ ));
82
+ $$;
83
+
84
+ CREATE OR REPLACE FUNCTION public.create_amalgm_webhook_trigger(
85
+ p_user_id uuid,
86
+ p_automation_id text,
87
+ p_trigger jsonb
88
+ )
89
+ RETURNS jsonb
90
+ LANGUAGE plpgsql
91
+ SECURITY DEFINER
92
+ SET search_path = public
93
+ AS $$
94
+ DECLARE
95
+ v_row public.amalgm_automation_triggers;
96
+ BEGIN
97
+ INSERT INTO public.amalgm_automation_triggers (
98
+ user_id, automation_id, id, kind, enabled, source, event, secret, endpoint_id
99
+ )
100
+ SELECT p_user_id, p_automation_id, p_trigger->>'id', 'webhook',
101
+ (p_trigger->>'enabled')::boolean, p_trigger->>'source', p_trigger->>'event',
102
+ NULLIF(p_trigger->>'secret', ''), p_trigger->>'endpointId'
103
+ WHERE EXISTS (
104
+ SELECT 1 FROM public.amalgm_automations
105
+ WHERE user_id = p_user_id AND id = p_automation_id
106
+ )
107
+ RETURNING * INTO v_row;
108
+ IF NOT FOUND THEN RETURN NULL; END IF;
109
+ RETURN public.amalgm_trigger_json(v_row);
110
+ END;
111
+ $$;
112
+
113
+ CREATE OR REPLACE FUNCTION public.update_amalgm_webhook_trigger(
114
+ p_user_id uuid,
115
+ p_automation_id text,
116
+ p_trigger_id text,
117
+ p_patch jsonb
118
+ )
119
+ RETURNS jsonb
120
+ LANGUAGE plpgsql
121
+ SECURITY DEFINER
122
+ SET search_path = public
123
+ AS $$
124
+ DECLARE
125
+ v_row public.amalgm_automation_triggers;
126
+ BEGIN
127
+ UPDATE public.amalgm_automation_triggers SET
128
+ source = CASE WHEN p_patch ? 'source' THEN p_patch->>'source' ELSE source END,
129
+ event = CASE WHEN p_patch ? 'event' THEN p_patch->>'event' ELSE event END,
130
+ secret = CASE WHEN p_patch ? 'secret' THEN p_patch->>'secret' ELSE secret END,
131
+ endpoint_id = CASE WHEN p_patch ? 'endpointId' THEN p_patch->>'endpointId' ELSE endpoint_id END,
132
+ enabled = CASE WHEN p_patch ? 'enabled' THEN (p_patch->>'enabled')::boolean ELSE enabled END,
133
+ updated_at = now()
134
+ WHERE user_id = p_user_id AND automation_id = p_automation_id
135
+ AND id = p_trigger_id AND kind = 'webhook'
136
+ RETURNING * INTO v_row;
137
+ IF NOT FOUND THEN RETURN NULL; END IF;
138
+ RETURN public.amalgm_trigger_json(v_row);
139
+ END;
140
+ $$;
141
+
142
+ CREATE FUNCTION public.resolve_amalgm_webhook(p_endpoint_id text)
143
+ RETURNS TABLE (
144
+ user_id uuid,
145
+ automation_id text,
146
+ trigger_id text,
147
+ target_id text,
148
+ endpoint_id text,
149
+ source text,
150
+ event text,
151
+ secret text
152
+ )
153
+ LANGUAGE sql
154
+ SECURITY DEFINER
155
+ SET search_path = public
156
+ AS $$
157
+ SELECT t.user_id, t.automation_id, t.id, a.target_id,
158
+ t.endpoint_id, t.source, t.event, t.secret
159
+ FROM public.amalgm_automation_triggers t
160
+ JOIN public.amalgm_automations a
161
+ ON a.user_id = t.user_id AND a.id = t.automation_id
162
+ JOIN public.amalgm_automation_workflows w
163
+ ON w.user_id = a.user_id AND w.automation_id = a.id
164
+ WHERE t.kind = 'webhook' AND t.endpoint_id = p_endpoint_id
165
+ AND t.enabled AND a.enabled
166
+ AND jsonb_typeof(w.compiled) = 'object'
167
+ AND w.compiled->>'version' = '1'
168
+ AND jsonb_typeof(w.compiled->'steps') = 'array'
169
+ AND jsonb_array_length(
170
+ CASE WHEN jsonb_typeof(w.compiled->'steps') = 'array'
171
+ THEN w.compiled->'steps' ELSE '[]'::jsonb END
172
+ ) BETWEEN 1 AND 100;
173
+ $$;
174
+
175
+ CREATE FUNCTION public.enqueue_amalgm_run(
176
+ p_kind text,
177
+ p_user_id uuid,
178
+ p_automation_id text,
179
+ p_trigger_id text,
180
+ p_target_id text,
181
+ p_endpoint_id text,
182
+ p_delivery_key text,
183
+ p_expected_next_run_at timestamptz,
184
+ p_next_run_at timestamptz,
185
+ p_input jsonb,
186
+ p_now timestamptz
187
+ ) RETURNS SETOF public.amalgm_automation_runs
188
+ LANGUAGE plpgsql
189
+ SECURITY DEFINER
190
+ SET search_path = public
191
+ AS $$
192
+ DECLARE
193
+ v_inserted integer := 0;
194
+ BEGIN
195
+ IF p_kind = 'cron' THEN
196
+ IF p_endpoint_id IS NOT NULL OR p_delivery_key IS NOT NULL THEN
197
+ RAISE EXCEPTION 'Schedule admission cannot carry webhook identity';
198
+ END IF;
199
+ PERFORM 1
200
+ FROM public.amalgm_automation_triggers t
201
+ JOIN public.amalgm_automations a
202
+ ON a.user_id = t.user_id AND a.id = t.automation_id
203
+ WHERE t.user_id = p_user_id AND t.automation_id = p_automation_id
204
+ AND t.id = p_trigger_id AND a.target_id = p_target_id
205
+ AND t.kind = 'schedule' AND t.next_run_at = p_expected_next_run_at
206
+ AND (t.remaining_occurrences IS NULL OR t.remaining_occurrences > 0)
207
+ AND t.enabled AND a.enabled
208
+ FOR UPDATE OF t;
209
+ ELSIF p_kind = 'event' THEN
210
+ IF p_endpoint_id IS NULL THEN RAISE EXCEPTION 'Webhook endpoint is required'; END IF;
211
+ PERFORM 1
212
+ FROM public.amalgm_automation_triggers t
213
+ JOIN public.amalgm_automations a
214
+ ON a.user_id = t.user_id AND a.id = t.automation_id
215
+ WHERE t.user_id = p_user_id AND t.automation_id = p_automation_id
216
+ AND t.id = p_trigger_id AND a.target_id = p_target_id
217
+ AND t.kind = 'webhook' AND t.endpoint_id = p_endpoint_id
218
+ AND t.enabled AND a.enabled;
219
+ ELSE
220
+ RAISE EXCEPTION 'Invalid automation trigger kind';
221
+ END IF;
222
+ IF NOT FOUND THEN RETURN; END IF;
223
+
224
+ RETURN QUERY
225
+ INSERT INTO public.amalgm_automation_runs (
226
+ user_id, target_id, automation_id, trigger_id, workflow_id,
227
+ automation_payload, input, status, delivery_key, created_at
228
+ )
229
+ SELECT a.user_id, a.target_id, a.id, t.id, w.id,
230
+ jsonb_strip_nulls(jsonb_build_object(
231
+ 'id', a.id, 'targetId', a.target_id, 'name', a.name,
232
+ 'description', a.description, 'enabled', a.enabled,
233
+ 'trigger', jsonb_strip_nulls(jsonb_build_object(
234
+ 'id', t.id,
235
+ 'kind', CASE WHEN t.kind = 'schedule' THEN 'cron' ELSE 'event' END,
236
+ 'enabled', t.enabled, 'cron', t.cron, 'timezone', t.timezone,
237
+ 'source', t.source, 'event', t.event
238
+ )),
239
+ 'workflow', jsonb_strip_nulls(jsonb_build_object(
240
+ 'id', w.id, 'name', w.name, 'script', w.script,
241
+ 'compiled', w.compiled, 'allowlist', w.allowlist, 'limits', w.limits
242
+ ))
243
+ )),
244
+ p_input, 'pending', p_delivery_key, p_now
245
+ FROM public.amalgm_automations a
246
+ JOIN public.amalgm_automation_triggers t
247
+ ON t.user_id = a.user_id AND t.automation_id = a.id
248
+ JOIN public.amalgm_automation_workflows w
249
+ ON w.user_id = a.user_id AND w.automation_id = a.id
250
+ WHERE a.user_id = p_user_id AND a.id = p_automation_id AND t.id = p_trigger_id
251
+ AND a.target_id = p_target_id AND t.enabled AND a.enabled
252
+ AND (p_kind = 'cron' OR (t.kind = 'webhook' AND t.endpoint_id = p_endpoint_id))
253
+ AND jsonb_typeof(w.compiled) = 'object'
254
+ AND w.compiled->>'version' = '1'
255
+ AND jsonb_typeof(w.compiled->'steps') = 'array'
256
+ AND jsonb_array_length(
257
+ CASE WHEN jsonb_typeof(w.compiled->'steps') = 'array'
258
+ THEN w.compiled->'steps' ELSE '[]'::jsonb END
259
+ ) BETWEEN 1 AND 100
260
+ ON CONFLICT (user_id, automation_id, trigger_id, delivery_key)
261
+ WHERE delivery_key IS NOT NULL
262
+ DO UPDATE SET delivery_key = EXCLUDED.delivery_key
263
+ RETURNING *;
264
+ GET DIAGNOSTICS v_inserted = ROW_COUNT;
265
+
266
+ IF p_kind = 'cron' AND v_inserted = 1 THEN
267
+ UPDATE public.amalgm_automation_triggers SET
268
+ next_run_at = p_next_run_at,
269
+ remaining_occurrences = CASE WHEN remaining_occurrences IS NULL
270
+ THEN NULL ELSE remaining_occurrences - 1 END,
271
+ enabled = CASE WHEN remaining_occurrences = 1 THEN false ELSE enabled END,
272
+ updated_at = p_now
273
+ WHERE user_id = p_user_id AND automation_id = p_automation_id
274
+ AND id = p_trigger_id AND kind = 'schedule'
275
+ AND next_run_at = p_expected_next_run_at;
276
+ END IF;
277
+ END;
278
+ $$;
279
+
280
+ REVOKE ALL ON FUNCTION public.resolve_amalgm_webhook(text) FROM PUBLIC;
281
+ REVOKE ALL ON FUNCTION public.enqueue_amalgm_run(
282
+ text, uuid, text, text, text, text, text, timestamptz, timestamptz, jsonb, timestamptz
283
+ ) FROM PUBLIC;
284
+ GRANT EXECUTE ON FUNCTION public.resolve_amalgm_webhook(text) TO service_role;
285
+ GRANT EXECUTE ON FUNCTION public.enqueue_amalgm_run(
286
+ text, uuid, text, text, text, text, text, timestamptz, timestamptz, jsonb, timestamptz
287
+ ) TO service_role;
@@ -0,0 +1,68 @@
1
+ -- Run Now is triggerless admission into the existing durable run ledger. The
2
+ -- selected machine claims and executes the resulting pending run normally.
3
+
4
+ CREATE FUNCTION public.enqueue_amalgm_manual_run(
5
+ p_user_id uuid,
6
+ p_automation_id text,
7
+ p_input jsonb,
8
+ p_idempotency_key text,
9
+ p_now timestamptz
10
+ ) RETURNS SETOF public.amalgm_automation_runs
11
+ LANGUAGE plpgsql
12
+ SECURITY DEFINER
13
+ SET search_path = public
14
+ AS $$
15
+ BEGIN
16
+ IF p_idempotency_key IS NOT NULL THEN
17
+ RETURN QUERY
18
+ SELECT r.*
19
+ FROM public.amalgm_automation_runs r
20
+ WHERE r.user_id = p_user_id
21
+ AND r.automation_id = p_automation_id
22
+ AND r.trigger_id = '@manual'
23
+ AND r.delivery_key = p_idempotency_key;
24
+ IF FOUND THEN RETURN; END IF;
25
+ END IF;
26
+
27
+ RETURN QUERY
28
+ INSERT INTO public.amalgm_automation_runs (
29
+ user_id, target_id, automation_id, trigger_id, workflow_id,
30
+ automation_payload, input, status, delivery_key, created_at
31
+ )
32
+ SELECT a.user_id, a.target_id, a.id, '@manual', w.id,
33
+ jsonb_strip_nulls(jsonb_build_object(
34
+ 'id', a.id, 'targetId', a.target_id, 'name', a.name,
35
+ 'description', a.description, 'enabled', a.enabled,
36
+ 'trigger', jsonb_build_object(
37
+ 'id', '@manual', 'kind', 'manual', 'enabled', true
38
+ ),
39
+ 'workflow', jsonb_strip_nulls(jsonb_build_object(
40
+ 'id', w.id, 'name', w.name, 'script', w.script,
41
+ 'compiled', w.compiled, 'allowlist', w.allowlist, 'limits', w.limits
42
+ ))
43
+ )),
44
+ p_input, 'pending', p_idempotency_key, p_now
45
+ FROM public.amalgm_automations a
46
+ JOIN public.amalgm_automation_workflows w
47
+ ON w.user_id = a.user_id AND w.automation_id = a.id
48
+ WHERE a.user_id = p_user_id AND a.id = p_automation_id AND a.enabled
49
+ AND jsonb_typeof(w.compiled) = 'object'
50
+ AND w.compiled->>'version' = '1'
51
+ AND jsonb_typeof(w.compiled->'steps') = 'array'
52
+ AND jsonb_array_length(
53
+ CASE WHEN jsonb_typeof(w.compiled->'steps') = 'array'
54
+ THEN w.compiled->'steps' ELSE '[]'::jsonb END
55
+ ) BETWEEN 1 AND 100
56
+ ON CONFLICT (user_id, automation_id, trigger_id, delivery_key)
57
+ WHERE delivery_key IS NOT NULL
58
+ DO UPDATE SET delivery_key = EXCLUDED.delivery_key
59
+ RETURNING *;
60
+ END;
61
+ $$;
62
+
63
+ REVOKE ALL ON FUNCTION public.enqueue_amalgm_manual_run(
64
+ uuid, text, jsonb, text, timestamptz
65
+ ) FROM PUBLIC;
66
+ GRANT EXECUTE ON FUNCTION public.enqueue_amalgm_manual_run(
67
+ uuid, text, jsonb, text, timestamptz
68
+ ) TO service_role;