@amalgm/automations 0.2.0 → 0.2.2

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 (49) hide show
  1. package/AXIOMS.md +37 -16
  2. package/PURPOSE.md +14 -1
  3. package/README.md +5 -2
  4. package/dist/host/config.d.ts +1 -0
  5. package/dist/host/config.js +1 -0
  6. package/dist/host/main.js +16 -4
  7. package/dist/host/server.d.ts +3 -0
  8. package/dist/host/server.js +27 -8
  9. package/dist/src/automations.d.ts +4 -7
  10. package/dist/src/automations.js +13 -65
  11. package/dist/src/client.d.ts +1 -0
  12. package/dist/src/client.js +3 -2
  13. package/dist/src/contract.d.ts +4 -30
  14. package/dist/src/crud/automations.js +3 -3
  15. package/dist/src/crud/context.d.ts +1 -0
  16. package/dist/src/crud/context.js +10 -1
  17. package/dist/src/events-http.js +4 -0
  18. package/dist/src/executor.d.ts +12 -3
  19. package/dist/src/executor.js +163 -44
  20. package/dist/src/http.js +4 -0
  21. package/dist/src/index.d.ts +4 -2
  22. package/dist/src/index.js +4 -2
  23. package/dist/src/machine-client.d.ts +1 -0
  24. package/dist/src/machine-client.js +2 -0
  25. package/dist/src/machine.d.ts +2 -1
  26. package/dist/src/machine.js +6 -1
  27. package/dist/src/mcp.d.ts +3 -0
  28. package/dist/src/mcp.js +53 -50
  29. package/dist/src/plan.d.ts +3 -0
  30. package/dist/src/plan.js +48 -0
  31. package/dist/src/run-contract.d.ts +46 -0
  32. package/dist/src/run-contract.js +1 -0
  33. package/dist/src/run-journal.d.ts +8 -0
  34. package/dist/src/run-journal.js +56 -0
  35. package/dist/src/runner.d.ts +14 -0
  36. package/dist/src/runner.js +70 -0
  37. package/dist/src/schema.d.ts +46 -46
  38. package/dist/src/schema.js +18 -3
  39. package/dist/src/supabase-crud/mappers.js +2 -0
  40. package/dist/src/supabase-crud/rows.d.ts +2 -0
  41. package/dist/src/supabase-machine.js +2 -0
  42. package/dist/src/supabase-store.d.ts +1 -4
  43. package/dist/src/supabase-store.js +0 -24
  44. package/dist/src/tool-surface.d.ts +46 -0
  45. package/dist/src/tool-surface.js +125 -0
  46. package/dist/src/types.d.ts +1 -16
  47. package/package.json +5 -5
  48. package/skills/automations/SKILL.md +20 -15
  49. package/supabase/migrations/20260830010000_durable_step_retries.sql +332 -0
package/AXIOMS.md CHANGED
@@ -10,42 +10,63 @@
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.
41
- 16. A finite schedule decrements its durable remaining occurrence count in the
44
+ 17. A finite schedule decrements its durable remaining occurrence count in the
42
45
  same transaction that admits a run; zero disables the trigger.
43
- 17. A machine receives work only by exclusively leasing runs whose persisted
46
+ 18. A machine receives work only by exclusively leasing runs whose persisted
44
47
  target equals the `computer_id` in its DPoP-bound access token.
45
- 18. Machine execution consumes the immutable workflow snapshot stored on the
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
46
51
  run. It never rediscovers or silently updates the automation definition.
47
- 19. A compiled workflow is a small declarative sequence of tool actions. The
52
+ 21. A compiled workflow is a small declarative sequence of one or more tool actions. The
48
53
  executor receives tool calling as a host capability and never embeds a
49
54
  Channels, Shell, CLI, or provider special case.
50
- 20. Automations is a standalone hosted service. Gateway owns none of its API,
55
+ 22. Automations is a standalone hosted service. Gateway owns none of its API,
51
56
  scheduling, claim, execution, or persistence path.
57
+ 23. The durable run ledger is the offline queue. The platform never keeps a
58
+ second online-machine delivery buffer and never drops an unclaimed run.
59
+ 24. A run advances through its immutable plan in order. Every step transition
60
+ commits under the run's current lease before execution advances, and a
61
+ completed step is never intentionally invoked again.
62
+ 25. A machine may begin an action only after the service confirms its current
63
+ lease. It renews that lease while the action is running and stops advancing
64
+ when renewal fails.
65
+ 26. One run-step pair has one stable idempotency key. Step ids are unique in a
66
+ plan, and every action host receives that key and a cancellation signal.
67
+ 27. Only transient transport failures retry. They release the same run with a
68
+ bounded future retry time and retain completed step output; invalid plans,
69
+ missing actions, authorization failures, and other deterministic errors
70
+ are terminal.
71
+ 28. The selected target executes every action effect. The hosted service owns
72
+ only configuration, admission, leases, the step journal, and run history.
package/PURPOSE.md CHANGED
@@ -23,7 +23,17 @@ The product has two composable halves over that one state:
23
23
  write path. When a machine is offline, new runs remain pending; when it
24
24
  reconnects, Shell claims pending runs directly from the Automations service
25
25
  with its machine-bound DPoP identity and executes the persisted tool-action
26
- plan. Execution itself stays on the selected machine.
26
+ plan. Execution itself stays on the selected machine. The platform never
27
+ needs to know whether a machine is online: an unclaimed run is the complete
28
+ offline queue.
29
+
30
+ One run is one immutable plan plus one durable ordered step journal. The
31
+ machine records a step as running before invoking its action and records its
32
+ output before advancing. Reconnect resumes at the first step that is not
33
+ already complete. A transient network failure releases the same run back to
34
+ the queue with a bounded future retry time; a configuration, authorization, or
35
+ action error fails it. Stable per-step idempotency keys make an uncertain
36
+ network acknowledgement safe to repeat.
27
37
 
28
38
  Automations is its own hosted service and Fly machine. Its API and scheduler
29
39
  share the same SDK and Supabase authority; neither is composed into, proxied by,
@@ -39,6 +49,9 @@ recent-event response is an ephemeral, secret-free operational projection.
39
49
 
40
50
  Amalgm supplies a resolved authenticated principal from its user session or
41
51
  HMAC-refresh flow; future API keys resolve to the same principal capability.
52
+ When that principal is bound to exactly one machine, automation creation uses
53
+ that target without asking a human or model to copy an opaque machine id. An
54
+ explicit target remains mandatory for unbound or multi-target principals.
42
55
  Core provides authenticated user identity, opaque machine identity,
43
56
  connectivity, and narrow host capabilities. The product receives identity and
44
57
  scopes — never raw credentials or Core storage — and neither side reaches into
package/README.md CHANGED
@@ -51,12 +51,15 @@ or webhook triggers and zero or one workflow. Supabase owns definitions,
51
51
  schedule clocks, immutable run snapshots, machine leases, and permanent run
52
52
  history. Webhook secrets are write-only.
53
53
 
54
- Compiled workflows use one small format:
54
+ Executable workflows use one small, non-empty format:
55
55
 
56
56
  ```ts
57
57
  type AutomationPlan = {
58
58
  version: 1;
59
- steps: Array<{ id: string; actionId: string; input: Json }>;
59
+ steps: [
60
+ { id: string; actionId: string; input: Json },
61
+ ...Array<{ id: string; actionId: string; input: Json }>,
62
+ ];
60
63
  };
61
64
  ```
62
65
 
@@ -1,5 +1,6 @@
1
1
  export interface AutomationsHostConfig {
2
2
  readonly port: number;
3
+ readonly publicOrigin: string;
3
4
  readonly supabaseUrl: string;
4
5
  readonly supabaseServiceRoleKey: string;
5
6
  readonly authorizationIssuer: string;
@@ -1,6 +1,7 @@
1
1
  export function automationsHostConfig(env = process.env) {
2
2
  return Object.freeze({
3
3
  port: integer(env.PORT, 8080, 1, 65_535),
4
+ publicOrigin: url(env.AMALGAM_PUBLIC_ORIGIN, 'AMALGAM_PUBLIC_ORIGIN'),
4
5
  supabaseUrl: url(env.SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL, 'SUPABASE_URL'),
5
6
  supabaseServiceRoleKey: required(env.SUPABASE_SERVICE_ROLE_KEY, 'SUPABASE_SERVICE_ROLE_KEY'),
6
7
  authorizationIssuer: url(env.AMALGM_AUTHORIZATION_ISSUER, 'AMALGM_AUTHORIZATION_ISSUER'),
package/dist/host/main.js CHANGED
@@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js';
2
2
  import { Automations } from '../src/automations.js';
3
3
  import { AutomationCrudService } from '../src/crud.js';
4
4
  import { createAutomationApi } from '../src/http.js';
5
+ import { createAutomationEventsApi } from '../src/events-http.js';
5
6
  import { createMachineRuns } from '../src/machine.js';
6
7
  import { createMachineRunsApi } from '../src/machine-http.js';
7
8
  import { SupabaseAutomationCrudRepository } from '../src/supabase-crud.js';
@@ -18,10 +19,7 @@ const authentication = createAutomationsAuthenticators({
18
19
  issuer: config.authorizationIssuer,
19
20
  supabase,
20
21
  });
21
- const delivery = new Automations(new SupabaseStore(supabase), {
22
- isOnline: () => false,
23
- send: async () => false,
24
- }, (event, details) => log(event, details));
22
+ const delivery = new Automations(new SupabaseStore(supabase), (event, details) => log(event, details));
25
23
  const controlApi = createAutomationApi({
26
24
  service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase)),
27
25
  authenticate: authentication.control,
@@ -31,9 +29,15 @@ const machineApi = createMachineRunsApi({
31
29
  authenticate: authentication.machine,
32
30
  runsFor: (principal) => createMachineRuns(machineRepository, principal),
33
31
  });
32
+ const eventsApi = createAutomationEventsApi({
33
+ delivery,
34
+ target: async (request) => eventTarget(request),
35
+ });
34
36
  const host = createAutomationsHost({
37
+ publicOrigin: config.publicOrigin,
35
38
  controlApi,
36
39
  machineApi,
40
+ eventsApi,
37
41
  fireSchedules: () => delivery.fireDueCrons(),
38
42
  schedulerIntervalMs: config.schedulerIntervalMs,
39
43
  log,
@@ -45,3 +49,11 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
45
49
  function log(event, details = {}) {
46
50
  console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
47
51
  }
52
+ function eventTarget(request) {
53
+ const userId = request.headers.get('x-amalgm-user-id')?.trim() ?? '';
54
+ const targetId = request.headers.get('x-amalgm-target-id')?.trim() ?? '';
55
+ if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(userId) || !targetId) {
56
+ throw Object.assign(new Error('Webhook routing headers are required'), { status: 400 });
57
+ }
58
+ return { userId, targetId };
59
+ }
@@ -1,9 +1,12 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  export declare function createAutomationsHost(options: {
3
+ readonly publicOrigin: string;
3
4
  readonly controlApi: (request: Request) => Promise<Response>;
4
5
  readonly machineApi: (request: Request) => Promise<Response>;
6
+ readonly eventsApi?: (request: Request) => Promise<Response>;
5
7
  readonly fireSchedules: () => Promise<unknown>;
6
8
  readonly schedulerIntervalMs: number;
9
+ readonly maxRequestBodyBytes?: number;
7
10
  readonly log?: (event: string, details?: Readonly<Record<string, unknown>>) => void;
8
11
  }): {
9
12
  server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
+ import { publicResourceRequestUrl } from '@amalgm/core/authorization';
2
3
  export function createAutomationsHost(options) {
3
4
  const log = options.log ?? (() => { });
4
5
  let scheduling = null;
@@ -16,14 +17,19 @@ export function createAutomationsHost(options) {
16
17
  try {
17
18
  if (incoming.url === '/healthz')
18
19
  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;
20
+ const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024);
21
+ const pathname = new URL(request.url).pathname;
22
+ const api = pathname === '/events' && options.eventsApi
23
+ ? options.eventsApi
24
+ : pathname.startsWith('/v1/machine/') ? options.machineApi : options.controlApi;
22
25
  await send(outgoing, await api(request));
23
26
  }
24
27
  catch (error) {
25
28
  log('request.failed', { error: safe(error) });
26
- await send(outgoing, Response.json({ error: 'Automations service failed' }, { status: 500 }));
29
+ const status = error instanceof HostRequestError ? error.status : 500;
30
+ await send(outgoing, Response.json({
31
+ error: status === 500 ? 'Automations service failed' : error instanceof Error ? error.message : String(error),
32
+ }, { status }));
27
33
  }
28
34
  });
29
35
  return {
@@ -35,17 +41,30 @@ export function createAutomationsHost(options) {
35
41
  },
36
42
  };
37
43
  }
38
- async function webRequest(request) {
44
+ async function webRequest(request, publicOrigin, maximumBodyBytes) {
39
45
  const chunks = [];
40
- for await (const chunk of request)
41
- chunks.push(Buffer.from(chunk));
46
+ let size = 0;
47
+ for await (const value of request) {
48
+ const chunk = Buffer.from(value);
49
+ size += chunk.length;
50
+ if (size > maximumBodyBytes)
51
+ throw new HostRequestError(413, 'Request body is too large');
52
+ chunks.push(chunk);
53
+ }
42
54
  const body = Buffer.concat(chunks);
43
- return new Request(new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`), {
55
+ return new Request(publicResourceRequestUrl(publicOrigin, request.url ?? '/'), {
44
56
  method: request.method ?? 'GET',
45
57
  headers: request.headers,
46
58
  ...(body.length ? { body } : {}),
47
59
  });
48
60
  }
61
+ class HostRequestError extends Error {
62
+ status;
63
+ constructor(status, message) {
64
+ super(message);
65
+ this.status = status;
66
+ }
67
+ }
49
68
  async function send(response, source) {
50
69
  response.writeHead(source.status, Object.fromEntries(source.headers));
51
70
  response.end(Buffer.from(await source.arrayBuffer()));
@@ -1,13 +1,13 @@
1
- import type { AutomationLog, AutomationRun, AutomationStore, AutomationTarget, AutomationTransport, Json, RunUpdate } from './types.js';
1
+ import type { AutomationLog, AutomationRun, AutomationStore, AutomationTarget, Json } from './types.js';
2
2
  export declare class EventRejectedError extends Error {
3
3
  constructor();
4
4
  }
5
+ /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
5
6
  export declare class Automations {
6
7
  #private;
7
8
  readonly store: AutomationStore;
8
- readonly transport: AutomationTransport;
9
9
  readonly log: AutomationLog;
10
- constructor(store: AutomationStore, transport: AutomationTransport, log?: AutomationLog);
10
+ constructor(store: AutomationStore, log?: AutomationLog);
11
11
  receiveEvent(input: {
12
12
  target: AutomationTarget;
13
13
  headers: Record<string, string>;
@@ -17,8 +17,5 @@ export declare class Automations {
17
17
  event?: string;
18
18
  now?: Date;
19
19
  }): Promise<AutomationRun[]>;
20
- fireDueCrons(now?: Date): Promise<AutomationRun[]>;
21
- targetOnline(target: AutomationTarget): Promise<number>;
22
- updateRun(target: AutomationTarget, runId: string, update: RunUpdate): Promise<AutomationRun | null>;
23
- drain(target: AutomationTarget): Promise<number>;
20
+ fireDueCrons(now?: Date, maximumRuns?: number): Promise<AutomationRun[]>;
24
21
  }
@@ -7,14 +7,12 @@ export class EventRejectedError extends Error {
7
7
  this.name = 'EventRejectedError';
8
8
  }
9
9
  }
10
+ /** Admits trigger occurrences to the durable run ledger. It never delivers them. */
10
11
  export class Automations {
11
12
  store;
12
- transport;
13
13
  log;
14
- #drains = new Map();
15
- constructor(store, transport, log = noop) {
14
+ constructor(store, log = noop) {
16
15
  this.store = store;
17
- this.transport = transport;
18
16
  this.log = log;
19
17
  }
20
18
  async receiveEvent(input) {
@@ -39,15 +37,19 @@ export class Automations {
39
37
  const now = input.now || new Date();
40
38
  const runInput = { kind: 'event', ...reference, payload: input.payload };
41
39
  const runs = (await Promise.all(triggers.map((trigger) => (this.store.enqueueEvent(trigger, runInput, now))))).filter((run) => run !== null);
42
- await this.#drainCreated(runs);
40
+ this.#logPending(runs);
43
41
  return runs;
44
42
  }
45
- async fireDueCrons(now = new Date()) {
43
+ async fireDueCrons(now = new Date(), maximumRuns = 1_000) {
44
+ if (!Number.isInteger(maximumRuns) || maximumRuns < 1)
45
+ throw new Error('maximumRuns must be a positive integer');
46
46
  const created = [];
47
47
  for (const trigger of await this.store.dueCronTriggers(now)) {
48
48
  let scheduledFor = trigger.nextRunAt;
49
49
  let remaining = trigger.remainingOccurrences;
50
- while (new Date(scheduledFor) <= now && (remaining === undefined || remaining > 0)) {
50
+ while (created.length < maximumRuns
51
+ && new Date(scheduledFor) <= now
52
+ && (remaining === undefined || remaining > 0)) {
51
53
  const nextRunAt = nextCronAt(trigger.cron, trigger.timezone, scheduledFor);
52
54
  const run = await this.store.enqueueCron({ ...trigger, nextRunAt: scheduledFor }, nextRunAt, now);
53
55
  if (!run)
@@ -57,31 +59,13 @@ export class Automations {
57
59
  if (remaining !== undefined)
58
60
  remaining -= 1;
59
61
  }
62
+ if (created.length === maximumRuns)
63
+ break;
60
64
  }
61
- await this.#drainCreated(created);
65
+ this.#logPending(created);
62
66
  return created;
63
67
  }
64
- targetOnline(target) {
65
- return this.drain(target);
66
- }
67
- updateRun(target, runId, update) {
68
- if (update.output !== undefined)
69
- assertJson(update.output, 'Run output');
70
- return this.store.updateRun(target, runId, update);
71
- }
72
- drain(target) {
73
- const key = `${target.userId}:${target.targetId}`;
74
- const active = this.#drains.get(key);
75
- if (active)
76
- return active;
77
- const drain = this.#drain(target).finally(() => {
78
- if (this.#drains.get(key) === drain)
79
- this.#drains.delete(key);
80
- });
81
- this.#drains.set(key, drain);
82
- return drain;
83
- }
84
- async #drainCreated(runs) {
68
+ #logPending(runs) {
85
69
  for (const run of runs) {
86
70
  this.log('run.pending', {
87
71
  runId: run.id,
@@ -89,42 +73,6 @@ export class Automations {
89
73
  automationId: run.automationId,
90
74
  });
91
75
  }
92
- const targets = new Map(runs.map((run) => [
93
- `${run.userId}:${run.targetId}`,
94
- { userId: run.userId, targetId: run.targetId },
95
- ]));
96
- await Promise.all([...targets.values()].map((target) => this.drain(target)));
97
- }
98
- async #drain(target) {
99
- if (!this.transport.isOnline(target))
100
- return 0;
101
- this.log('drain.started', { targetId: target.targetId });
102
- let sent = 0;
103
- const failed = new Set();
104
- while (this.transport.isOnline(target)) {
105
- const runs = (await this.store.pendingRuns(target)).filter(({ id }) => !failed.has(id));
106
- if (runs.length === 0)
107
- return sent;
108
- for (const run of runs) {
109
- if (!this.transport.isOnline(target))
110
- return sent;
111
- try {
112
- if (!await this.transport.send(run)) {
113
- this.log('run.failed', { runId: run.id, targetId: target.targetId });
114
- failed.add(run.id);
115
- continue;
116
- }
117
- if (await this.store.markSent(run.id, target, new Date()))
118
- sent += 1;
119
- this.log('run.sent', { runId: run.id, targetId: target.targetId });
120
- }
121
- catch {
122
- this.log('run.failed', { runId: run.id, targetId: target.targetId });
123
- failed.add(run.id);
124
- }
125
- }
126
- }
127
- return sent;
128
76
  }
129
77
  }
130
78
  function assertJson(value, label) {
@@ -6,4 +6,5 @@ export declare function createAutomationClient(options: {
6
6
  authorization?: AutomationAuthorization;
7
7
  headers?: AutomationHeaders;
8
8
  fetch?: typeof globalThis.fetch;
9
+ requestTimeoutMs?: number;
9
10
  }): 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.headers, options.fetch || globalThis.fetch);
4
+ const request = createRequester(baseUrl, options.authorization, options.headers, options.fetch || globalThis.fetch, options.requestTimeoutMs ?? 15_000);
5
5
  return {
6
6
  automations: {
7
7
  create: (input) => request('/v1/automations', 'POST', input),
@@ -39,7 +39,7 @@ export function createAutomationClient(options) {
39
39
  },
40
40
  };
41
41
  }
42
- function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
42
+ function createRequester(baseUrl, authorization, additionalHeaders, fetch, requestTimeoutMs) {
43
43
  return async (path, method, body, nullable = false) => {
44
44
  const url = `${baseUrl}${path}`;
45
45
  const token = await authorization?.();
@@ -52,6 +52,7 @@ function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
52
52
  ...await additionalHeaders?.(method, url),
53
53
  },
54
54
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
55
+ signal: AbortSignal.timeout(requestTimeoutMs),
55
56
  });
56
57
  if (response.status === 204)
57
58
  return undefined;
@@ -1,6 +1,8 @@
1
1
  export type Json = null | boolean | number | string | Json[] | {
2
2
  [key: string]: Json;
3
3
  };
4
+ import type { AutomationRun, ListRuns } from './run-contract.js';
5
+ export type { AutomationPlan, AutomationRun, AutomationRunJournal, AutomationStepRun, AutomationStepStatus, ListRuns, RunStatus, ToolActionStep, } from './run-contract.js';
4
6
  export type AutomationScope = 'automations:read' | 'automations:write' | 'runs:read' | 'runs:execute' | '*';
5
7
  /** Identity resolved by Amalgm before the SDK is bound to a caller. */
6
8
  export interface AutomationPrincipal {
@@ -31,7 +33,8 @@ export interface Automation {
31
33
  }
32
34
  export interface CreateAutomation {
33
35
  id?: string;
34
- targetId: string;
36
+ /** Omit when the authenticated principal resolves exactly one target. */
37
+ targetId?: string;
35
38
  name?: string;
36
39
  description?: string;
37
40
  enabled?: boolean;
@@ -83,15 +86,6 @@ export interface UpdateScheduleTrigger {
83
86
  /** Set a new finite budget, or null to make the schedule unbounded. */
84
87
  maxOccurrences?: number | null;
85
88
  }
86
- export interface ToolActionStep {
87
- id: string;
88
- actionId: string;
89
- input: Json;
90
- }
91
- export interface AutomationPlan {
92
- version: 1;
93
- steps: ToolActionStep[];
94
- }
95
89
  export interface CreateWebhookTrigger {
96
90
  id?: string;
97
91
  source?: string;
@@ -131,25 +125,6 @@ export interface UpdateWorkflow {
131
125
  allowlist?: Json | null;
132
126
  limits?: Json | null;
133
127
  }
134
- export type RunStatus = 'pending' | 'sent' | 'running' | 'completed' | 'failed';
135
- export interface AutomationRun {
136
- id: string;
137
- automationId: string;
138
- triggerId: string;
139
- workflowId: string;
140
- targetId: string;
141
- status: RunStatus;
142
- input: Json;
143
- createdAt: string;
144
- sentAt?: string;
145
- startedAt?: string;
146
- finishedAt?: string;
147
- output?: Json;
148
- error?: string;
149
- }
150
- export interface ListRuns extends PageQuery {
151
- status?: RunStatus;
152
- }
153
128
  export interface AutomationCrud {
154
129
  readonly automations: {
155
130
  create(input: CreateAutomation): Promise<Automation>;
@@ -189,4 +164,3 @@ export interface AutomationCrud {
189
164
  export interface AutomationCrudService {
190
165
  for(principal: AutomationPrincipal): AutomationCrud;
191
166
  }
192
- export {};
@@ -1,20 +1,20 @@
1
1
  import { ConflictError, NotFoundError } from '../errors.js';
2
2
  import { parseCreateAutomation, parseListAutomations, parseUpdateAutomation } from '../schema.js';
3
3
  import { id, newId, page } from '../validation.js';
4
- import { assertTarget } from './context.js';
4
+ import { assertTarget, resolveTarget } from './context.js';
5
5
  export function automationOperations(context) {
6
6
  const { principal, repository } = context;
7
7
  return {
8
8
  create: async (input) => {
9
9
  context.write();
10
10
  const parsed = parseCreateAutomation(input);
11
- assertTarget(principal, parsed.targetId);
11
+ const targetId = resolveTarget(principal, parsed.targetId);
12
12
  if (parsed.id && await repository.getAutomation(principal.userId, id(parsed.id, 'Automation id'))) {
13
13
  throw new ConflictError('Automation');
14
14
  }
15
15
  return repository.createAutomation(principal.userId, {
16
16
  id: parsed.id ? id(parsed.id, 'Automation id') : newId('automation'),
17
- targetId: parsed.targetId,
17
+ targetId,
18
18
  name: parsed.name || '',
19
19
  description: parsed.description || '',
20
20
  enabled: parsed.enabled !== false,
@@ -11,3 +11,4 @@ export interface CrudContext {
11
11
  }
12
12
  export declare function createContext(repository: AutomationCrudRepository, principal: AutomationPrincipal, clock: () => Date): CrudContext;
13
13
  export declare function assertTarget(principal: AutomationPrincipal, targetId: string): void;
14
+ export declare function resolveTarget(principal: AutomationPrincipal, targetId?: string): string;
@@ -1,4 +1,4 @@
1
- import { ForbiddenError, NotFoundError } from '../errors.js';
1
+ import { ForbiddenError, NotFoundError, ValidationError } from '../errors.js';
2
2
  import { id, requiredText } from '../validation.js';
3
3
  export function createContext(repository, principal, clock) {
4
4
  assertPrincipal(principal);
@@ -21,6 +21,15 @@ export function assertTarget(principal, targetId) {
21
21
  throw new ForbiddenError('This credential cannot access that target');
22
22
  }
23
23
  }
24
+ export function resolveTarget(principal, targetId) {
25
+ if (targetId) {
26
+ assertTarget(principal, targetId);
27
+ return id(targetId, 'targetId');
28
+ }
29
+ if (principal.targetIds?.length === 1)
30
+ return principal.targetIds[0];
31
+ throw new ValidationError('targetId is required unless the credential resolves exactly one target');
32
+ }
24
33
  function assertPrincipal(principal) {
25
34
  requiredText(principal.userId, 'Authenticated user id');
26
35
  if (!Array.isArray(principal.scopes))
@@ -54,6 +54,10 @@ export function createAutomationEventsApi(config) {
54
54
  return json(401, { error: error.message });
55
55
  if (error instanceof EventHttpError)
56
56
  return json(error.status, { error: error.message });
57
+ const status = Number(error?.status);
58
+ if (Number.isInteger(status) && status >= 400 && status <= 499) {
59
+ return json(status, { error: error instanceof Error ? error.message : String(error) });
60
+ }
57
61
  return json(500, { error: error instanceof Error ? error.message : String(error) });
58
62
  }
59
63
  };
@@ -1,16 +1,25 @@
1
- import type { AutomationPlan, Json } from './contract.js';
1
+ import type { Json } from './contract.js';
2
2
  import type { ClaimedAutomationRun, MachineRuns } from './machine.js';
3
+ export { automationPlan } from './plan.js';
3
4
  export interface AutomationActionPort {
4
5
  call(input: Readonly<{
5
6
  actionId: string;
6
7
  payload: Json;
7
8
  idempotencyKey: string;
9
+ signal: AbortSignal;
8
10
  }>): Promise<Json>;
9
11
  }
12
+ export interface AutomationRunExecutorOptions {
13
+ readonly now?: () => Date;
14
+ readonly heartbeatMs?: number;
15
+ readonly maxAttempts?: number;
16
+ readonly retryDelayMs?: (attempt: number) => number;
17
+ }
10
18
  export declare class AutomationRunExecutor {
19
+ #private;
11
20
  private readonly runs;
12
21
  private readonly actions;
13
- constructor(runs: MachineRuns, actions: AutomationActionPort);
22
+ constructor(runs: MachineRuns, actions: AutomationActionPort, options?: AutomationRunExecutorOptions);
14
23
  execute(run: ClaimedAutomationRun): Promise<void>;
15
24
  }
16
- export declare function automationPlan(snapshot: Json): AutomationPlan;
25
+ export declare function transientFailure(error: unknown): boolean;