Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2b18101
feat(workflows): add durable workflow schema
Waishnav Jul 16, 2026
1134de8
feat(workflows): add durable orchestration service
Waishnav Jul 16, 2026
fa9cef5
test(workflows): cover durable orchestration
Waishnav Jul 16, 2026
688f2d4
feat(workflows): add local agent execution policy
Waishnav Jul 16, 2026
59870dc
feat(agents): add streaming run handle core
Waishnav Jul 16, 2026
51a0bd3
feat(process): add graceful tree termination
Waishnav Jul 16, 2026
de05f2c
feat(agents): stream provider lifecycle events
Waishnav Jul 16, 2026
cea7b23
test(agents): cover streaming lifecycle and policy
Waishnav Jul 16, 2026
bf86c90
feat(workflows): add supervisor persistence primitives
Waishnav Jul 16, 2026
c5837db
feat(workflows): add durable workflow supervisor
Waishnav Jul 16, 2026
33378dc
feat(cli): add durable workflow commands
Waishnav Jul 16, 2026
0092d28
test(workflows): cover supervisor and CLI lifecycle
Waishnav Jul 16, 2026
da50699
feat(workflows): add durable DAG scheduling
Waishnav Jul 16, 2026
8df9d4d
feat(mcp): expose durable workflow tools
Waishnav Jul 16, 2026
bafab26
test(workflows): cover DAG and MCP contracts
Waishnav Jul 16, 2026
d8b15fa
fix(workflows): harden MCP DAG lifecycle
Waishnav Jul 17, 2026
7fa5918
fix(process): escalate Windows interrupt cleanup
Waishnav Jul 17, 2026
d136508
fix(db): close failed workflow database initialization
Waishnav Jul 17, 2026
e4566a6
test(workflows): use portable loader URL
Waishnav Jul 17, 2026
71e762d
fix(workflows): detach supervisor from caller directory
Waishnav Jul 17, 2026
57b1c22
fix(db): retry concurrent WAL initialization
Waishnav Jul 17, 2026
7392b93
test(workflows): avoid terminal wait race
Waishnav Jul 17, 2026
f425bc8
feat(workflows): add restricted JavaScript runtime
Waishnav Jul 17, 2026
298e9a9
fix(workflows): confine JavaScript runtime with SES
Waishnav Jul 17, 2026
0052715
test(workflows): retry detached supervisor cleanup
Waishnav Jul 17, 2026
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
66 changes: 66 additions & 0 deletions docs/workflow-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Workflow CLI

DevSpace workflows are durable, single-agent runs intended for shell parent processes. Submission stores an immutable execution snapshot and wakes a detached supervisor. The workflow continues after the submitting shell exits.

## Workspace identity

Every command requires a workspace identity and canonical workspace root. The root must be inside `DEVSPACE_ALLOWED_ROOTS`.

```sh
export DEVSPACE_WORKSPACE_ID="checkout-42"
export DEVSPACE_WORKSPACE_ROOT="$(git rev-parse --show-toplevel)"
export DEVSPACE_ALLOWED_ROOTS="$HOME/src"
```

A workflow ID does not grant access by itself. Status, events, wait, and cancellation must use the same workspace ID and canonical root used at submission.

## Submit and wait

`run --json` writes exactly one versioned JSON object to stdout after the request is durable and supervisor wakeup has been requested.

```sh
accepted="$({
devspace workflows run reviewer \
--prompt "Review the current change" \
--idempotency-key "review-$GITHUB_SHA" \
--json
})"
workflow_id="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).workflow.id)' "$accepted")"

# The submitting process may exit here. Another process can wait later.
result="$(devspace workflows wait "$workflow_id" --timeout-ms 300000 --after 0 --json)"
status="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).workflow.status)' "$result")"
```

`wait` is bounded, repeatable, non-destructive, and exits zero when the timeout expires or when the workflow reaches `failed` or `cancelled`. Inspect `timedOut` and `workflow.status` in the JSON response.

The maximum wait timeout is 300000 milliseconds. Use the returned `cursor` with `--after` to replay only newer events:

```sh
devspace workflows events "$workflow_id" --after "$cursor" --json
devspace workflows wait "$workflow_id" --after "$cursor" --timeout-ms 300000 --json
```

## Other commands

```sh
devspace workflows status "$workflow_id" --json
devspace workflows cancel "$workflow_id" --json
```

Cancellation is idempotent. Pending work is cancelled without dispatch; active providers receive cooperative cancellation and process cleanup through their existing provider handles.

## Submission options

```text
--prompt <task> required; no positional prompt is accepted
--idempotency-key <key> optional durable deduplication key
--model <model> optional provider model override
--thinking <setting> optional provider thinking override
--access read_only|workspace_write
--timeout-ms <milliseconds> optional node execution timeout
```

The persisted snapshot includes the resolved profile body and hash, profile name, provider, model, thinking setting, effective access/environment policy, canonical workspace root, prompt, and timeout. Later profile-file changes do not affect an accepted workflow.

JSON errors use the same versioned envelope and a nonzero exit code. Diagnostics go to stderr; prompts and provider output are never written to diagnostic logs.
98 changes: 98 additions & 0 deletions docs/workflow-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Durable workflow MCP tools

DevSpace exposes durable local-agent workflows when subagents are enabled (`DEVSPACE_SUBAGENTS=1`). Workflow execution is owned by the DevSpace process and its detached supervisor, not by an individual MCP transport. Closing or reconnecting an MCP session does not cancel submitted work.

## Tools

All tools require `workspaceId`. A workflow ID alone is never authorization: the workflow must belong to the same workspace ID and current canonical workspace root.

### `workflow_run`

Submit either one local agent or a versioned DAG.

Single-agent example:

```json
{
"workspaceId": "ws_...",
"target": "reviewer",
"prompt": "Review the authentication changes",
"access": "read_only",
"timeoutMs": 120000,
"retry": {
"maxAttempts": 2,
"retryOn": ["provider_failed"],
"backoffMs": 1000
},
"idempotencyKey": "auth-review-v1"
}
```

DAG example:

```json
{
"workspaceId": "ws_...",
"dag": {
"version": 1,
"access": "read_only",
"maxConcurrency": 3,
"nodes": [
{ "key": "tests", "target": "qa", "prompt": "Run focused tests" },
{ "key": "security", "target": "reviewer", "prompt": "Audit security" },
{ "key": "summary", "target": "claude", "prompt": "Synthesize the findings" }
],
"edges": [
{ "from": "tests", "to": "summary" },
{ "from": "security", "to": "summary" }
]
}
}
```

The DAG is rejected before persistence when it exceeds 64 nodes or 256 edges, uses duplicate or unsafe keys, references missing endpoints, contains duplicate edges or a cycle, requests an unsupported target or access mode, or exceeds timeout/retry/concurrency bounds.

### `workflow_status`

Returns the current redacted run and node states.

### `workflow_wait`

Waits for at most 300 seconds and returns the latest state. A timeout is not cancellation and does not destroy the result; callers may wait again.

### `workflow_events`

Reads ordered lifecycle events using an `after` cursor and a limit of at most 1,000. MCP event projections omit prompts, roots, profile bodies, environment data, claim tokens, process IDs, leases, and provider session IDs.

### `workflow_cancel`

Durably requests cancellation. Repeated requests are idempotent. Ready and pending nodes are terminalized, while active provider handles receive cancellation through the supervisor.

## Scheduling semantics

- Ready nodes are selected fairly across runs using persisted last-dispatch order.
- Both a process-wide supervisor limit and each run's persisted `maxConcurrency` are enforced.
- A node becomes ready only after all predecessors succeed.
- Exhausted failure skips unopened dependents and cancels active siblings.
- A run succeeds only after every node succeeds or is validly skipped; failures and user cancellation converge deterministically.
- Claims and heartbeats are lease-fenced. Stale attempts cannot append events or complete a replacement attempt.

## Retry semantics

Retries are opt-in and default to one attempt. Only `provider_failed` and `timed_out` may be configured as retryable. Each retry creates a new durable attempt and emits `node.retry_scheduled`.

Automatic retries are limited to `read_only` nodes. DevSpace does not retry worker-loss/unknown-outcome failures or `workspace_write` side effects automatically.

## Worktree isolation

Every `workspace_write` attempt receives a unique detached Git worktree at the base SHA pinned when the workflow was submitted.

- Changes are never merged, copied, committed, rebased, cherry-picked, pushed, or applied to the source checkout automatically.
- A successful attempt is durably completed before guarded cleanup.
- Failed or cancelled attempt worktrees are preserved for diagnosis with a seven-day retention timestamp.
- Cleanup verifies managed-root containment, persisted workflow/node/attempt ownership, Git worktree registration, and the expected base SHA. A failed verification preserves the directory and records `cleanup_failed`; there is no unverified recursive-delete fallback.
- Dependent nodes do not automatically inherit predecessor filesystem changes. Handoffs must use durable results/artifacts rather than an implicit shared working tree.

## CLI compatibility

Existing `devspace workflows run|status|wait|events|cancel` commands use the same submission snapshots and durable store. MCP and CLI can observe the same workflow when they use the same workspace ID and root. The CLI remains the shell-compatible fallback for parent harnesses that cannot call MCP tools directly.
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.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"dev": "node scripts/dev-server.mjs",
"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 && tsx src/cli.test.ts",
"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/runtime.test.ts && tsx src/workflows/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand All @@ -52,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
19 changes: 16 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { stdin as input, stdout as output } from "node:process";
import { stderr as errorOutput, stdin as input, stdout as output } from "node:process";
import { spawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
Expand Down Expand Up @@ -39,8 +39,9 @@ import {
} from "./user-config.js";
import { expandHomePath } from "./roots.js";
import { shutdownHttpServer } from "./server-shutdown.js";
import { runWorkflowsCli } from "./workflows/cli.js";

type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version";
type Command = "serve" | "init" | "doctor" | "config" | "agents" | "workflows" | "help" | "version";
const require = createRequire(import.meta.url);
const SUPPORTED_NODE_RANGE = ">=20.12 <27";

Expand All @@ -67,6 +68,16 @@ async function main(argv: string[]): Promise<void> {
case "agents":
await runAgentsCommand(args);
return;
case "workflows":
process.exitCode = await runWorkflowsCli({
argv: args,
cwd: process.cwd(),
env: process.env,
stdout: output,
stderr: errorOutput,
cliEntrypoint: fileURLToPath(import.meta.url),
});
return;
case "help":
printHelp();
return;
Expand All @@ -78,7 +89,7 @@ async function main(argv: string[]): Promise<void> {

function normalizeCommand(command: string | undefined): Command {
if (!command || command === "serve" || command === "start") return "serve";
if (command === "init" || command === "doctor" || command === "config" || command === "agents") return command;
if (command === "init" || command === "doctor" || command === "config" || command === "agents" || command === "workflows") return command;
if (command === "help" || command === "--help" || command === "-h") return "help";
if (command === "version" || command === "--version" || command === "-v") return "version";
throw new Error(`Unknown command: ${command}`);
Expand Down Expand Up @@ -312,6 +323,8 @@ function printHelp(): void {
" devspace agents ls List subagent sessions",
" devspace agents run <profile-or-provider-or-id> [--model <model>] <prompt>",
" devspace agents show <id>",
" devspace workflows run <profile-or-provider> --prompt <task> --json",
" devspace workflows help",
" devspace -v, --version Print the installed version",
"",
"For temporary tunnels:",
Expand Down
Loading
Loading