@amalgm/automations 0.4.2 → 0.4.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.
package/AXIOMS.md CHANGED
@@ -6,8 +6,9 @@
6
6
  skill adapters translate inputs and call it; none accesses Supabase or owns
7
7
  validation, authorization, or persistence rules.
8
8
  3. Every call is scoped by a resolved Amalgm principal. Public inputs never
9
- contain a user id or raw credential; session, HMAC-refresh, and future API
10
- key authentication all resolve to the same principal shape.
9
+ contain a user id or raw credential; session, HMAC-refresh, and future API
10
+ key authentication all resolve to the same principal shape. Database
11
+ functions that accept an owner id are private to the hosted service role.
11
12
  4. An automation belongs to exactly one user and exactly one target. It may
12
13
  have zero or more triggers and zero or one workflow.
13
14
  5. A principal bound to exactly one target supplies that target implicitly on
@@ -64,9 +65,6 @@
64
65
  aliases for one another.
65
66
  23. Automations is a standalone hosted service. Gateway owns none of its API,
66
67
  scheduling, claim, execution, or persistence path.
67
- Its host validates Core's complete environment binding before opening its
68
- database, issuer, public API or webhook authority; local has no managed
69
- service fallback.
70
68
  24. The durable run ledger is the offline queue. The platform never keeps a
71
69
  second online-machine delivery buffer and never drops an unclaimed run.
72
70
  25. A run advances through its immutable plan in order. Every step transition
@@ -147,3 +145,13 @@
147
145
  49. A complete definition validates all supplied configuration before its
148
146
  first write; a later service failure preserves an identifiable disabled
149
147
  draft and the original failure code.
148
+ 50. Launch quotas belong to the authenticated account in Supabase, shared by
149
+ every client, target, trigger, and Fly replica, and use the database clock.
150
+ 51. Configuration API writes and new run admissions each allow ten operations
151
+ per rolling minute; retries of an admitted delivery consume no new-run slot.
152
+ 52. An account retains at most one hundred unfinished runs; rejecting new work
153
+ neither removes accepted runs nor advances a schedule's occurrence count.
154
+ 53. Only five-field cron may fire; existing finer schedules remain inspectable
155
+ but disabled until the owner replaces their expression.
156
+ 54. Reads, ingress attempts, claims, stream openings, and execution updates
157
+ have independent bounded transport budgets, separate from new-work quotas.
package/PURPOSE.md CHANGED
@@ -2,9 +2,6 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Managed releases bind to one exact published Core version and build from the
6
- locked dependency graph on the supported Node toolchain.
7
-
8
5
  Automations is the sole owner of Amalgm automation behavior and data. It lets an
9
6
  authenticated user create, inspect, change, delete, and manually run automation
10
7
  configuration — an automation belongs to one user and one target, may own any
@@ -13,10 +10,14 @@ have each trigger occurrence run on the selected existing Amalgm machine.
13
10
  Supabase holds everything except execution: the automation, its triggers, the
14
11
  complete workflow, and permanent run history.
15
12
 
16
- Each hosted environment reaches only its declared UI issuer, database and
17
- webhook authority. Main and preview initially share the database by owner
18
- direction; their public API and issuer identities remain distinct. Durable
19
- claims remain the single authority for work even when hosts share data.
13
+ The initial launch is free, with no payment prerequisite or automatic paid
14
+ cutover. Shared account limits protect the hosted service: ten configuration
15
+ API writes and ten newly admitted runs per rolling minute, and at most one
16
+ hundred unfinished runs. Accepted work remains durable; quota rejection never
17
+ deletes it or advances a schedule. Schedules use standard five-field cron, with
18
+ one minute as the finest interval. Reads, webhook attempts, machine claims,
19
+ stream openings, and execution updates have separate transport allowances so
20
+ accepted work can finish after new-work capacity is exhausted.
20
21
 
21
22
  The product has two composable halves over that one state:
22
23
 
@@ -70,6 +71,10 @@ the user reach aayush@amalgm.ai with bounded, redacted diagnostic evidence when
70
71
  the failing boundary cannot be repaired. The packaged skill is the source for
71
72
  installed copies and public setup/support guidance.
72
73
 
74
+ Amalgm's public skills collection distributes reviewed copies of this skill
75
+ from a recorded source commit. It is the short installation entry point; this
76
+ repository remains the authority for Automations instructions.
77
+
73
78
  The standalone service publishes a public skill discovery index and an
74
79
  integrity-checked archive built from that same packaged skill. Installation
75
80
  needs no private repository access, and the archive includes its referenced
package/README.md CHANGED
@@ -3,6 +3,52 @@
3
3
  The SDK and standalone hosted service for automation definitions, triggers,
4
4
  durable runs, and execution delivery to an Amalgm machine.
5
5
 
6
+ ## Free launch limits
7
+
8
+ Amalgm is free during launch, with no payment prerequisite or automatic paid
9
+ cutover. Each account shares these rolling one-minute allowances across all
10
+ clients, computers, and Fly replicas:
11
+
12
+ | Operation | Allowance per minute |
13
+ | --- | ---: |
14
+ | Configuration API writes | 10 |
15
+ | New runs, across cron, webhook, and Run Now | 10 |
16
+ | Reads | 120 |
17
+ | Webhook and Run Now attempts, including retries/unmatched events | 120 |
18
+ | Machine claim requests | 120 |
19
+ | Notification stream openings | 20 |
20
+ | Execution journal and lease updates | 3,000 |
21
+
22
+ A complete CLI create with one schedule and workflow uses four configuration
23
+ writes. Multi-resource changes may leave an inspectable disabled draft if the
24
+ allowance is exhausted during staging; wait and repair that same draft.
25
+ Reads and execution updates remain available when a new-work allowance is full.
26
+ The existing 2 MiB request and 1.5 MiB run-journal bounds still apply.
27
+
28
+ At most 100 runs per account may be unfinished (`pending`, `sent`, or `running`).
29
+ Excess admissions return HTTP 429 with an error code and `Retry-After`.
30
+ Duplicate delivery keys return their already-admitted run without spending a
31
+ new-run slot. A full queue preserves every accepted run. Cron waits for capacity
32
+ without advancing its firing instant or finite occurrence count; webhook
33
+ senders must retry a rejected delivery. Minute limits bound rates, not a total
34
+ monthly bill or protection against someone creating many accounts.
35
+
36
+ Schedules require standard five-field cron; seconds and cron macros are
37
+ rejected. Existing expressions outside that format are disabled by migration
38
+ and remain inspectable. Replace the expression before enabling them again.
39
+
40
+ The hosted service authenticates configuration and machine requests before
41
+ checking account quotas. A signed webhook URL is a bearer capability for one
42
+ trigger; provider signatures are checked when configured. Supabase functions
43
+ and tables cannot be accessed directly by anonymous or signed-in client roles.
44
+ Health checks and skill downloads intentionally remain public.
45
+
46
+ The Fly configuration uses two `performance-1x` machines with 2 GiB each in
47
+ San Jose. The database owns shared quotas; no machine-local counter can grant
48
+ additional capacity.
49
+
50
+ ## Example
51
+
6
52
  ```ts
7
53
  const sdk = service.for(principal);
8
54
  const automation = await sdk.automations.create({
@@ -107,20 +153,23 @@ compatibility, the standalone entry point still accepts the prior paired
107
153
  `AMALGM_AUTOMATIONS_API_URL` and `AMALGM_AUTOMATIONS_AUTHORIZATION`
108
154
  environment variables.
109
155
 
110
- For public local MCP clients, Shell 0.1.176+ provides
111
- `amalgm automations mcp --user person@example.com`. It exposes the same six
112
- tools over stdio and discovers the current runtime connection per call.
113
- `amalgm status --all` lists local account registrations. No pasted token or
114
- private repository access is part of either flow; the standalone
115
- `amalgm-automations-mcp` entry point remains for custom authenticated hosts.
156
+ CLI and skill are the first public launch surfaces. The local stdio MCP
157
+ adapter and `amalgm status --all` account discovery are prepared for a later
158
+ Shell release. Do not advertise an unreleased Shell version as available.
159
+ The standalone `amalgm-automations-mcp` entry point remains for custom
160
+ authenticated hosts.
116
161
 
117
162
  Install the complete portable skill with Node.js 22.20+:
118
163
 
119
164
  ```bash
120
- npx skills add https://automations.amalgm.ai --skill amalgm-automations
165
+ npx skills add amalgm-inc/skills
121
166
  ```
122
167
 
123
- The host serves standard discovery at `/.well-known/agent-skills/index.json`
168
+ The public [Amalgm collection](https://github.com/amalgm-inc/skills) distributes
169
+ this repository's canonical skill, including its references and agent metadata.
170
+ Users can refresh installed instructions with `npx skills update`.
171
+
172
+ The host also serves standard discovery at `/.well-known/agent-skills/index.json`
124
173
  and a checksum-addressed archive built from `skills/amalgm-automations`.
125
174
  The archive includes its references and agent metadata and requires no login.
126
175
 
@@ -251,6 +300,8 @@ Run inserts and eligibility changes publish small owner/target wakeups from
251
300
  the database transaction. Fly re-reads the earliest retry/lease deadline only
252
301
  when a target connects, changes, or reaches that deadline. Losing the database
253
302
  subscription closes the machine streams so they reconnect and catch up.
303
+ Progress updates, lease extensions, and completion do not publish another
304
+ wakeup: an existing earlier timer re-reads the current ledger at its deadline.
254
305
  Cron scheduling remains hosted; no schedule timer runs on the user's machine.
255
306
 
256
307
  Rollout order: apply the product migrations, deploy the Fly host, publish the
@@ -270,6 +321,9 @@ npm pack --dry-run
270
321
 
271
322
  `test:supabase` requires an empty disposable Postgres database; its fixture
272
323
  records the Supabase Broadcast boundary inside the same transaction.
324
+ It also examines concurrent shared quotas, all trigger admission paths,
325
+ idempotent retries at capacity, queue preservation, and direct Supabase-role
326
+ denial even when schema defaults initially grant those roles access.
273
327
  `test:notifications` requires Node.js 22+, Docker, and Supabase CLI 2.26.9
274
328
  (also pinned in CI). It starts and removes its own local Supabase project,
275
329
  using real database Broadcast and two HTTP hosts to verify delivery, idle
@@ -1,16 +1,12 @@
1
- import { isLoopbackEndpoint, resolveRuntimeEndpoint, validateRuntimeEndpointEnvironment } from '@amalgm/core/binding';
2
1
  export function automationsHostConfig(env = process.env) {
3
- const publicOrigin = required(env.AMALGAM_PUBLIC_ORIGIN, 'AMALGAM_PUBLIC_ORIGIN');
4
- const isolatedOrigin = isLoopbackEndpoint(publicOrigin) ? publicOrigin : undefined;
5
- const binding = validateRuntimeEndpointEnvironment(env, isolatedOrigin);
6
2
  return Object.freeze({
7
3
  port: integer(env.PORT, 8080, 1, 65_535),
8
- publicOrigin: resolveRuntimeEndpoint(binding, 'automations', publicOrigin, isolatedOrigin),
9
- webhookOrigin: resolveRuntimeEndpoint(binding, 'automations', required(env.AUTOMATIONS_WEBHOOK_ORIGIN, 'AUTOMATIONS_WEBHOOK_ORIGIN'), isolatedOrigin),
4
+ publicOrigin: url(env.AMALGAM_PUBLIC_ORIGIN, 'AMALGAM_PUBLIC_ORIGIN'),
5
+ webhookOrigin: url(env.AUTOMATIONS_WEBHOOK_ORIGIN, 'AUTOMATIONS_WEBHOOK_ORIGIN'),
10
6
  webhookTokenKey: secret(env.AUTOMATIONS_WEBHOOK_TOKEN_KEY, 'AUTOMATIONS_WEBHOOK_TOKEN_KEY'),
11
- supabaseUrl: resolveRuntimeEndpoint(binding, 'supabase', required(env.SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL, 'SUPABASE_URL'), isolatedOrigin),
7
+ supabaseUrl: url(env.SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL, 'SUPABASE_URL'),
12
8
  supabaseServiceRoleKey: required(env.SUPABASE_SERVICE_ROLE_KEY, 'SUPABASE_SERVICE_ROLE_KEY'),
13
- authorizationIssuer: resolveRuntimeEndpoint(binding, 'app', required(env.AMALGM_AUTHORIZATION_ISSUER, 'AMALGM_AUTHORIZATION_ISSUER'), isolatedOrigin),
9
+ authorizationIssuer: url(env.AMALGM_AUTHORIZATION_ISSUER, 'AMALGM_AUTHORIZATION_ISSUER'),
14
10
  schedulerIntervalMs: integer(env.AUTOMATIONS_SCHEDULER_INTERVAL_MS, 1_000, 250, 60_000),
15
11
  });
16
12
  }
@@ -25,6 +21,14 @@ function secret(value, name) {
25
21
  throw new Error(`${name} must contain at least 32 bytes`);
26
22
  return result;
27
23
  }
24
+ function url(value, name) {
25
+ const parsed = new URL(required(value, name));
26
+ const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
27
+ if (parsed.protocol !== 'https:' && !(local && parsed.protocol === 'http:')) {
28
+ throw new Error(`${name} must use HTTPS`);
29
+ }
30
+ return parsed.toString().replace(/\/$/, '');
31
+ }
28
32
  function integer(value, fallback, minimum, maximum) {
29
33
  const parsed = value === undefined ? fallback : Number(value);
30
34
  if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
@@ -0,0 +1,5 @@
1
+ import type { SupabaseRpcClient } from '../src/supabase-store.js';
2
+ /** The database owns the budget. This cache only repeats a known rejection. */
3
+ export declare function createRequestLimits(client: SupabaseRpcClient): Readonly<{
4
+ check(userId: string, request: Request, door: "control" | "machine"): Promise<void>;
5
+ }>;
@@ -0,0 +1,44 @@
1
+ import { AutomationError } from '../src/errors.js';
2
+ import { AutomationRateLimitError } from '../src/rate-limits.js';
3
+ /** The database owns the budget. This cache only repeats a known rejection. */
4
+ export function createRequestLimits(client) {
5
+ const deniedUntil = new Map();
6
+ return Object.freeze({
7
+ async check(userId, request, door) {
8
+ const bucket = requestBucket(request, door);
9
+ const key = JSON.stringify([userId, bucket]);
10
+ const until = deniedUntil.get(key) ?? 0;
11
+ let retryAfter = Math.ceil((until - Date.now()) / 1000);
12
+ if (retryAfter <= 0) {
13
+ deniedUntil.delete(key);
14
+ const { data, error } = await client.rpc('consume_amalgm_automation_budget', {
15
+ p_user_id: userId, p_bucket: bucket,
16
+ });
17
+ if (error || typeof data !== 'number' || !Number.isInteger(data) || data < 0) {
18
+ throw new AutomationError('unavailable', 'Request allowance could not be checked', 503);
19
+ }
20
+ retryAfter = data;
21
+ if (retryAfter > 0) {
22
+ if (deniedUntil.size >= 10_000)
23
+ deniedUntil.clear();
24
+ deniedUntil.set(key, Date.now() + retryAfter * 1000);
25
+ }
26
+ }
27
+ if (retryAfter > 0) {
28
+ throw new AutomationRateLimitError('rate_limited', `Account ${bucket} allowance exhausted. Retry after ${retryAfter} seconds.`, retryAfter);
29
+ }
30
+ },
31
+ });
32
+ }
33
+ function requestBucket(request, door) {
34
+ const parts = new URL(request.url).pathname.split('/').filter(Boolean).map(decodeURIComponent);
35
+ if (door === 'machine') {
36
+ if (parts[3] === 'notifications' && request.method === 'GET')
37
+ return 'streams';
38
+ return request.method === 'PATCH' ? 'execution' : 'claims';
39
+ }
40
+ if (request.method === 'GET')
41
+ return 'reads';
42
+ return parts.length === 4 && parts[3] === 'runs' && request.method === 'POST'
43
+ ? 'ingress' : 'configuration';
44
+ }
package/dist/host/main.js CHANGED
@@ -15,6 +15,7 @@ import { createAutomationsHost } from './server.js';
15
15
  import { createMachineRunNotifications } from '../src/machine-notifications.js';
16
16
  import { subscribeToRunChanges } from './notifications.js';
17
17
  import { createPublicSkillApi } from './skill.js';
18
+ import { createRequestLimits } from './limits.js';
18
19
  const config = automationsHostConfig();
19
20
  const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
20
21
  auth: { persistSession: false, autoRefreshToken: false },
@@ -23,11 +24,16 @@ const authentication = createAutomationsAuthenticators({
23
24
  issuer: config.authorizationIssuer,
24
25
  supabase,
25
26
  });
27
+ const limits = createRequestLimits(supabase);
26
28
  const webhooks = new WebhookEndpoints(config.webhookOrigin, config.webhookTokenKey);
27
29
  const delivery = new Automations(new SupabaseStore(supabase), (event, details) => log(event, details));
28
30
  const controlApi = createAutomationApi({
29
31
  service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase), () => new Date(), webhooks),
30
- authenticate: authentication.control,
32
+ authenticate: async (request) => {
33
+ const principal = await authentication.control(request);
34
+ await limits.check(principal.userId, request, 'control');
35
+ return principal;
36
+ },
31
37
  });
32
38
  const machineRepository = new SupabaseMachineRunRepository(supabase);
33
39
  const notifications = createMachineRunNotifications({
@@ -36,7 +42,11 @@ const notifications = createMachineRunNotifications({
36
42
  });
37
43
  const closeNotifications = subscribeToRunChanges(supabase, notifications);
38
44
  const machineApi = createMachineRunsApi({
39
- authenticate: authentication.machine,
45
+ authenticate: async (request) => {
46
+ const principal = await authentication.machine(request);
47
+ await limits.check(principal.userId, request, 'machine');
48
+ return principal;
49
+ },
40
50
  runsFor: (principal) => createMachineRuns(machineRepository, principal),
41
51
  notifications,
42
52
  });
@@ -1 +1 @@
1
- {"$schema":"https://schemas.agentskills.io/discovery/0.2.0/schema.json","skills":[{"name":"amalgm-automations","description":"Set up, operate, and troubleshoot Amalgm Automations through MCP or the amalgm automations CLI. Use for installation, Google sign-in, scheduled or webhook workflows, Run Now, run history, and Automations support.","type":"archive","url":"./amalgm-automations.f64db3414c22f330c2c83a5d1dc76949d7a95f3c9ba06a94c5fbd9c4024d4acd.tgz","digest":"sha256:f64db3414c22f330c2c83a5d1dc76949d7a95f3c9ba06a94c5fbd9c4024d4acd"}]}
1
+ {"$schema":"https://schemas.agentskills.io/discovery/0.2.0/schema.json","skills":[{"name":"amalgm-automations","description":"Set up and use Amalgm to schedule workflows, run native agents and scripts, receive webhooks, and inspect automation results through the amalgm CLI or an existing MCP connection.","type":"archive","url":"./amalgm-automations.97a9eb82d8185b0730c018c7e82479f2597efd19e2a828cfb256d64ac27082af.tgz","digest":"sha256:97a9eb82d8185b0730c018c7e82479f2597efd19e2a828cfb256d64ac27082af"}]}
@@ -1,4 +1,5 @@
1
1
  import { nextCronAt } from './schedule.js';
2
+ import { AutomationRateLimitError } from './rate-limits.js';
2
3
  import { eventReferences, verifyEventSecret } from './webhook.js';
3
4
  const noop = () => { };
4
5
  export class EventRejectedError extends Error {
@@ -49,14 +50,26 @@ export class Automations {
49
50
  if (!Number.isInteger(maximumRuns) || maximumRuns < 1)
50
51
  throw new Error('maximumRuns must be a positive integer');
51
52
  const created = [];
53
+ const limitedAccounts = new Set();
52
54
  for (const trigger of await this.store.dueCronTriggers(now)) {
55
+ if (limitedAccounts.has(trigger.userId))
56
+ continue;
53
57
  let scheduledFor = trigger.nextRunAt;
54
58
  let remaining = trigger.remainingOccurrences;
55
59
  while (created.length < maximumRuns
56
60
  && new Date(scheduledFor) <= now
57
61
  && (remaining === undefined || remaining > 0)) {
58
62
  const nextRunAt = nextCronAt(trigger.cron, trigger.timezone, scheduledFor);
59
- const run = await this.store.enqueueCron({ ...trigger, nextRunAt: scheduledFor }, nextRunAt, now);
63
+ let run;
64
+ try {
65
+ run = await this.store.enqueueCron({ ...trigger, nextRunAt: scheduledFor }, nextRunAt, now);
66
+ }
67
+ catch (error) {
68
+ if (!(error instanceof AutomationRateLimitError))
69
+ throw error;
70
+ limitedAccounts.add(trigger.userId);
71
+ break;
72
+ }
60
73
  if (!run)
61
74
  break;
62
75
  created.push(run);
@@ -1,5 +1,7 @@
1
1
  import { Automations, EventEndpointNotFoundError, EventRejectedError } from './automations.js';
2
2
  import { webhookDeliveryKey } from './webhook.js';
3
+ import { AutomationError } from './errors.js';
4
+ import { rateLimitHeaders } from './rate-limits.js';
3
5
  // Engine reference: amalgm-engine/runtime/scripts/amalgm-mcp/events/ingress.js.
4
6
  // The 2 MiB transport bound is preserved. Success means the matching run is
5
7
  // durable in Supabase, never that an envelope was written to a local inbox.
@@ -40,6 +42,9 @@ export function createAutomationEventsApi(config) {
40
42
  return json(202, { ok: true, accepted: true, ...receipt });
41
43
  }
42
44
  catch (error) {
45
+ if (error instanceof AutomationError && error.status) {
46
+ return json(error.status, { error: error.message, code: error.code }, rateLimitHeaders(error));
47
+ }
43
48
  if (error instanceof EventEndpointNotFoundError)
44
49
  return json(404, { error: error.message });
45
50
  if (error instanceof EventRejectedError)
@@ -95,9 +100,9 @@ function parsePayload(body) {
95
100
  function positiveInteger(value, fallback) {
96
101
  return Number.isInteger(value) && value > 0 ? value : fallback;
97
102
  }
98
- function json(status, body) {
103
+ function json(status, body, headers = {}) {
99
104
  return new Response(JSON.stringify(body), {
100
105
  status,
101
- headers: { 'content-type': 'application/json' },
106
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers },
102
107
  });
103
108
  }
package/dist/src/http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AutomationError } from './errors.js';
2
+ import { rateLimitHeaders } from './rate-limits.js';
2
3
  import { parseCreateAutomation, parseCreateScheduleTrigger, parseCreateWebhookTrigger, parseCreateWorkflow, parseListAutomations, parseListRuns, parseRunAutomationNow, parsePageQuery, parseUpdateAutomation, parseUpdateScheduleTrigger, parseUpdateWebhookTrigger, parseUpdateWorkflow, } from './schema.js';
3
4
  export function createAutomationApi(options) {
4
5
  return async (request) => {
@@ -101,7 +102,7 @@ export function createAutomationApi(options) {
101
102
  }
102
103
  catch (error) {
103
104
  if (error instanceof AutomationError)
104
- return response(status(error), { error: error.message, code: error.code });
105
+ return response(status(error), { error: error.message, code: error.code }, rateLimitHeaders(error));
105
106
  const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
106
107
  if (code.startsWith('dpop_') || code.includes('access_token') || code.includes('authorization')) {
107
108
  return response(401, { error: 'Authorization denied', code });
@@ -142,13 +143,15 @@ function booleanQuery(url, name) {
142
143
  function nullable(value) {
143
144
  return value === null ? response(404, { error: 'Not found', code: 'not_found' }) : response(200, value);
144
145
  }
145
- function response(status, body) {
146
+ function response(status, body, headers = {}) {
146
147
  return new Response(JSON.stringify(body), {
147
148
  status,
148
- headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
149
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers },
149
150
  });
150
151
  }
151
152
  function status(error) {
153
+ if (error.status !== undefined)
154
+ return error.status;
152
155
  if (error.code === 'validation')
153
156
  return 400;
154
157
  if (error.code === 'forbidden')
@@ -1,4 +1,5 @@
1
1
  import { AutomationError, ValidationError } from './errors.js';
2
+ import { rateLimitHeaders } from './rate-limits.js';
2
3
  export function createMachineRunsApi(options) {
3
4
  return async (request) => {
4
5
  try {
@@ -26,7 +27,7 @@ export function createMachineRunsApi(options) {
26
27
  if (error instanceof AutomationError)
27
28
  return json(error.status ?? (error.code === 'validation' ? 400 : 403), {
28
29
  error: error.message, code: error.code,
29
- });
30
+ }, rateLimitHeaders(error));
30
31
  const code = error instanceof Error && 'code' in error ? String(error.code) : 'internal';
31
32
  return json(code.startsWith('dpop_') || code.includes('access') ? 401 : 500, {
32
33
  error: code === 'internal' ? 'Automations service failed' : 'Authorization denied', code,
@@ -41,6 +42,6 @@ async function body(request) {
41
42
  }
42
43
  return value;
43
44
  }
44
- function json(status, value) {
45
- return Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
45
+ function json(status, value, headers = {}) {
46
+ return Response.json(value, { status, headers: { 'cache-control': 'no-store', ...headers } });
46
47
  }
@@ -0,0 +1,11 @@
1
+ import { AutomationError } from './errors.js';
2
+ export declare class AutomationRateLimitError extends AutomationError {
3
+ readonly retryAfterSeconds: number;
4
+ constructor(code: string, message: string, retryAfterSeconds?: number);
5
+ }
6
+ /** Translate the admission authority's stable errors at every transport door. */
7
+ export declare function admissionLimitError(error: {
8
+ code?: string;
9
+ message?: string;
10
+ }): AutomationRateLimitError | null;
11
+ export declare function rateLimitHeaders(error: unknown): Record<string, string>;
@@ -0,0 +1,25 @@
1
+ import { AutomationError } from './errors.js';
2
+ export class AutomationRateLimitError extends AutomationError {
3
+ retryAfterSeconds;
4
+ constructor(code, message, retryAfterSeconds = 60) {
5
+ super(code, message, 429);
6
+ this.retryAfterSeconds = retryAfterSeconds;
7
+ this.name = 'AutomationRateLimitError';
8
+ }
9
+ }
10
+ /** Translate the admission authority's stable errors at every transport door. */
11
+ export function admissionLimitError(error) {
12
+ if (error.code !== 'PT429')
13
+ return null;
14
+ if (error.message === 'queue_full') {
15
+ return new AutomationRateLimitError('queue_full', 'This account already has 100 unfinished runs. Complete existing work before admitting more.');
16
+ }
17
+ if (error.message === 'run_rate_limited') {
18
+ return new AutomationRateLimitError('run_rate_limited', 'This account can admit 10 new runs per minute. Retry after 60 seconds.');
19
+ }
20
+ return new AutomationRateLimitError('rate_limited', 'Request allowance exhausted. Retry after 60 seconds.');
21
+ }
22
+ export function rateLimitHeaders(error) {
23
+ return error instanceof AutomationRateLimitError
24
+ ? { 'retry-after': String(error.retryAfterSeconds) } : {};
25
+ }
@@ -1,5 +1,9 @@
1
1
  import { CronExpressionParser } from 'cron-parser';
2
+ import { ValidationError } from './errors.js';
2
3
  export function nextCronAt(cron, timezone, after) {
4
+ if (cron.trim().split(/\s+/).length !== 5) {
5
+ throw new ValidationError('Schedules require five-field cron; the minimum interval is one minute');
6
+ }
3
7
  return CronExpressionParser.parse(cron, {
4
8
  currentDate: after,
5
9
  tz: timezone,
@@ -128,14 +128,14 @@ export declare const createWebhookTriggerSchema: z.ZodObject<{
128
128
  enabled?: boolean | undefined;
129
129
  event?: string | undefined;
130
130
  secret?: string | undefined;
131
- id?: string | undefined;
132
131
  source?: string | undefined;
132
+ id?: string | undefined;
133
133
  }, {
134
134
  enabled?: boolean | undefined;
135
135
  event?: string | undefined;
136
136
  secret?: string | undefined;
137
- id?: string | undefined;
138
137
  source?: string | undefined;
138
+ id?: string | undefined;
139
139
  }>;
140
140
  export declare const updateWebhookTriggerSchema: z.ZodEffects<z.ZodObject<{
141
141
  source: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
@@ -1,8 +1,12 @@
1
1
  import { ConflictError } from '../errors.js';
2
+ import { admissionLimitError } from '../rate-limits.js';
2
3
  export async function call(client, functionName, arguments_) {
3
4
  const { data, error } = await client.rpc(functionName, arguments_);
4
5
  if (!error)
5
6
  return data;
7
+ const limitError = admissionLimitError(error);
8
+ if (limitError)
9
+ throw limitError;
6
10
  if (error.code === '23505' || /duplicate key|unique constraint/i.test(error.message || '')) {
7
11
  throw new ConflictError(resource(functionName));
8
12
  }
@@ -3,6 +3,7 @@ export interface SupabaseRpcClient {
3
3
  rpc(functionName: string, arguments_: Record<string, unknown>): PromiseLike<{
4
4
  data: unknown;
5
5
  error: {
6
+ code?: string;
6
7
  message?: string;
7
8
  } | null;
8
9
  }>;
@@ -1,3 +1,4 @@
1
+ import { admissionLimitError } from './rate-limits.js';
1
2
  function automationRun(row) {
2
3
  return {
3
4
  id: row.id,
@@ -81,7 +82,7 @@ export class SupabaseStore {
81
82
  async #call(functionName, arguments_) {
82
83
  const { data, error } = await this.client.rpc(functionName, arguments_);
83
84
  if (error)
84
- throw new Error(error.message || `Supabase function ${functionName} failed`);
85
+ throw admissionLimitError(error) ?? new Error(error.message || `Supabase function ${functionName} failed`);
85
86
  return data;
86
87
  }
87
88
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { CronExpressionParser } from 'cron-parser';
2
+ import { validateCron } from './schedule.js';
3
3
  import { ValidationError } from './errors.js';
4
4
  export const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
5
5
  const MAX_PAGE_SIZE = 100;
@@ -32,9 +32,11 @@ export function schedule(cron, timezone) {
32
32
  requiredText(cron, 'Schedule cron');
33
33
  requiredText(timezone, 'Schedule timezone');
34
34
  try {
35
- CronExpressionParser.parse(cron, { currentDate: new Date(0), tz: timezone });
35
+ validateCron(cron, timezone);
36
36
  }
37
- catch {
37
+ catch (error) {
38
+ if (error instanceof ValidationError)
39
+ throw error;
38
40
  throw new ValidationError('Schedule cron or timezone is invalid');
39
41
  }
40
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amalgm/automations",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Amalgm's automation SDK: durable trigger admission and target-machine execution.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "README.md"
46
46
  ],
47
47
  "scripts": {
48
- "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsx scripts/mark-executables.ts && tsx scripts/build-public-skill.ts",
48
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && tsx scripts/mark-executables.ts && tsx scripts/build-public-skill.ts",
49
49
  "check": "tsx scripts/check-tree.ts && tsc -p tsconfig.json --noEmit",
50
50
  "test": "tsx --test test/*.test.ts",
51
51
  "test:supabase": "tsx --test test/integration/postgres.test.ts",
@@ -56,10 +56,10 @@
56
56
  "start": "node dist/host/main.js"
57
57
  },
58
58
  "engines": {
59
- "node": ">=24"
59
+ "node": ">=20"
60
60
  },
61
61
  "dependencies": {
62
- "@amalgm/core": "0.4.7",
62
+ "@amalgm/core": "0.4.4",
63
63
  "@modelcontextprotocol/sdk": "^1.30.0",
64
64
  "@supabase/supabase-js": "2.57.4",
65
65
  "cron-parser": "^5.4.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: amalgm-automations
3
- description: Set up, operate, and troubleshoot Amalgm Automations through MCP or the amalgm automations CLI. Use for installation, Google sign-in, scheduled or webhook workflows, Run Now, run history, and Automations support.
3
+ description: Set up and use Amalgm to schedule workflows, run native agents and scripts, receive webhooks, and inspect automation results through the amalgm CLI or an existing MCP connection.
4
4
  ---
5
5
 
6
6
  # Amalgm Automations
@@ -10,14 +10,14 @@ workflow into one durable Amalgm automation and verify what actually ran.
10
10
  Automations owns configuration and permanent run history; the selected Amalgm
11
11
  machine executes the workflow.
12
12
 
13
- Amalgm Automations is free. Guide the person through Google sign-in and computer
13
+ Amalgm Automations is free during launch. Guide the person through Google sign-in and computer
14
14
  approval when needed; do not introduce a payment step. A native agent or other
15
15
  external service still uses its own account and may have its own usage costs.
16
16
 
17
17
  ## Choose the available adapter
18
18
 
19
- Prefer the six `amalgm_automations_*` MCP tools when they are callable. In a
20
- native local agent without that MCP server, use the matching global CLI:
19
+ Use the global `amalgm automations` CLI in a native local agent. If an existing
20
+ MCP connection already exposes the six `amalgm_automations_*` tools, reuse it.
21
21
 
22
22
  ```text
23
23
  MCP CLI
@@ -85,6 +85,13 @@ id, get and repair that draft with `update`; do not create a replacement.
85
85
 
86
86
  Use a finite `maxOccurrences` when the user's request is bounded. Do not turn
87
87
  "ten times" into an unbounded schedule plus a future cleanup promise.
88
+ Use standard five-field cron; the finest interval is one minute. Seconds and
89
+ cron macros are rejected. Account limits are 10 configuration API writes and
90
+ 10 new runs per minute, shared by every client and trigger, with at most 100
91
+ unfinished runs. One complete create with a schedule and workflow uses four
92
+ writes. On HTTP 429, wait for `Retry-After` (or 60 seconds if unavailable), then
93
+ repair an identified draft or retry the same delivery key; never create a new
94
+ definition or key to evade the limit. Accepted runs remain durable.
88
95
 
89
96
  Choose workflow lanes explicitly:
90
97
 
@@ -12,11 +12,12 @@ GitHub account or access to Amalgm's source repositories. With Node.js 22.20+
12
12
  (or Amalgm's supplied Node runtime), run:
13
13
 
14
14
  ```bash
15
- npx skills add https://automations.amalgm.ai --skill amalgm-automations
15
+ npx skills add amalgm-inc/skills
16
16
  ```
17
17
 
18
- Choose the intended agent in the installer's prompt. Installing the skill
19
- supplies guidance; use the connection steps below to access Automations.
18
+ Choose the intended agent in the installer's prompt. Use `npx skills update`
19
+ to refresh installed instructions. Installing the skill supplies guidance;
20
+ use the connection steps below to access Automations.
20
21
 
21
22
  ## Start with the connection you have
22
23
 
@@ -47,27 +48,9 @@ A successful Automations response is a JSON `result` envelope; an empty list
47
48
  is valid. The six Automations commands use that envelope. Shell's `status`
48
49
  returns a different JSON shape; `login` and `run` report progress as text.
49
50
 
50
- For a local stdio MCP client, configure this server using the actual selected
51
- account email (Shell 0.1.176 or newer):
52
-
53
- ```json
54
- {
55
- "mcpServers": {
56
- "amalgm-automations": {
57
- "command": "amalgm",
58
- "args": ["automations", "mcp", "--user", "person@example.com"]
59
- }
60
- }
61
- }
62
- ```
63
-
64
- This is the server entry for clients using `mcpServers` JSON; clients with a
65
- different configuration format use the same command and arguments. Use the
66
- exact installed launcher path if the client cannot find `amalgm` on PATH.
67
- Shell supplies the current local connection on every tool call, including
68
- after a runtime restart. Do not add credentials or environment variables.
69
- Listing tools works before login; calling them requires a running signed-in
70
- runtime. After configuring the client, verify its Automations `list` tool.
51
+ CLI and skill are the first public launch surfaces; local stdio MCP setup is
52
+ a follow-up. Use the CLI for new local setup. Reuse an already working MCP
53
+ connection without configuring a second connection or copying credentials.
71
54
 
72
55
  ## Install only when needed
73
56
 
@@ -140,11 +123,11 @@ do not reopen approval until they choose to continue.
140
123
 
141
124
  ## Reuse or resume a registered computer
142
125
 
143
- Discover registered accounts with `amalgm status --all` (Shell 0.1.176+).
144
- It returns email and registration state only. A sole ready account is selected
145
- implicitly; multiple ready accounts require `--user`. Once the account email
146
- is known, inspect it explicitly. Replace the example email below with the
147
- user's actual selected account:
126
+ Use the account email selected during setup. If it is unknown, ask the user
127
+ which account they connected; do not read credential files to discover it.
128
+ If the installed `amalgm --help` advertises `status --all`, that command can
129
+ list public registration metadata. Multiple accounts require an explicit
130
+ choice. Replace the example email below with the user's selected account:
148
131
 
149
132
  ```bash
150
133
  amalgm status --user person@example.com
@@ -186,7 +169,7 @@ reinstallation, or repeated writes.
186
169
 
187
170
  | Failure | What to do |
188
171
  | --- | --- |
189
- | `user_not_registered` or `user_selection_required` | Use `amalgm status --all` to discover local accounts, then select the intended email or complete login for it. |
172
+ | `user_not_registered` or `user_selection_required` | Select the intended account email, inspect its status, and complete login if needed. Use `status --all` only when the installed CLI advertises it. |
190
173
  | `runtime_unavailable`, connection refused, or Shell says it is not running | Inspect the selected account's status and use the resume/setup decision above. A standalone adapter asking for environment credentials should be replaced by the global `amalgm automations` path. |
191
174
  | `runtime_unauthorized` | Retry the read once through the public Shell command, which obtains the current local connection. If it still fails, keep the error for support; never extract or replace tokens manually. |
192
175
  | `runtime_transport` or `runtime_protocol` | Check local readiness, connectivity, and the installed version. A timeout or service outage is not evidence that the user needs a new account. |
@@ -0,0 +1,56 @@
1
+ -- The hosted principal is the only authority allowed to choose a user_id.
2
+ -- Supabase schema defaults grant anon/authenticated EXECUTE directly: removing
3
+ -- PUBLIC alone does not remove those grants. Keep this list at the product
4
+ -- boundary; never alter another product's functions or schema-wide defaults.
5
+ DO $$
6
+ DECLARE v_function regprocedure;
7
+ BEGIN
8
+ FOR v_function IN
9
+ SELECT p.oid::regprocedure FROM pg_proc p
10
+ JOIN pg_namespace n ON n.oid = p.pronamespace
11
+ WHERE n.nspname = 'public' AND p.proname IN (
12
+ 'amalgm_machine_wake_delay',
13
+ 'amalgm_trigger_json',
14
+ 'amalgm_workflow_is_executable',
15
+ 'claim_amalgm_machine_runs',
16
+ 'create_amalgm_automation',
17
+ 'create_amalgm_schedule_trigger',
18
+ 'create_amalgm_webhook_trigger',
19
+ 'create_amalgm_workflow',
20
+ 'delete_amalgm_automation',
21
+ 'delete_amalgm_schedule_trigger',
22
+ 'delete_amalgm_webhook_trigger',
23
+ 'delete_amalgm_workflow',
24
+ 'enqueue_amalgm_manual_run',
25
+ 'enqueue_amalgm_run',
26
+ 'get_amalgm_automation',
27
+ 'get_amalgm_run',
28
+ 'get_amalgm_schedule_trigger',
29
+ 'get_amalgm_webhook_trigger',
30
+ 'get_amalgm_workflow',
31
+ 'list_amalgm_automations',
32
+ 'list_amalgm_event_triggers',
33
+ 'list_amalgm_pending_runs',
34
+ 'list_amalgm_runs',
35
+ 'list_amalgm_schedule_triggers',
36
+ 'list_amalgm_triggers',
37
+ 'list_amalgm_webhook_triggers',
38
+ 'list_due_amalgm_crons',
39
+ 'mark_amalgm_run_sent',
40
+ 'notify_amalgm_machine_run',
41
+ 'resolve_amalgm_webhook',
42
+ 'update_amalgm_automation',
43
+ 'update_amalgm_claimed_run',
44
+ 'update_amalgm_run',
45
+ 'update_amalgm_schedule_trigger',
46
+ 'update_amalgm_webhook_trigger',
47
+ 'update_amalgm_workflow'
48
+ )
49
+ LOOP
50
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', v_function);
51
+ END LOOP;
52
+ END;
53
+ $$;
54
+ REVOKE ALL ON public.amalgm_automations, public.amalgm_automation_triggers,
55
+ public.amalgm_automation_workflows, public.amalgm_automation_runs
56
+ FROM PUBLIC, anon, authenticated;
@@ -0,0 +1,340 @@
1
+ -- Free launch policy. All budgets use the database clock and one account key.
2
+ CREATE TABLE public.amalgm_automation_rate_limits (
3
+ user_id uuid NOT NULL,
4
+ bucket text NOT NULL,
5
+ admitted_at timestamptz[] NOT NULL DEFAULT '{}',
6
+ PRIMARY KEY (user_id, bucket)
7
+ );
8
+ ALTER TABLE public.amalgm_automation_rate_limits ENABLE ROW LEVEL SECURITY;
9
+ REVOKE ALL ON public.amalgm_automation_rate_limits FROM PUBLIC, anon, authenticated;
10
+
11
+ -- Returns zero on admission, otherwise the number of seconds until capacity.
12
+ -- A bounded sliding window avoids double bursts at wall-clock minute boundaries.
13
+ CREATE FUNCTION public.consume_amalgm_automation_budget(p_user_id uuid, p_bucket text)
14
+ RETURNS integer
15
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
16
+ AS $$
17
+ DECLARE
18
+ v_limit integer;
19
+ v_now timestamptz;
20
+ v_times timestamptz[];
21
+ BEGIN
22
+ v_limit := CASE p_bucket
23
+ WHEN 'configuration' THEN 10 WHEN 'runs' THEN 10
24
+ WHEN 'reads' THEN 120 WHEN 'ingress' THEN 120
25
+ WHEN 'claims' THEN 120 WHEN 'streams' THEN 20
26
+ WHEN 'execution' THEN 3000 ELSE NULL END;
27
+ IF p_user_id IS NULL OR v_limit IS NULL THEN
28
+ RAISE EXCEPTION 'Invalid Automations budget';
29
+ END IF;
30
+ INSERT INTO public.amalgm_automation_rate_limits (user_id, bucket)
31
+ VALUES (p_user_id, p_bucket) ON CONFLICT DO NOTHING;
32
+ SELECT admitted_at INTO v_times FROM public.amalgm_automation_rate_limits
33
+ WHERE user_id = p_user_id AND bucket = p_bucket FOR UPDATE;
34
+ v_now := clock_timestamp();
35
+ SELECT coalesce(array_agg(t ORDER BY t), '{}'::timestamptz[]) INTO v_times
36
+ FROM unnest(v_times) t WHERE t > v_now - interval '1 minute';
37
+ IF cardinality(v_times) >= v_limit THEN
38
+ RETURN greatest(1, ceil(extract(epoch FROM (v_times[1] + interval '1 minute' - v_now)))::integer);
39
+ END IF;
40
+ UPDATE public.amalgm_automation_rate_limits SET admitted_at = array_append(v_times, v_now)
41
+ WHERE user_id = p_user_id AND bucket = p_bucket;
42
+ RETURN 0;
43
+ END;
44
+ $$;
45
+ REVOKE ALL ON FUNCTION public.consume_amalgm_automation_budget(uuid, text) FROM PUBLIC, anon, authenticated;
46
+ GRANT EXECUTE ON FUNCTION public.consume_amalgm_automation_budget(uuid, text) TO service_role;
47
+
48
+ CREATE INDEX amalgm_automation_runs_unfinished_account
49
+ ON public.amalgm_automation_runs(user_id)
50
+ WHERE status IN ('pending', 'sent', 'running');
51
+
52
+ -- Admission functions take this account lock before reading duplicate keys or
53
+ -- trigger rows. The insert guard also covers future service-owned admissions.
54
+ CREATE FUNCTION public.guard_amalgm_run_admission()
55
+ RETURNS trigger
56
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
57
+ AS $$
58
+ BEGIN
59
+ PERFORM pg_advisory_xact_lock(hashtextextended('amalgm-admission:' || NEW.user_id::text, 0));
60
+ IF (SELECT count(*) FROM public.amalgm_automation_runs
61
+ WHERE user_id = NEW.user_id AND status IN ('pending', 'sent', 'running')) >= 100 THEN
62
+ RAISE SQLSTATE 'PT429' USING MESSAGE = 'queue_full',
63
+ DETAIL = 'This account already has 100 unfinished runs. Complete existing work before admitting more.';
64
+ END IF;
65
+ IF public.consume_amalgm_automation_budget(NEW.user_id, 'runs') > 0 THEN
66
+ RAISE SQLSTATE 'PT429' USING MESSAGE = 'run_rate_limited',
67
+ DETAIL = 'This account can admit 10 new runs per minute. Retry after 60 seconds.';
68
+ END IF;
69
+ RETURN NEW;
70
+ END;
71
+ $$;
72
+ REVOKE ALL ON FUNCTION public.guard_amalgm_run_admission() FROM PUBLIC, anon, authenticated;
73
+ CREATE TRIGGER amalgm_run_admission_guard
74
+ BEFORE INSERT ON public.amalgm_automation_runs
75
+ FOR EACH ROW EXECUTE FUNCTION public.guard_amalgm_run_admission();
76
+
77
+ CREATE FUNCTION public.amalgm_cron_has_minute_granularity(p_cron text)
78
+ RETURNS boolean LANGUAGE sql IMMUTABLE SET search_path = ''
79
+ AS $$ SELECT p_cron IS NOT NULL AND cardinality(regexp_split_to_array(btrim(p_cron), '\s+')) = 5; $$;
80
+ REVOKE ALL ON FUNCTION public.amalgm_cron_has_minute_granularity(text) FROM PUBLIC, anon, authenticated;
81
+
82
+ -- Preserve the definition and run history; an old seconds expression cannot
83
+ -- continue firing or be enabled again without changing its expression.
84
+ UPDATE public.amalgm_automation_triggers SET enabled = false, updated_at = now()
85
+ WHERE kind = 'schedule' AND NOT public.amalgm_cron_has_minute_granularity(cron);
86
+ ALTER TABLE public.amalgm_automation_triggers ADD CONSTRAINT amalgm_schedule_minute_granularity
87
+ CHECK (kind <> 'schedule' OR NOT enabled OR public.amalgm_cron_has_minute_granularity(cron));
88
+
89
+ CREATE OR REPLACE FUNCTION public.enqueue_amalgm_run(
90
+ p_kind text,
91
+ p_user_id uuid,
92
+ p_automation_id text,
93
+ p_trigger_id text,
94
+ p_target_id text,
95
+ p_endpoint_id text,
96
+ p_delivery_key text,
97
+ p_expected_next_run_at timestamptz,
98
+ p_next_run_at timestamptz,
99
+ p_input jsonb,
100
+ p_now timestamptz
101
+ ) RETURNS SETOF public.amalgm_automation_runs
102
+ LANGUAGE plpgsql
103
+ SECURITY DEFINER
104
+ SET search_path = public
105
+ AS $$
106
+ DECLARE
107
+ v_inserted integer := 0;
108
+ BEGIN
109
+ PERFORM pg_advisory_xact_lock(hashtextextended('amalgm-admission:' || p_user_id::text, 0));
110
+ IF p_kind = 'cron' THEN
111
+ IF p_next_run_at IS NULL OR p_next_run_at < p_expected_next_run_at + interval '1 minute' THEN
112
+ RAISE EXCEPTION 'Schedule intervals must be at least one minute';
113
+ END IF;
114
+ IF p_endpoint_id IS NOT NULL OR p_delivery_key IS NOT NULL THEN
115
+ RAISE EXCEPTION 'Schedule admission cannot carry webhook identity';
116
+ END IF;
117
+ PERFORM 1
118
+ FROM public.amalgm_automation_triggers t
119
+ JOIN public.amalgm_automations a
120
+ ON a.user_id = t.user_id AND a.id = t.automation_id
121
+ WHERE t.user_id = p_user_id AND t.automation_id = p_automation_id
122
+ AND t.id = p_trigger_id AND a.target_id = p_target_id
123
+ AND t.kind = 'schedule' AND public.amalgm_cron_has_minute_granularity(t.cron)
124
+ AND t.next_run_at = p_expected_next_run_at
125
+ AND (t.remaining_occurrences IS NULL OR t.remaining_occurrences > 0)
126
+ AND t.enabled AND a.enabled
127
+ FOR UPDATE OF t;
128
+ ELSIF p_kind = 'event' THEN
129
+ IF p_endpoint_id IS NULL THEN RAISE EXCEPTION 'Webhook endpoint is required'; END IF;
130
+ PERFORM 1
131
+ FROM public.amalgm_automation_triggers t
132
+ JOIN public.amalgm_automations a
133
+ ON a.user_id = t.user_id AND a.id = t.automation_id
134
+ WHERE t.user_id = p_user_id AND t.automation_id = p_automation_id
135
+ AND t.id = p_trigger_id AND a.target_id = p_target_id
136
+ AND t.kind = 'webhook' AND t.endpoint_id = p_endpoint_id
137
+ AND t.enabled AND a.enabled;
138
+ ELSE
139
+ RAISE EXCEPTION 'Invalid automation trigger kind';
140
+ END IF;
141
+ IF NOT FOUND THEN RETURN; END IF;
142
+ IF p_delivery_key IS NOT NULL THEN
143
+ RETURN QUERY SELECT r.* FROM public.amalgm_automation_runs r
144
+ WHERE r.user_id = p_user_id AND r.automation_id = p_automation_id
145
+ AND r.trigger_id = p_trigger_id AND r.delivery_key = p_delivery_key;
146
+ IF FOUND THEN RETURN; END IF;
147
+ END IF;
148
+
149
+ RETURN QUERY
150
+ INSERT INTO public.amalgm_automation_runs (
151
+ user_id, target_id, automation_id, trigger_id, workflow_id,
152
+ automation_payload, input, status, delivery_key, created_at
153
+ )
154
+ SELECT a.user_id, a.target_id, a.id, t.id, w.id,
155
+ jsonb_strip_nulls(jsonb_build_object(
156
+ 'id', a.id, 'targetId', a.target_id, 'name', a.name,
157
+ 'description', a.description, 'enabled', a.enabled,
158
+ 'trigger', jsonb_strip_nulls(jsonb_build_object(
159
+ 'id', t.id,
160
+ 'kind', CASE WHEN t.kind = 'schedule' THEN 'cron' ELSE 'event' END,
161
+ 'enabled', t.enabled, 'cron', t.cron, 'timezone', t.timezone,
162
+ 'source', t.source, 'event', t.event
163
+ )),
164
+ 'workflow', jsonb_strip_nulls(jsonb_build_object(
165
+ 'id', w.id, 'name', w.name, 'script', w.script,
166
+ 'compiled', w.compiled, 'allowlist', w.allowlist, 'limits', w.limits
167
+ ))
168
+ )),
169
+ p_input, 'pending', p_delivery_key, p_now
170
+ FROM public.amalgm_automations a
171
+ JOIN public.amalgm_automation_triggers t
172
+ ON t.user_id = a.user_id AND t.automation_id = a.id
173
+ JOIN public.amalgm_automation_workflows w
174
+ ON w.user_id = a.user_id AND w.automation_id = a.id
175
+ WHERE a.user_id = p_user_id AND a.id = p_automation_id AND t.id = p_trigger_id
176
+ AND a.target_id = p_target_id AND t.enabled AND a.enabled
177
+ AND (p_kind = 'cron' OR (t.kind = 'webhook' AND t.endpoint_id = p_endpoint_id))
178
+ AND public.amalgm_workflow_is_executable(w.compiled)
179
+ ON CONFLICT (user_id, automation_id, trigger_id, delivery_key)
180
+ WHERE delivery_key IS NOT NULL
181
+ DO UPDATE SET delivery_key = EXCLUDED.delivery_key
182
+ RETURNING *;
183
+ GET DIAGNOSTICS v_inserted = ROW_COUNT;
184
+
185
+ IF p_kind = 'cron' AND v_inserted = 1 THEN
186
+ UPDATE public.amalgm_automation_triggers SET
187
+ next_run_at = p_next_run_at,
188
+ remaining_occurrences = CASE WHEN remaining_occurrences IS NULL
189
+ THEN NULL ELSE remaining_occurrences - 1 END,
190
+ enabled = CASE WHEN remaining_occurrences = 1 THEN false ELSE enabled END,
191
+ updated_at = p_now
192
+ WHERE user_id = p_user_id AND automation_id = p_automation_id
193
+ AND id = p_trigger_id AND kind = 'schedule'
194
+ AND next_run_at = p_expected_next_run_at;
195
+ END IF;
196
+ END;
197
+ $$;
198
+
199
+ CREATE OR REPLACE FUNCTION public.enqueue_amalgm_manual_run(
200
+ p_user_id uuid,
201
+ p_automation_id text,
202
+ p_input jsonb,
203
+ p_idempotency_key text,
204
+ p_now timestamptz
205
+ ) RETURNS SETOF public.amalgm_automation_runs
206
+ LANGUAGE plpgsql
207
+ SECURITY DEFINER
208
+ SET search_path = public
209
+ AS $$
210
+ BEGIN
211
+ PERFORM pg_advisory_xact_lock(hashtextextended('amalgm-admission:' || p_user_id::text, 0));
212
+ IF p_idempotency_key IS NOT NULL THEN
213
+ RETURN QUERY
214
+ SELECT r.*
215
+ FROM public.amalgm_automation_runs r
216
+ WHERE r.user_id = p_user_id
217
+ AND r.automation_id = p_automation_id
218
+ AND r.trigger_id = '@manual'
219
+ AND r.delivery_key = p_idempotency_key;
220
+ IF FOUND THEN RETURN; END IF;
221
+ END IF;
222
+
223
+ RETURN QUERY
224
+ INSERT INTO public.amalgm_automation_runs (
225
+ user_id, target_id, automation_id, trigger_id, workflow_id,
226
+ automation_payload, input, status, delivery_key, created_at
227
+ )
228
+ SELECT a.user_id, a.target_id, a.id, '@manual', w.id,
229
+ jsonb_strip_nulls(jsonb_build_object(
230
+ 'id', a.id, 'targetId', a.target_id, 'name', a.name,
231
+ 'description', a.description, 'enabled', a.enabled,
232
+ 'trigger', jsonb_build_object(
233
+ 'id', '@manual', 'kind', 'manual', 'enabled', true
234
+ ),
235
+ 'workflow', jsonb_strip_nulls(jsonb_build_object(
236
+ 'id', w.id, 'name', w.name, 'script', w.script,
237
+ 'compiled', w.compiled, 'allowlist', w.allowlist, 'limits', w.limits
238
+ ))
239
+ )),
240
+ p_input, 'pending', p_idempotency_key, p_now
241
+ FROM public.amalgm_automations a
242
+ JOIN public.amalgm_automation_workflows w
243
+ ON w.user_id = a.user_id AND w.automation_id = a.id
244
+ WHERE a.user_id = p_user_id AND a.id = p_automation_id AND a.enabled
245
+ AND public.amalgm_workflow_is_executable(w.compiled)
246
+ ON CONFLICT (user_id, automation_id, trigger_id, delivery_key)
247
+ WHERE delivery_key IS NOT NULL
248
+ DO UPDATE SET delivery_key = EXCLUDED.delivery_key
249
+ RETURNING *;
250
+ END;
251
+ $$;
252
+
253
+ CREATE OR REPLACE FUNCTION public.list_due_amalgm_crons(p_now timestamptz)
254
+ RETURNS TABLE (
255
+ user_id uuid,
256
+ automation_id text,
257
+ trigger_id text,
258
+ target_id text,
259
+ cron text,
260
+ timezone text,
261
+ next_run_at timestamptz,
262
+ remaining_occurrences integer
263
+ )
264
+ LANGUAGE sql
265
+ SECURITY DEFINER
266
+ SET search_path = public
267
+ AS $$
268
+ SELECT t.user_id, t.automation_id, t.id, a.target_id,
269
+ t.cron, t.timezone, t.next_run_at, t.remaining_occurrences
270
+ FROM public.amalgm_automation_triggers t
271
+ JOIN public.amalgm_automations a
272
+ ON a.user_id = t.user_id AND a.id = t.automation_id
273
+ JOIN public.amalgm_automation_workflows w
274
+ ON w.user_id = a.user_id AND w.automation_id = a.id
275
+ WHERE t.kind = 'schedule' AND t.next_run_at <= p_now
276
+ AND (t.remaining_occurrences IS NULL OR t.remaining_occurrences > 0)
277
+ AND t.enabled AND a.enabled
278
+ AND public.amalgm_cron_has_minute_granularity(t.cron)
279
+ AND public.amalgm_workflow_is_executable(w.compiled)
280
+ AND NOT EXISTS (SELECT 1 FROM public.amalgm_automation_rate_limits q
281
+ WHERE q.user_id = t.user_id AND q.bucket = 'runs'
282
+ AND cardinality(q.admitted_at) >= 10
283
+ AND q.admitted_at[1] > clock_timestamp() - interval '1 minute')
284
+ AND (SELECT count(*) FROM public.amalgm_automation_runs r
285
+ WHERE r.user_id = t.user_id AND r.status IN ('pending', 'sent', 'running')) < 100
286
+ ORDER BY t.next_run_at, t.user_id, t.automation_id, t.id;
287
+ $$;
288
+
289
+ CREATE OR REPLACE FUNCTION public.resolve_amalgm_webhook(p_endpoint_id text)
290
+ RETURNS TABLE (
291
+ user_id uuid, automation_id text, trigger_id text, target_id text,
292
+ endpoint_id text, source text, event text, secret text
293
+ )
294
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
295
+ AS $$
296
+ DECLARE v_owner uuid;
297
+ BEGIN
298
+ SELECT t.user_id INTO v_owner FROM public.amalgm_automation_triggers t
299
+ WHERE t.kind = 'webhook' AND t.endpoint_id = p_endpoint_id;
300
+ IF v_owner IS NULL THEN RETURN; END IF;
301
+ IF public.consume_amalgm_automation_budget(v_owner, 'ingress') > 0 THEN
302
+ RAISE SQLSTATE 'PT429' USING MESSAGE = 'rate_limited';
303
+ END IF;
304
+ RETURN QUERY
305
+ SELECT t.user_id, t.automation_id, t.id, a.target_id,
306
+ t.endpoint_id, t.source, t.event, t.secret
307
+ FROM public.amalgm_automation_triggers t
308
+ JOIN public.amalgm_automations a
309
+ ON a.user_id = t.user_id AND a.id = t.automation_id
310
+ JOIN public.amalgm_automation_workflows w
311
+ ON w.user_id = a.user_id AND w.automation_id = a.id
312
+ WHERE t.kind = 'webhook' AND t.endpoint_id = p_endpoint_id
313
+ AND t.enabled AND a.enabled
314
+ AND public.amalgm_workflow_is_executable(w.compiled);
315
+ END;
316
+ $$;
317
+
318
+ -- A later eligibility deadline cannot require an earlier wakeup. Existing
319
+ -- timers re-read the ledger at their deadline; journal writes and heartbeat
320
+ -- renewals need no Broadcast. Earlier eligibility still wakes every host.
321
+ CREATE OR REPLACE FUNCTION public.notify_amalgm_machine_run()
322
+ RETURNS trigger
323
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
324
+ AS $$
325
+ BEGIN
326
+ IF TG_OP = 'UPDATE' THEN
327
+ IF (OLD.status, OLD.retry_at, OLD.lease_expires_at) IS NOT DISTINCT FROM
328
+ (NEW.status, NEW.retry_at, NEW.lease_expires_at) THEN RETURN NEW; END IF;
329
+ IF NEW.status IN ('completed', 'failed') THEN RETURN NEW; END IF;
330
+ IF OLD.status IN ('sent', 'running') AND NEW.status IN ('sent', 'running')
331
+ AND NEW.lease_expires_at >= OLD.lease_expires_at THEN RETURN NEW; END IF;
332
+ END IF;
333
+ PERFORM realtime.send(
334
+ jsonb_build_object('userId', NEW.user_id, 'targetId', NEW.target_id),
335
+ 'changed', 'amalgm:automations:runs', true
336
+ );
337
+ RETURN NEW;
338
+ END;
339
+ $$;
340
+ REVOKE ALL ON FUNCTION public.notify_amalgm_machine_run() FROM PUBLIC, anon, authenticated;