From 332aa8e7a6ef44a852f4f8154dbf54e027edf645 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:30:41 +0000 Subject: [PATCH 1/3] feat(workflow): query runs by workspace --- src/workflow-store.test.ts | 26 ++++++++++++++++++++ src/workflow-store.ts | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts index 142e6a2c..3865a4f2 100644 --- a/src/workflow-store.test.ts +++ b/src/workflow-store.test.ts @@ -135,6 +135,32 @@ try { assert.equal(store.getRun(run2.id)?.status, "completed"); assert.equal(store.getRun(run2.id)?.resultJson, JSON.stringify({ ok: 1 })); + const otherProjectRun = store.createRun({ + name: "other-project", + source: "inline", + scriptPath: join(root, "other.js"), + scriptHash: "other", + workspaceRoot: join(root, "other-project"), + }); + assert.deepEqual( + store + .listRunsForWorkspace(join(root, "project")) + .map((entry) => entry.id) + .sort(), + [run.id, run2.id].sort(), + ); + assert.deepEqual( + store + .listRunsForWorkspace(join(root, "project"), { statuses: ["completed"] }) + .map((entry) => entry.id), + [run2.id], + ); + assert.equal( + store.listRunsForWorkspace(join(root, "other-project"))[0]?.id, + otherProjectRun.id, + ); + assert.deepEqual(store.listEvents(run.id, 2).map((event) => event.seq), [2, 3]); + // Reap: stale heartbeat + dead pid (force heartbeat via shared sqlite handle) const run3 = store.createRun({ name: "stale", diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 0cfdaae3..7568dce5 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -248,6 +248,40 @@ export class WorkflowStore { return rows.map(rowToRun); } + listRunsForWorkspace( + workspaceRoot: string, + options: { + statuses?: WorkflowRunStatus[]; + limit?: number; + } = {}, + ): WorkflowRunRecord[] { + const root = resolve(workspaceRoot); + const limit = Math.max(1, Math.min(options.limit ?? 50, 500)); + const statuses = options.statuses?.filter((status, index, values) => + values.indexOf(status) === index, + ); + + if (!statuses?.length) { + const rows = this.database.sqlite + .prepare( + "select * from workflow_runs where workspace_root = ? order by updated_at desc limit ?", + ) + .all(root, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + + const placeholders = statuses.map(() => "?").join(", "); + const rows = this.database.sqlite + .prepare( + `select * from workflow_runs + where workspace_root = ? and status in (${placeholders}) + order by updated_at desc + limit ?`, + ) + .all(root, ...statuses, limit) as WorkflowRunRow[]; + return rows.map(rowToRun); + } + /** * Atomically claim a starting run for the worker. * Returns undefined if the run is missing or not claimable. @@ -570,6 +604,21 @@ export class WorkflowStore { }; } + listEvents(runId: string, limit = 100): WorkflowEventRecord[] { + const capped = Math.max(1, Math.min(limit, WORKFLOW_LIMITS.eventDrainMax)); + const rows = this.database.sqlite + .prepare( + `select * from ( + select * from workflow_events + where run_id = ? + order by seq desc + limit ? + ) order by seq asc`, + ) + .all(runId, capped) as WorkflowEventRow[]; + return rows.map(rowToEvent); + } + beginAgentCall(input: BeginAgentCallInput): WorkflowAgentCallRecord { const now = isoNow(); const isolation: AgentIsolationMode = input.isolation === "worktree" ? "worktree" : "shared"; From a499c2e8b0a35ce48fba813f170917cd99bd225d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:32:37 +0000 Subject: [PATCH 2/3] feat(workflow): project workflow read model --- package.json | 2 +- src/workflow-view.test.ts | 114 +++++++++++++++++ src/workflow-view.ts | 259 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/workflow-view.test.ts create mode 100644 src/workflow-view.ts diff --git a/package.json b/package.json index f30a7c98..0b4ed820 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "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 && 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-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.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 && 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-view.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-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-view.test.ts b/src/workflow-view.test.ts new file mode 100644 index 00000000..a96ddd8b --- /dev/null +++ b/src/workflow-view.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { buildWorkflowRunView } from "./workflow-view.js"; +import type { + WorkflowAgentCallRecord, + WorkflowEventRecord, + WorkflowRunRecord, +} from "./workflow-types.js"; + +const run: WorkflowRunRecord = { + id: "wfr_view", + name: "Review auth", + source: "named", + scriptPath: "/tmp/review-auth.js", + scriptHash: "abc", + workspaceRoot: "/tmp/project", + argsJson: "null", + status: "running", + cancelRequested: false, + createdAt: "2026-07-26T10:00:00.000Z", + startedAt: "2026-07-26T10:00:01.000Z", + updatedAt: "2026-07-26T10:00:05.000Z", +}; + +const calls: WorkflowAgentCallRecord[] = [ + { + runId: run.id, + callIndex: 0, + cacheKey: "a", + prompt: "Inspect auth", + provider: "codex", + label: "Inspect auth", + phase: "Planning", + status: "completed", + fromCache: false, + isolation: "shared", + createdAt: "2026-07-26T10:00:02.000Z", + startedAt: "2026-07-26T10:00:02.000Z", + completedAt: "2026-07-26T10:00:03.000Z", + updatedAt: "2026-07-26T10:00:03.000Z", + }, + { + runId: run.id, + callIndex: 1, + cacheKey: "b", + prompt: "Patch auth", + provider: "claude", + label: "Patch auth", + phase: "Implementation", + status: "running", + fromCache: false, + isolation: "worktree", + worktreePath: "/tmp/worktree", + createdAt: "2026-07-26T10:00:04.000Z", + startedAt: "2026-07-26T10:00:04.000Z", + updatedAt: "2026-07-26T10:00:04.000Z", + }, + { + runId: run.id, + callIndex: 2, + cacheKey: "c", + prompt: "Cached review", + provider: "claude", + status: "from_cache", + fromCache: true, + replayMatch: "same_index", + replayedFromRunId: "wfr_old", + replayedFromCallIndex: 2, + isolation: "shared", + createdAt: "2026-07-26T10:00:04.000Z", + completedAt: "2026-07-26T10:00:04.000Z", + updatedAt: "2026-07-26T10:00:04.000Z", + }, +]; + +const events: WorkflowEventRecord[] = [ + { + runId: run.id, + seq: 1, + type: "phase_started", + phase: "Planning", + dataJson: JSON.stringify({ title: "Planning" }), + createdAt: "2026-07-26T10:00:01.000Z", + }, + { + runId: run.id, + seq: 2, + type: "phase_started", + phase: "Implementation", + dataJson: JSON.stringify({ title: "Implementation" }), + createdAt: "2026-07-26T10:00:04.000Z", + }, + { + runId: run.id, + seq: 3, + type: "log", + phase: "Implementation", + dataJson: JSON.stringify({ message: "Running tests" }), + createdAt: "2026-07-26T10:00:05.000Z", + }, +]; + +const view = buildWorkflowRunView(run, calls, events); +assert.equal(view.currentPhase, "Implementation"); +assert.equal(view.calls.completed, 1); +assert.equal(view.calls.running, 1); +assert.equal(view.calls.cached, 1); +assert.equal(view.calls.observed, 3); +assert.deepEqual(view.phases.map((phase) => phase.title), ["Planning", "Implementation"]); +assert.equal(view.phases[1]?.calls[0]?.worktreePath, "/tmp/worktree"); +assert.equal(view.unphasedCalls[0]?.replayedFromRunId, "wfr_old"); +assert.equal(view.recentActivity.at(-1)?.detail, "Running tests"); +assert.equal(view.latestEventSeq, 3); + +console.log("workflow-view.test.ts: ok"); diff --git a/src/workflow-view.ts b/src/workflow-view.ts new file mode 100644 index 00000000..218bc246 --- /dev/null +++ b/src/workflow-view.ts @@ -0,0 +1,259 @@ +import { resolve } from "node:path"; +import { parseWorkflowEventPayload } from "./workflow-contracts.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import type { + WorkflowAgentCallRecord, + WorkflowAgentCallStatus, + WorkflowErrorKind, + WorkflowEventRecord, + WorkflowEventType, + WorkflowRunRecord, + WorkflowRunSource, + WorkflowRunStatus, +} from "./workflow-types.js"; + +export const ACTIVE_WORKFLOW_STATUSES = ["starting", "running"] as const satisfies readonly WorkflowRunStatus[]; + +export interface WorkflowCallCounts { + running: number; + completed: number; + cached: number; + failed: number; + cancelled: number; + observed: number; +} + +export interface WorkflowCallView { + callIndex: number; + status: WorkflowAgentCallStatus; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + isolation: "shared" | "worktree"; + worktreePath?: string; + dirty?: boolean; + fromCache: boolean; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + error?: string; + errorKind?: WorkflowErrorKind; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export interface WorkflowPhaseView { + title: string; + calls: WorkflowCallView[]; +} + +export interface WorkflowActivityView { + seq: number; + type: WorkflowEventType; + phase?: string; + label?: string; + detail?: string; + createdAt: string; +} + +export interface WorkflowRunView { + id: string; + name: string; + status: WorkflowRunStatus; + source: WorkflowRunSource; + scriptPath: string; + scriptHash: string; + workspaceRoot: string; + resumedFromRunId?: string; + currentPhase?: string; + calls: WorkflowCallCounts; + phases: WorkflowPhaseView[]; + unphasedCalls: WorkflowCallView[]; + recentActivity: WorkflowActivityView[]; + latestEventSeq: number; + version: string; + error?: string; + errorKind?: WorkflowErrorKind; + createdAt: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export interface WorkflowProjectView { + workspaceRoot: string; + runs: WorkflowRunView[]; + version: string; +} + +export function loadWorkflowProjectView( + store: WorkflowStore, + workspaceRoot: string, + options: { + statuses?: WorkflowRunStatus[]; + limit?: number; + eventLimit?: number; + } = {}, +): WorkflowProjectView { + const root = resolve(workspaceRoot); + const runs = store + .listRunsForWorkspace(root, { + statuses: options.statuses, + limit: options.limit, + }) + .map((run) => + buildWorkflowRunView( + run, + store.listAgentCalls(run.id), + store.listEvents(run.id, options.eventLimit ?? 100), + ), + ); + + return { + workspaceRoot: root, + runs, + version: runs.map((run) => `${run.id}:${run.version}`).join("|"), + }; +} + +export function buildWorkflowRunView( + run: WorkflowRunRecord, + calls: WorkflowAgentCallRecord[], + events: WorkflowEventRecord[], +): WorkflowRunView { + const callViews = calls.map(toCallView); + const phaseOrder: string[] = []; + let currentPhase: string | undefined; + + for (const event of events) { + if (event.type !== "phase_started") continue; + const title = event.phase ?? parsePhaseTitle(event); + if (!title) continue; + currentPhase = title; + if (!phaseOrder.includes(title)) phaseOrder.push(title); + } + for (const call of callViews) { + if (call.phase && !phaseOrder.includes(call.phase)) phaseOrder.push(call.phase); + } + + const phases = phaseOrder.map((title) => ({ + title, + calls: callViews.filter((call) => call.phase === title), + })); + const latestEventSeq = events.at(-1)?.seq ?? 0; + const latestCallUpdate = calls.reduce( + (latest, call) => call.updatedAt > latest ? call.updatedAt : latest, + run.updatedAt, + ); + + return { + id: run.id, + name: run.name, + status: run.status, + source: run.source, + scriptPath: run.scriptPath, + scriptHash: run.scriptHash, + workspaceRoot: run.workspaceRoot, + resumedFromRunId: run.resumedFromRunId, + currentPhase, + calls: countCalls(callViews), + phases, + unphasedCalls: callViews.filter((call) => !call.phase), + recentActivity: events.map(toActivityView), + latestEventSeq, + version: `${run.updatedAt}:${latestCallUpdate}:${latestEventSeq}`, + error: run.error, + errorKind: run.errorKind, + createdAt: run.createdAt, + startedAt: run.startedAt, + completedAt: run.completedAt, + updatedAt: run.updatedAt, + }; +} + +function toCallView(call: WorkflowAgentCallRecord): WorkflowCallView { + return { + callIndex: call.callIndex, + status: call.status, + provider: call.provider, + model: call.model, + effort: call.effort, + label: call.label, + phase: call.phase, + isolation: call.isolation, + worktreePath: call.worktreePath, + dirty: call.dirty, + fromCache: call.fromCache, + replayMatch: call.replayMatch, + replayedFromRunId: call.replayedFromRunId, + replayedFromCallIndex: call.replayedFromCallIndex, + replayReason: call.replayReason, + error: call.error, + errorKind: call.errorKind, + startedAt: call.startedAt, + completedAt: call.completedAt, + updatedAt: call.updatedAt, + }; +} + +function countCalls(calls: WorkflowCallView[]): WorkflowCallCounts { + const counts: WorkflowCallCounts = { + running: 0, + completed: 0, + cached: 0, + failed: 0, + cancelled: 0, + observed: calls.length, + }; + for (const call of calls) { + if (call.status === "running") counts.running += 1; + else if (call.status === "completed") counts.completed += 1; + else if (call.status === "from_cache") counts.cached += 1; + else if (call.status === "failed") counts.failed += 1; + else if (call.status === "cancelled") counts.cancelled += 1; + } + return counts; +} + +function toActivityView(event: WorkflowEventRecord): WorkflowActivityView { + return { + seq: event.seq, + type: event.type, + phase: event.phase, + label: event.label, + detail: activityDetail(event), + createdAt: event.createdAt, + }; +} + +function activityDetail(event: WorkflowEventRecord): string | undefined { + try { + if (event.type === "log") { + return parseWorkflowEventPayload("log", JSON.parse(event.dataJson) as unknown).message; + } + if (event.type === "agent_call_failed") { + return parseWorkflowEventPayload( + "agent_call_failed", + JSON.parse(event.dataJson) as unknown, + ).error; + } + } catch { + return undefined; + } + return undefined; +} + +function parsePhaseTitle(event: WorkflowEventRecord): string | undefined { + try { + return parseWorkflowEventPayload( + "phase_started", + JSON.parse(event.dataJson) as unknown, + ).title; + } catch { + return undefined; + } +} From 5ee0022e94edaab38ebc7701860247eaeb9c390c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:36:10 +0000 Subject: [PATCH 3/3] feat(workflow): add read-only project TUI --- package.json | 2 +- skills/dynamic-workflows/SKILL.md | 2 + src/workflow-cli.ts | 6 + src/workflow-tui.test.ts | 71 +++++++ src/workflow-tui.ts | 304 ++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 src/workflow-tui.test.ts create mode 100644 src/workflow-tui.ts diff --git a/package.json b/package.json index 0b4ed820..77f4d982 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "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 && 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-view.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-replay.test.ts && tsx src/workflow-schema.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 && 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-view.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-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index 87322cb8..d0ba9562 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -25,6 +25,7 @@ devspace workflow cancel devspace workflow ls devspace workflow calls devspace workflow call +devspace workflow tui [runId] ``` Named scripts: `.devspace/workflows/.js` or `workflows/.js`. @@ -115,6 +116,7 @@ depending on a replayed mutating call. ## When to use CLI vs MCP - **CLI**: host agent can shell; prefer for long runs + `--follow`. +- **TUI**: `devspace workflow tui` opens a read-only live view for workflows associated with the current working directory. - **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. Disconnecting MCP does **not** kill the worker. ## Worked mini-examples diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index 10e3cc0d..1dbc7bdc 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -69,6 +69,11 @@ export async function runWorkflowCommand( case "call": await runWorkflowCall(rest, config); return; + case "tui": { + const { runWorkflowTui } = await import("./workflow-tui.js"); + await runWorkflowTui(rest, config); + return; + } case "__worker": await runWorkflowWorker(rest, config); return; @@ -96,6 +101,7 @@ export function printWorkflowHelp(): void { " devspace workflow ls", " devspace workflow calls ", " devspace workflow call ", + " devspace workflow tui [runId] # current working directory", ].join("\n"), ); } diff --git a/src/workflow-tui.test.ts b/src/workflow-tui.test.ts new file mode 100644 index 00000000..59696588 --- /dev/null +++ b/src/workflow-tui.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { + renderWorkflowTui, + resolveWorkflowTuiWorkspaceRoot, +} from "./workflow-tui.js"; +import type { WorkflowProjectView } from "./workflow-view.js"; + +const project: WorkflowProjectView = { + workspaceRoot: "/tmp/project", + version: "1", + runs: [ + { + id: "wfr_1", + name: "Review auth", + status: "running", + source: "named", + scriptPath: "/tmp/review.js", + scriptHash: "abc", + workspaceRoot: "/tmp/project", + currentPhase: "Implementation", + calls: { + running: 1, + completed: 1, + cached: 0, + failed: 0, + cancelled: 0, + observed: 2, + }, + phases: [ + { + title: "Implementation", + calls: [ + { + callIndex: 1, + status: "running", + provider: "codex", + label: "Patch auth", + isolation: "worktree", + fromCache: false, + updatedAt: "2026-07-26T10:00:02.000Z", + }, + ], + }, + ], + unphasedCalls: [], + recentActivity: [ + { + seq: 1, + type: "log", + detail: "Running tests", + createdAt: "2026-07-26T10:00:03.000Z", + }, + ], + latestEventSeq: 1, + version: "v1", + createdAt: "2026-07-26T10:00:00.000Z", + startedAt: "2026-07-26T10:00:00.000Z", + updatedAt: "2026-07-26T10:00:03.000Z", + }, + ], +}; + +const rendered = renderWorkflowTui(project, 0, 100, 30, { ansi: false }); +assert.match(rendered, /DevSpace workflows · \/tmp\/project/); +assert.match(rendered, /Review auth · Implementation/); +assert.match(rendered, /Patch auth codex · worktree/); +assert.match(rendered, /Running tests/); +assert.match(rendered, /refreshes automatically/); +assert.equal(resolveWorkflowTuiWorkspaceRoot("./test-project").endsWith("test-project"), true); + +console.log("workflow-tui.test.ts: ok"); diff --git a/src/workflow-tui.ts b/src/workflow-tui.ts new file mode 100644 index 00000000..5cdc0e32 --- /dev/null +++ b/src/workflow-tui.ts @@ -0,0 +1,304 @@ +import { resolve } from "node:path"; +import { emitKeypressEvents } from "node:readline"; +import type { ServerConfig } from "./config.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import { + ACTIVE_WORKFLOW_STATUSES, + loadWorkflowProjectView, + type WorkflowCallView, + type WorkflowProjectView, + type WorkflowRunView, +} from "./workflow-view.js"; + +const REFRESH_MS = 750; + +export async function runWorkflowTui( + args: string[], + config: ServerConfig, +): Promise { + const requestedRunId = args.find((arg) => !arg.startsWith("-")); + const workspaceRoot = resolveWorkflowTuiWorkspaceRoot(); + const store = createWorkflowStore(config); + + const load = (): WorkflowProjectView => + loadWorkflowProjectView(store, workspaceRoot, { + statuses: requestedRunId ? undefined : [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + eventLimit: 100, + }); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + try { + const view = load(); + const selectedIndex = findInitialSelection(view, requestedRunId); + process.stdout.write( + `${renderWorkflowTui(view, selectedIndex, 100, 40, { ansi: false })}\n`, + ); + return; + } finally { + store.close(); + } + } + + let project = load(); + let selectedIndex = findInitialSelection(project, requestedRunId); + let closed = false; + let rendering = false; + + const render = (): void => { + if (rendering || closed) return; + rendering = true; + try { + project = load(); + selectedIndex = clampSelection(project, selectedIndex, requestedRunId); + process.stdout.write( + `\u001b[H\u001b[2J${renderWorkflowTui( + project, + selectedIndex, + process.stdout.columns || 100, + process.stdout.rows || 40, + { ansi: true }, + )}`, + ); + } finally { + rendering = false; + } + }; + + await new Promise((done) => { + let timer: NodeJS.Timeout; + + const finish = (): void => { + if (closed) return; + closed = true; + clearInterval(timer); + process.stdin.off("keypress", onKeypress); + process.stdout.off("resize", render); + process.off("SIGINT", finish); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\u001b[?25h\u001b[?1049l"); + store.close(); + done(); + }; + + const onKeypress = ( + _input: string, + key: { name?: string; ctrl?: boolean }, + ): void => { + if ((key.ctrl && key.name === "c") || key.name === "q" || key.name === "escape") { + finish(); + return; + } + if (key.name === "up") { + selectedIndex = Math.max(0, selectedIndex - 1); + render(); + } else if (key.name === "down") { + selectedIndex = Math.min(Math.max(0, project.runs.length - 1), selectedIndex + 1); + render(); + } + }; + + emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on("keypress", onKeypress); + process.stdout.on("resize", render); + process.on("SIGINT", finish); + process.stdout.write("\u001b[?1049h\u001b[?25l"); + timer = setInterval(render, REFRESH_MS); + render(); + }); +} + +export function resolveWorkflowTuiWorkspaceRoot(cwd = process.cwd()): string { + return resolve(cwd); +} + +export function renderWorkflowTui( + project: WorkflowProjectView, + selectedIndex: number, + columns: number, + rows: number, + options: { ansi?: boolean } = {}, +): string { + const ansi = options.ansi !== false; + const width = Math.max(48, columns); + const selected = project.runs[selectedIndex]; + const lines: string[] = []; + + lines.push(style(truncate(`DevSpace workflows · ${project.workspaceRoot}`, width), "bold", ansi)); + lines.push(rule(width)); + + if (project.runs.length === 0) { + lines.push("No active workflows in the current directory."); + lines.push(""); + lines.push(style("q quit", "muted", ansi)); + return fitRows(lines, rows).join("\n"); + } + + const maxRunRows = Math.max(3, Math.min(8, Math.floor(rows / 4))); + lines.push(style("Active workflows", "heading", ansi)); + for (const [index, run] of project.runs.slice(0, maxRunRows).entries()) { + const marker = index === selectedIndex ? "›" : " "; + const phase = run.currentPhase ? ` · ${run.currentPhase}` : ""; + lines.push( + truncate( + `${marker} ${statusGlyph(run.status)} ${run.name}${phase} · ${callSummary(run)}`, + width, + ), + ); + } + if (project.runs.length > maxRunRows) { + lines.push(style(` +${project.runs.length - maxRunRows} more`, "muted", ansi)); + } + + lines.push(rule(width)); + if (selected) renderRunDetails(lines, selected, width, rows, ansi); + lines.push(rule(width)); + lines.push(style("↑/↓ select · q/esc quit · refreshes automatically", "muted", ansi)); + return fitRows(lines, rows).join("\n"); +} + +function renderRunDetails( + lines: string[], + run: WorkflowRunView, + width: number, + rows: number, + ansi: boolean, +): void { + lines.push(`${style(run.name, "bold", ansi)} ${statusGlyph(run.status)} ${run.status}`); + lines.push( + truncate( + `${run.currentPhase ? `Phase: ${run.currentPhase} · ` : ""}${callSummary(run)} · ${elapsedLabel(run)}`, + width, + ), + ); + + const phaseBudget = Math.max(4, Math.floor(rows / 2)); + let renderedCalls = 0; + for (const phase of run.phases) { + if (renderedCalls >= phaseBudget) break; + lines.push(style(`\n${phase.title}`, "heading", ansi)); + for (const call of phase.calls) { + if (renderedCalls >= phaseBudget) break; + lines.push(truncate(formatCall(call), width)); + renderedCalls += 1; + } + } + if (run.unphasedCalls.length > 0 && renderedCalls < phaseBudget) { + lines.push(style("\nOther calls", "heading", ansi)); + for (const call of run.unphasedCalls) { + if (renderedCalls >= phaseBudget) break; + lines.push(truncate(formatCall(call), width)); + renderedCalls += 1; + } + } + + const activity = run.recentActivity.slice(-4); + if (activity.length > 0) { + lines.push(style("\nRecent activity", "heading", ansi)); + for (const event of activity) { + const time = new Date(event.createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const label = event.label ?? event.phase ?? event.type.replaceAll("_", " "); + const detail = event.detail ? `: ${event.detail}` : ""; + lines.push(truncate(`${time} ${label}${detail}`, width)); + } + } + + if (run.error) { + lines.push(style(`\n${run.errorKind ?? "error"}: ${run.error}`, "error", ansi)); + } +} + +function findInitialSelection( + project: WorkflowProjectView, + requestedRunId: string | undefined, +): number { + if (!requestedRunId) return 0; + const index = project.runs.findIndex((run) => run.id === requestedRunId); + if (index < 0) { + throw new Error( + `Workflow ${requestedRunId} does not belong to the current directory: ${project.workspaceRoot}`, + ); + } + return index; +} + +function clampSelection( + project: WorkflowProjectView, + selectedIndex: number, + requestedRunId: string | undefined, +): number { + if (requestedRunId) return findInitialSelection(project, requestedRunId); + return Math.min(Math.max(0, selectedIndex), Math.max(0, project.runs.length - 1)); +} + +function formatCall(call: WorkflowCallView): string { + const label = call.label ?? `Agent #${call.callIndex}`; + const provider = call.model ? `${call.provider}/${call.model}` : call.provider; + const worktree = call.isolation === "worktree" ? " · worktree" : ""; + const replay = call.fromCache ? " · replayed" : ""; + const error = call.error ? ` · ${call.errorKind ?? "error"}: ${call.error}` : ""; + return ` ${statusGlyph(call.status)} ${label} ${provider}${worktree}${replay}${error}`; +} + +function callSummary(run: WorkflowRunView): string { + const parts = [ + run.calls.completed ? `${run.calls.completed} done` : undefined, + run.calls.cached ? `${run.calls.cached} replayed` : undefined, + run.calls.running ? `${run.calls.running} running` : undefined, + run.calls.failed ? `${run.calls.failed} failed` : undefined, + run.calls.cancelled ? `${run.calls.cancelled} cancelled` : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" · ") : "no agent calls yet"; +} + +function elapsedLabel(run: WorkflowRunView): string { + const start = Date.parse(run.startedAt ?? run.createdAt); + const end = run.completedAt ? Date.parse(run.completedAt) : Date.now(); + const seconds = Math.max(0, Math.floor((end - start) / 1_000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + if (minutes < 60) return `${minutes}m ${remaining}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +function statusGlyph(status: WorkflowRunView["status"] | WorkflowCallView["status"]): string { + if (status === "completed" || status === "from_cache") return "✓"; + if (status === "failed") return "✕"; + if (status === "cancelled") return "−"; + if (status === "running") return "●"; + return "◌"; +} + +function rule(width: number): string { + return "─".repeat(width); +} + +function truncate(value: string, width: number): string { + if (value.length <= width) return value; + return `${value.slice(0, Math.max(0, width - 1))}…`; +} + +function fitRows(lines: string[], rows: number): string[] { + if (rows <= 0 || lines.length <= rows) return lines; + return lines.slice(0, Math.max(1, rows)); +} + +function style( + value: string, + tone: "bold" | "heading" | "muted" | "error", + ansi: boolean, +): string { + if (!ansi) return value; + if (tone === "bold") return `\u001b[1m${value}\u001b[0m`; + if (tone === "heading") return `\u001b[1;36m${value}\u001b[0m`; + if (tone === "error") return `\u001b[31m${value}\u001b[0m`; + return `\u001b[2m${value}\u001b[0m`; +}