@amalgm/automations 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AXIOMS.md +47 -8
- package/PURPOSE.md +45 -11
- package/README.md +116 -12
- package/dist/src/cli/arguments.d.ts +20 -0
- package/dist/src/cli/arguments.js +49 -0
- package/dist/src/cli/input.d.ts +6 -0
- package/dist/src/cli/input.js +48 -0
- package/dist/src/cli/main.d.ts +7 -0
- package/dist/src/cli/main.js +39 -0
- package/dist/src/cli/run.d.ts +13 -0
- package/dist/src/cli/run.js +51 -0
- package/dist/src/cli/shell-runtime.d.ts +9 -0
- package/dist/src/cli/shell-runtime.js +102 -0
- package/dist/src/cli-main.js +2 -20
- package/dist/src/cli.d.ts +3 -10
- package/dist/src/cli.js +3 -186
- package/dist/src/command-result.d.ts +8 -0
- package/dist/src/command-result.js +13 -0
- package/dist/src/command-surface.d.ts +25 -0
- package/dist/src/command-surface.js +125 -0
- package/dist/src/contract.d.ts +1 -1
- package/dist/src/execution-contract.d.ts +28 -0
- package/dist/src/execution-contract.js +1 -0
- package/dist/src/execution-errors.d.ts +6 -0
- package/dist/src/execution-errors.js +48 -0
- package/dist/src/executor.d.ts +3 -16
- package/dist/src/executor.js +31 -36
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.js +6 -2
- package/dist/src/input-template.d.ts +2 -2
- package/dist/src/input-template.js +4 -4
- package/dist/src/mcp-main.js +0 -0
- package/dist/src/mcp.js +15 -79
- package/dist/src/node-process-host.d.ts +13 -0
- package/dist/src/node-process-host.js +116 -0
- package/dist/src/node-process-runner.d.ts +23 -0
- package/dist/src/node-process-runner.js +140 -0
- package/dist/src/plan.js +125 -9
- package/dist/src/run-contract.d.ts +41 -1
- package/dist/src/run-journal.d.ts +2 -2
- package/dist/src/schema.d.ts +4 -4
- package/package.json +3 -3
- package/skills/automations/SKILL.md +69 -6
package/dist/src/index.d.ts
CHANGED
|
@@ -2,10 +2,13 @@ export { createAutomationClient, type AutomationAuthorization, type AutomationHe
|
|
|
2
2
|
export { AutomationCrudService, type AutomationCrudRepository } from './crud.js';
|
|
3
3
|
export { AutomationError, ConflictError, ForbiddenError, NotFoundError, ValidationError, type AutomationErrorCode, } from './errors.js';
|
|
4
4
|
export { createAutomationApi, type AuthenticateAutomationRequest, type AutomationApi } from './http.js';
|
|
5
|
-
export { runAutomationCli } from './cli.js';
|
|
5
|
+
export { automationCliHelp, createShellAutomationCommands, runAutomationCli, runAutomationCliMain, shellAutomationEndpoint, type AutomationCliIo, type AutomationCliMainOptions, type ShellAutomationCommandOptions, } from './cli.js';
|
|
6
|
+
export { automationCommandDefinition, automationCommandDefinitions, createAutomationCommands, type AutomationCommandAnnotations, type AutomationCommandDefinition, type AutomationCommandName, type AutomationCommands, } from './command-surface.js';
|
|
7
|
+
export { automationFailure, type AutomationFailureEnvelope } from './command-result.js';
|
|
6
8
|
export { createAutomationMcpServer } from './mcp.js';
|
|
9
|
+
export { createAutomationToolSurface, type AutomationToolSurface, type AutomationView, type CreateAutomationDefinition, type UpdateAutomationDefinition, type WorkflowChange, } from './tool-surface.js';
|
|
7
10
|
export { SupabaseAutomationCrudRepository, type SupabaseRpcClient } from './supabase-crud.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, RunAutomationNow, ScheduleTrigger, Trigger, ToolActionStep, UpdateAutomation, UpdateScheduleTrigger, UpdateWebhookTrigger, UpdateWorkflow, WebhookTrigger, Workflow, } from './contract.js';
|
|
11
|
+
export type { Automation, AutomationActionPlan, AutomationCrud, AutomationCrudService as AutomationCrudServiceContract, AutomationPrincipal, AutomationPlan, AutomationRunJournal, AutomationScriptRuntime, AutomationStep, AutomationTypedPlan, AutomationStepRun, AutomationStepStatus, AutomationRun, AutomationScope, CreateAutomation, CreateScheduleTrigger, CreateWebhookTrigger, CreateWorkflow, CodeScriptStep, DirectCommandStep, Json, ListAutomations, ListRuns, Page, PageQuery, RunStatus, RunAutomationNow, ScheduleTrigger, Trigger, ToolActionStep, UpdateAutomation, UpdateScheduleTrigger, UpdateWebhookTrigger, UpdateWorkflow, WebhookTrigger, Workflow, } from './contract.js';
|
|
9
12
|
export { Automations, EventEndpointNotFoundError, EventRejectedError } from './automations.js';
|
|
10
13
|
export { createAutomationEventsApi, type AutomationEventReceipt, type AutomationEventsApi, type AutomationEventsApiConfig, } from './events-http.js';
|
|
11
14
|
export { nextCronAt, validateCron } from './schedule.js';
|
|
@@ -16,7 +19,8 @@ export { createMachineRuns } from './machine.js';
|
|
|
16
19
|
export type { AutomationMachinePrincipal, ClaimedAutomationRun, MachineRunRepository, MachineRuns, MachineRunUpdate, } from './machine.js';
|
|
17
20
|
export { createMachineRunsApi } from './machine-http.js';
|
|
18
21
|
export { createMachineRunsClient, type AutomationRequestHeaders } from './machine-client.js';
|
|
19
|
-
export { AutomationRunExecutor, automationPlan, type AutomationActionPort } from './executor.js';
|
|
22
|
+
export { AutomationRunExecutor, automationPlan, type AutomationActionPort, type AutomationProcessExecution, type AutomationProcessPort, type AutomationRunExecutorOptions, } from './executor.js';
|
|
23
|
+
export { createNodeAutomationProcessHost, type NodeAutomationProcessHostOptions, } from './node-process-host.js';
|
|
20
24
|
export { createAutomationMachineRunner, type AutomationMachineRunnerOptions, } from './runner.js';
|
|
21
25
|
export { compiledAutomationPlan } from './plan.js';
|
|
22
26
|
export type { SupabaseRpcClient as SupabaseStoreRpcClient } from './supabase-store.js';
|
package/dist/src/index.js
CHANGED
|
@@ -9,8 +9,11 @@ export { createAutomationClient } from './client.js';
|
|
|
9
9
|
export { AutomationCrudService } from './crud.js';
|
|
10
10
|
export { AutomationError, ConflictError, ForbiddenError, NotFoundError, ValidationError, } from './errors.js';
|
|
11
11
|
export { createAutomationApi } from './http.js';
|
|
12
|
-
export { runAutomationCli } from './cli.js';
|
|
12
|
+
export { automationCliHelp, createShellAutomationCommands, runAutomationCli, runAutomationCliMain, shellAutomationEndpoint, } from './cli.js';
|
|
13
|
+
export { automationCommandDefinition, automationCommandDefinitions, createAutomationCommands, } from './command-surface.js';
|
|
14
|
+
export { automationFailure } from './command-result.js';
|
|
13
15
|
export { createAutomationMcpServer } from './mcp.js';
|
|
16
|
+
export { createAutomationToolSurface, } from './tool-surface.js';
|
|
14
17
|
export { SupabaseAutomationCrudRepository } from './supabase-crud.js';
|
|
15
18
|
export { Automations, EventEndpointNotFoundError, EventRejectedError } from './automations.js';
|
|
16
19
|
export { createAutomationEventsApi, } from './events-http.js';
|
|
@@ -21,6 +24,7 @@ export { SupabaseMachineRunRepository } from './supabase-machine.js';
|
|
|
21
24
|
export { createMachineRuns } from './machine.js';
|
|
22
25
|
export { createMachineRunsApi } from './machine-http.js';
|
|
23
26
|
export { createMachineRunsClient } from './machine-client.js';
|
|
24
|
-
export { AutomationRunExecutor, automationPlan } from './executor.js';
|
|
27
|
+
export { AutomationRunExecutor, automationPlan, } from './executor.js';
|
|
28
|
+
export { createNodeAutomationProcessHost, } from './node-process-host.js';
|
|
25
29
|
export { createAutomationMachineRunner, } from './runner.js';
|
|
26
30
|
export { compiledAutomationPlan } from './plan.js';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { Json } from './contract.js';
|
|
2
|
-
/** Resolve the one explicit dynamic value permitted in
|
|
3
|
-
export declare function
|
|
2
|
+
/** Resolve the one explicit dynamic value permitted in immutable step input. */
|
|
3
|
+
export declare function resolveInputTemplate(template: Json, runInput: Json): Json;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const RUN_INPUT_REFERENCE = '$runInput';
|
|
2
|
-
/** Resolve the one explicit dynamic value permitted in
|
|
3
|
-
export function
|
|
2
|
+
/** Resolve the one explicit dynamic value permitted in immutable step input. */
|
|
3
|
+
export function resolveInputTemplate(template, runInput) {
|
|
4
4
|
if (Array.isArray(template))
|
|
5
|
-
return template.map((value) =>
|
|
5
|
+
return template.map((value) => resolveInputTemplate(value, runInput));
|
|
6
6
|
if (!template || typeof template !== 'object')
|
|
7
7
|
return template;
|
|
8
8
|
const entries = Object.entries(template);
|
|
@@ -14,6 +14,6 @@ export function resolveActionInput(template, runInput) {
|
|
|
14
14
|
}
|
|
15
15
|
return Object.fromEntries(entries.map(([key, value]) => [
|
|
16
16
|
key,
|
|
17
|
-
|
|
17
|
+
resolveInputTemplate(value, runInput),
|
|
18
18
|
]));
|
|
19
19
|
}
|
package/dist/src/mcp-main.js
CHANGED
|
File without changes
|
package/dist/src/mcp.js
CHANGED
|
@@ -1,75 +1,27 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod/v3';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { automationCommandDefinitions, createAutomationCommands, } from './command-surface.js';
|
|
4
|
+
import { automationFailure } from './command-result.js';
|
|
5
5
|
import { createAutomationToolSurface } from './tool-surface.js';
|
|
6
6
|
export { createAutomationToolSurface };
|
|
7
7
|
const resultSchema = { result: z.unknown() };
|
|
8
|
-
const scheduleChangesSchema = z.object({
|
|
9
|
-
create: z.array(createScheduleTriggerSchema).max(100).optional(),
|
|
10
|
-
update: z.array(z.object({ id: identifierSchema, patch: updateScheduleTriggerSchema }).strict()).max(100).optional(),
|
|
11
|
-
delete: z.array(identifierSchema).max(100).optional(),
|
|
12
|
-
}).strict();
|
|
13
|
-
const webhookChangesSchema = z.object({
|
|
14
|
-
create: z.array(createWebhookTriggerSchema).max(100).optional(),
|
|
15
|
-
update: z.array(z.object({ id: identifierSchema, patch: updateWebhookTriggerSchema }).strict()).max(100).optional(),
|
|
16
|
-
delete: z.array(identifierSchema).max(100).optional(),
|
|
17
|
-
}).strict();
|
|
18
|
-
const workflowChangeSchema = z.union([
|
|
19
|
-
z.object({ create: createWorkflowSchema }).strict(),
|
|
20
|
-
z.object({ update: updateWorkflowSchema }).strict(),
|
|
21
|
-
z.object({ delete: z.literal(true) }).strict(),
|
|
22
|
-
]);
|
|
23
8
|
export function createAutomationMcpServer(sdk) {
|
|
24
9
|
const server = new McpServer({ name: 'amalgm-automations-mcp-server', version: '0.1.0' });
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
schedules: z.array(createScheduleTriggerSchema).max(100).optional(),
|
|
29
|
-
webhooks: z.array(createWebhookTriggerSchema).max(100).optional(),
|
|
30
|
-
workflow: createWorkflowSchema.optional(),
|
|
31
|
-
}, write(false), (input) => tools.create(input));
|
|
32
|
-
register(server, 'amalgm_automations_list', 'List automations', 'List the caller\'s automations with optional target, enabled-state, and pagination filters.', {
|
|
33
|
-
...listAutomationsSchema.shape,
|
|
34
|
-
}, read(), (query) => tools.list(query));
|
|
35
|
-
register(server, 'amalgm_automations_get', 'Get automation', 'Get one complete automation definition with its typed triggers and workflow. Recent run history is optional.', {
|
|
36
|
-
automation_id: identifierSchema,
|
|
37
|
-
include_runs: z.boolean().optional(),
|
|
38
|
-
run_query: listRunsSchema.optional(),
|
|
39
|
-
}, read(), ({ automation_id, include_runs, run_query }) => (tools.get(automation_id, include_runs || run_query ? (run_query ?? { limit: 20 }) : false)));
|
|
40
|
-
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.', {
|
|
41
|
-
automation_id: identifierSchema,
|
|
42
|
-
patch: updateAutomationSchema.optional(),
|
|
43
|
-
schedules: scheduleChangesSchema.optional(),
|
|
44
|
-
webhooks: webhookChangesSchema.optional(),
|
|
45
|
-
workflow: workflowChangeSchema.optional(),
|
|
46
|
-
}, write(false), ({ automation_id, ...input }) => tools.update(automation_id, input));
|
|
47
|
-
register(server, 'amalgm_automations_delete', 'Delete automation', 'Delete an automation and its current triggers and workflow. Historical runs remain.', {
|
|
48
|
-
automation_id: identifierSchema,
|
|
49
|
-
}, destructive(), ({ automation_id }) => tools.delete(automation_id));
|
|
50
|
-
register(server, 'amalgm_automations_run_now', 'Run automation now', 'Durably admit one manual run using the automation\'s current enabled configuration and compiled workflow. The selected machine executes it through the normal pending-run queue; this call never executes the workflow inline.', {
|
|
51
|
-
automation_id: identifierSchema,
|
|
52
|
-
input: runAutomationNowSchema.shape.input,
|
|
53
|
-
idempotency_key: runAutomationNowSchema.shape.idempotencyKey,
|
|
54
|
-
}, write(false), ({ automation_id, input, idempotency_key }) => tools.runNow(automation_id, {
|
|
55
|
-
...(input === undefined ? {} : { input }),
|
|
56
|
-
...(idempotency_key === undefined ? {} : { idempotencyKey: idempotency_key }),
|
|
57
|
-
}));
|
|
10
|
+
const commands = createAutomationCommands(sdk);
|
|
11
|
+
for (const definition of automationCommandDefinitions)
|
|
12
|
+
register(server, commands, definition);
|
|
58
13
|
return server;
|
|
59
14
|
}
|
|
60
|
-
function register(server,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
title,
|
|
66
|
-
description,
|
|
67
|
-
inputSchema,
|
|
15
|
+
function register(server, commands, definition) {
|
|
16
|
+
server.registerTool(definition.mcpName, {
|
|
17
|
+
title: definition.title,
|
|
18
|
+
description: definition.description,
|
|
19
|
+
inputSchema: definition.inputShape,
|
|
68
20
|
outputSchema: resultSchema,
|
|
69
|
-
annotations,
|
|
21
|
+
annotations: definition.annotations,
|
|
70
22
|
}, async (input) => {
|
|
71
23
|
try {
|
|
72
|
-
const result = await
|
|
24
|
+
const result = await commands.execute(definition.name, input);
|
|
73
25
|
return toolSuccess(result);
|
|
74
26
|
}
|
|
75
27
|
catch (error) {
|
|
@@ -84,30 +36,14 @@ function toolSuccess(result) {
|
|
|
84
36
|
};
|
|
85
37
|
}
|
|
86
38
|
function toolFailure(error) {
|
|
87
|
-
const
|
|
88
|
-
const code
|
|
89
|
-
const status = error instanceof AutomationError ? error.status : undefined;
|
|
39
|
+
const failure = automationFailure(error);
|
|
40
|
+
const { code, message, status } = failure.error;
|
|
90
41
|
return {
|
|
91
42
|
isError: true,
|
|
92
43
|
content: [{
|
|
93
44
|
type: 'text',
|
|
94
45
|
text: `${code}: ${message}${status === undefined ? '' : ` (HTTP ${status})`}`,
|
|
95
46
|
}],
|
|
96
|
-
structuredContent: {
|
|
97
|
-
error: {
|
|
98
|
-
code,
|
|
99
|
-
message,
|
|
100
|
-
...(status === undefined ? {} : { status }),
|
|
101
|
-
},
|
|
102
|
-
},
|
|
47
|
+
structuredContent: { ...failure },
|
|
103
48
|
};
|
|
104
49
|
}
|
|
105
|
-
const read = () => ({
|
|
106
|
-
readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false,
|
|
107
|
-
});
|
|
108
|
-
const write = (idempotentHint) => ({
|
|
109
|
-
readOnlyHint: false, destructiveHint: false, idempotentHint, openWorldHint: false,
|
|
110
|
-
});
|
|
111
|
-
const destructive = () => ({
|
|
112
|
-
readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false,
|
|
113
|
-
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AutomationProcessPort } from './execution-contract.js';
|
|
2
|
+
export interface NodeAutomationProcessHostOptions {
|
|
3
|
+
/** Used only when a step omits cwd. Shell should supply the user's OS home. */
|
|
4
|
+
readonly defaultCwd?: string;
|
|
5
|
+
/** Machine-host environment inherited by child processes. */
|
|
6
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
7
|
+
readonly shellCommand?: string;
|
|
8
|
+
readonly nodeCommand?: string;
|
|
9
|
+
readonly pythonCommand?: string;
|
|
10
|
+
readonly terminateGraceMs?: number;
|
|
11
|
+
}
|
|
12
|
+
/** Node machine adapter for the SDK's process capability. */
|
|
13
|
+
export declare function createNodeAutomationProcessHost(options?: NodeAutomationProcessHostOptions): AutomationProcessPort;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { captureProcess, processError, processOutput, stringEnvironment, } from './node-process-runner.js';
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
6
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
7
|
+
const DEFAULT_TERMINATE_GRACE_MS = 1_000;
|
|
8
|
+
/** Node machine adapter for the SDK's process capability. */
|
|
9
|
+
export function createNodeAutomationProcessHost(options = {}) {
|
|
10
|
+
const defaultCwd = options.defaultCwd ?? process.cwd();
|
|
11
|
+
if (!isAbsolute(defaultCwd))
|
|
12
|
+
throw new Error('Automation process defaultCwd must be absolute');
|
|
13
|
+
const environment = stringEnvironment(options.environment ?? process.env);
|
|
14
|
+
const terminateGraceMs = positiveInteger(options.terminateGraceMs ?? DEFAULT_TERMINATE_GRACE_MS, 'terminateGraceMs');
|
|
15
|
+
return Object.freeze({
|
|
16
|
+
execute: (input) => executeProcess(input, {
|
|
17
|
+
defaultCwd,
|
|
18
|
+
environment,
|
|
19
|
+
shellCommand: options.shellCommand,
|
|
20
|
+
nodeCommand: options.nodeCommand,
|
|
21
|
+
pythonCommand: options.pythonCommand,
|
|
22
|
+
terminateGraceMs,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
async function executeProcess(input, options) {
|
|
27
|
+
input.signal.throwIfAborted();
|
|
28
|
+
const prepared = await prepareProcess(input, options);
|
|
29
|
+
let result;
|
|
30
|
+
try {
|
|
31
|
+
result = await captureProcess(prepared.invocation, {
|
|
32
|
+
...input,
|
|
33
|
+
cwd: input.cwd ?? options.defaultCwd,
|
|
34
|
+
env: {
|
|
35
|
+
...options.environment,
|
|
36
|
+
...input.env,
|
|
37
|
+
AMALGM_AUTOMATION_IDEMPOTENCY_KEY: input.idempotencyKey,
|
|
38
|
+
},
|
|
39
|
+
}, {
|
|
40
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
41
|
+
maximumBytes: DEFAULT_MAX_OUTPUT_BYTES,
|
|
42
|
+
terminateGraceMs: options.terminateGraceMs,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await prepared.cleanup();
|
|
47
|
+
}
|
|
48
|
+
const output = processOutput(result);
|
|
49
|
+
if (result.aborted)
|
|
50
|
+
throw processError('process_aborted', 'Automation process was cancelled', output);
|
|
51
|
+
if (result.timedOut) {
|
|
52
|
+
throw processError('process_timeout', `Automation process timed out after ${input.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`, output);
|
|
53
|
+
}
|
|
54
|
+
if (result.code !== 0) {
|
|
55
|
+
const detail = (result.stderr || result.stdout).trim();
|
|
56
|
+
throw processError('process_exit', `Automation process exited with ${result.code ?? result.signal ?? 'unknown'}${detail ? `: ${oneLine(detail)}` : ''}`, output);
|
|
57
|
+
}
|
|
58
|
+
return output;
|
|
59
|
+
}
|
|
60
|
+
async function prepareProcess(input, options) {
|
|
61
|
+
if (input.kind === 'command') {
|
|
62
|
+
return {
|
|
63
|
+
invocation: { command: input.command, args: [...(input.args ?? [])] },
|
|
64
|
+
cleanup: async () => undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const directory = await mkdtemp(join(tmpdir(), 'amalgm-automation-'));
|
|
68
|
+
const extension = input.runtime === 'node' ? 'mjs' : input.runtime === 'python' ? 'py' : 'sh';
|
|
69
|
+
const script = join(directory, `step.${extension}`);
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(script, input.source, { encoding: 'utf8', mode: 0o600 });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
await rm(directory, { recursive: true, force: true });
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const cleanup = () => rm(directory, { recursive: true, force: true });
|
|
78
|
+
if (input.runtime === 'node') {
|
|
79
|
+
return {
|
|
80
|
+
invocation: {
|
|
81
|
+
command: options.nodeCommand ?? process.execPath,
|
|
82
|
+
args: [script, ...(input.args ?? [])],
|
|
83
|
+
},
|
|
84
|
+
cleanup,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (input.runtime === 'python') {
|
|
88
|
+
return {
|
|
89
|
+
invocation: {
|
|
90
|
+
command: options.pythonCommand ?? 'python3',
|
|
91
|
+
args: [script, ...(input.args ?? [])],
|
|
92
|
+
},
|
|
93
|
+
cleanup,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
invocation: process.platform === 'win32'
|
|
98
|
+
? {
|
|
99
|
+
command: options.shellCommand ?? 'powershell.exe',
|
|
100
|
+
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-File', script, ...(input.args ?? [])],
|
|
101
|
+
}
|
|
102
|
+
: {
|
|
103
|
+
command: options.shellCommand ?? '/bin/sh',
|
|
104
|
+
args: [script, ...(input.args ?? [])],
|
|
105
|
+
},
|
|
106
|
+
cleanup,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function positiveInteger(value, label) {
|
|
110
|
+
if (!Number.isInteger(value) || value < 1)
|
|
111
|
+
throw new Error(`${label} must be a positive integer`);
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function oneLine(value) {
|
|
115
|
+
return value.replace(/[\r\n]+/g, ' ').slice(0, 500);
|
|
116
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Json } from './contract.js';
|
|
2
|
+
import type { AutomationProcessExecution } from './execution-contract.js';
|
|
3
|
+
export interface ProcessInvocation {
|
|
4
|
+
readonly command: string;
|
|
5
|
+
readonly args: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface CapturedProcess {
|
|
8
|
+
readonly code: number | null;
|
|
9
|
+
readonly signal: NodeJS.Signals | null;
|
|
10
|
+
readonly stdout: string;
|
|
11
|
+
readonly stderr: string;
|
|
12
|
+
readonly truncated: boolean;
|
|
13
|
+
readonly timedOut: boolean;
|
|
14
|
+
readonly aborted: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function captureProcess(invocation: ProcessInvocation, input: AutomationProcessExecution, defaults: Readonly<{
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
maximumBytes: number;
|
|
19
|
+
terminateGraceMs: number;
|
|
20
|
+
}>): Promise<CapturedProcess>;
|
|
21
|
+
export declare function processOutput(result: CapturedProcess): Json;
|
|
22
|
+
export declare function processError(code: string, message: string, output: Json): Error;
|
|
23
|
+
export declare function stringEnvironment(source: NodeJS.ProcessEnv): Record<string, string>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function captureProcess(invocation, input, defaults) {
|
|
3
|
+
const maximumBytes = input.maxOutputBytes ?? defaults.maximumBytes;
|
|
4
|
+
const timeoutMs = input.timeoutMs ?? defaults.timeoutMs;
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
let child;
|
|
7
|
+
try {
|
|
8
|
+
child = spawn(invocation.command, invocation.args, {
|
|
9
|
+
cwd: input.cwd ?? process.cwd(),
|
|
10
|
+
env: input.env,
|
|
11
|
+
detached: process.platform !== 'win32',
|
|
12
|
+
shell: false,
|
|
13
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
reject(spawnError(error));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const stdout = [];
|
|
22
|
+
const stderr = [];
|
|
23
|
+
let capturedBytes = 0;
|
|
24
|
+
let truncated = false;
|
|
25
|
+
let timedOut = false;
|
|
26
|
+
let aborted = false;
|
|
27
|
+
let terminating = false;
|
|
28
|
+
let escalation;
|
|
29
|
+
let settled = false;
|
|
30
|
+
const capture = (target) => (chunk) => {
|
|
31
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
32
|
+
const remaining = maximumBytes - capturedBytes;
|
|
33
|
+
if (remaining > 0) {
|
|
34
|
+
const kept = bytes.subarray(0, remaining);
|
|
35
|
+
target.push(kept);
|
|
36
|
+
capturedBytes += kept.byteLength;
|
|
37
|
+
}
|
|
38
|
+
if (bytes.byteLength > remaining)
|
|
39
|
+
truncated = true;
|
|
40
|
+
};
|
|
41
|
+
child.stdout?.on('data', capture(stdout));
|
|
42
|
+
child.stderr?.on('data', capture(stderr));
|
|
43
|
+
child.stdin?.on('error', () => undefined);
|
|
44
|
+
const terminate = () => {
|
|
45
|
+
if (terminating)
|
|
46
|
+
return;
|
|
47
|
+
terminating = true;
|
|
48
|
+
stopProcess(child, 'SIGTERM');
|
|
49
|
+
escalation = setTimeout(() => stopProcess(child, 'SIGKILL'), defaults.terminateGraceMs);
|
|
50
|
+
escalation.unref?.();
|
|
51
|
+
};
|
|
52
|
+
const onAbort = () => {
|
|
53
|
+
aborted = true;
|
|
54
|
+
terminate();
|
|
55
|
+
};
|
|
56
|
+
input.signal.addEventListener('abort', onAbort, { once: true });
|
|
57
|
+
const timeout = setTimeout(() => {
|
|
58
|
+
timedOut = true;
|
|
59
|
+
terminate();
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
timeout.unref?.();
|
|
62
|
+
child.once('error', (error) => {
|
|
63
|
+
if (settled)
|
|
64
|
+
return;
|
|
65
|
+
settled = true;
|
|
66
|
+
cleanup();
|
|
67
|
+
reject(spawnError(error));
|
|
68
|
+
});
|
|
69
|
+
child.once('close', (code, signal) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
cleanup();
|
|
74
|
+
resolve({
|
|
75
|
+
code,
|
|
76
|
+
signal,
|
|
77
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
78
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
79
|
+
truncated,
|
|
80
|
+
timedOut,
|
|
81
|
+
aborted,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
child.stdin?.end(input.stdin);
|
|
85
|
+
if (input.signal.aborted)
|
|
86
|
+
onAbort();
|
|
87
|
+
function cleanup() {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
if (escalation)
|
|
90
|
+
clearTimeout(escalation);
|
|
91
|
+
input.signal.removeEventListener('abort', onAbort);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export function processOutput(result) {
|
|
96
|
+
return {
|
|
97
|
+
exitCode: result.code,
|
|
98
|
+
signal: result.signal,
|
|
99
|
+
stdout: result.stdout,
|
|
100
|
+
stderr: result.stderr,
|
|
101
|
+
truncated: result.truncated,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function processError(code, message, output) {
|
|
105
|
+
return Object.assign(new Error(message), { code, output });
|
|
106
|
+
}
|
|
107
|
+
export function stringEnvironment(source) {
|
|
108
|
+
return Object.fromEntries(Object.entries(source).filter((entry) => (typeof entry[1] === 'string' && !PRIVATE_HOST_ENV.has(entry[0]))));
|
|
109
|
+
}
|
|
110
|
+
const PRIVATE_HOST_ENV = new Set([
|
|
111
|
+
'AMALGM_AUTOMATIONS_AUTHORIZATION',
|
|
112
|
+
'AMALGM_COMPUTER_AUTH_TOKEN',
|
|
113
|
+
'AMALGM_RUNTIME_TOKEN',
|
|
114
|
+
'AMALGM_TUNNEL_TOKEN',
|
|
115
|
+
]);
|
|
116
|
+
function spawnError(error) {
|
|
117
|
+
const source = error instanceof Error ? error : new Error(String(error));
|
|
118
|
+
return Object.assign(new Error(`Automation process could not start: ${source.message}`), {
|
|
119
|
+
code: source.code ?? 'process_spawn_failed',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function stopProcess(child, signal) {
|
|
123
|
+
if (child.exitCode !== null)
|
|
124
|
+
return;
|
|
125
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
126
|
+
try {
|
|
127
|
+
process.kill(-child.pid, signal);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// The child may not have established its process group yet.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
child.kill(signal);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// The close event remains authoritative.
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/src/plan.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { ValidationError } from './errors.js';
|
|
2
|
+
const MAX_STEPS = 100;
|
|
3
|
+
const MAX_ARGUMENTS = 1_000;
|
|
4
|
+
const MAX_ARGUMENT_LENGTH = 100_000;
|
|
5
|
+
const MAX_SOURCE_LENGTH = 1_000_000;
|
|
6
|
+
const MAX_TIMEOUT_MS = 86_400_000;
|
|
7
|
+
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const RESERVED_PROCESS_ENV = new Set([
|
|
9
|
+
'AMALGM_AUTOMATION_IDEMPOTENCY_KEY',
|
|
10
|
+
'AMALGM_AUTOMATIONS_AUTHORIZATION',
|
|
11
|
+
'AMALGM_COMPUTER_AUTH_TOKEN',
|
|
12
|
+
'AMALGM_RUNTIME_TOKEN',
|
|
13
|
+
'AMALGM_TUNNEL_TOKEN',
|
|
14
|
+
]);
|
|
2
15
|
export function automationPlan(snapshot) {
|
|
3
16
|
const root = record(snapshot, 'Automation snapshot');
|
|
4
17
|
const workflow = record(root.workflow, 'Workflow snapshot');
|
|
@@ -6,28 +19,72 @@ export function automationPlan(snapshot) {
|
|
|
6
19
|
}
|
|
7
20
|
export function compiledAutomationPlan(value) {
|
|
8
21
|
const compiled = record(value, 'Compiled workflow');
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
const version = compiled.version;
|
|
23
|
+
if ((version !== 1 && version !== 2) || !Array.isArray(compiled.steps)
|
|
24
|
+
|| compiled.steps.length < 1 || compiled.steps.length > MAX_STEPS) {
|
|
25
|
+
throw new ValidationError(`Compiled workflow must be a version 1 or 2 plan with 1 to ${MAX_STEPS} steps`);
|
|
12
26
|
}
|
|
13
|
-
const steps = compiled.steps.map((item, index) => step(item, index));
|
|
27
|
+
const steps = compiled.steps.map((item, index) => step(item, index, version));
|
|
14
28
|
const ids = new Set();
|
|
15
29
|
for (const item of steps) {
|
|
16
30
|
if (ids.has(item.id))
|
|
17
31
|
throw new ValidationError(`Workflow step id is duplicated: ${item.id}`);
|
|
18
32
|
ids.add(item.id);
|
|
19
33
|
}
|
|
20
|
-
return
|
|
34
|
+
return version === 1
|
|
35
|
+
? { version: 1, steps: steps }
|
|
36
|
+
: { version: 2, steps };
|
|
21
37
|
}
|
|
22
|
-
function step(value, index) {
|
|
38
|
+
function step(value, index, version) {
|
|
23
39
|
const item = record(value, `Workflow step ${index + 1}`);
|
|
24
|
-
|
|
25
|
-
|
|
40
|
+
const id = requiredText(item.id, `Workflow step ${index + 1} needs an id`, 500);
|
|
41
|
+
if (version === 1 && item.kind !== undefined) {
|
|
42
|
+
throw new ValidationError(`Workflow step ${index + 1} requires plan version 2`);
|
|
43
|
+
}
|
|
44
|
+
if (item.kind === 'command')
|
|
45
|
+
return commandStep(item, id, index);
|
|
46
|
+
if (item.kind === 'script')
|
|
47
|
+
return scriptStep(item, id, index);
|
|
48
|
+
if (item.kind !== undefined) {
|
|
49
|
+
throw new ValidationError(`Workflow step ${index + 1} has an unsupported kind`);
|
|
50
|
+
}
|
|
26
51
|
if (typeof item.actionId !== 'string' || !item.actionId.includes('.')) {
|
|
27
52
|
throw new ValidationError(`Workflow step ${index + 1} needs a qualified actionId`);
|
|
28
53
|
}
|
|
29
54
|
assertJson(item.input);
|
|
30
|
-
return { id
|
|
55
|
+
return { id, actionId: item.actionId.trim(), input: item.input };
|
|
56
|
+
}
|
|
57
|
+
function commandStep(item, id, index) {
|
|
58
|
+
const label = `Workflow command step ${index + 1}`;
|
|
59
|
+
return compact({
|
|
60
|
+
id,
|
|
61
|
+
kind: 'command',
|
|
62
|
+
command: requiredText(item.command, `${label} needs a command`, 10_000),
|
|
63
|
+
args: argumentsList(item.args, label),
|
|
64
|
+
cwd: workingDirectory(item.cwd, label),
|
|
65
|
+
env: environment(item.env, label),
|
|
66
|
+
stdin: jsonInput(item.stdin, label),
|
|
67
|
+
timeoutMs: boundedInteger(item.timeoutMs, 1, MAX_TIMEOUT_MS, `${label} timeoutMs`),
|
|
68
|
+
maxOutputBytes: boundedInteger(item.maxOutputBytes, 1, MAX_OUTPUT_BYTES, `${label} maxOutputBytes`),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function scriptStep(item, id, index) {
|
|
72
|
+
const label = `Workflow script step ${index + 1}`;
|
|
73
|
+
if (item.runtime !== 'shell' && item.runtime !== 'node' && item.runtime !== 'python') {
|
|
74
|
+
throw new ValidationError(`${label} runtime must be shell, node, or python`);
|
|
75
|
+
}
|
|
76
|
+
return compact({
|
|
77
|
+
id,
|
|
78
|
+
kind: 'script',
|
|
79
|
+
runtime: item.runtime,
|
|
80
|
+
source: requiredText(item.source, `${label} needs source`, MAX_SOURCE_LENGTH, false),
|
|
81
|
+
args: argumentsList(item.args, label),
|
|
82
|
+
cwd: workingDirectory(item.cwd, label),
|
|
83
|
+
env: environment(item.env, label),
|
|
84
|
+
stdin: jsonInput(item.stdin, label),
|
|
85
|
+
timeoutMs: boundedInteger(item.timeoutMs, 1, MAX_TIMEOUT_MS, `${label} timeoutMs`),
|
|
86
|
+
maxOutputBytes: boundedInteger(item.maxOutputBytes, 1, MAX_OUTPUT_BYTES, `${label} maxOutputBytes`),
|
|
87
|
+
});
|
|
31
88
|
}
|
|
32
89
|
function record(value, name) {
|
|
33
90
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -46,3 +103,62 @@ function assertJson(value) {
|
|
|
46
103
|
}
|
|
47
104
|
throw new ValidationError('Workflow step input must be JSON');
|
|
48
105
|
}
|
|
106
|
+
function jsonInput(value, label) {
|
|
107
|
+
if (value === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
try {
|
|
110
|
+
assertJson(value);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new ValidationError(`${label} stdin must be JSON`);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
function argumentsList(value, label) {
|
|
118
|
+
if (value === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
if (!Array.isArray(value) || value.length > MAX_ARGUMENTS
|
|
121
|
+
|| value.some((argument) => typeof argument !== 'string' || argument.length > MAX_ARGUMENT_LENGTH)) {
|
|
122
|
+
throw new ValidationError(`${label} args must contain at most ${MAX_ARGUMENTS} strings`);
|
|
123
|
+
}
|
|
124
|
+
return [...value];
|
|
125
|
+
}
|
|
126
|
+
function workingDirectory(value, label) {
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
if (typeof value !== 'string' || !absolutePath(value)) {
|
|
130
|
+
throw new ValidationError(`${label} cwd must be an absolute path`);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
function environment(value, label) {
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
138
|
+
|| Object.entries(value).some(([name, content]) => (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
|
|
139
|
+
|| RESERVED_PROCESS_ENV.has(name)
|
|
140
|
+
|| typeof content !== 'string'))) {
|
|
141
|
+
throw new ValidationError(`${label} env must map non-reserved environment variable names to strings`);
|
|
142
|
+
}
|
|
143
|
+
return { ...value };
|
|
144
|
+
}
|
|
145
|
+
function boundedInteger(value, minimum, maximum, label) {
|
|
146
|
+
if (value === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
if (!Number.isInteger(value) || Number(value) < minimum || Number(value) > maximum) {
|
|
149
|
+
throw new ValidationError(`${label} must be an integer from ${minimum} to ${maximum}`);
|
|
150
|
+
}
|
|
151
|
+
return Number(value);
|
|
152
|
+
}
|
|
153
|
+
function requiredText(value, message, maximum, trim = true) {
|
|
154
|
+
if (typeof value !== 'string' || !value.trim() || value.length > maximum) {
|
|
155
|
+
throw new ValidationError(message);
|
|
156
|
+
}
|
|
157
|
+
return trim ? value.trim() : value;
|
|
158
|
+
}
|
|
159
|
+
function absolutePath(value) {
|
|
160
|
+
return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\');
|
|
161
|
+
}
|
|
162
|
+
function compact(value) {
|
|
163
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
164
|
+
}
|