@amalgm/automations 0.4.2 → 0.4.4-main.34176529830
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 +13 -2
- package/PURPOSE.md +42 -0
- package/README.md +96 -8
- package/dist/host/limits.d.ts +5 -0
- package/dist/host/limits.js +44 -0
- package/dist/host/main.js +12 -2
- package/dist/skills/amalgm-automations.a46100bf875bc64b8668dc705003d6e31bddd32cb61cda74852786370842c94e.tgz +0 -0
- package/dist/skills/index.json +1 -1
- package/dist/src/automations.js +14 -1
- package/dist/src/events-http.js +7 -2
- package/dist/src/http.js +6 -3
- package/dist/src/machine-http.js +4 -3
- package/dist/src/rate-limits.d.ts +11 -0
- package/dist/src/rate-limits.js +25 -0
- package/dist/src/schedule.js +4 -0
- package/dist/src/schema.d.ts +2 -2
- package/dist/src/supabase-crud/rpc.js +4 -0
- package/dist/src/supabase-store.d.ts +1 -0
- package/dist/src/supabase-store.js +2 -1
- package/dist/src/validation.js +5 -3
- package/package.json +49 -3
- package/skills/amalgm-automations/SKILL.md +11 -4
- package/skills/amalgm-automations/references/setup-and-support.md +13 -30
- package/supabase/migrations/20260907000000_service_only_permissions.sql +56 -0
- package/supabase/migrations/20260907010000_free_launch_limits.sql +340 -0
- package/dist/skills/amalgm-automations.f64db3414c22f330c2c83a5d1dc76949d7a95f3c9ba06a94c5fbd9c4024d4acd.tgz +0 -0
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
|
-
|
|
10
|
-
|
|
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
|
|
@@ -147,3 +148,13 @@
|
|
|
147
148
|
49. A complete definition validates all supplied configuration before its
|
|
148
149
|
first write; a later service failure preserves an identifiable disabled
|
|
149
150
|
draft and the original failure code.
|
|
151
|
+
50. Launch quotas belong to the authenticated account in Supabase, shared by
|
|
152
|
+
every client, target, trigger, and Fly replica, and use the database clock.
|
|
153
|
+
51. Configuration API writes and new run admissions each allow ten operations
|
|
154
|
+
per rolling minute; retries of an admitted delivery consume no new-run slot.
|
|
155
|
+
52. An account retains at most one hundred unfinished runs; rejecting new work
|
|
156
|
+
neither removes accepted runs nor advances a schedule's occurrence count.
|
|
157
|
+
53. Only five-field cron may fire; existing finer schedules remain inspectable
|
|
158
|
+
but disabled until the owner replaces their expression.
|
|
159
|
+
54. Reads, ingress attempts, claims, stream openings, and execution updates
|
|
160
|
+
have independent bounded transport budgets, separate from new-work quotas.
|
package/PURPOSE.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
+
Consumer assembly derives exact same-branch dependency pins from the declared
|
|
6
|
+
package manifests and registry candidate records. One pending composition per
|
|
7
|
+
branch preserves human edits; complete consumer checks for the exact merge tree govern its
|
|
8
|
+
merge, and publication never implies installed adoption.
|
|
9
|
+
|
|
5
10
|
Managed releases bind to one exact published Core version and build from the
|
|
6
11
|
locked dependency graph on the supported Node toolchain.
|
|
7
12
|
|
|
@@ -18,6 +23,15 @@ webhook authority. Main and preview initially share the database by owner
|
|
|
18
23
|
direction; their public API and issuer identities remain distinct. Durable
|
|
19
24
|
claims remain the single authority for work even when hosts share data.
|
|
20
25
|
|
|
26
|
+
The initial launch is free, with no payment prerequisite or automatic paid
|
|
27
|
+
cutover. Shared account limits protect the hosted service: ten configuration
|
|
28
|
+
API writes and ten newly admitted runs per rolling minute, and at most one
|
|
29
|
+
hundred unfinished runs. Accepted work remains durable; quota rejection never
|
|
30
|
+
deletes it or advances a schedule. Schedules use standard five-field cron, with
|
|
31
|
+
one minute as the finest interval. Reads, webhook attempts, machine claims,
|
|
32
|
+
stream openings, and execution updates have separate transport allowances so
|
|
33
|
+
accepted work can finish after new-work capacity is exhausted.
|
|
34
|
+
|
|
21
35
|
The product has two composable halves over that one state:
|
|
22
36
|
|
|
23
37
|
- **The configuration control plane** — one ergonomic, auth-bound SDK contract
|
|
@@ -70,6 +84,10 @@ the user reach aayush@amalgm.ai with bounded, redacted diagnostic evidence when
|
|
|
70
84
|
the failing boundary cannot be repaired. The packaged skill is the source for
|
|
71
85
|
installed copies and public setup/support guidance.
|
|
72
86
|
|
|
87
|
+
Amalgm's public skills collection distributes reviewed copies of this skill
|
|
88
|
+
from a recorded source commit. It is the short installation entry point; this
|
|
89
|
+
repository remains the authority for Automations instructions.
|
|
90
|
+
|
|
73
91
|
The standalone service publishes a public skill discovery index and an
|
|
74
92
|
integrity-checked archive built from that same packaged skill. Installation
|
|
75
93
|
needs no private repository access, and the archive includes its referenced
|
|
@@ -144,3 +162,27 @@ authority. Shell calls the published machine-claim and execution contracts
|
|
|
144
162
|
directly; the UI calls the hosted control plane through the published client.
|
|
145
163
|
Any remaining Engine automation code is deprecated migration/parity evidence,
|
|
146
164
|
not a cutover dependency or compatibility authority.
|
|
165
|
+
|
|
166
|
+
## Managed package delivery
|
|
167
|
+
|
|
168
|
+
Managed package delivery starts from a clean, exact main or preview source
|
|
169
|
+
commit. The workflow occurrence supplies only its generated version and source
|
|
170
|
+
record; tests run against those candidate inputs before packing. Every native
|
|
171
|
+
gate and publication use that same tarball and integrity. A candidate tag makes
|
|
172
|
+
bytes available to release assembly; it never promotes an installed runtime.
|
|
173
|
+
Release tooling remains available inside each owner repository so a developer
|
|
174
|
+
or CI job never needs access to another private product repository.
|
|
175
|
+
Registry credentials are available only to the final protected publication job,
|
|
176
|
+
after every native examination proves the exact candidate bytes.
|
|
177
|
+
Native consumers install the archive as a dependency, so SDK development
|
|
178
|
+
dependencies never become an end-user installation requirement.
|
|
179
|
+
The repository token creates only its own dependency proposals and explicitly
|
|
180
|
+
dispatches checks and delivery; every admitted gate proves the actual checkout,
|
|
181
|
+
including the exact two-parent merge commit that becomes managed source.
|
|
182
|
+
|
|
183
|
+
Hosted delivery examines the final immutable image under both declared managed
|
|
184
|
+
bindings before deploying that exact digest to its source branch's Fly app.
|
|
185
|
+
The host's own pipeline owns replacement, health and provider verification;
|
|
186
|
+
no hosted process updates its package tree in place. A local image examination
|
|
187
|
+
proves image behavior, while Fly networking and durable product journeys retain
|
|
188
|
+
their separate provider proof.
|
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
|
-
|
|
111
|
-
`amalgm
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
165
|
+
npx skills add amalgm-inc/skills
|
|
121
166
|
```
|
|
122
167
|
|
|
123
|
-
The
|
|
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
|
|
@@ -277,3 +331,37 @@ behavior, offline catch-up, retry deadlines, and abandoned claims.
|
|
|
277
331
|
|
|
278
332
|
See [PURPOSE.md](./PURPOSE.md) and [AXIOMS.md](./AXIOMS.md) for the governing
|
|
279
333
|
ownership and behavior laws.
|
|
334
|
+
|
|
335
|
+
## Managed package delivery
|
|
336
|
+
|
|
337
|
+
Managed npm delivery uses `.github/workflows/publish-package.yml` after the
|
|
338
|
+
exact main/preview owner checks pass. The occurrence generates a unique
|
|
339
|
+
prerelease version and records its source SHA; it reruns the owner suite, packs
|
|
340
|
+
once, and installs that tarball on Linux, macOS and Windows before publication.
|
|
341
|
+
The `candidate-main`/`candidate-preview` tags are release-assembly inputs and
|
|
342
|
+
never select installed runtimes. `package.json` declares this owner's gates,
|
|
343
|
+
CLI probes, peer-only exports and lifecycle permissions. The standalone runner
|
|
344
|
+
under `scripts/release/` is intentionally copied across owners to preserve
|
|
345
|
+
private repository access boundaries. The final publish step uses the `NPM_TOKEN`
|
|
346
|
+
secret in the `npm-release` environment. Where npm trusted publishing is
|
|
347
|
+
configured for this repository's `publish-package.yml` and that environment,
|
|
348
|
+
the token can be omitted. Authentication does not replace native certification,
|
|
349
|
+
exact source records or artifact integrity; private source repositories do not
|
|
350
|
+
receive npm's public-source provenance.
|
|
351
|
+
|
|
352
|
+
Hosted delivery is separately wired in `release-hosted.yml`: it builds the exact
|
|
353
|
+
source image, tests both declared bindings without network access, deploys the
|
|
354
|
+
same digest to the selected Fly app, then verifies machine identity and public
|
|
355
|
+
readiness. `package.json` owns the host declaration. The Python effect runner is
|
|
356
|
+
an intentional standalone copy from UI; no other private repository is required.
|
|
357
|
+
Live provider and durable product-flow proof remain separate release obligations.
|
|
358
|
+
|
|
359
|
+
Managed dependency reconciliation reads this owner’s declared manifests and
|
|
360
|
+
updates exact same-branch registry candidates in one pending composition PR per
|
|
361
|
+
branch. The repository token explicitly dispatches the owner checks. Every declared gate must
|
|
362
|
+
pass for the exact merge tree; an atomic expected-base update prevents merging
|
|
363
|
+
with untested concurrent source. Human additions or amendments are preserved.
|
|
364
|
+
The half-hour CI reconciliation recovers missed dispatch hints. Assembly runs
|
|
365
|
+
no dependency code or lifecycle hooks, and repeated owner checks do not publish
|
|
366
|
+
another candidate for unchanged source. Repository policy must allow GitHub Actions to create pull requests; no
|
|
367
|
+
cross-repository credential or paid auto-merge feature is required.
|
|
@@ -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:
|
|
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:
|
|
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
|
});
|
|
Binary file
|
package/dist/skills/index.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"$schema":"https://schemas.agentskills.io/discovery/0.2.0/schema.json","skills":[{"name":"amalgm-automations","description":"Set up
|
|
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.a46100bf875bc64b8668dc705003d6e31bddd32cb61cda74852786370842c94e.tgz","digest":"sha256:a46100bf875bc64b8668dc705003d6e31bddd32cb61cda74852786370842c94e"}]}
|
package/dist/src/automations.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/src/events-http.js
CHANGED
|
@@ -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')
|
package/dist/src/machine-http.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/schedule.js
CHANGED
|
@@ -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,
|
package/dist/src/schema.d.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
}
|
package/dist/src/validation.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.4-main.34176529830",
|
|
4
4
|
"description": "Amalgm's automation SDK: durable trigger admission and target-machine execution.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -53,13 +53,19 @@
|
|
|
53
53
|
"verify": "npm run check && npm test && npm run build && tsx scripts/check-cli-artifact.ts",
|
|
54
54
|
"release:check": "npm run verify && npm run test:supabase",
|
|
55
55
|
"prepack": "npm run build",
|
|
56
|
-
"start": "node dist/host/main.js"
|
|
56
|
+
"start": "node dist/host/main.js",
|
|
57
|
+
"release:admit": "node scripts/release/package.mjs admit",
|
|
58
|
+
"release:prepare": "node scripts/release/package.mjs prepare",
|
|
59
|
+
"release:examine": "node scripts/release/package.mjs examine",
|
|
60
|
+
"release:publish": "node scripts/release/package.mjs publish",
|
|
61
|
+
"release:compose": "node scripts/release/compose.mjs",
|
|
62
|
+
"test:release": "node --test scripts/release/*.test.mjs"
|
|
57
63
|
},
|
|
58
64
|
"engines": {
|
|
59
65
|
"node": ">=24"
|
|
60
66
|
},
|
|
61
67
|
"dependencies": {
|
|
62
|
-
"@amalgm/core": "0.4.
|
|
68
|
+
"@amalgm/core": "0.4.8-main.34168902443",
|
|
63
69
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
64
70
|
"@supabase/supabase-js": "2.57.4",
|
|
65
71
|
"cron-parser": "^5.4.0",
|
|
@@ -72,5 +78,45 @@
|
|
|
72
78
|
"pg": "^8.16.3",
|
|
73
79
|
"tsx": "^4.23.1",
|
|
74
80
|
"typescript": "5.9.3"
|
|
81
|
+
},
|
|
82
|
+
"amalgmRelease": {
|
|
83
|
+
"schema": 1,
|
|
84
|
+
"workflow": "verify.yml",
|
|
85
|
+
"jobs": [
|
|
86
|
+
"product",
|
|
87
|
+
"notifications"
|
|
88
|
+
],
|
|
89
|
+
"verify": "verify",
|
|
90
|
+
"cli": [
|
|
91
|
+
"amalgm-automations"
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
"allowScripts": {
|
|
95
|
+
"esbuild@0.28.1": true,
|
|
96
|
+
"fsevents@2.3.3": false
|
|
97
|
+
},
|
|
98
|
+
"amalgmHosts": {
|
|
99
|
+
"automations": {
|
|
100
|
+
"directory": ".",
|
|
101
|
+
"context": ".",
|
|
102
|
+
"dockerfile": "Dockerfile"
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"amalgmComposition": {
|
|
106
|
+
"schema": 1,
|
|
107
|
+
"manifests": [
|
|
108
|
+
"package.json"
|
|
109
|
+
],
|
|
110
|
+
"afterMergeWorkflows": [
|
|
111
|
+
"publish-package.yml",
|
|
112
|
+
"release-hosted.yml"
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
"gitHead": "09ce702dd22145ddc2684e0dc1bfde1639d66e33",
|
|
116
|
+
"amalgmSource": {
|
|
117
|
+
"repository": "amalgm-inc/amalgm-automations",
|
|
118
|
+
"branch": "main",
|
|
119
|
+
"commit": "09ce702dd22145ddc2684e0dc1bfde1639d66e33",
|
|
120
|
+
"occurrence": "34176529830"
|
|
75
121
|
}
|
|
76
122
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: amalgm-automations
|
|
3
|
-
description: Set up
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
15
|
+
npx skills add amalgm-inc/skills
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Choose the intended agent in the installer's prompt.
|
|
19
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
user's
|
|
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` |
|
|
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;
|