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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json",
"build:app": "vite build",
"dev": "node scripts/dev-server.mjs",
"dev:tui-fixture": "tsx scripts/workflow-tui-fixture.ts",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/cli-workspace.test.ts && tsx src/cli-output.test.ts && tsx src/open-workspace-capabilities.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-capabilities.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-resolution.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skill-install.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 && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-lifecycle.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-summary.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-launch.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts",
Expand Down
328 changes: 328 additions & 0 deletions scripts/workflow-tui-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { databasePath } from "../src/db/client.js";
import { WorkflowStore } from "../src/workflow-store.js";
import type { WorkflowRunRecord } from "../src/workflow-types.js";

const FIXTURE_VERSION = "large-v1";
const WORKFLOW_NAME = "Ship multi-service authentication";

const fixtureNames = [
"empty",
"starting",
"running",
"phased-running",
"replayed",
"call-failed",
"completed",
"failed",
"cancelled",
] as const;

type FixtureName = (typeof fixtureNames)[number];

interface FixtureResult {
name: FixtureName;
stateDir: string;
run?: WorkflowRunRecord;
}

const { values } = parseArgs({
options: {
state: { type: "string", default: "all" },
"state-dir": { type: "string" },
"workspace-root": { type: "string" },
},
strict: true,
});

const requestedState = values.state;
const selectedFixtures = requestedState === "all"
? [...fixtureNames]
: fixtureNames.includes(requestedState as FixtureName)
? [requestedState as FixtureName]
: fail(`Unknown fixture state: ${requestedState}. Use all or one of: ${fixtureNames.join(", ")}`);
const fixtureRoot = resolve(
values["state-dir"] ?? join(tmpdir(), "devspace-workflow-tui-fixtures"),
);
const workspaceRoot = resolve(values["workspace-root"] ?? process.cwd());

const results = selectedFixtures.map((name) => seedFixture(name, fixtureRoot, workspaceRoot));

console.log(`Workflow TUI fixtures for ${workspaceRoot}`);
console.log("");
for (const result of results) {
console.log(`${result.name}:`);
console.log(` database: ${databasePath(result.stateDir)}`);
if (result.run) console.log(` run: ${result.run.id}`);
const runArgument = result.run && !["starting", "running"].includes(result.run.status)
? ` ${result.run.id}`
: "";
console.log(
` DEVSPACE_STATE_DIR=${JSON.stringify(result.stateDir)} DEVSPACE_WORKFLOWS=1 devspace workflow tui${runArgument}`,
);
console.log("");
}

function seedFixture(
name: FixtureName,
root: string,
workspace: string,
): FixtureResult {
const stateDir = join(root, name);
const store = new WorkflowStore(stateDir);
try {
if (name === "empty") return { name, stateDir };

const scriptHash = `workflow-tui-fixture:${name}:${FIXTURE_VERSION}`;
const existing = store
.listRunsForWorkspace(workspace)
.find((run) => run.scriptHash === scriptHash);
if (existing) return { name, stateDir, run: existing };

const run = store.createRun({
name: WORKFLOW_NAME,
source: name === "replayed" ? "resume" : "inline",
scriptPath: join(stateDir, "fixtures", `${name}.js`),
scriptHash,
workspaceRoot: workspace,
resumedFromRunId: name === "replayed" ? "wfr_previous_fixture" : undefined,
});
Comment on lines +86 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether resumedFromRunId and replayedFromRunId are dereferenced anywhere.
set -euo pipefail
rg -n -C6 --type=ts 'resumedFromRunId|replayedFromRunId|replayedFromCallIndex' src

Repository: Waishnav/devspace

Length of output: 27201


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
fd -a 'workflow-tui-fixture|workflow-worker|workflow-view|workflow-store|workflow-cli|workflow-output' . | sed 's#^\./##'

echo
echo "== scripts/workflow-tui-fixture.ts relevant sections =="
wc -l scripts/workflow-tui-fixture.ts
sed -n '1,130p' scripts/workflow-tui-fixture.ts
sed -n '260,310p' scripts/workflow-tui-fixture.ts

echo
echo "== workflow-worker.ts replay handling =="
wc -l src/workflow-worker.ts
sed -n '55,90p' src/workflow-worker.ts

echo
echo "== all references for resumedFromRunId / replayedFromRunId =="
rg -n -C3 --type=ts '(resumedFromRunId|replayedFromRunId|createWorkflowReplay|listAgentCalls|getAgentRun|getRun|workflowAgentCalls)' src scripts | sed -n '1,220p'

Repository: Waishnav/devspace

Length of output: 20088


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== createWorkflowReplay implementation =="
wc -l src/workflow-replay.ts
sed -n '1,120p' src/workflow-replay.ts

echo
echo "== listAgentCalls implementation =="
rg -n -C8 --type=ts 'listAgentCalls|getAgentCalls|agent_calls|workflow_agent_calls' src/workflow-store.ts

echo
echo "== createWorkflowReplay usages =="
rg -n -C5 --type=ts 'createWorkflowReplay' src scripts

echo
echo "== workflow-summary excerpt for resume path =="
sed -n '60,120p' src/workflow-summary.ts

Repository: Waishnav/devspace

Length of output: 16661


Seed a real replay parent for the fixture.

replayedFromRunId: "wfr_previous_fixture" is only display and provenance metadata. The fixture should seed a real prior run and reference that id so it does not represent a missing run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/workflow-tui-fixture.ts` around lines 86 - 91, Update the fixture
setup around the run configuration containing resumedFromRunId so the replayed
case first seeds a real prior run, then references that created run’s actual ID
instead of the hardcoded wfr_previous_fixture placeholder. Preserve the existing
inline behavior for non-replayed fixtures and ensure the seeded parent is
available before creating the replayed run.


if (name === "starting") return { name, stateDir, run };

store.claimRun(run.id, process.pid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find code that treats a persisted workflow run pid as a liveness or staleness signal.
set -euo pipefail
rg -n -C6 --type=ts '\bpid\b' src | rg -n -C6 'kill|process\.kill|heartbeat|stale|reap|alive|orphan' || echo 'no liveness usage found'

Repository: Waishnav/devspace

Length of output: 16128


Do not claim running TUI fixtures with the fixture CLI pid.

claimRun(run.id, process.pid) persists the short-lived fixture CLI pid for the running, phased-running, replayed, and call-failed fixtures. WorkflowStore.reapStale() treats a stale heartbeat with no live pid as run_failed, so these fixtures can change state before use. Use a fixed sentinel pid for fixture runs, or confirm fixtures live only under a known process-controlled reaper/staleness path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/workflow-tui-fixture.ts` at line 95, Update the fixture run setup
around store.claimRun so TUI fixtures do not persist the short-lived fixture CLI
process.pid. Use a fixed sentinel PID for these fixture runs, or ensure they are
exclusively managed by a known process-controlled reaper path, preserving the
intended running state for running, phased-running, replayed, and call-failed
fixtures.

Source: Coding guidelines

store.appendEvent({
runId: run.id,
type: "run_started",
data: { name: run.name, scriptHash, concurrency: 2 },
});

if (name === "running") {
startPhase(store, run.id, "Discovery");
addCompletedCall(store, run.id, 0, "Discovery", "Map authentication services", "codex");
addCompletedCall(store, run.id, 1, "Discovery", "Audit token storage", "claude");
startCall(store, run.id, 2, "Trace client login flows", "codex", "Discovery");
startCall(store, run.id, 3, "Inventory migration risks", "claude", "Discovery");
} else if (name === "phased-running") {
addCompletedPhase(store, run.id, "Discovery", 0, [
["Map authentication services", "codex"],
["Audit token storage", "claude"],
["Trace client login flows", "codex"],
]);
addCompletedPhase(store, run.id, "Architecture", 3, [
["Design session boundaries", "claude"],
["Plan database migration", "codex"],
]);
startPhase(store, run.id, "Backend implementation");
startCall(store, run.id, 5, "Implement OAuth store", "codex", "Backend implementation", true);
startCall(store, run.id, 6, "Add session rotation", "claude", "Backend implementation");
startCall(store, run.id, 7, "Migrate authentication API", "codex", "Backend implementation", true);
store.appendEvent({
runId: run.id,
type: "log",
phase: "Backend implementation",
data: { message: "Running service-level authentication tests" },
});
} else if (name === "replayed") {
startPhase(store, run.id, "Discovery");
addCachedCall(store, run.id, 0, "Discovery", "Map authentication services", "codex");
addCachedCall(store, run.id, 1, "Discovery", "Audit token storage", "claude");
addCachedCall(store, run.id, 2, "Discovery", "Trace client login flows", "codex");
startPhase(store, run.id, "Architecture");
addCachedCall(store, run.id, 3, "Architecture", "Design session boundaries", "claude");
addCachedCall(store, run.id, 4, "Architecture", "Plan database migration", "codex");
startPhase(store, run.id, "Backend implementation");
startCall(store, run.id, 5, "Implement OAuth store", "codex", "Backend implementation", true);
startCall(store, run.id, 6, "Add session rotation", "claude", "Backend implementation");
} else if (name === "call-failed") {
addCompletedPhase(store, run.id, "Discovery", 0, [
["Map authentication services", "codex"],
["Audit token storage", "claude"],
["Trace client login flows", "codex"],
]);
addCompletedPhase(store, run.id, "Architecture", 3, [
["Design session boundaries", "claude"],
["Plan database migration", "codex"],
]);
startPhase(store, run.id, "Backend implementation");
startCall(store, run.id, 5, "Migrate authentication API", "codex", "Backend implementation", true);
store.failAgentCall({
runId: run.id,
callIndex: 5,
error: "Provider process exited while updating the API",
errorKind: "provider",
});
startCall(store, run.id, 6, "Implement OAuth store", "claude", "Backend implementation");
startCall(store, run.id, 7, "Inspect client impact", "codex", "Backend implementation");
} else if (name === "completed") {
seedCompletedWorkflow(store, run.id);
store.completeRun(run.id, { resultJson: JSON.stringify({ ok: true }), callCount: 12 });
} else if (name === "failed") {
addCompletedPhase(store, run.id, "Discovery", 0, [
["Map authentication services", "codex"],
["Audit token storage", "claude"],
]);
addCompletedPhase(store, run.id, "Architecture", 2, [
["Design session boundaries", "claude"],
["Plan database migration", "codex"],
]);
addCompletedPhase(store, run.id, "Backend implementation", 4, [
["Implement OAuth store", "codex"],
["Add session rotation", "claude"],
["Migrate authentication API", "codex"],
]);
addCompletedPhase(store, run.id, "Frontend integration", 7, [
["Update login experience", "claude"],
["Handle session expiry", "codex"],
]);
startPhase(store, run.id, "Verification");
startCall(store, run.id, 9, "Run cross-service integration tests", "claude", "Verification");
store.failAgentCall({
runId: run.id,
callIndex: 9,
error: "Cross-service integration tests failed",
errorKind: "internal",
});
store.failRun(run.id, {
error: "Workflow stopped because cross-service integration tests failed",
errorKind: "internal",
});
} else if (name === "cancelled") {
addCompletedPhase(store, run.id, "Discovery", 0, [
["Map authentication services", "codex"],
["Audit token storage", "claude"],
["Trace client login flows", "codex"],
]);
addCompletedPhase(store, run.id, "Architecture", 3, [
["Design session boundaries", "claude"],
["Plan database migration", "codex"],
]);
startPhase(store, run.id, "Backend implementation");
store.cancelRun(run.id, "Cancelled by user");
}

return { name, stateDir, run: store.getRun(run.id) ?? run };
} finally {
store.close();
}
}

function startCall(
store: WorkflowStore,
runId: string,
callIndex: number,
label: string,
provider: "codex" | "claude",
phase?: string,
worktree = false,
): void {
store.startAgentCall({
runId,
callIndex,
cacheKey: `fixture-${callIndex}`,
prompt: label,
provider,
model: provider === "codex" ? "gpt-5.4" : "sonnet",
label,
phase,
isolation: worktree ? "worktree" : "shared",
worktreePath: worktree ? `/tmp/devspace-fixture-worktree-${callIndex}` : undefined,
});
}

function startPhase(store: WorkflowStore, runId: string, phase: string): void {
store.appendEvent({
runId,
type: "phase_started",
phase,
data: { title: phase },
});
}

function addCompletedCall(
store: WorkflowStore,
runId: string,
callIndex: number,
phase: string,
label: string,
provider: "codex" | "claude",
worktree = false,
): void {
startCall(store, runId, callIndex, label, provider, phase, worktree);
store.completeAgentCall({ runId, callIndex, responseText: `${label} completed` });
}

function addCompletedPhase(
store: WorkflowStore,
runId: string,
phase: string,
firstCallIndex: number,
calls: ReadonlyArray<readonly [string, "codex" | "claude"]>,
): void {
startPhase(store, runId, phase);
calls.forEach(([label, provider], offset) => {
addCompletedCall(store, runId, firstCallIndex + offset, phase, label, provider, offset % 3 === 2);
});
}

function addCachedCall(
store: WorkflowStore,
runId: string,
callIndex: number,
phase: string,
label: string,
provider: "codex" | "claude",
): void {
store.cacheAgentCall({
runId,
callIndex,
cacheKey: `fixture-replayed-${callIndex}`,
prompt: label,
provider,
model: provider === "codex" ? "gpt-5.4" : "sonnet",
label,
phase,
replayMatch: "same_index",
replayedFromRunId: "wfr_previous_fixture",
replayedFromCallIndex: callIndex,
responseText: `${label} reused from the previous run`,
});
}

function seedCompletedWorkflow(store: WorkflowStore, runId: string): void {
addCompletedPhase(store, runId, "Discovery", 0, [
["Map authentication services", "codex"],
["Audit token storage", "claude"],
["Trace client login flows", "codex"],
]);
addCompletedPhase(store, runId, "Architecture", 3, [
["Design session boundaries", "claude"],
["Plan database migration", "codex"],
]);
addCompletedPhase(store, runId, "Backend implementation", 5, [
["Implement OAuth store", "codex"],
["Add session rotation", "claude"],
["Migrate authentication API", "codex"],
]);
addCompletedPhase(store, runId, "Frontend integration", 8, [
["Update login experience", "claude"],
["Handle session expiry", "codex"],
]);
addCompletedPhase(store, runId, "Verification", 10, [
["Run cross-service integration tests", "claude"],
["Review security boundaries", "codex"],
]);
startPhase(store, runId, "Release");
store.appendEvent({
runId,
type: "log",
phase: "Release",
data: { message: "Authentication rollout is ready" },
});
}

function fail(message: string): never {
throw new Error(message);
}
38 changes: 37 additions & 1 deletion src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ const migrations: Migration[] = [
name: "workflow-agent-profiles",
up: migrateWorkflowAgentProfiles,
},
{
version: 9,
name: "workflow-observability",
up: migrateWorkflowObservability,
},
];

export function migrateDatabase(sqlite: Database.Database): void {
Expand Down Expand Up @@ -324,9 +329,40 @@ function migrateWorkflowAgentProfiles(sqlite: Database.Database): void {
addColumnIfMissing(sqlite, "workflow_agent_calls", "profile_fingerprint", "text");
}

function migrateWorkflowObservability(sqlite: Database.Database): void {
addColumnIfMissing(sqlite, "workflow_runs", "phases_json", "text not null default '[]'");
addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_input_tokens", "integer");
addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_cached_input_tokens", "integer");
addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_cache_creation_input_tokens", "integer");
addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_output_tokens", "integer");
addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_total_tokens", "integer");
addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_state", "text");
addColumnIfMissing(sqlite, "workflow_agent_calls", "usage_updated_at", "text");

sqlite.exec(`
create table if not exists workflow_agent_activity (
run_id text not null,
call_index integer not null,
seq integer not null,
kind text not null,
status text not null,
label text not null,
detail text,
started_at text,
completed_at text,
created_at text not null,
primary key (run_id, call_index, seq),
foreign key (run_id) references workflow_runs(id) on delete cascade
);

create index if not exists workflow_agent_activity_call_seq_idx
on workflow_agent_activity(run_id, call_index, seq);
`);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls",
table: "workspace_sessions" | "local_agent_sessions" | "workflow_runs" | "workflow_agent_calls",
column: string,
definition: string,
): void {
Expand Down
Loading
Loading