@amalgm/automations 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AXIOMS.md +27 -11
  2. package/PURPOSE.md +13 -3
  3. package/README.md +60 -113
  4. package/dist/host/auth.d.ts +11 -0
  5. package/dist/host/auth.js +89 -0
  6. package/dist/host/config.d.ts +9 -0
  7. package/dist/host/config.js +30 -0
  8. package/dist/host/index.d.ts +3 -0
  9. package/dist/host/index.js +3 -0
  10. package/dist/host/main.d.ts +1 -0
  11. package/dist/host/main.js +48 -0
  12. package/dist/host/server.d.ts +12 -0
  13. package/dist/host/server.js +54 -0
  14. package/dist/src/automations.js +4 -1
  15. package/dist/src/client.d.ts +3 -1
  16. package/dist/src/client.js +6 -4
  17. package/dist/src/contract.d.ts +19 -2
  18. package/dist/src/crud/automations.d.ts +3 -0
  19. package/dist/src/crud/automations.js +55 -0
  20. package/dist/src/crud/context.d.ts +14 -0
  21. package/dist/src/crud/context.js +43 -0
  22. package/dist/src/crud/repository.d.ts +35 -0
  23. package/dist/src/crud/repository.js +1 -0
  24. package/dist/src/crud/runs.d.ts +3 -0
  25. package/dist/src/crud/runs.js +19 -0
  26. package/dist/src/crud/triggers.d.ts +3 -0
  27. package/dist/src/crud/triggers.js +118 -0
  28. package/dist/src/crud/workflow.d.ts +3 -0
  29. package/dist/src/crud/workflow.js +42 -0
  30. package/dist/src/crud.d.ts +3 -33
  31. package/dist/src/crud.js +10 -237
  32. package/dist/src/executor.d.ts +16 -0
  33. package/dist/src/executor.js +76 -0
  34. package/dist/src/index.d.ts +8 -2
  35. package/dist/src/index.js +5 -0
  36. package/dist/src/machine-client.d.ts +7 -0
  37. package/dist/src/machine-client.js +30 -0
  38. package/dist/src/machine-http.d.ts +5 -0
  39. package/dist/src/machine-http.js +40 -0
  40. package/dist/src/machine.d.ts +41 -0
  41. package/dist/src/machine.js +43 -0
  42. package/dist/src/mcp.js +3 -3
  43. package/dist/src/schema.d.ts +50 -42
  44. package/dist/src/schema.js +3 -1
  45. package/dist/src/supabase-crud/automations.d.ts +5 -0
  46. package/dist/src/supabase-crud/automations.js +36 -0
  47. package/dist/src/supabase-crud/mappers.d.ts +8 -0
  48. package/dist/src/supabase-crud/mappers.js +81 -0
  49. package/dist/src/supabase-crud/rows.d.ts +56 -0
  50. package/dist/src/supabase-crud/rows.js +1 -0
  51. package/dist/src/supabase-crud/rpc.d.ts +10 -0
  52. package/dist/src/supabase-crud/rpc.js +19 -0
  53. package/dist/src/supabase-crud/triggers.d.ts +5 -0
  54. package/dist/src/supabase-crud/triggers.js +46 -0
  55. package/dist/src/supabase-crud/workflow-runs.d.ts +5 -0
  56. package/dist/src/supabase-crud/workflow-runs.js +41 -0
  57. package/dist/src/supabase-crud.d.ts +5 -41
  58. package/dist/src/supabase-crud.js +4 -267
  59. package/dist/src/supabase-machine.d.ts +16 -0
  60. package/dist/src/supabase-machine.js +63 -0
  61. package/dist/src/supabase-store.js +1 -0
  62. package/dist/src/types.d.ts +1 -0
  63. package/package.json +12 -3
  64. package/skills/automations/SKILL.md +52 -0
  65. package/supabase/migrations/20260829010000_bounded_schedules_and_machine_claims.sql +311 -0
package/AXIOMS.md CHANGED
@@ -10,31 +10,47 @@
10
10
  key authentication all resolve to the same principal shape.
11
11
  4. An automation belongs to exactly one user and exactly one target. It may
12
12
  have zero or more triggers and zero or one workflow.
13
- 5. Scheduled triggers and webhook triggers are distinct first-class resources
13
+ 5. A principal bound to exactly one target supplies that target implicitly on
14
+ create. Unbound or multi-target principals must choose explicitly; adapters
15
+ and models never guess opaque target ids.
16
+ 6. Scheduled triggers and webhook triggers are distinct first-class resources
14
17
  with their own CRUD operations and validation rules.
15
- 6. A workflow is the one script resource owned by its automation. It may be
18
+ 7. A workflow is the one script resource owned by its automation. It may be
16
19
  created, read, updated, or deleted independently of the automation and its
17
20
  triggers.
18
- 7. An incomplete automation is valid configuration. Future execution behavior
21
+ 8. An incomplete automation is valid configuration. Future execution behavior
19
22
  must be derived from its persisted configuration rather than repaired by the
20
23
  CRUD layer.
21
- 8. Supabase is authoritative for automations, triggers, workflow source, and
24
+ 9. Supabase is authoritative for automations, triggers, workflow source, and
22
25
  permanent run history. A definition edit or deletion never rewrites prior
23
26
  runs.
24
- 9. Webhook secrets are persisted but write-only: normal reads reveal only that
27
+ 10. Webhook secrets are persisted but write-only: normal reads reveal only that
25
28
  a secret is configured.
26
- 10. Run history is read-only in the control plane and always scoped to its
29
+ 11. Run history is read-only in the control plane and always scoped to its
27
30
  automation's owner.
28
- 11. Storage records, Supabase RPCs, HTTP details, and MCP protocol details are
31
+ 12. Storage records, Supabase RPCs, HTTP details, and MCP protocol details are
29
32
  implementation concerns, not SDK concepts.
30
- 12. The control-plane SDK is the only configuration write path. The delivery
33
+ 13. The control-plane SDK is the only configuration write path. The delivery
31
34
  rail reads that configuration and writes only schedule clocks and run
32
35
  state; it never owns a second automation-definition API.
33
- 13. Admitting a run atomically verifies the current enabled configuration,
36
+ 14. Admitting a run atomically verifies the current enabled configuration,
34
37
  stores a secret-free snapshot, and, for a schedule, advances exactly the
35
38
  firing instant that was claimed.
36
- 14. Legacy local automation storage is not a compatibility authority. Engine
39
+ 15. Legacy local automation storage is not a compatibility authority. Engine
37
40
  cutover migrates callers to this SDK and then deletes the old store.
38
- 15. Event ingress returns success only after every admitted run is durable in
41
+ 16. Event ingress returns success only after every admitted run is durable in
39
42
  Supabase. Its recent-event list is a bounded, secret-free operational view,
40
43
  never a second event or run authority.
44
+ 17. A finite schedule decrements its durable remaining occurrence count in the
45
+ same transaction that admits a run; zero disables the trigger.
46
+ 18. A machine receives work only by exclusively leasing runs whose persisted
47
+ target equals the `computer_id` in its DPoP-bound access token.
48
+ 19. The configured public origin, never forwarding headers, reconstructs the
49
+ DPoP request URL behind the Fly proxy.
50
+ 20. Machine execution consumes the immutable workflow snapshot stored on the
51
+ run. It never rediscovers or silently updates the automation definition.
52
+ 21. A compiled workflow is a small declarative sequence of tool actions. The
53
+ executor receives tool calling as a host capability and never embeds a
54
+ Channels, Shell, CLI, or provider special case.
55
+ 22. Automations is a standalone hosted service. Gateway owns none of its API,
56
+ scheduling, claim, execution, or persistence path.
package/PURPOSE.md CHANGED
@@ -21,9 +21,16 @@ The product has two composable halves over that one state:
21
21
  idempotency laws that decide when a run happens. It consumes the control
22
22
  plane's persisted configuration; it does not expose another definition
23
23
  write path. When a machine is offline, new runs remain pending; when it
24
- reconnects, every pending run is sent through Amalgm's existing transport
25
- and started by the existing host runtime. Execution itself stays on the
26
- machine.
24
+ reconnects, Shell claims pending runs directly from the Automations service
25
+ with its machine-bound DPoP identity and executes the persisted tool-action
26
+ plan. Execution itself stays on the selected machine.
27
+
28
+ Automations is its own hosted service and Fly machine. Its API and scheduler
29
+ share the same SDK and Supabase authority; neither is composed into, proxied by,
30
+ or dependent on Amalgm Gateway. A finite schedule carries its remaining
31
+ occurrence count in durable state, so “every minute for ten minutes” means ten
32
+ admitted runs and then an automatically disabled trigger — not a timer that a
33
+ machine must remember.
27
34
 
28
35
  The event HTTP adapter is one thin door over that delivery rail. It bounds and
29
36
  parses the webhook body, resolves the authenticated target supplied by the
@@ -32,6 +39,9 @@ recent-event response is an ephemeral, secret-free operational projection.
32
39
 
33
40
  Amalgm supplies a resolved authenticated principal from its user session or
34
41
  HMAC-refresh flow; future API keys resolve to the same principal capability.
42
+ When that principal is bound to exactly one machine, automation creation uses
43
+ that target without asking a human or model to copy an opaque machine id. An
44
+ explicit target remains mandatory for unbound or multi-target principals.
35
45
  Core provides authenticated user identity, opaque machine identity,
36
46
  connectivity, and narrow host capabilities. The product receives identity and
37
47
  scopes — never raw credentials or Core storage — and neither side reaches into
package/README.md CHANGED
@@ -1,142 +1,89 @@
1
1
  # @amalgm/automations
2
2
 
3
- The Automations product extracted from `amalgm-mcp`: a Supabase-backed
4
- configuration control plane plus the delivery rail that decides when runs
5
- happen. Supabase owns everything except execution.
6
-
7
- ## SDK contract (configuration control plane)
8
-
9
- The public control plane is an auth-bound SDK. API, CLI, MCP, and the
10
- automation skill adapt this contract; only the service implementation accesses
11
- Supabase.
3
+ The SDK and standalone hosted service for automation definitions, triggers,
4
+ durable runs, and execution delivery to an Amalgm machine.
12
5
 
13
6
  ```ts
14
7
  const sdk = service.for(principal);
15
-
16
- await sdk.automations.create({ targetId: 'machine-1', name: 'Daily summary' });
17
- await sdk.triggers.schedule.create('automation-id', { cron: '0 9 * * 1-5' });
18
- await sdk.triggers.webhook.create('automation-id', {
19
- source: 'github', event: 'push', secret: 'a-secret-at-least-16-characters',
8
+ const automation = await sdk.automations.create({
9
+ targetId: computerId,
10
+ name: 'Call Mom',
20
11
  });
21
- await sdk.workflow.create('automation-id', {
22
- script: 'export default workflow({ cells: [] });',
12
+ await sdk.workflow.create(automation.id, {
13
+ script: 'Notify me to call Mom.',
14
+ compiled: {
15
+ version: 1,
16
+ steps: [{
17
+ id: 'notify',
18
+ actionId: 'channels.notify_user',
19
+ input: { message: 'Call Mom' },
20
+ }],
21
+ },
23
22
  });
24
- const history = await sdk.runs.list('automation-id');
25
- ```
26
-
27
- An automation may have any number of scheduled and webhook triggers, and zero
28
- or one workflow. Every component can be created, read, changed, and deleted
29
- independently. Webhook secrets are write-only; reads return `secretConfigured`.
30
- Deleting configuration never deletes run history.
31
-
32
- ### Composition
33
-
34
- ```ts
35
- import {
36
- AutomationCrudService,
37
- SupabaseAutomationCrudRepository,
38
- createAutomationApi,
39
- } from '@amalgm/automations';
40
-
41
- const service = new AutomationCrudService(
42
- new SupabaseAutomationCrudRepository(supabaseRpcClient),
43
- );
44
-
45
- const api = createAutomationApi({
46
- service,
47
- authenticate: resolveAmalgmPrincipal,
23
+ await sdk.triggers.schedule.create(automation.id, {
24
+ cron: '* * * * *',
25
+ timezone: 'America/Los_Angeles',
26
+ maxOccurrences: 10,
48
27
  });
49
28
  ```
50
29
 
51
- `authenticate` resolves the existing Amalgm session or HMAC-refresh credential
52
- to an `AutomationPrincipal`. Future API keys resolve to that same type. The
53
- SDK receives a principal with scopes and never raw credentials or a caller-
54
- supplied user ID.
55
-
56
- Use `createAutomationClient` to call the Fetch API from another process. The
57
- CLI and MCP server use this same client with
58
- `AMALGM_AUTOMATIONS_API_URL` and `AMALGM_AUTOMATIONS_AUTHORIZATION`.
30
+ That schedule admits exactly ten durable runs, then disables itself. If the
31
+ machine is offline, the runs remain pending. Shell later claims only work whose
32
+ target matches its DPoP `computer_id`, executes the immutable tool-action plan,
33
+ and commits the result.
59
34
 
60
- ```sh
61
- amalgm-automations automations list
62
- amalgm-automations triggers schedule create automation-id schedule.json
63
- amalgm-automations triggers webhook create automation-id webhook.json
64
- amalgm-automations workflow update automation-id workflow.json
65
- ```
35
+ ## Surfaces
66
36
 
67
- The MCP server command is `amalgm-automations-mcp`; it exposes one typed,
68
- atomic MCP tool for every SDK operation.
37
+ - `@amalgm/automations`: SDK, API/client, scheduler, machine claim client, and
38
+ generic tool-action executor.
39
+ - `@amalgm/automations/mcp`: agent tools over the same SDK.
40
+ - `@amalgm/automations/host`: standalone Fly service composition.
41
+ - `amalgm-automations`: CLI adapter.
69
42
 
70
- ## Delivery rail
43
+ The hosted service accepts verified Supabase user sessions for browser control
44
+ and Core-issued `amalgm-automations` DPoP grants for Shell. It does not run in
45
+ or depend on Amalgm Gateway.
71
46
 
72
- The Core boundary is deliberately small. Core translates its authenticated
73
- machine record into an opaque `AutomationTarget`; the product never queries a
74
- Core table. Core also supplies the existing machine transport:
47
+ ## Durable model
75
48
 
76
- ```ts
77
- import { Automations, SupabaseStore } from '@amalgm/automations';
49
+ An automation belongs to one user and target. It owns any number of schedule
50
+ or webhook triggers and zero or one workflow. Supabase owns definitions,
51
+ schedule clocks, immutable run snapshots, machine leases, and permanent run
52
+ history. Webhook secrets are write-only.
78
53
 
79
- const automations = new Automations(new SupabaseStore(getSupabase()), {
80
- isOnline: ({ targetId }) => existingTunnel.hasConnection(targetId),
81
- send: (run) => existingTunnel.send(run.targetId, run),
82
- });
54
+ Compiled workflows use one small format:
83
55
 
84
- await automations.receiveEvent({
85
- target: await core.resolveEventTarget(eventRef),
86
- headers,
87
- body,
88
- payload,
89
- });
90
-
91
- await automations.targetOnline({ userId, targetId });
92
-
93
- await automations.updateRun({ userId, targetId }, runId, {
94
- status: 'completed',
95
- output,
96
- });
97
-
98
- const history = await sdk.runs.list(automationId);
56
+ ```ts
57
+ type AutomationPlan = {
58
+ version: 1;
59
+ steps: Array<{ id: string; actionId: string; input: Json }>;
60
+ };
99
61
  ```
100
62
 
101
- The brick also owns the `/events` HTTP contract; a host supplies only target
102
- identity and forwards the Fetch request:
63
+ The executor does not know about Channels or any other product. Shell injects
64
+ one action-calling capability, and each step receives the stable idempotency key
65
+ `<run-id>:<step-id>`.
103
66
 
104
- ```ts
105
- import { createAutomationEventsApi } from '@amalgm/automations';
67
+ ## HTTP
106
68
 
107
- const events = createAutomationEventsApi({
108
- delivery: automations,
109
- target: async (request) => resolveAutomationTarget(request),
110
- });
69
+ The control API lives under `/v1/automations`. The machine execution API is:
111
70
 
112
- const response = await events(request);
71
+ ```text
72
+ POST /v1/machine/runs/claim
73
+ PATCH /v1/machine/runs/:runId
113
74
  ```
114
75
 
115
- Configuration is written only through the auth-bound SDK shown above. The
116
- delivery rail exposes `receiveEvent`, `fireDueCrons`, `targetOnline`, and
117
- `updateRun`; it has no parallel save, delete, or history API. The surrounding
118
- Engine route, scheduler callback, and tunnel callback only translate and call
119
- these SDK operations.
120
-
121
- Every firing stores an immutable run snapshot in Supabase. Delivery and machine
122
- execution update that record instead of deleting it. When a machine is offline,
123
- new runs remain pending; on reconnect every pending run drains through the
124
- existing transport.
125
-
126
- The implementation covers Supabase definitions, cron and event admission,
127
- secret verification, permanent run history, and reconnect drain. Engine
128
- cutover must call these published contracts and delete its local automation
129
- store; this package intentionally provides no compatibility storage path.
130
-
131
- The purpose is in [PURPOSE.md](./PURPOSE.md), and the non-negotiable ownership
132
- rules are in [AXIOMS.md](./AXIOMS.md).
76
+ The target is never accepted in a machine request. It comes from the verified
77
+ access token. Run leases expire and are safely reclaimable; terminal updates
78
+ must present the active lease token.
133
79
 
134
80
  ## Verification
135
81
 
136
- ```sh
137
- npm install
138
- npm run release:check
82
+ ```bash
83
+ npm run verify
84
+ TEST_DATABASE_URL=postgres://... npm run test:supabase
85
+ npm pack --dry-run
139
86
  ```
140
87
 
141
- `release:check` includes deterministic unit tests and a real Postgres contract.
142
- Set `TEST_DATABASE_URL` to an empty Postgres 16 database for the latter.
88
+ See [PURPOSE.md](./PURPOSE.md) and [AXIOMS.md](./AXIOMS.md) for the governing
89
+ ownership and behavior laws.
@@ -0,0 +1,11 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { AutomationPrincipal } from '../src/contract.js';
3
+ import type { AutomationMachinePrincipal } from '../src/machine.js';
4
+ export declare function createAutomationsAuthenticators(options: {
5
+ readonly issuer: string;
6
+ readonly supabase: SupabaseClient;
7
+ readonly now?: () => number;
8
+ }): Readonly<{
9
+ control: (request: Request) => Promise<AutomationPrincipal>;
10
+ machine: (request: Request) => Promise<AutomationMachinePrincipal>;
11
+ }>;
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { authorizeMachineResource, } from '@amalgm/core/authorization';
3
+ import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, importJWK, jwtVerify, } from 'jose';
4
+ import { ForbiddenError } from '../src/errors.js';
5
+ export function createAutomationsAuthenticators(options) {
6
+ const jwks = createRemoteJWKSet(new URL(`${options.issuer}/.well-known/jwks.json`));
7
+ const ports = verifierPorts(options, jwks);
8
+ return Object.freeze({
9
+ control: async (request) => {
10
+ const authorization = request.headers.get('authorization');
11
+ if (authorization?.startsWith('Bearer ')) {
12
+ const result = await options.supabase.auth.getUser(authorization.slice(7).trim());
13
+ if (result.error || !result.data.user)
14
+ throw new ForbiddenError('User authorization denied');
15
+ return {
16
+ userId: result.data.user.id,
17
+ scopes: ['automations:read', 'automations:write', 'runs:read'],
18
+ };
19
+ }
20
+ const scope = controlScope(request);
21
+ const principal = await authorize(request, options.issuer, scope, ports);
22
+ return {
23
+ userId: principal.userId,
24
+ computerId: principal.computerId,
25
+ scopes: automationScopes(principal.scopes),
26
+ targetIds: [principal.computerId],
27
+ };
28
+ },
29
+ machine: async (request) => {
30
+ const principal = await authorize(request, options.issuer, 'runs:execute', ports);
31
+ return {
32
+ userId: principal.userId,
33
+ computerId: principal.computerId,
34
+ scopes: automationScopes(principal.scopes),
35
+ };
36
+ },
37
+ });
38
+ }
39
+ async function authorize(request, issuer, scope, ports) {
40
+ return authorizeMachineResource({
41
+ authorization: request.headers.get('authorization'),
42
+ dpop: request.headers.get('dpop'),
43
+ method: request.method,
44
+ url: request.url,
45
+ issuer,
46
+ audience: 'amalgm-automations',
47
+ requiredScopes: [scope],
48
+ }, ports);
49
+ }
50
+ function verifierPorts(options, jwks) {
51
+ return {
52
+ nowSeconds: options.now ?? (() => Math.floor(Date.now() / 1000)),
53
+ async verifyAccessToken(token) {
54
+ const value = await jwtVerify(token, jwks, {
55
+ issuer: options.issuer, audience: 'amalgm-automations', algorithms: ['ES256'],
56
+ });
57
+ return { header: value.protectedHeader, claims: value.payload };
58
+ },
59
+ async verifyDpopProof(proof) {
60
+ const header = decodeProtectedHeader(proof);
61
+ const jwk = header.jwk;
62
+ if (!jwk)
63
+ throw new Error('DPoP proof has no public key');
64
+ const value = await jwtVerify(proof, await importJWK(jwk, 'ES256'), {
65
+ algorithms: ['ES256'], typ: 'dpop+jwt',
66
+ });
67
+ return { header: value.protectedHeader, claims: value.payload };
68
+ },
69
+ jwkThumbprint: (jwk) => calculateJwkThumbprint(jwk, 'sha256'),
70
+ accessTokenHash: async (token) => createHash('sha256').update(token).digest('base64url'),
71
+ async consumeProof(keyThumbprint, jti) {
72
+ const result = await options.supabase.rpc('consume_authorization_dpop_proof', {
73
+ p_key_thumbprint: keyThumbprint, p_jti: jti,
74
+ });
75
+ if (result.error)
76
+ throw result.error;
77
+ return result.data === true;
78
+ },
79
+ };
80
+ }
81
+ function controlScope(request) {
82
+ if (request.method.toUpperCase() !== 'GET')
83
+ return 'automations:write';
84
+ return new URL(request.url).pathname.includes('/runs') ? 'runs:read' : 'automations:read';
85
+ }
86
+ function automationScopes(scopes) {
87
+ return scopes.filter((scope) => (scope === 'automations:read' || scope === 'automations:write'
88
+ || scope === 'runs:read' || scope === 'runs:execute'));
89
+ }
@@ -0,0 +1,9 @@
1
+ export interface AutomationsHostConfig {
2
+ readonly port: number;
3
+ readonly publicOrigin: string;
4
+ readonly supabaseUrl: string;
5
+ readonly supabaseServiceRoleKey: string;
6
+ readonly authorizationIssuer: string;
7
+ readonly schedulerIntervalMs: number;
8
+ }
9
+ export declare function automationsHostConfig(env?: Readonly<Record<string, string | undefined>>): AutomationsHostConfig;
@@ -0,0 +1,30 @@
1
+ export function automationsHostConfig(env = process.env) {
2
+ return Object.freeze({
3
+ port: integer(env.PORT, 8080, 1, 65_535),
4
+ publicOrigin: url(env.AMALGAM_PUBLIC_ORIGIN, 'AMALGAM_PUBLIC_ORIGIN'),
5
+ supabaseUrl: url(env.SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL, 'SUPABASE_URL'),
6
+ supabaseServiceRoleKey: required(env.SUPABASE_SERVICE_ROLE_KEY, 'SUPABASE_SERVICE_ROLE_KEY'),
7
+ authorizationIssuer: url(env.AMALGM_AUTHORIZATION_ISSUER, 'AMALGM_AUTHORIZATION_ISSUER'),
8
+ schedulerIntervalMs: integer(env.AUTOMATIONS_SCHEDULER_INTERVAL_MS, 1_000, 250, 60_000),
9
+ });
10
+ }
11
+ function required(value, name) {
12
+ if (!value?.trim())
13
+ throw new Error(`${name} is required`);
14
+ return value.trim();
15
+ }
16
+ function url(value, name) {
17
+ const parsed = new URL(required(value, name));
18
+ const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
19
+ if (parsed.protocol !== 'https:' && !(local && parsed.protocol === 'http:')) {
20
+ throw new Error(`${name} must use HTTPS`);
21
+ }
22
+ return parsed.toString().replace(/\/$/, '');
23
+ }
24
+ function integer(value, fallback, minimum, maximum) {
25
+ const parsed = value === undefined ? fallback : Number(value);
26
+ if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
27
+ throw new Error(`Expected an integer from ${minimum} to ${maximum}`);
28
+ }
29
+ return parsed;
30
+ }
@@ -0,0 +1,3 @@
1
+ export * from './auth.js';
2
+ export * from './config.js';
3
+ export * from './server.js';
@@ -0,0 +1,3 @@
1
+ export * from './auth.js';
2
+ export * from './config.js';
3
+ export * from './server.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { Automations } from '../src/automations.js';
3
+ import { AutomationCrudService } from '../src/crud.js';
4
+ import { createAutomationApi } from '../src/http.js';
5
+ import { createMachineRuns } from '../src/machine.js';
6
+ import { createMachineRunsApi } from '../src/machine-http.js';
7
+ import { SupabaseAutomationCrudRepository } from '../src/supabase-crud.js';
8
+ import { SupabaseMachineRunRepository } from '../src/supabase-machine.js';
9
+ import { SupabaseStore } from '../src/supabase-store.js';
10
+ import { createAutomationsAuthenticators } from './auth.js';
11
+ import { automationsHostConfig } from './config.js';
12
+ import { createAutomationsHost } from './server.js';
13
+ const config = automationsHostConfig();
14
+ const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
15
+ auth: { persistSession: false, autoRefreshToken: false },
16
+ });
17
+ const authentication = createAutomationsAuthenticators({
18
+ issuer: config.authorizationIssuer,
19
+ supabase,
20
+ });
21
+ const delivery = new Automations(new SupabaseStore(supabase), {
22
+ isOnline: () => false,
23
+ send: async () => false,
24
+ }, (event, details) => log(event, details));
25
+ const controlApi = createAutomationApi({
26
+ service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase)),
27
+ authenticate: authentication.control,
28
+ });
29
+ const machineRepository = new SupabaseMachineRunRepository(supabase);
30
+ const machineApi = createMachineRunsApi({
31
+ authenticate: authentication.machine,
32
+ runsFor: (principal) => createMachineRuns(machineRepository, principal),
33
+ });
34
+ const host = createAutomationsHost({
35
+ publicOrigin: config.publicOrigin,
36
+ controlApi,
37
+ machineApi,
38
+ fireSchedules: () => delivery.fireDueCrons(),
39
+ schedulerIntervalMs: config.schedulerIntervalMs,
40
+ log,
41
+ });
42
+ host.server.listen(config.port, '0.0.0.0', () => log('host.ready', { port: config.port }));
43
+ for (const signal of ['SIGINT', 'SIGTERM']) {
44
+ process.once(signal, () => void host.close().finally(() => process.exit(0)));
45
+ }
46
+ function log(event, details = {}) {
47
+ console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
48
+ }
@@ -0,0 +1,12 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ export declare function createAutomationsHost(options: {
3
+ readonly publicOrigin: string;
4
+ readonly controlApi: (request: Request) => Promise<Response>;
5
+ readonly machineApi: (request: Request) => Promise<Response>;
6
+ readonly fireSchedules: () => Promise<unknown>;
7
+ readonly schedulerIntervalMs: number;
8
+ readonly log?: (event: string, details?: Readonly<Record<string, unknown>>) => void;
9
+ }): {
10
+ server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
11
+ close(): Promise<void>;
12
+ };
@@ -0,0 +1,54 @@
1
+ import { createServer } from 'node:http';
2
+ import { publicResourceRequestUrl } from '@amalgm/core/authorization';
3
+ export function createAutomationsHost(options) {
4
+ const log = options.log ?? (() => { });
5
+ let scheduling = null;
6
+ const tick = () => {
7
+ if (scheduling)
8
+ return;
9
+ scheduling = options.fireSchedules()
10
+ .catch((error) => log('scheduler.failed', { error: safe(error) }))
11
+ .finally(() => { scheduling = null; });
12
+ };
13
+ const timer = setInterval(tick, options.schedulerIntervalMs);
14
+ timer.unref();
15
+ tick();
16
+ const server = createServer(async (incoming, outgoing) => {
17
+ try {
18
+ if (incoming.url === '/healthz')
19
+ return send(outgoing, Response.json({ ok: true }));
20
+ const request = await webRequest(incoming, options.publicOrigin);
21
+ const api = new URL(request.url).pathname.startsWith('/v1/machine/')
22
+ ? options.machineApi : options.controlApi;
23
+ await send(outgoing, await api(request));
24
+ }
25
+ catch (error) {
26
+ log('request.failed', { error: safe(error) });
27
+ await send(outgoing, Response.json({ error: 'Automations service failed' }, { status: 500 }));
28
+ }
29
+ });
30
+ return {
31
+ server,
32
+ async close() {
33
+ clearInterval(timer);
34
+ await scheduling;
35
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
36
+ },
37
+ };
38
+ }
39
+ async function webRequest(request, publicOrigin) {
40
+ const chunks = [];
41
+ for await (const chunk of request)
42
+ chunks.push(Buffer.from(chunk));
43
+ const body = Buffer.concat(chunks);
44
+ return new Request(publicResourceRequestUrl(publicOrigin, request.url ?? '/'), {
45
+ method: request.method ?? 'GET',
46
+ headers: request.headers,
47
+ ...(body.length ? { body } : {}),
48
+ });
49
+ }
50
+ async function send(response, source) {
51
+ response.writeHead(source.status, Object.fromEntries(source.headers));
52
+ response.end(Buffer.from(await source.arrayBuffer()));
53
+ }
54
+ const safe = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 500);
@@ -46,13 +46,16 @@ export class Automations {
46
46
  const created = [];
47
47
  for (const trigger of await this.store.dueCronTriggers(now)) {
48
48
  let scheduledFor = trigger.nextRunAt;
49
- while (new Date(scheduledFor) <= now) {
49
+ let remaining = trigger.remainingOccurrences;
50
+ while (new Date(scheduledFor) <= now && (remaining === undefined || remaining > 0)) {
50
51
  const nextRunAt = nextCronAt(trigger.cron, trigger.timezone, scheduledFor);
51
52
  const run = await this.store.enqueueCron({ ...trigger, nextRunAt: scheduledFor }, nextRunAt, now);
52
53
  if (!run)
53
54
  break;
54
55
  created.push(run);
55
56
  scheduledFor = nextRunAt;
57
+ if (remaining !== undefined)
58
+ remaining -= 1;
56
59
  }
57
60
  }
58
61
  await this.#drainCreated(created);
@@ -1,7 +1,9 @@
1
1
  import type { AutomationCrud } from './contract.js';
2
2
  export type AutomationAuthorization = () => string | undefined | Promise<string | undefined>;
3
+ export type AutomationHeaders = (method: string, url: string) => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
3
4
  export declare function createAutomationClient(options: {
4
5
  baseUrl: string;
5
- authorization: AutomationAuthorization;
6
+ authorization?: AutomationAuthorization;
7
+ headers?: AutomationHeaders;
6
8
  fetch?: typeof globalThis.fetch;
7
9
  }): AutomationCrud;
@@ -1,7 +1,7 @@
1
1
  import { AutomationError } from './errors.js';
2
2
  export function createAutomationClient(options) {
3
3
  const baseUrl = options.baseUrl.replace(/\/$/, '');
4
- const request = createRequester(baseUrl, options.authorization, options.fetch || globalThis.fetch);
4
+ const request = createRequester(baseUrl, options.authorization, options.headers, options.fetch || globalThis.fetch);
5
5
  return {
6
6
  automations: {
7
7
  create: (input) => request('/v1/automations', 'POST', input),
@@ -39,15 +39,17 @@ export function createAutomationClient(options) {
39
39
  },
40
40
  };
41
41
  }
42
- function createRequester(baseUrl, authorization, fetch) {
42
+ function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
43
43
  return async (path, method, body, nullable = false) => {
44
- const token = await authorization();
45
- const response = await fetch(`${baseUrl}${path}`, {
44
+ const url = `${baseUrl}${path}`;
45
+ const token = await authorization?.();
46
+ const response = await fetch(url, {
46
47
  method,
47
48
  headers: {
48
49
  accept: 'application/json',
49
50
  ...(body === undefined ? {} : { 'content-type': 'application/json' }),
50
51
  ...(token ? { authorization: token } : {}),
52
+ ...await additionalHeaders?.(method, url),
51
53
  },
52
54
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
53
55
  });