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
14 changes: 14 additions & 0 deletions docs/dynamic-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ devspace workflow calls <run-id> --json
devspace workflow call <run-id> <call-index> --json
devspace workflow cancel <run-id> --json
devspace workflow ls --json
devspace workflow tui [run-id]
```

Named scripts live in `.devspace/workflows/<name>.js`. A script can combine
Expand All @@ -63,6 +64,19 @@ This avoids coupling a long workflow lifetime to one tool-call timeout.
`--follow` remains available for interactive terminals with long-running
process support.

`workflow tui` opens a project-scoped, read-only Navigator. The first screen
lists workflows. Opening a run shows its declared phases beside the agent calls
in the selected phase; opening a call exposes normalized activity, prompt,
result, worktree details, and provider metadata. Use arrow keys (or `j`/`k`) to
navigate, `Tab` to switch panes or inspector sections, `Enter` to open, `Esc`
to go back, and `q` to quit.
Comment on lines +67 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Three Navigator behaviors are missing or contradicted here.

  • Line 67-68 states that the first screen lists workflows. Line 54 documents devspace workflow tui [run-id]. When the user supplies run-id, createWorkflowTuiState returns the workflow screen directly and skips the list. State the two entry points.
  • Line 68-69 describes phases shown beside the calls. renderNavigator uses that two-pane layout only when the terminal is at least 80 columns wide. Below 80 columns it shows one pane at a time and Tab switches between them. Document the narrow layout.
  • navigatorPhases appends a synthetic phase named Other that holds every call without a declared phase. The PR objectives describe this grouping, but this section does not. A user who sees Other beside the declared phases has no explanation for it.
📝 Proposed wording
-`workflow tui` opens a project-scoped, read-only Navigator. The first screen
-lists workflows. Opening a run shows its declared phases beside the agent calls
-in the selected phase; opening a call exposes normalized activity, prompt,
-result, worktree details, and provider metadata. Use arrow keys (or `j`/`k`) to
-navigate, `Tab` to switch panes or inspector sections, `Enter` to open, `Esc`
-to go back, and `q` to quit.
+`workflow tui` opens a project-scoped, read-only Navigator. Without a run id it
+starts on the workflow list. With a run id it opens that run directly, and the
+run must belong to the current project. Opening a run shows its declared phases
+beside the agent calls in the selected phase. Calls that declare no phase are
+grouped under a synthetic `Other` phase. Opening a call exposes normalized
+activity, prompt, result, worktree details, and provider metadata. Use arrow
+keys (or `j`/`k`) to navigate, `Tab` to switch panes or inspector sections,
+`Enter` to open, `Esc` to go back, and `q` to quit.
+
+Terminals narrower than 80 columns show one pane at a time. `Tab` then switches
+between the phase pane and the agent pane.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`workflow tui` opens a project-scoped, read-only Navigator. The first screen
lists workflows. Opening a run shows its declared phases beside the agent calls
in the selected phase; opening a call exposes normalized activity, prompt,
result, worktree details, and provider metadata. Use arrow keys (or `j`/`k`) to
navigate, `Tab` to switch panes or inspector sections, `Enter` to open, `Esc`
to go back, and `q` to quit.
`workflow tui` opens a project-scoped, read-only Navigator. Without a run id it
starts on the workflow list. With a run id it opens that run directly, and the
run must belong to the current project. Opening a run shows its declared phases
beside the agent calls in the selected phase. Calls that declare no phase are
grouped under a synthetic `Other` phase. Opening a call exposes normalized
activity, prompt, result, worktree details, and provider metadata. Use arrow
keys (or `j`/`k`) to navigate, `Tab` to switch panes or inspector sections,
`Enter` to open, `Esc` to go back, and `q` to quit.
Terminals narrower than 80 columns show one pane at a time. `Tab` then switches
between the phase pane and the agent pane.
🤖 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 `@docs/dynamic-workflows.md` around lines 67 - 72, Update the workflow TUI
documentation to describe both entry points: `workflow tui` opens the workflow
list, while `devspace workflow tui [run-id]` opens the specified run directly.
Clarify that phases and calls use a two-pane layout only at terminal widths of
at least 80 columns; narrower terminals show one pane at a time, with `Tab`
switching panes. Explain that the synthetic `Other` phase groups calls without a
declared phase.


Elapsed time is derived from persisted call timestamps. Token counts are
best-effort provider observations: a running call may show a partial snapshot,
while a completed call shows its final provider-reported total. Providers that
cannot report a value remain visibly unavailable instead of being estimated.
Replayed calls do not contribute tokens to the current run.

Failed and cancelled workflows are terminal. `workflow run --resume <run-id>`
creates a new run, reuses the unchanged successful prefix when safe, and
continues live from the first failed or changed call.
Expand Down
54 changes: 53 additions & 1 deletion scripts/workflow-tui-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ 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 FIXTURE_VERSION = "large-v2";
const WORKFLOW_NAME = "Ship multi-service authentication";
const WORKFLOW_PHASES = [
{ title: "Discovery", detail: "Map the existing authentication surface" },
{ title: "Architecture", detail: "Choose service and data boundaries" },
{ title: "Backend implementation", detail: "Implement services and migrations" },
{ title: "Frontend integration", detail: "Connect the client experience" },
{ title: "Verification", detail: "Exercise security and integration boundaries" },
{ title: "Release", detail: "Prepare the rollout" },
];

const fixtureNames = [
"empty",
Expand Down Expand Up @@ -87,6 +95,7 @@ function seedFixture(
scriptPath: join(stateDir, "fixtures", `${name}.js`),
scriptHash,
workspaceRoot: workspace,
phases: WORKFLOW_PHASES,
resumedFromRunId: name === "replayed" ? "wfr_previous_fixture" : undefined,
});

Expand Down Expand Up @@ -230,6 +239,24 @@ function startCall(
isolation: worktree ? "worktree" : "shared",
worktreePath: worktree ? `/tmp/devspace-fixture-worktree-${callIndex}` : undefined,
});
store.attachAgentSession(runId, callIndex, `${provider}-fixture-${callIndex}`);
store.updateAgentUsage(runId, callIndex, fixtureUsage(callIndex, "partial"));
store.appendAgentActivity({
runId,
callIndex,
kind: "status",
status: "completed",
label: "session started",
detail: `${provider} accepted the task`,
});
store.appendAgentActivity({
runId,
callIndex,
kind: worktree ? "file" : "tool",
status: "running",
label: worktree ? "editing isolated worktree" : "inspecting workspace",
detail: label,
});
}

function startPhase(store: WorkflowStore, runId: string, phase: string): void {
Expand All @@ -251,9 +278,34 @@ function addCompletedCall(
worktree = false,
): void {
startCall(store, runId, callIndex, label, provider, phase, worktree);
store.appendAgentActivity({
runId,
callIndex,
kind: worktree ? "file" : "tool",
status: "completed",
label: worktree ? "updated implementation" : "inspected workspace",
detail: label,
});
store.updateAgentUsage(runId, callIndex, fixtureUsage(callIndex, "final"));
store.completeAgentCall({ runId, callIndex, responseText: `${label} completed` });
}

function fixtureUsage(
callIndex: number,
state: "partial" | "final",
): Parameters<WorkflowStore["updateAgentUsage"]>[2] {
const multiplier = state === "final" ? 1 : 0.7;
const inputTokens = Math.floor((18_000 + callIndex * 4_300) * multiplier);
const outputTokens = Math.floor((4_500 + callIndex * 1_700) * multiplier);
return {
inputTokens,
cachedInputTokens: Math.floor(inputTokens * 0.25),
outputTokens,
totalTokens: inputTokens + outputTokens,
state,
};
}

function addCompletedPhase(
store: WorkflowStore,
runId: string,
Expand Down
109 changes: 101 additions & 8 deletions src/workflow-tui.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import assert from "node:assert/strict";
import {
createWorkflowTuiState,
reduceWorkflowTuiState,
reconcileWorkflowTuiState,
renderWorkflowTui,
resolveWorkflowTuiWorkspaceRoot,
} from "./workflow-tui.js";
Expand All @@ -26,23 +29,50 @@ const project: WorkflowProjectView = {
cancelled: 0,
observed: 2,
},
totalTokens: 2_400,
phases: [
{
title: "Planning",
status: "completed",
calls: [],
},
{
title: "Implementation",
status: "running",
calls: [
{
callIndex: 1,
status: "running",
provider: "codex",
label: "Patch auth",
phase: "Implementation",
isolation: "worktree",
fromCache: false,
prompt: "Patch the auth flow",
providerSessionId: "session_1",
usage: {
inputTokens: 1_600,
outputTokens: 800,
totalTokens: 2_400,
state: "partial",
updatedAt: "2026-07-26T10:00:02.000Z",
},
updatedAt: "2026-07-26T10:00:02.000Z",
},
],
},
],
unphasedCalls: [],
unphasedCalls: [{
callIndex: 2,
status: "completed",
provider: "claude",
label: "Summarize rollout",
isolation: "shared",
fromCache: false,
prompt: "Summarize the rollout",
responseText: "Ready",
updatedAt: "2026-07-26T10:00:03.000Z",
}],
recentActivity: [
{
seq: 1,
Expand All @@ -60,12 +90,75 @@ const project: WorkflowProjectView = {
],
};

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);
let state = createWorkflowTuiState(project);
let rendered = renderWorkflowTui(project, state, 100, 30, { ansi: false });
assert.match(rendered, /Workflows · \/tmp\/project/);
assert.match(rendered, /Review auth Implementation/);

state = reduceWorkflowTuiState(project, state, "return");
assert.equal(state.screen, "workflow");
rendered = renderWorkflowTui(project, state, 100, 30, { ansi: false });
assert.match(rendered, /Workflow › Review auth/);
assert.match(rendered, /PHASES\s+│ AGENTS · Implementation/);
assert.match(rendered, /Patch auth codex 2\.4k/);
assert.match(rendered, /Other 1\/1/);

state = reduceWorkflowTuiState(project, state, "tab");
state = reduceWorkflowTuiState(project, state, "return");
assert.equal(state.screen, "call");
rendered = renderWorkflowTui(project, state, 72, 30, {
ansi: false,
activity: [{
runId: "wfr_1",
callIndex: 1,
seq: 1,
kind: "tool",
status: "completed",
label: "bash",
detail: "npm test",
createdAt: "2026-07-26T10:00:03.000Z",
}],
});
assert.match(rendered, /Workflow › Implementation › Patch auth/);
assert.match(rendered, /tool\s+bash · npm test/);
Comment on lines +122 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the activity timestamp, and pin the locale in timeLabel.

Line 123 asserts only the kind, label, and detail of the activity row. It does not assert the leading timestamp. timeLabel in src/workflow-tui.ts Line 450 calls toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }). It passes no locale and no timeZone, so the rendered value depends on the host ICU locale and the TZ environment variable. Two developers running the same fixture see different activity rows.

Pin the format in timeLabel, then assert it here. The root cause is in src/workflow-tui.ts Line 449-451.

🤖 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 `@src/workflow-tui.test.ts` around lines 122 - 123, Update timeLabel to use an
explicit locale and timeZone so activity timestamps are deterministic across
environments, then extend the rendered activity assertion in the workflow TUI
test to verify the leading timestamp format alongside the existing activity
text.


let unphasedState = createWorkflowTuiState(project, "wfr_1");
unphasedState = reduceWorkflowTuiState(project, unphasedState, "down");
assert.equal(unphasedState.screen === "workflow" && unphasedState.phaseIndex, 2);
unphasedState = reduceWorkflowTuiState(project, unphasedState, "tab");
unphasedState = reduceWorkflowTuiState(project, unphasedState, "return");
assert.equal(unphasedState.screen, "call");
assert.match(renderWorkflowTui(project, unphasedState, 80, 20, { ansi: false }), /Other › Summarize rollout/);

const reorderedProject = { ...project, runs: [{ ...project.runs[0]!, id: "wfr_new" }, project.runs[0]!] };
const reconciled = reconcileWorkflowTuiState(project, reorderedProject, {
screen: "workflow",
runIndex: 0,
phaseIndex: 1,
callIndex: 0,
focus: "calls",
});
assert.equal(reconciled.runIndex, 1);

const unsafeProject = {
...project,
workspaceRoot: "/tmp/project\u001b]52;c;clipboard\u0007",
runs: [{ ...project.runs[0]!, name: "Review\u001b[2Jauth" }],
};
const safeRender = renderWorkflowTui(unsafeProject, createWorkflowTuiState(unsafeProject), 100, 20, { ansi: false });
assert.doesNotMatch(safeRender, /\u001b|\u0007/);
assert.match(safeRender, /\\x1b/);

const narrow = renderWorkflowTui(project, {
screen: "workflow",
runIndex: 0,
phaseIndex: 1,
callIndex: 0,
focus: "phases",
}, 60, 20, { ansi: false });
assert.match(narrow, /PHASES/);
assert.doesNotMatch(narrow, /AGENTS · Implementation/);

assert.equal(resolveWorkflowTuiWorkspaceRoot(process.cwd()), process.cwd());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

This assertion depends on the ambient environment and can fail in CI.

resolveWorkflowTuiWorkspaceRoot delegates to resolveCliWorkspaceContext(process.env, cwd). That function returns resolve(DEVSPACE_WORKSPACE_ROOT) when the variable is set, and otherwise findDevspaceProjectRoot(cwd, env, gitRoot) ?? gitRoot ?? resolve(cwd). The process.cwd() fallback is the last of four branches.

Two environments break the assertion:

  • A developer or CI job that exports DEVSPACE_WORKSPACE_ROOT gets the injected root.
  • A test runner invoked from a subdirectory of the repository gets the Git root.

Isolate the environment, or assert the delegation instead of the value.

🛠️ Proposed fix: isolate the environment for the assertion
-assert.equal(resolveWorkflowTuiWorkspaceRoot(process.cwd()), process.cwd());
+const previousRoot = process.env.DEVSPACE_WORKSPACE_ROOT;
+const isolated = mkdtempSync(join(tmpdir(), "devspace-tui-"));
+process.env.DEVSPACE_WORKSPACE_ROOT = isolated;
+try {
+  assert.equal(resolveWorkflowTuiWorkspaceRoot(isolated), resolve(isolated));
+} finally {
+  if (previousRoot === undefined) delete process.env.DEVSPACE_WORKSPACE_ROOT;
+  else process.env.DEVSPACE_WORKSPACE_ROOT = previousRoot;
+}

Add the supporting imports:

import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert.equal(resolveWorkflowTuiWorkspaceRoot(process.cwd()), process.cwd());
const previousRoot = process.env.DEVSPACE_WORKSPACE_ROOT;
const isolated = mkdtempSync(join(tmpdir(), "devspace-tui-"));
process.env.DEVSPACE_WORKSPACE_ROOT = isolated;
try {
assert.equal(resolveWorkflowTuiWorkspaceRoot(isolated), resolve(isolated));
} finally {
if (previousRoot === undefined) delete process.env.DEVSPACE_WORKSPACE_ROOT;
else process.env.DEVSPACE_WORKSPACE_ROOT = previousRoot;
}
🤖 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 `@src/workflow-tui.test.ts` at line 162, Make the assertion around
resolveWorkflowTuiWorkspaceRoot deterministic by isolating it from ambient
environment and repository context: create a temporary directory outside the
project, invoke the resolver with that directory as the working directory, and
temporarily ensure DEVSPACE_WORKSPACE_ROOT is unset while restoring the
environment afterward. Use the existing resolver symbols and required
filesystem, OS, and path utilities rather than asserting against process.cwd().


console.log("workflow-tui.test.ts: ok");
Loading
Loading