@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.
- package/AXIOMS.md +37 -16
- package/PURPOSE.md +14 -1
- package/README.md +5 -2
- package/dist/host/config.d.ts +1 -0
- package/dist/host/config.js +1 -0
- package/dist/host/main.js +16 -4
- package/dist/host/server.d.ts +3 -0
- package/dist/host/server.js +27 -8
- package/dist/src/automations.d.ts +4 -7
- package/dist/src/automations.js +13 -65
- package/dist/src/client.d.ts +1 -0
- package/dist/src/client.js +3 -2
- package/dist/src/contract.d.ts +4 -30
- package/dist/src/crud/automations.js +3 -3
- package/dist/src/crud/context.d.ts +1 -0
- package/dist/src/crud/context.js +10 -1
- package/dist/src/events-http.js +4 -0
- package/dist/src/executor.d.ts +12 -3
- package/dist/src/executor.js +163 -44
- package/dist/src/http.js +4 -0
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +4 -2
- package/dist/src/machine-client.d.ts +1 -0
- package/dist/src/machine-client.js +2 -0
- package/dist/src/machine.d.ts +2 -1
- package/dist/src/machine.js +6 -1
- package/dist/src/mcp.d.ts +3 -0
- package/dist/src/mcp.js +53 -50
- package/dist/src/plan.d.ts +3 -0
- package/dist/src/plan.js +48 -0
- package/dist/src/run-contract.d.ts +46 -0
- package/dist/src/run-contract.js +1 -0
- package/dist/src/run-journal.d.ts +8 -0
- package/dist/src/run-journal.js +56 -0
- package/dist/src/runner.d.ts +14 -0
- package/dist/src/runner.js +70 -0
- package/dist/src/schema.d.ts +46 -46
- package/dist/src/schema.js +18 -3
- package/dist/src/supabase-crud/mappers.js +2 -0
- package/dist/src/supabase-crud/rows.d.ts +2 -0
- package/dist/src/supabase-machine.js +2 -0
- package/dist/src/supabase-store.d.ts +1 -4
- package/dist/src/supabase-store.js +0 -24
- package/dist/src/tool-surface.d.ts +46 -0
- package/dist/src/tool-surface.js +125 -0
- package/dist/src/types.d.ts +1 -16
- package/package.json +5 -5
- package/skills/automations/SKILL.md +20 -15
- package/supabase/migrations/20260830010000_durable_step_retries.sql +332 -0
package/dist/src/executor.js
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
-
|
|
29
|
+
journal = runJournal(run.output, plan.steps);
|
|
13
30
|
for (const step of plan.steps) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
return
|
|
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
|
|
59
|
-
if (!value ||
|
|
60
|
-
throw new Error(`${name}
|
|
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
|
|
64
|
-
|
|
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
|
};
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
5
|
-
// admission, schedule claims,
|
|
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';
|
|
@@ -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)
|
package/dist/src/machine.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/src/machine.js
CHANGED
|
@@ -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,
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
register(server, '
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
register(server, '
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
});
|
package/dist/src/plan.js
ADDED
|
@@ -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;
|