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
24 changes: 22 additions & 2 deletions skills/dynamic-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ review, migrate-and-verify, research panels — **not** a single subagent turn.

```bash
devspace workflow run --file path/to/script.js [--arg k=v]... [--follow]
devspace workflow run --script-path path/to/script.js [--resume <runId>] [--follow]
devspace workflow run --name review-auth [--follow]
devspace workflow run --resume <runId>
devspace workflow status <runId> [--follow]
devspace workflow cancel <runId>
devspace workflow ls
devspace workflow calls <runId>
devspace workflow call <runId> <callIndex>
```

Named scripts: `.devspace/workflows/<name>.js` or `workflows/<name>.js`.
Expand Down Expand Up @@ -57,7 +60,6 @@ return { summary, findings }
| `pipeline(items, ...stages)` | Per-item chains; no cross-item barrier |
| `phase(title)` / `log(msg)` | Progress; journaled |
| `args` | Run input (object preferred) |
| `budget` | Stub: `total: null`, `remaining(): Infinity` — do not loop on budget alone |
| `workflow(name\|{scriptPath}, args?)` | Nested, depth 1, shared call index |

**No `writeMode`.** Teach read-only vs write in the prompt. Use `isolation: 'worktree'` when parallel mutators would conflict (git required).
Expand Down Expand Up @@ -86,7 +88,25 @@ Default: first **enabled ∩ available** provider (`agentProviders.enabled` in c

### Resume

`devspace workflow run --resume <runId>` creates a **new** run that replays completed agent calls by cache key (callIndex+key, then consume-once by key).
Failed and cancelled runs are terminal. Recovery creates a **new** run:

1. Inspect the prior run with `workflow status`, `workflow calls`, and
`workflow call`.
2. Edit the persisted `scriptPath` reported by the run, or pass a different
`--script-path`.
3. Keep prompts and agent options stable for completed calls whose return values
should be reused.
4. Run `devspace workflow run --resume <runId>` (optionally with
`--script-path <path>`).

Replay first matches the same call index and cache key, then consumes one
compatible prior cache key after reordering. The new run records whether each
call was reused by same-index or compatible-key matching, and where it came
from. Failed, interrupted, changed, or unmatched calls execute live.

Replay restores an agent's **return value**. It does not recreate shared-checkout
edits or reapply a prior worktree diff. Verify required filesystem state before
depending on a replayed mutating call.

### Cancel

Expand Down
22 changes: 21 additions & 1 deletion src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const migrations: Migration[] = [
name: "workflow-journal",
up: migrateWorkflowJournal,
},
{
version: 6,
name: "workflow-replay-provenance",
up: migrateWorkflowReplayProvenance,
},
];

export function migrateDatabase(sqlite: Database.Database): void {
Expand Down Expand Up @@ -282,9 +287,24 @@ function migrateWorkflowJournal(sqlite: Database.Database): void {
`);
}

function migrateWorkflowReplayProvenance(sqlite: Database.Database): void {
addColumnIfMissing(sqlite, "workflow_agent_calls", "prompt", "text not null default ''");
addColumnIfMissing(sqlite, "workflow_agent_calls", "schema_json", "text");
addColumnIfMissing(sqlite, "workflow_agent_calls", "error_kind", "text");
addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_match", "text");
addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_run_id", "text");
addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_call_index", "integer");
addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_reason", "text");

sqlite.exec(`
create index if not exists workflow_agent_calls_replay_source_idx
on workflow_agent_calls(replayed_from_run_id, replayed_from_call_index);
`);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions" | "local_agent_sessions",
table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls",
column: string,
definition: string,
): void {
Expand Down
11 changes: 11 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export const workflowAgentCalls = sqliteTable(
.references(() => workflowRuns.id, { onDelete: "cascade" }),
callIndex: integer("call_index").notNull(),
cacheKey: text("cache_key").notNull(),
prompt: text("prompt").notNull().default(""),
schemaJson: text("schema_json"),
provider: text("provider").notNull(),
model: text("model"),
effort: text("effort"),
Expand All @@ -168,6 +170,11 @@ export const workflowAgentCalls = sqliteTable(
responseText: text("response_text"),
structuredJson: text("structured_json"),
error: text("error"),
errorKind: text("error_kind"),
replayMatch: text("replay_match"),
replayedFromRunId: text("replayed_from_run_id"),
replayedFromCallIndex: integer("replayed_from_call_index"),
replayReason: text("replay_reason"),
isolation: text("isolation").notNull().default("shared"),
worktreePath: text("worktree_path"),
dirty: text("dirty"),
Expand All @@ -179,6 +186,10 @@ export const workflowAgentCalls = sqliteTable(
(table) => [
primaryKey({ columns: [table.runId, table.callIndex] }),
index("workflow_agent_calls_cache_key_idx").on(table.runId, table.cacheKey),
index("workflow_agent_calls_replay_source_idx").on(
table.replayedFromRunId,
table.replayedFromCallIndex,
),
],
);

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 @@ -46,6 +46,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
{ version: 3, name: "local-agent-sessions" },
{ version: 4, name: "local-agent-effort-rename" },
{ version: 5, name: "workflow-journal" },
{ version: 6, name: "workflow-replay-provenance" },
]);
} finally {
database.close();
Expand Down
63 changes: 57 additions & 6 deletions src/workflow-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildAgentCacheKeyInput,
createStubBudget,
type AgentIsolationMode,
type AgentCacheKeyInput,
type AgentOpts,
type AppendWorkflowEventInput,
type WorkflowMeta,
Expand Down Expand Up @@ -64,10 +65,30 @@ export interface WorkflowReplayHit {
responseText?: string;
structuredJson?: string;
providerSessionId?: string;
replayMatch: "same_index" | "compatible_key";
replayedFromRunId: string;
replayedFromCallIndex: number;
}

export interface WorkflowReplayMiss {
reason:
| "no_compatible_call"
| "prior_call_not_replayable"
| "compatible_result_consumed"
| "identity_changed";
changedFields?: Array<keyof AgentCacheKeyInput>;
}

export type WorkflowReplayDecision =
| { hit: WorkflowReplayHit; miss?: never }
| { hit?: never; miss: WorkflowReplayMiss };

export interface WorkflowReplay {
match(callIndex: number, cacheKey: string): WorkflowReplayHit | null;
decide(
callIndex: number,
cacheKey: string,
input: AgentCacheKeyInput,
): WorkflowReplayDecision;
}

export interface WorkflowJournal {
Expand All @@ -78,13 +99,19 @@ export interface WorkflowJournal {
runId: string;
callIndex: number;
cacheKey: string;
prompt: string;
schemaJson?: string;
provider: LocalAgentProvider;
model?: string;
effort?: string;
label?: string;
phase?: string;
isolation?: AgentIsolationMode;
worktreePath?: string;
replayMatch?: "same_index" | "compatible_key";
replayedFromRunId?: string;
replayedFromCallIndex?: number;
replayReason?: string;
}): unknown;
completeAgentCall(input: {
runId: string;
Expand All @@ -100,6 +127,7 @@ export interface WorkflowJournal {
runId: string;
callIndex: number;
error: string;
errorKind?: import("./workflow-types.js").WorkflowErrorKind;
worktreePath?: string;
dirty?: boolean;
}): unknown;
Expand Down Expand Up @@ -233,19 +261,24 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
});
const cacheKey = hashCacheKey(cacheKeyInput);

if (deps.replay) {
const hit = deps.replay.match(index, cacheKey);
if (hit) {
const replayDecision = deps.replay?.decide(index, cacheKey, cacheKeyInput);
if (replayDecision?.hit) {
const hit = replayDecision.hit;
deps.journal.beginAgentCall({
runId: deps.runId,
callIndex: index,
cacheKey,
prompt,
schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined,
provider,
model: agentOpts.model,
effort: agentOpts.effort,
label: agentOpts.label,
phase,
isolation,
replayMatch: hit.replayMatch,
replayedFromRunId: hit.replayedFromRunId,
replayedFromCallIndex: hit.replayedFromCallIndex,
});
deps.journal.completeAgentCall({
runId: deps.runId,
Expand All @@ -260,10 +293,16 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
type: "agent_call_cached",
phase,
label: agentOpts.label,
data: { callIndex: index, cacheKey, provider },
data: {
callIndex: index,
cacheKey,
provider,
replayMatch: hit.replayMatch,
replayedFromRunId: hit.replayedFromRunId,
replayedFromCallIndex: hit.replayedFromCallIndex,
},
});
return hit.value;
}
}

await semaphore.acquire(deps.signal);
Expand Down Expand Up @@ -300,13 +339,18 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
runId: deps.runId,
callIndex: index,
cacheKey,
prompt,
schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined,
provider,
model: agentOpts.model,
effort: agentOpts.effort,
label: agentOpts.label,
phase,
isolation,
worktreePath,
replayReason: replayDecision?.miss
? formatReplayMiss(replayDecision.miss)
: undefined,
});
agentCallBegun = true;
deps.journal.appendEvent({
Expand Down Expand Up @@ -453,6 +497,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
runId: deps.runId,
callIndex: index,
error: message,
errorKind: error instanceof WorkflowEngineError ? error.kind : "internal",
worktreePath,
});
}
Expand Down Expand Up @@ -594,6 +639,12 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
};
}

function formatReplayMiss(miss: WorkflowReplayMiss): string {
return miss.reason === "identity_changed" && miss.changedFields?.length
? `${miss.reason}:${miss.changedFields.join(",")}`
: miss.reason;
}

/** Test helper: read current ALS phase (undefined outside phase). */
export function getCurrentWorkflowPhase(): string | undefined {
return phaseAls.getStore();
Expand Down
Loading
Loading