@amalgm/automations 0.1.1 → 0.2.0

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 (67) hide show
  1. package/AXIOMS.md +14 -0
  2. package/PURPOSE.md +15 -3
  3. package/README.md +61 -100
  4. package/dist/host/auth.d.ts +11 -0
  5. package/dist/host/auth.js +89 -0
  6. package/dist/host/config.d.ts +8 -0
  7. package/dist/host/config.js +29 -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 +47 -0
  12. package/dist/host/server.d.ts +11 -0
  13. package/dist/host/server.js +53 -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 +17 -1
  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 +13 -0
  21. package/dist/src/crud/context.js +34 -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/events-http.d.ts +19 -0
  33. package/dist/src/events-http.js +107 -0
  34. package/dist/src/executor.d.ts +16 -0
  35. package/dist/src/executor.js +76 -0
  36. package/dist/src/index.d.ts +9 -2
  37. package/dist/src/index.js +6 -0
  38. package/dist/src/machine-client.d.ts +7 -0
  39. package/dist/src/machine-client.js +30 -0
  40. package/dist/src/machine-http.d.ts +5 -0
  41. package/dist/src/machine-http.js +40 -0
  42. package/dist/src/machine.d.ts +41 -0
  43. package/dist/src/machine.js +43 -0
  44. package/dist/src/mcp.js +2 -2
  45. package/dist/src/schema.d.ts +8 -0
  46. package/dist/src/schema.js +2 -0
  47. package/dist/src/supabase-crud/automations.d.ts +5 -0
  48. package/dist/src/supabase-crud/automations.js +36 -0
  49. package/dist/src/supabase-crud/mappers.d.ts +8 -0
  50. package/dist/src/supabase-crud/mappers.js +81 -0
  51. package/dist/src/supabase-crud/rows.d.ts +56 -0
  52. package/dist/src/supabase-crud/rows.js +1 -0
  53. package/dist/src/supabase-crud/rpc.d.ts +10 -0
  54. package/dist/src/supabase-crud/rpc.js +19 -0
  55. package/dist/src/supabase-crud/triggers.d.ts +5 -0
  56. package/dist/src/supabase-crud/triggers.js +46 -0
  57. package/dist/src/supabase-crud/workflow-runs.d.ts +5 -0
  58. package/dist/src/supabase-crud/workflow-runs.js +41 -0
  59. package/dist/src/supabase-crud.d.ts +5 -41
  60. package/dist/src/supabase-crud.js +4 -267
  61. package/dist/src/supabase-machine.d.ts +16 -0
  62. package/dist/src/supabase-machine.js +63 -0
  63. package/dist/src/supabase-store.js +1 -0
  64. package/dist/src/types.d.ts +1 -0
  65. package/package.json +13 -4
  66. package/skills/automations/SKILL.md +52 -0
  67. package/supabase/migrations/20260829010000_bounded_schedules_and_machine_claims.sql +311 -0
package/AXIOMS.md CHANGED
@@ -35,3 +35,17 @@
35
35
  firing instant that was claimed.
36
36
  14. Legacy local automation storage is not a compatibility authority. Engine
37
37
  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
39
+ Supabase. Its recent-event list is a bounded, secret-free operational view,
40
+ never a second event or run authority.
41
+ 16. A finite schedule decrements its durable remaining occurrence count in the
42
+ same transaction that admits a run; zero disables the trigger.
43
+ 17. A machine receives work only by exclusively leasing runs whose persisted
44
+ target equals the `computer_id` in its DPoP-bound access token.
45
+ 18. Machine execution consumes the immutable workflow snapshot stored on the
46
+ run. It never rediscovers or silently updates the automation definition.
47
+ 19. A compiled workflow is a small declarative sequence of tool actions. The
48
+ executor receives tool calling as a host capability and never embeds a
49
+ Channels, Shell, CLI, or provider special case.
50
+ 20. Automations is a standalone hosted service. Gateway owns none of its API,
51
+ scheduling, claim, execution, or persistence path.
package/PURPOSE.md CHANGED
@@ -21,9 +21,21 @@ 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.
34
+
35
+ The event HTTP adapter is one thin door over that delivery rail. It bounds and
36
+ parses the webhook body, resolves the authenticated target supplied by the
37
+ host, and returns only after matching runs have been stored in Supabase. Its
38
+ recent-event response is an ephemeral, secret-free operational projection.
27
39
 
28
40
  Amalgm supplies a resolved authenticated principal from its user session or
29
41
  HMAC-refresh flow; future API keys resolve to the same principal capability.
package/README.md CHANGED
@@ -1,128 +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.
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.
55
34
 
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`.
35
+ ## Surfaces
59
36
 
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
- ```
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.
66
42
 
67
- The MCP server command is `amalgm-automations-mcp`; it exposes one typed,
68
- atomic MCP tool for every SDK operation.
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.
69
46
 
70
- ## Delivery rail
47
+ ## Durable model
71
48
 
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:
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.
53
+
54
+ Compiled workflows use one small format:
75
55
 
76
56
  ```ts
77
- import { Automations, SupabaseStore } from '@amalgm/automations';
57
+ type AutomationPlan = {
58
+ version: 1;
59
+ steps: Array<{ id: string; actionId: string; input: Json }>;
60
+ };
61
+ ```
78
62
 
79
- const automations = new Automations(new SupabaseStore(getSupabase()), {
80
- isOnline: ({ targetId }) => existingTunnel.hasConnection(targetId),
81
- send: (run) => existingTunnel.send(run.targetId, run),
82
- });
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>`.
83
66
 
84
- await automations.receiveEvent({
85
- target: await core.resolveEventTarget(eventRef),
86
- headers,
87
- body,
88
- payload,
89
- });
67
+ ## HTTP
90
68
 
91
- await automations.targetOnline({ userId, targetId });
69
+ The control API lives under `/v1/automations`. The machine execution API is:
92
70
 
93
- await automations.updateRun({ userId, targetId }, runId, {
94
- status: 'completed',
95
- output,
96
- });
97
-
98
- const history = await sdk.runs.list(automationId);
71
+ ```text
72
+ POST /v1/machine/runs/claim
73
+ PATCH /v1/machine/runs/:runId
99
74
  ```
100
75
 
101
- Configuration is written only through the auth-bound SDK shown above. The
102
- delivery rail exposes `receiveEvent`, `fireDueCrons`, `targetOnline`, and
103
- `updateRun`; it has no parallel save, delete, or history API. The surrounding
104
- Engine route, scheduler callback, and tunnel callback only translate and call
105
- these SDK operations.
106
-
107
- Every firing stores an immutable run snapshot in Supabase. Delivery and machine
108
- execution update that record instead of deleting it. When a machine is offline,
109
- new runs remain pending; on reconnect every pending run drains through the
110
- existing transport.
111
-
112
- The implementation covers Supabase definitions, cron and event admission,
113
- secret verification, permanent run history, and reconnect drain. Engine
114
- cutover must call these published contracts and delete its local automation
115
- store; this package intentionally provides no compatibility storage path.
116
-
117
- The purpose is in [PURPOSE.md](./PURPOSE.md), and the non-negotiable ownership
118
- 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.
119
79
 
120
80
  ## Verification
121
81
 
122
- ```sh
123
- npm install
124
- npm run release:check
82
+ ```bash
83
+ npm run verify
84
+ TEST_DATABASE_URL=postgres://... npm run test:supabase
85
+ npm pack --dry-run
125
86
  ```
126
87
 
127
- `release:check` includes deterministic unit tests and a real Postgres contract.
128
- 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,8 @@
1
+ export interface AutomationsHostConfig {
2
+ readonly port: number;
3
+ readonly supabaseUrl: string;
4
+ readonly supabaseServiceRoleKey: string;
5
+ readonly authorizationIssuer: string;
6
+ readonly schedulerIntervalMs: number;
7
+ }
8
+ export declare function automationsHostConfig(env?: Readonly<Record<string, string | undefined>>): AutomationsHostConfig;
@@ -0,0 +1,29 @@
1
+ export function automationsHostConfig(env = process.env) {
2
+ return Object.freeze({
3
+ port: integer(env.PORT, 8080, 1, 65_535),
4
+ supabaseUrl: url(env.SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL, 'SUPABASE_URL'),
5
+ supabaseServiceRoleKey: required(env.SUPABASE_SERVICE_ROLE_KEY, 'SUPABASE_SERVICE_ROLE_KEY'),
6
+ authorizationIssuer: url(env.AMALGM_AUTHORIZATION_ISSUER, 'AMALGM_AUTHORIZATION_ISSUER'),
7
+ schedulerIntervalMs: integer(env.AUTOMATIONS_SCHEDULER_INTERVAL_MS, 1_000, 250, 60_000),
8
+ });
9
+ }
10
+ function required(value, name) {
11
+ if (!value?.trim())
12
+ throw new Error(`${name} is required`);
13
+ return value.trim();
14
+ }
15
+ function url(value, name) {
16
+ const parsed = new URL(required(value, name));
17
+ const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
18
+ if (parsed.protocol !== 'https:' && !(local && parsed.protocol === 'http:')) {
19
+ throw new Error(`${name} must use HTTPS`);
20
+ }
21
+ return parsed.toString().replace(/\/$/, '');
22
+ }
23
+ function integer(value, fallback, minimum, maximum) {
24
+ const parsed = value === undefined ? fallback : Number(value);
25
+ if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
26
+ throw new Error(`Expected an integer from ${minimum} to ${maximum}`);
27
+ }
28
+ return parsed;
29
+ }
@@ -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,47 @@
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
+ controlApi,
36
+ machineApi,
37
+ fireSchedules: () => delivery.fireDueCrons(),
38
+ schedulerIntervalMs: config.schedulerIntervalMs,
39
+ log,
40
+ });
41
+ host.server.listen(config.port, '0.0.0.0', () => log('host.ready', { port: config.port }));
42
+ for (const signal of ['SIGINT', 'SIGTERM']) {
43
+ process.once(signal, () => void host.close().finally(() => process.exit(0)));
44
+ }
45
+ function log(event, details = {}) {
46
+ console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
47
+ }
@@ -0,0 +1,11 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ export declare function createAutomationsHost(options: {
3
+ readonly controlApi: (request: Request) => Promise<Response>;
4
+ readonly machineApi: (request: Request) => Promise<Response>;
5
+ readonly fireSchedules: () => Promise<unknown>;
6
+ readonly schedulerIntervalMs: number;
7
+ readonly log?: (event: string, details?: Readonly<Record<string, unknown>>) => void;
8
+ }): {
9
+ server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
10
+ close(): Promise<void>;
11
+ };
@@ -0,0 +1,53 @@
1
+ import { createServer } from 'node:http';
2
+ export function createAutomationsHost(options) {
3
+ const log = options.log ?? (() => { });
4
+ let scheduling = null;
5
+ const tick = () => {
6
+ if (scheduling)
7
+ return;
8
+ scheduling = options.fireSchedules()
9
+ .catch((error) => log('scheduler.failed', { error: safe(error) }))
10
+ .finally(() => { scheduling = null; });
11
+ };
12
+ const timer = setInterval(tick, options.schedulerIntervalMs);
13
+ timer.unref();
14
+ tick();
15
+ const server = createServer(async (incoming, outgoing) => {
16
+ try {
17
+ if (incoming.url === '/healthz')
18
+ return send(outgoing, Response.json({ ok: true }));
19
+ const request = await webRequest(incoming);
20
+ const api = new URL(request.url).pathname.startsWith('/v1/machine/')
21
+ ? options.machineApi : options.controlApi;
22
+ await send(outgoing, await api(request));
23
+ }
24
+ catch (error) {
25
+ log('request.failed', { error: safe(error) });
26
+ await send(outgoing, Response.json({ error: 'Automations service failed' }, { status: 500 }));
27
+ }
28
+ });
29
+ return {
30
+ server,
31
+ async close() {
32
+ clearInterval(timer);
33
+ await scheduling;
34
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
35
+ },
36
+ };
37
+ }
38
+ async function webRequest(request) {
39
+ const chunks = [];
40
+ for await (const chunk of request)
41
+ chunks.push(Buffer.from(chunk));
42
+ const body = Buffer.concat(chunks);
43
+ return new Request(new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`), {
44
+ method: request.method ?? 'GET',
45
+ headers: request.headers,
46
+ ...(body.length ? { body } : {}),
47
+ });
48
+ }
49
+ async function send(response, source) {
50
+ response.writeHead(source.status, Object.fromEntries(source.headers));
51
+ response.end(Buffer.from(await source.arrayBuffer()));
52
+ }
53
+ 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
  });
@@ -1,7 +1,7 @@
1
1
  export type Json = null | boolean | number | string | Json[] | {
2
2
  [key: string]: Json;
3
3
  };
4
- export type AutomationScope = 'automations:read' | 'automations:write' | 'runs:read' | '*';
4
+ export type AutomationScope = 'automations:read' | 'automations:write' | 'runs:read' | 'runs:execute' | '*';
5
5
  /** Identity resolved by Amalgm before the SDK is bound to a caller. */
6
6
  export interface AutomationPrincipal {
7
7
  userId: string;
@@ -57,6 +57,9 @@ export interface ScheduleTrigger extends TriggerBase {
57
57
  kind: 'schedule';
58
58
  cron: string;
59
59
  timezone: string;
60
+ nextRunAt: string;
61
+ maxOccurrences?: number;
62
+ remainingOccurrences?: number;
60
63
  }
61
64
  export interface WebhookTrigger extends TriggerBase {
62
65
  kind: 'webhook';
@@ -70,11 +73,24 @@ export interface CreateScheduleTrigger {
70
73
  cron: string;
71
74
  timezone?: string;
72
75
  enabled?: boolean;
76
+ /** Omit for an unbounded schedule. */
77
+ maxOccurrences?: number;
73
78
  }
74
79
  export interface UpdateScheduleTrigger {
75
80
  cron?: string;
76
81
  timezone?: string;
77
82
  enabled?: boolean;
83
+ /** Set a new finite budget, or null to make the schedule unbounded. */
84
+ maxOccurrences?: number | null;
85
+ }
86
+ export interface ToolActionStep {
87
+ id: string;
88
+ actionId: string;
89
+ input: Json;
90
+ }
91
+ export interface AutomationPlan {
92
+ version: 1;
93
+ steps: ToolActionStep[];
78
94
  }
79
95
  export interface CreateWebhookTrigger {
80
96
  id?: string;
@@ -0,0 +1,3 @@
1
+ import type { AutomationCrud } from '../contract.js';
2
+ import { type CrudContext } from './context.js';
3
+ export declare function automationOperations(context: CrudContext): AutomationCrud['automations'];