Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/workflow-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Restricted JavaScript workflow runtime

DevSpace can run a bounded JavaScript workflow that coordinates the same durable local-agent workflows exposed through CLI and MCP. The script is orchestration code only: provider execution, retries, policy enforcement, worktrees, persistence, and cancellation remain owned by the shared workflow store and supervisor.

## Run a script

```bash
devspace workflows script ./review.workflow.js \
--args-json '{"topic":"authentication"}' \
--idempotency-key review-auth-v1 \
--json
```

The script must be inside the canonical `DEVSPACE_WORKSPACE_ROOT` and use a `.js` or `.mjs` extension. `--args-json` must contain a JSON object. The command emits one versioned JSON envelope and waits for the script to reach a terminal state.

## Script contract

```js
// @devspace-workflow {"version":1,"name":"review","maxAgentCalls":4,"maxConcurrency":2,"timeoutMs":600000}
export default async function ({ agent, parallel, pipeline, phase, log, args, budget }) {
const reviews = await phase("review", () => parallel([
() => agent({ target: "security", prompt: `Review ${args.topic}` }),
() => agent({ target: "tests", prompt: `Test ${args.topic}` })
]));

const summary = await pipeline([
(items) => agent({
target: "reviewer",
prompt: `Synthesize: ${JSON.stringify(items)}`
})
], reviews);

log("review complete", { workflowId: summary.workflowId });
return { reviews, summary };
}
```

The optional first non-empty line is strict JSON metadata. Supported fields are:

- `version`: must be `1`.
- `name`: at most 128 characters.
- `description`: at most 1,024 characters.
- `maxAgentCalls`: 1–64; default 16.
- `maxConcurrency`: 1–16; default 4.
- `timeoutMs`: 1–86,400,000; default 15 minutes.

Unknown metadata and `agent()` fields are rejected.

## Primitives

- `agent(options)`: submits one durable local-agent workflow and returns `{ workflowId, status, finalResponse }`. Options support `target`, `prompt`, `model`, `thinking`, `access`, `timeoutMs`, and the existing bounded read-only retry policy.
- `parallel(tasks)`: runs task functions concurrently. Actual agent dispatch remains bounded by `maxConcurrency` and supervisor limits.
- `pipeline(tasks, initialValue)`: awaits task functions in sequence, passing each result to the next task.
- `phase(name, task)`: emits durable `phase.started` and `phase.completed` runtime events.
- `log(message, data)`: emits a bounded durable log event.
- `args`: deeply frozen JSON supplied by `--args-json`.
- `budget`: deeply frozen effective `maxAgentCalls`, `maxConcurrency`, and `timeoutMs` values.

Every `agent()` promise must be awaited, directly or through `parallel`, `pipeline`, or another awaited operation. Returning while calls are outstanding fails the runtime and requests cancellation of active children.

## Isolation and limits

The workflow body runs in a separate Node process with the permission model enabled, an empty environment, no inherited stdio, and a 64 MiB old-space limit. Before evaluating workflow code, the child applies SES `lockdown()` and creates a fresh authority-free `Compartment`. Only a hardened orchestration API and bounded JSON values cross into that compartment; Node APIs, imports, `process`, `require`, ambient environment variables, filesystem, network, database handles, and the orchestrator are not endowed.

The child can read only the installed SES runtime packages. Node permissions provide a second boundary around filesystem, subprocess, worker, addon, and—where supported by the running Node release—network access. Source, arguments, logs, event payloads, results, call counts, concurrency, and wall-clock duration are bounded. Scripts can only cause agent execution through `agent()`.

Keep Node and the pinned SES dependency current. For hostile multi-tenant execution requiring protection beyond the JavaScript object-capability boundary, run DevSpace inside an operator-managed OS or container sandbox as an additional layer.

## Durability and replay

Runtime runs, events, and each indexed `agent()` call are journaled in SQLite. Reusing an idempotency key with identical source, arguments, metadata, budget, and workspace returns the existing successful result. Reusing it with different input is rejected.

After a failed or interrupted runtime, invoking the same request re-executes the orchestration function from the beginning. A completed call prefix is replayed only when the call index and normalized request hash match exactly; divergence fails closed. In-flight child workflow IDs are reused rather than submitted twice.

Replay does not make arbitrary side effects safe. The restricted runtime intentionally offers no raw filesystem, network, shell, or database primitive. `workspace_write` agents use the workflow scheduler's isolated managed-worktree semantics and are not automatically retried.
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && npm run test:workflows && tsx src/cli.test.ts",
"test:workflows": "tsx src/workflows/migrations.test.ts && tsx src/workflows/policy.test.ts && tsx src/workflows/workflows.test.ts && tsx src/workflows/supervisor.test.ts && tsx src/workflows/dag-scheduler.test.ts && tsx src/workflows/mcp.test.ts && tsx src/workflows/cli.test.ts",
"test:workflows": "tsx src/workflows/migrations.test.ts && tsx src/workflows/policy.test.ts && tsx src/workflows/workflows.test.ts && tsx src/workflows/supervisor.test.ts && tsx src/workflows/dag-scheduler.test.ts && tsx src/workflows/mcp.test.ts && tsx src/workflows/runtime.test.ts && tsx src/workflows/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand All @@ -53,6 +53,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"semver": "^7.8.4",
"ses": "^2.2.0",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
Expand Down
61 changes: 61 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ const migrations: Migration[] = [
name: "workflow-dag-scheduler",
up: migrateWorkflowDagScheduler,
},
{
version: 7,
name: "workflow-js-runtime",
up: migrateWorkflowJsRuntime,
},
];

export function migrateDatabase(sqlite: Database.Database): void {
Expand Down Expand Up @@ -380,6 +385,62 @@ function migrateWorkflowDagScheduler(sqlite: Database.Database): void {
`);
}

function migrateWorkflowJsRuntime(sqlite: Database.Database): void {
sqlite.exec(`
create table if not exists workflow_runtime_runs (
id text primary key,
workspace_id text not null,
workspace_root text not null,
source_hash text not null,
args_json text not null,
metadata_json text not null,
budget_json text not null,
idempotency_key text,
request_hash text not null,
status text not null check (status in ('running', 'succeeded', 'failed', 'cancelled')),
result_json text,
error_json text,
created_at text not null,
updated_at text not null,
completed_at text,
unique (workspace_id, workspace_root, idempotency_key)
);

create table if not exists workflow_runtime_calls (
runtime_run_id text not null,
call_index integer not null,
request_hash text not null,
request_json text not null,
workflow_run_id text,
status text not null check (status in ('pending', 'running', 'succeeded', 'failed')),
result_json text,
error_json text,
created_at text not null,
updated_at text not null,
completed_at text,
primary key (runtime_run_id, call_index),
foreign key (runtime_run_id) references workflow_runtime_runs(id) on delete cascade,
foreign key (workflow_run_id) references workflow_runs(id) on delete set null
);

create table if not exists workflow_runtime_events (
runtime_run_id text not null,
sequence integer not null,
event_type text not null,
payload_json text not null,
created_at text not null,
primary key (runtime_run_id, sequence),
foreign key (runtime_run_id) references workflow_runtime_runs(id) on delete cascade
);

create index if not exists workflow_runtime_runs_workspace_idx
on workflow_runtime_runs(workspace_id, workspace_root, created_at);

create index if not exists workflow_runtime_calls_workflow_idx
on workflow_runtime_calls(workflow_run_id);
`);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions" | "local_agent_sessions" | "workflow_runs" | "workflow_nodes",
Expand Down
70 changes: 70 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,76 @@ export const workflowProviderEvents = sqliteTable(
],
);

export const workflowRuntimeRuns = sqliteTable(
"workflow_runtime_runs",
{
id: text("id").primaryKey(),
workspaceId: text("workspace_id").notNull(),
workspaceRoot: text("workspace_root").notNull(),
sourceHash: text("source_hash").notNull(),
argsJson: text("args_json").notNull(),
metadataJson: text("metadata_json").notNull(),
budgetJson: text("budget_json").notNull(),
idempotencyKey: text("idempotency_key"),
requestHash: text("request_hash").notNull(),
status: text("status").notNull(),
resultJson: text("result_json"),
errorJson: text("error_json"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
completedAt: text("completed_at"),
},
(table) => [
uniqueIndex("workflow_runtime_runs_idempotency_idx").on(
table.workspaceId,
table.workspaceRoot,
table.idempotencyKey,
),
index("workflow_runtime_runs_workspace_idx").on(
table.workspaceId,
table.workspaceRoot,
table.createdAt,
),
],
);

export const workflowRuntimeCalls = sqliteTable(
"workflow_runtime_calls",
{
runtimeRunId: text("runtime_run_id")
.notNull()
.references(() => workflowRuntimeRuns.id, { onDelete: "cascade" }),
callIndex: integer("call_index").notNull(),
requestHash: text("request_hash").notNull(),
requestJson: text("request_json").notNull(),
workflowRunId: text("workflow_run_id").references(() => workflowRuns.id, { onDelete: "set null" }),
status: text("status").notNull(),
resultJson: text("result_json"),
errorJson: text("error_json"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
completedAt: text("completed_at"),
},
(table) => [
primaryKey({ columns: [table.runtimeRunId, table.callIndex] }),
index("workflow_runtime_calls_workflow_idx").on(table.workflowRunId),
],
);

export const workflowRuntimeEvents = sqliteTable(
"workflow_runtime_events",
{
runtimeRunId: text("runtime_run_id")
.notNull()
.references(() => workflowRuntimeRuns.id, { onDelete: "cascade" }),
sequence: integer("sequence").notNull(),
eventType: text("event_type").notNull(),
payloadJson: text("payload_json").notNull(),
createdAt: text("created_at").notNull(),
},
(table) => [primaryKey({ columns: [table.runtimeRunId, table.sequence] })],
);

export const workflowWorktrees = sqliteTable(
"workflow_worktrees",
{
Expand Down
1 change: 1 addition & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
{ version: 4, name: "durable-workflows" },
{ version: 5, name: "workflow-supervisor" },
{ version: 6, name: "workflow-dag-scheduler" },
{ version: 7, name: "workflow-js-runtime" },
]);
} finally {
database.close();
Expand Down
42 changes: 41 additions & 1 deletion src/workflows/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ mkdirSync(stateDir, { recursive: true });
mkdirSync(agentsDir, { recursive: true });

const profilePath = join(agentsDir, "reviewer.md");
const workflowScriptPath = join(project, "example.workflow.js");
writeProfile(profilePath, "Original immutable profile.");
writeFileSync(workflowScriptPath, `
// @devspace-workflow {"version":1,"name":"cli-test","maxAgentCalls":2,"maxConcurrency":1,"timeoutMs":5000}
export default function ({ args, budget, log }) {
log("script executed", { value: args.value });
return { value: args.value, maxAgentCalls: budget.maxAgentCalls };
}
`);

const baseEnv = {
...process.env,
Expand Down Expand Up @@ -117,8 +125,39 @@ try {
], baseEnv);
assert.notEqual(conflict.status, 0);
assert.equal((JSON.parse(conflict.stdout) as Envelope).error!.code, "idempotency_conflict");

const scripted = runCli([
"workflows", "script", workflowScriptPath, "--args-json", JSON.stringify({ value: "from-cli" }),
"--idempotency-key", "script-shell-parent", "--json",
], baseEnv);
assert.equal(scripted.status, 0, `${scripted.stderr}\n${scripted.stdout}`);
const scriptEnvelope = JSON.parse(scripted.stdout) as Envelope;
assert.equal(scriptEnvelope.ok, true);
assert.equal(scriptEnvelope.created, true);
assert.equal(scriptEnvelope.runtime!.status, "succeeded");
assert.deepEqual(scriptEnvelope.runtime!.result, { value: "from-cli", maxAgentCalls: 2 });

const scriptReplay = runCli([
"workflows", "script", workflowScriptPath, "--args-json", JSON.stringify({ value: "from-cli" }),
"--idempotency-key", "script-shell-parent", "--json",
], baseEnv);
assert.equal(scriptReplay.status, 0, scriptReplay.stderr);
assert.equal((JSON.parse(scriptReplay.stdout) as Envelope).created, false);

const scriptConflict = runCli([
"workflows", "script", workflowScriptPath, "--args-json", JSON.stringify({ value: "different" }),
"--idempotency-key", "script-shell-parent", "--json",
], baseEnv);
assert.notEqual(scriptConflict.status, 0);
assert.equal((JSON.parse(scriptConflict.stdout) as Envelope).error!.code, "idempotency_conflict");

const invalidScriptArgs = runCli([
"workflows", "script", workflowScriptPath, "--args-json", "[]", "--json",
], baseEnv);
assert.notEqual(invalidScriptArgs.status, 0);
assert.equal((JSON.parse(invalidScriptArgs.stdout) as Envelope).error!.code, "invalid_input");
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(root, { recursive: true, force: true, maxRetries: 50, retryDelay: 100 });
}

function runCli(args: string[], env: NodeJS.ProcessEnv) {
Expand Down Expand Up @@ -157,5 +196,6 @@ interface Envelope {
definition: { nodes: Array<{ config: { profileBody: string } }> };
};
events?: Array<{ type: string; sequence: number }>;
runtime?: { status: string; result?: unknown };
error?: { code: string; message: string };
}
Loading
Loading