@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
@@ -1,76 +1,195 @@
1
+ import { automationPlan } from './plan.js';
2
+ import { completed, emptyJournal, journalJson, putStep, runJournal, stepAttempt, } from './run-journal.js';
3
+ export { automationPlan } from './plan.js';
1
4
  export class AutomationRunExecutor {
2
5
  runs;
3
6
  actions;
4
- constructor(runs, actions) {
7
+ #now;
8
+ #heartbeatMs;
9
+ #maxAttempts;
10
+ #retryDelayMs;
11
+ constructor(runs, actions, options = {}) {
5
12
  this.runs = runs;
6
13
  this.actions = actions;
14
+ this.#now = options.now ?? (() => new Date());
15
+ this.#heartbeatMs = integer(options.heartbeatMs ?? 20_000, 1, 'heartbeatMs');
16
+ this.#maxAttempts = integer(options.maxAttempts ?? 5, 1, 'maxAttempts');
17
+ this.#retryDelayMs = options.retryDelayMs ?? defaultRetryDelayMs;
7
18
  }
8
19
  async execute(run) {
9
- await this.runs.update(run.id, { leaseToken: run.leaseToken, status: 'running' });
20
+ const confirmed = await this.runs.update(run.id, {
21
+ leaseToken: run.leaseToken,
22
+ status: 'running',
23
+ });
24
+ if (!confirmed)
25
+ return;
26
+ let journal = emptyJournal();
10
27
  try {
11
28
  const plan = automationPlan(run.automation);
12
- const steps = [];
29
+ journal = runJournal(run.output, plan.steps);
13
30
  for (const step of plan.steps) {
14
- const output = await this.actions.call({
15
- actionId: step.actionId,
16
- payload: step.input,
17
- idempotencyKey: `${run.id}:${step.id}`,
31
+ if (completed(journal, step.id))
32
+ continue;
33
+ const attempt = stepAttempt(journal, step.id) + 1;
34
+ const startedAt = this.#now().toISOString();
35
+ journal = putStep(journal, {
36
+ id: step.id,
37
+ status: 'running',
38
+ attempts: attempt,
39
+ startedAt,
18
40
  });
19
- steps.push({ id: step.id, output });
41
+ const started = await this.runs.update(run.id, {
42
+ leaseToken: run.leaseToken,
43
+ status: 'running',
44
+ output: journalJson(journal),
45
+ });
46
+ if (!started)
47
+ return;
48
+ const action = await this.#callWithHeartbeat(run, step);
49
+ if (action.leaseLost)
50
+ return;
51
+ if ('error' in action) {
52
+ const error = safeError(action.error);
53
+ const failureKind = transientFailure(action.error) ? 'transient' : 'permanent';
54
+ journal = putStep(journal, {
55
+ id: step.id,
56
+ status: 'failed',
57
+ attempts: attempt,
58
+ startedAt,
59
+ finishedAt: this.#now().toISOString(),
60
+ error,
61
+ failureKind,
62
+ });
63
+ if (failureKind === 'transient' && run.attempts < this.#maxAttempts) {
64
+ await this.runs.update(run.id, {
65
+ leaseToken: run.leaseToken,
66
+ status: 'pending',
67
+ retryAt: new Date(this.#now().getTime() + this.#retryDelayMs(run.attempts)).toISOString(),
68
+ output: journalJson(journal),
69
+ error,
70
+ });
71
+ }
72
+ else {
73
+ await this.runs.update(run.id, {
74
+ leaseToken: run.leaseToken,
75
+ status: 'failed',
76
+ output: journalJson(journal),
77
+ error,
78
+ });
79
+ }
80
+ return;
81
+ }
82
+ journal = putStep(journal, {
83
+ id: step.id,
84
+ status: 'completed',
85
+ attempts: attempt,
86
+ startedAt,
87
+ finishedAt: this.#now().toISOString(),
88
+ output: action.output,
89
+ });
90
+ const committed = await this.runs.update(run.id, {
91
+ leaseToken: run.leaseToken,
92
+ status: 'running',
93
+ output: journalJson(journal),
94
+ });
95
+ if (!committed)
96
+ return;
20
97
  }
21
98
  await this.runs.update(run.id, {
22
99
  leaseToken: run.leaseToken,
23
100
  status: 'completed',
24
- output: { steps },
101
+ output: journalJson(journal),
25
102
  });
26
103
  }
27
104
  catch (error) {
28
105
  await this.runs.update(run.id, {
29
106
  leaseToken: run.leaseToken,
30
107
  status: 'failed',
108
+ output: journalJson(journal),
31
109
  error: safeError(error),
32
110
  });
33
111
  }
34
112
  }
35
- }
36
- export function automationPlan(snapshot) {
37
- const root = record(snapshot, 'Automation snapshot');
38
- const workflow = record(root.workflow, 'Workflow snapshot');
39
- const compiled = record(workflow.compiled, 'Compiled workflow');
40
- if (compiled.version !== 1 || !Array.isArray(compiled.steps) || compiled.steps.length > 100) {
41
- throw new Error('Compiled workflow must be a version 1 plan with at most 100 steps');
113
+ async #callWithHeartbeat(run, step) {
114
+ const controller = new AbortController();
115
+ let leaseLost = false;
116
+ let stopped = false;
117
+ let heartbeat = null;
118
+ const renew = () => {
119
+ if (stopped || heartbeat)
120
+ return;
121
+ heartbeat = this.runs.update(run.id, {
122
+ leaseToken: run.leaseToken,
123
+ status: 'running',
124
+ }).then((current) => {
125
+ if (!current) {
126
+ leaseLost = true;
127
+ controller.abort(new Error('Automation run lease was lost'));
128
+ }
129
+ }).catch(() => {
130
+ leaseLost = true;
131
+ controller.abort(new Error('Automation run lease renewal failed'));
132
+ }).finally(() => {
133
+ heartbeat = null;
134
+ });
135
+ };
136
+ const timer = setInterval(renew, this.#heartbeatMs);
137
+ timer.unref?.();
138
+ try {
139
+ const output = await this.actions.call({
140
+ actionId: step.actionId,
141
+ payload: step.input,
142
+ idempotencyKey: `${run.id}:${step.id}`,
143
+ signal: controller.signal,
144
+ });
145
+ return leaseLost ? { leaseLost: true } : { leaseLost: false, output };
146
+ }
147
+ catch (error) {
148
+ return leaseLost ? { leaseLost: true } : { leaseLost: false, error };
149
+ }
150
+ finally {
151
+ stopped = true;
152
+ clearInterval(timer);
153
+ await heartbeat;
154
+ }
42
155
  }
43
- return {
44
- version: 1,
45
- steps: compiled.steps.map((value, index) => step(value, index)),
46
- };
47
156
  }
48
- function step(value, index) {
49
- const item = record(value, `Workflow step ${index + 1}`);
50
- if (typeof item.id !== 'string' || !item.id.trim())
51
- throw new Error(`Workflow step ${index + 1} needs an id`);
52
- if (typeof item.actionId !== 'string' || !item.actionId.includes('.')) {
53
- throw new Error(`Workflow step ${index + 1} needs a qualified actionId`);
157
+ export function transientFailure(error) {
158
+ for (let current = error, depth = 0; current && depth < 4; depth += 1) {
159
+ if (typeof current !== 'object')
160
+ break;
161
+ const item = current;
162
+ const status = Number(item.status);
163
+ if (status === 408 || status === 429 || status >= 500)
164
+ return true;
165
+ const code = String(item.code ?? '');
166
+ if (TRANSIENT_CODES.has(code))
167
+ return true;
168
+ current = item.cause;
54
169
  }
55
- assertJson(item.input);
56
- return { id: item.id, actionId: item.actionId, input: item.input };
170
+ const message = safeError(error).toLowerCase();
171
+ return message.includes('fetch failed') || message.includes('network error');
172
+ }
173
+ const TRANSIENT_CODES = new Set([
174
+ 'ECONNREFUSED',
175
+ 'ECONNRESET',
176
+ 'EAI_AGAIN',
177
+ 'ENETDOWN',
178
+ 'ENETUNREACH',
179
+ 'ETIMEDOUT',
180
+ 'UND_ERR_CONNECT_TIMEOUT',
181
+ 'UND_ERR_HEADERS_TIMEOUT',
182
+ 'UND_ERR_SOCKET',
183
+ 'internal',
184
+ ]);
185
+ function defaultRetryDelayMs(attempt) {
186
+ return Math.min(5 * 60_000, 2_000 * (2 ** Math.max(0, attempt - 1)));
57
187
  }
58
- function record(value, name) {
59
- if (!value || typeof value !== 'object' || Array.isArray(value))
60
- throw new Error(`${name} is missing`);
188
+ function integer(value, minimum, name) {
189
+ if (!Number.isInteger(value) || value < minimum)
190
+ throw new Error(`${name} must be an integer of at least ${minimum}`);
61
191
  return value;
62
192
  }
63
- function assertJson(value) {
64
- if (value === null || typeof value === 'string' || typeof value === 'boolean')
65
- return;
66
- if (typeof value === 'number' && Number.isFinite(value))
67
- return;
68
- if (Array.isArray(value))
69
- return void value.forEach(assertJson);
70
- if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
71
- return void Object.values(value).forEach(assertJson);
72
- }
73
- throw new Error('Workflow step input must be JSON');
193
+ function safeError(error) {
194
+ return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, ' ').slice(0, 1_000);
74
195
  }
75
- const safeError = (error) => (error instanceof Error ? error.message : String(error))
76
- .replace(/[\r\n]+/g, ' ').slice(0, 1_000);
package/dist/src/http.js CHANGED
@@ -99,6 +99,10 @@ export function createAutomationApi(options) {
99
99
  catch (error) {
100
100
  if (error instanceof AutomationError)
101
101
  return response(status(error), { error: error.message, code: error.code });
102
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
103
+ if (code.startsWith('dpop_') || code.includes('access_token') || code.includes('authorization')) {
104
+ return response(401, { error: 'Authorization denied', code });
105
+ }
102
106
  return response(500, { error: 'Automations service failed' });
103
107
  }
104
108
  };
@@ -5,7 +5,7 @@ export { createAutomationApi, type AuthenticateAutomationRequest, type Automatio
5
5
  export { runAutomationCli } from './cli.js';
6
6
  export { createAutomationMcpServer } from './mcp.js';
7
7
  export { SupabaseAutomationCrudRepository, type SupabaseRpcClient } from './supabase-crud.js';
8
- export type { Automation, AutomationCrud, AutomationCrudService as AutomationCrudServiceContract, AutomationPrincipal, AutomationPlan, AutomationRun, AutomationScope, CreateAutomation, CreateScheduleTrigger, CreateWebhookTrigger, CreateWorkflow, Json, ListAutomations, ListRuns, Page, PageQuery, RunStatus, ScheduleTrigger, Trigger, ToolActionStep, UpdateAutomation, UpdateScheduleTrigger, UpdateWebhookTrigger, UpdateWorkflow, WebhookTrigger, Workflow, } from './contract.js';
8
+ export type { Automation, AutomationCrud, AutomationCrudService as AutomationCrudServiceContract, AutomationPrincipal, AutomationPlan, AutomationRunJournal, AutomationStepRun, AutomationStepStatus, AutomationRun, AutomationScope, CreateAutomation, CreateScheduleTrigger, CreateWebhookTrigger, CreateWorkflow, Json, ListAutomations, ListRuns, Page, PageQuery, RunStatus, ScheduleTrigger, Trigger, ToolActionStep, UpdateAutomation, UpdateScheduleTrigger, UpdateWebhookTrigger, UpdateWorkflow, WebhookTrigger, Workflow, } from './contract.js';
9
9
  export { Automations, EventRejectedError } from './automations.js';
10
10
  export { createAutomationEventsApi, type AutomationEventReceipt, type AutomationEventsApi, type AutomationEventsApiConfig, } from './events-http.js';
11
11
  export { nextCronAt, validateCron } from './schedule.js';
@@ -17,5 +17,7 @@ export type { AutomationMachinePrincipal, ClaimedAutomationRun, MachineRunReposi
17
17
  export { createMachineRunsApi } from './machine-http.js';
18
18
  export { createMachineRunsClient, type AutomationRequestHeaders } from './machine-client.js';
19
19
  export { AutomationRunExecutor, automationPlan, type AutomationActionPort } from './executor.js';
20
+ export { createAutomationMachineRunner, type AutomationMachineRunnerOptions, } from './runner.js';
21
+ export { compiledAutomationPlan } from './plan.js';
20
22
  export type { SupabaseRpcClient as SupabaseStoreRpcClient } from './supabase-store.js';
21
- export type { AutomationLog, AutomationRun as AutomationRunRecord, AutomationStore, AutomationTarget, AutomationTransport, DueCronTrigger, RunInput, RunUpdate, StoredEventTrigger, } from './types.js';
23
+ export type { AutomationLog, AutomationRun as AutomationRunRecord, AutomationStore, AutomationTarget, DueCronTrigger, RunInput, StoredEventTrigger, } from './types.js';
package/dist/src/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // The automation product has two composable halves:
2
2
  // - the configuration service (contract/crud/http/mcp/cli) — Supabase owns
3
3
  // everything except execution;
4
- // - the delivery rail (automations/webhook/schedule/store) — authenticated
5
- // admission, schedule claims, reconnect drain, and run-state transitions.
4
+ // - the durable rail (automations/webhook/schedule/store/machine/executor) —
5
+ // admission, schedule claims, machine leases, retry, and step journals.
6
6
  // Colliding names keep the contract's spelling; the rail's store-level
7
7
  // records are exported under Store-scoped aliases.
8
8
  export { createAutomationClient } from './client.js';
@@ -22,3 +22,5 @@ export { createMachineRuns } from './machine.js';
22
22
  export { createMachineRunsApi } from './machine-http.js';
23
23
  export { createMachineRunsClient } from './machine-client.js';
24
24
  export { AutomationRunExecutor, automationPlan } from './executor.js';
25
+ export { createAutomationMachineRunner, } from './runner.js';
26
+ export { compiledAutomationPlan } from './plan.js';
@@ -4,4 +4,5 @@ export declare function createMachineRunsClient(options: {
4
4
  readonly baseUrl: string;
5
5
  readonly headers: AutomationRequestHeaders;
6
6
  readonly fetch?: typeof globalThis.fetch;
7
+ readonly requestTimeoutMs?: number;
7
8
  }): MachineRuns;
@@ -2,6 +2,7 @@ import { AutomationError } from './errors.js';
2
2
  export function createMachineRunsClient(options) {
3
3
  const baseUrl = options.baseUrl.replace(/\/$/, '');
4
4
  const fetch = options.fetch ?? globalThis.fetch;
5
+ const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
5
6
  const request = async (path, method, body) => {
6
7
  const url = `${baseUrl}${path}`;
7
8
  const response = await fetch(url, {
@@ -12,6 +13,7 @@ export function createMachineRunsClient(options) {
12
13
  ...await options.headers(method, url),
13
14
  },
14
15
  body: JSON.stringify(body),
16
+ signal: AbortSignal.timeout(requestTimeoutMs),
15
17
  });
16
18
  const payload = await response.json().catch(() => ({}));
17
19
  if (!response.ok)
@@ -12,7 +12,8 @@ export interface ClaimedAutomationRun extends AutomationRun {
12
12
  }
13
13
  export interface MachineRunUpdate {
14
14
  readonly leaseToken: string;
15
- readonly status: Extract<RunStatus, 'running' | 'completed' | 'failed'>;
15
+ readonly status: Extract<RunStatus, 'pending' | 'running' | 'completed' | 'failed'>;
16
+ readonly retryAt?: string;
16
17
  readonly output?: Json;
17
18
  readonly error?: string;
18
19
  }
@@ -18,9 +18,14 @@ export function createMachineRuns(repository, principal, now = () => new Date())
18
18
  if (!runId.trim() || !update.leaseToken.trim()) {
19
19
  throw new ValidationError('Run id and lease token are required');
20
20
  }
21
- if (!['running', 'completed', 'failed'].includes(update.status)) {
21
+ if (!['pending', 'running', 'completed', 'failed'].includes(update.status)) {
22
22
  throw new ValidationError('Run status is invalid');
23
23
  }
24
+ if (update.retryAt !== undefined) {
25
+ if (update.status !== 'pending' || !Number.isFinite(Date.parse(update.retryAt))) {
26
+ throw new ValidationError('retryAt requires a pending run and an ISO timestamp');
27
+ }
28
+ }
24
29
  return repository.update({
25
30
  userId: principal.userId,
26
31
  targetId: principal.computerId,
package/dist/src/mcp.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { AutomationCrud } from './contract.js';
3
+ import { createAutomationToolSurface } from './tool-surface.js';
4
+ export { createAutomationToolSurface };
5
+ export type { AutomationToolSurface, AutomationView, CreateAutomationDefinition, UpdateAutomationDefinition, WorkflowChange, } from './tool-surface.js';
3
6
  export declare function createAutomationMcpServer(sdk: AutomationCrud): McpServer;
package/dist/src/mcp.js CHANGED
@@ -1,52 +1,55 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod/v3';
3
- import { createAutomationSchema, createScheduleTriggerSchema, createWebhookTriggerSchema, createWorkflowSchema, identifierSchema, listAutomationsSchema, listRunsSchema, pageQuerySchema, updateAutomationSchema, updateScheduleTriggerSchema, updateWebhookTriggerSchema, updateWorkflowSchema, } from './schema.js';
3
+ import { createAutomationSchema, createScheduleTriggerSchema, createWebhookTriggerSchema, createWorkflowSchema, identifierSchema, listAutomationsSchema, listRunsSchema, updateAutomationSchema, updateScheduleTriggerSchema, updateWebhookTriggerSchema, updateWorkflowSchema, } from './schema.js';
4
+ import { createAutomationToolSurface } from './tool-surface.js';
5
+ export { createAutomationToolSurface };
4
6
  const resultSchema = { result: z.unknown() };
5
- const automationIdInput = { automation_id: identifierSchema };
6
- const triggerIdInput = { ...automationIdInput, trigger_id: identifierSchema };
7
- const runIdInput = { ...automationIdInput, run_id: identifierSchema };
7
+ const scheduleChangesSchema = z.object({
8
+ create: z.array(createScheduleTriggerSchema).max(100).optional(),
9
+ update: z.array(z.object({ id: identifierSchema, patch: updateScheduleTriggerSchema }).strict()).max(100).optional(),
10
+ delete: z.array(identifierSchema).max(100).optional(),
11
+ }).strict();
12
+ const webhookChangesSchema = z.object({
13
+ create: z.array(createWebhookTriggerSchema).max(100).optional(),
14
+ update: z.array(z.object({ id: identifierSchema, patch: updateWebhookTriggerSchema }).strict()).max(100).optional(),
15
+ delete: z.array(identifierSchema).max(100).optional(),
16
+ }).strict();
17
+ const workflowChangeSchema = z.union([
18
+ z.object({ create: createWorkflowSchema }).strict(),
19
+ z.object({ update: updateWorkflowSchema }).strict(),
20
+ z.object({ delete: z.literal(true) }).strict(),
21
+ ]);
8
22
  export function createAutomationMcpServer(sdk) {
9
23
  const server = new McpServer({ name: 'amalgm-automations-mcp-server', version: '0.1.0' });
10
- register(server, 'amalgm_automations_create', 'Create automation', 'Create an automation configuration without triggers or workflow.', { input: createAutomationSchema }, write(false), ({ input }) => sdk.automations.create(input));
11
- register(server, 'amalgm_automations_list', 'List automations', 'List the caller\'s automations with optional target and enabled filters.', { query: listAutomationsSchema.optional() }, read(), ({ query }) => sdk.automations.list(query));
12
- register(server, 'amalgm_automations_get', 'Get automation', 'Get one automation by id.', automationIdInput, read(), ({ automation_id }) => sdk.automations.get(automation_id));
13
- register(server, 'amalgm_automations_update', 'Update automation', 'Update automation metadata, target, or enabled state.', { automation_id: identifierSchema, patch: updateAutomationSchema }, write(true), ({ automation_id, patch }) => sdk.automations.update(automation_id, patch));
14
- register(server, 'amalgm_automations_delete', 'Delete automation', 'Delete an automation and its current triggers and workflow. Historical runs remain.', automationIdInput, destructive(), async ({ automation_id }) => {
15
- await sdk.automations.delete(automation_id);
16
- return { deleted: automation_id };
17
- });
18
- register(server, 'amalgm_automation_triggers_list', 'List automation triggers', 'List all schedule and webhook triggers belonging to one automation.', automationIdInput, read(), ({ automation_id }) => sdk.triggers.list(automation_id));
19
- register(server, 'amalgm_schedule_triggers_create', 'Create schedule trigger', 'Create a cron schedule trigger for one automation. Set maxOccurrences for a bounded request such as “every minute for ten minutes”; the service disables it after exactly that many admitted runs.', { automation_id: identifierSchema, input: createScheduleTriggerSchema }, write(false), ({ automation_id, input }) => sdk.triggers.schedule.create(automation_id, input));
20
- register(server, 'amalgm_schedule_triggers_list', 'List schedule triggers', 'List schedule triggers for one automation.', { automation_id: identifierSchema, query: pageQuerySchema.optional() }, read(), ({ automation_id, query }) => sdk.triggers.schedule.list(automation_id, query));
21
- register(server, 'amalgm_schedule_triggers_get', 'Get schedule trigger', 'Get one schedule trigger by automation and trigger id.', triggerIdInput, read(), ({ automation_id, trigger_id }) => sdk.triggers.schedule.get(automation_id, trigger_id));
22
- register(server, 'amalgm_schedule_triggers_update', 'Update schedule trigger', 'Update a cron schedule trigger without changing its type.', { automation_id: identifierSchema, trigger_id: identifierSchema, patch: updateScheduleTriggerSchema }, write(true), ({ automation_id, trigger_id, patch }) => sdk.triggers.schedule.update(automation_id, trigger_id, patch));
23
- register(server, 'amalgm_schedule_triggers_delete', 'Delete schedule trigger', 'Delete one schedule trigger. The automation and its workflow remain.', triggerIdInput, destructive(), async ({ automation_id, trigger_id }) => {
24
- await sdk.triggers.schedule.delete(automation_id, trigger_id);
25
- return { deleted: trigger_id };
26
- });
27
- register(server, 'amalgm_webhook_triggers_create', 'Create webhook trigger', 'Create a webhook trigger. The secret is accepted only on writes and never returned.', { automation_id: identifierSchema, input: createWebhookTriggerSchema }, write(false), ({ automation_id, input }) => sdk.triggers.webhook.create(automation_id, input));
28
- register(server, 'amalgm_webhook_triggers_list', 'List webhook triggers', 'List webhook triggers for one automation. Secrets are never returned.', { automation_id: identifierSchema, query: pageQuerySchema.optional() }, read(), ({ automation_id, query }) => sdk.triggers.webhook.list(automation_id, query));
29
- register(server, 'amalgm_webhook_triggers_get', 'Get webhook trigger', 'Get one webhook trigger by automation and trigger id. The secret is never returned.', triggerIdInput, read(), ({ automation_id, trigger_id }) => sdk.triggers.webhook.get(automation_id, trigger_id));
30
- register(server, 'amalgm_webhook_triggers_update', 'Update webhook trigger', 'Update webhook matching, secret, or enabled state without changing its type.', { automation_id: identifierSchema, trigger_id: identifierSchema, patch: updateWebhookTriggerSchema }, write(true), ({ automation_id, trigger_id, patch }) => sdk.triggers.webhook.update(automation_id, trigger_id, patch));
31
- register(server, 'amalgm_webhook_triggers_delete', 'Delete webhook trigger', 'Delete one webhook trigger. The automation and its workflow remain.', triggerIdInput, destructive(), async ({ automation_id, trigger_id }) => {
32
- await sdk.triggers.webhook.delete(automation_id, trigger_id);
33
- return { deleted: trigger_id };
34
- });
35
- register(server, 'amalgm_workflow_create', 'Create automation workflow', 'Create the one workflow owned by an automation. For tool execution, compiled must be {version:1,steps:[{id,actionId,input}]}; for a reminder use actionId “channels.notify_user” and input {message,title?}.', { automation_id: identifierSchema, input: createWorkflowSchema }, write(false), ({ automation_id, input }) => sdk.workflow.create(automation_id, input));
36
- register(server, 'amalgm_workflow_get', 'Get automation workflow', 'Get the workflow script owned by an automation.', automationIdInput, read(), ({ automation_id }) => sdk.workflow.get(automation_id));
37
- register(server, 'amalgm_workflow_update', 'Update automation workflow', 'Update the workflow script or its configuration.', { automation_id: identifierSchema, patch: updateWorkflowSchema }, write(true), ({ automation_id, patch }) => sdk.workflow.update(automation_id, patch));
38
- register(server, 'amalgm_workflow_delete', 'Delete automation workflow', 'Delete the workflow script. The automation and its triggers remain.', automationIdInput, destructive(), async ({ automation_id }) => {
39
- await sdk.workflow.delete(automation_id);
40
- return { deleted: automation_id };
41
- });
42
- register(server, 'amalgm_automation_runs_list', 'List automation runs', 'List historical runs for one automation. This tool never changes run state.', { automation_id: identifierSchema, query: listRunsSchema.optional() }, read(), ({ automation_id, query }) => sdk.runs.list(automation_id, query));
43
- register(server, 'amalgm_automation_runs_get', 'Get automation run', 'Get one historical run for an automation. This tool never changes run state.', runIdInput, read(), ({ automation_id, run_id }) => sdk.runs.get(automation_id, run_id));
24
+ const tools = createAutomationToolSurface(sdk);
25
+ register(server, 'amalgm_automations_create', 'Create automation', 'Create one complete automation definition, including its schedules, webhooks, and optional workflow. The service stages configuration disabled and enables it only after setup succeeds. Omit targetId in a machine-bound session; never guess a target id.', {
26
+ ...createAutomationSchema.shape,
27
+ schedules: z.array(createScheduleTriggerSchema).max(100).optional(),
28
+ webhooks: z.array(createWebhookTriggerSchema).max(100).optional(),
29
+ workflow: createWorkflowSchema.optional(),
30
+ }, write(false), (input) => tools.create(input));
31
+ register(server, 'amalgm_automations_list', 'List automations', 'List the caller\'s automations with optional target, enabled-state, and pagination filters.', {
32
+ ...listAutomationsSchema.shape,
33
+ }, read(), (query) => tools.list(query));
34
+ register(server, 'amalgm_automations_get', 'Get automation', 'Get one complete automation definition with its typed triggers and workflow. Recent run history is optional.', {
35
+ automation_id: identifierSchema,
36
+ include_runs: z.boolean().optional(),
37
+ run_query: listRunsSchema.optional(),
38
+ }, read(), ({ automation_id, include_runs, run_query }) => (tools.get(automation_id, include_runs || run_query ? (run_query ?? { limit: 20 }) : false)));
39
+ register(server, 'amalgm_automations_update', 'Update automation', 'Update an automation and grouped trigger or workflow changes in one task-level call. Resource changes are staged while the automation is disabled; a failed change leaves a safe disabled draft.', {
40
+ automation_id: identifierSchema,
41
+ patch: updateAutomationSchema.optional(),
42
+ schedules: scheduleChangesSchema.optional(),
43
+ webhooks: webhookChangesSchema.optional(),
44
+ workflow: workflowChangeSchema.optional(),
45
+ }, write(false), ({ automation_id, ...input }) => tools.update(automation_id, input));
46
+ register(server, 'amalgm_automations_delete', 'Delete automation', 'Delete an automation and its current triggers and workflow. Historical runs remain.', {
47
+ automation_id: identifierSchema,
48
+ }, destructive(), ({ automation_id }) => tools.delete(automation_id));
44
49
  return server;
45
50
  }
46
51
  function register(server, name, title, description, inputSchema, annotations, handler) {
47
- registerUnchecked(server, name, title, description, inputSchema, annotations, async (input) => {
48
- return handler(input);
49
- });
52
+ registerUnchecked(server, name, title, description, inputSchema, annotations, async (input) => (handler(input)));
50
53
  }
51
54
  function registerUnchecked(server, name, title, description, inputSchema, annotations, handler) {
52
55
  server.registerTool(name, {
@@ -77,12 +80,12 @@ function toolFailure(error) {
77
80
  content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
78
81
  };
79
82
  }
80
- function read() {
81
- return { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
82
- }
83
- function write(idempotentHint) {
84
- return { readOnlyHint: false, destructiveHint: false, idempotentHint, openWorldHint: false };
85
- }
86
- function destructive() {
87
- return { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
88
- }
83
+ const read = () => ({
84
+ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false,
85
+ });
86
+ const write = (idempotentHint) => ({
87
+ readOnlyHint: false, destructiveHint: false, idempotentHint, openWorldHint: false,
88
+ });
89
+ const destructive = () => ({
90
+ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false,
91
+ });
@@ -0,0 +1,3 @@
1
+ import type { AutomationPlan, Json } from './contract.js';
2
+ export declare function automationPlan(snapshot: Json): AutomationPlan;
3
+ export declare function compiledAutomationPlan(value: unknown): AutomationPlan;
@@ -0,0 +1,48 @@
1
+ import { ValidationError } from './errors.js';
2
+ export function automationPlan(snapshot) {
3
+ const root = record(snapshot, 'Automation snapshot');
4
+ const workflow = record(root.workflow, 'Workflow snapshot');
5
+ return compiledAutomationPlan(workflow.compiled);
6
+ }
7
+ export function compiledAutomationPlan(value) {
8
+ const compiled = record(value, 'Compiled workflow');
9
+ if (compiled.version !== 1 || !Array.isArray(compiled.steps)
10
+ || compiled.steps.length < 1 || compiled.steps.length > 100) {
11
+ throw new ValidationError('Compiled workflow must be a version 1 plan with 1 to 100 steps');
12
+ }
13
+ const steps = compiled.steps.map((item, index) => step(item, index));
14
+ const ids = new Set();
15
+ for (const item of steps) {
16
+ if (ids.has(item.id))
17
+ throw new ValidationError(`Workflow step id is duplicated: ${item.id}`);
18
+ ids.add(item.id);
19
+ }
20
+ return { version: 1, steps };
21
+ }
22
+ function step(value, index) {
23
+ const item = record(value, `Workflow step ${index + 1}`);
24
+ if (typeof item.id !== 'string' || !item.id.trim())
25
+ throw new ValidationError(`Workflow step ${index + 1} needs an id`);
26
+ if (typeof item.actionId !== 'string' || !item.actionId.includes('.')) {
27
+ throw new ValidationError(`Workflow step ${index + 1} needs a qualified actionId`);
28
+ }
29
+ assertJson(item.input);
30
+ return { id: item.id.trim(), actionId: item.actionId.trim(), input: item.input };
31
+ }
32
+ function record(value, name) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value))
34
+ throw new ValidationError(`${name} is missing`);
35
+ return value;
36
+ }
37
+ function assertJson(value) {
38
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
39
+ return;
40
+ if (typeof value === 'number' && Number.isFinite(value))
41
+ return;
42
+ if (Array.isArray(value))
43
+ return void value.forEach(assertJson);
44
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
45
+ return void Object.values(value).forEach(assertJson);
46
+ }
47
+ throw new ValidationError('Workflow step input must be JSON');
48
+ }
@@ -0,0 +1,46 @@
1
+ import type { Json, PageQuery } from './contract.js';
2
+ export type RunStatus = 'pending' | 'sent' | 'running' | 'completed' | 'failed';
3
+ export interface ToolActionStep {
4
+ id: string;
5
+ actionId: string;
6
+ input: Json;
7
+ }
8
+ export interface AutomationPlan {
9
+ version: 1;
10
+ steps: ToolActionStep[];
11
+ }
12
+ export type AutomationStepStatus = 'running' | 'completed' | 'failed';
13
+ export interface AutomationStepRun {
14
+ id: string;
15
+ status: AutomationStepStatus;
16
+ attempts: number;
17
+ startedAt: string;
18
+ finishedAt?: string;
19
+ output?: Json;
20
+ error?: string;
21
+ failureKind?: 'transient' | 'permanent';
22
+ }
23
+ export interface AutomationRunJournal {
24
+ version: 1;
25
+ steps: AutomationStepRun[];
26
+ }
27
+ export interface AutomationRun {
28
+ id: string;
29
+ automationId: string;
30
+ triggerId: string;
31
+ workflowId: string;
32
+ targetId: string;
33
+ status: RunStatus;
34
+ attempts?: number;
35
+ retryAt?: string;
36
+ input: Json;
37
+ createdAt: string;
38
+ sentAt?: string;
39
+ startedAt?: string;
40
+ finishedAt?: string;
41
+ output?: Json;
42
+ error?: string;
43
+ }
44
+ export interface ListRuns extends PageQuery {
45
+ status?: RunStatus;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { AutomationRunJournal, AutomationStepRun, Json, ToolActionStep } from './contract.js';
2
+ export declare function emptyJournal(): AutomationRunJournal;
3
+ export declare function runJournal(value: Json | undefined, plan: readonly ToolActionStep[]): AutomationRunJournal;
4
+ export declare function completed(journal: AutomationRunJournal, stepId: string): boolean;
5
+ export declare function stepAttempt(journal: AutomationRunJournal, stepId: string): number;
6
+ export declare function putStep(journal: AutomationRunJournal, step: AutomationStepRun): AutomationRunJournal;
7
+ /** Journals are constructed from JSON fields only; this is the persistence boundary. */
8
+ export declare function journalJson(journal: AutomationRunJournal): Json;