feat: add lda report workflow demo panel

This commit is contained in:
lda
2026-07-03 00:20:41 +07:00 Verified
parent 86ccbe35cf
commit f5a0d2d9e2
18 changed files with 1166 additions and 12 deletions
+6 -3
View File
@@ -57,9 +57,12 @@ Implementation order:
[`workflow console lifecycle explorer plan`](historical/superpowers/plans/2026-07-02-workflow-console-lifecycle-explorer.md). [`workflow console lifecycle explorer plan`](historical/superpowers/plans/2026-07-02-workflow-console-lifecycle-explorer.md).
Draft workspace inspection reuses the same shell after the first vertical Draft workspace inspection reuses the same shell after the first vertical
path. path.
5. Add lifecycle autoplay, typed approval, issue-board output, and replay. 5. Completed: the web console can operate the prepared
6. Add a constrained demo agent that invokes one prepared recipe macro. `examples/lda_report_workflow/` deployment through run start, typed
7. Add an Astro presentation app and appendix routes for the 15-minute defense. `issue_review` interrupt, resume, trace, and final output inspection.
6. Add lifecycle autoplay, typed approval, issue-board output, and replay.
7. Add a constrained demo agent that invokes one prepared recipe macro.
8. Add an Astro presentation app and appendix routes for the 15-minute defense.
Boundaries: this is not a production admin panel, generic visual workflow Boundaries: this is not a production admin panel, generic visual workflow
editor, scheduler, external Google Drive/mail integration, or benchmark evidence editor, scheduler, external Google Drive/mail integration, or benchmark evidence
+20
View File
@@ -117,3 +117,23 @@ pnpm --dir web dev
Connect to `http://127.0.0.1:8765/rpc`. The smoke passes when artifact list, Connect to `http://127.0.0.1:8765/rpc`. The smoke passes when artifact list,
deployment list, run list, graph visualization, trace frames, and raw evidence deployment list, run list, graph visualization, trace frames, and raw evidence
are all visible. are all visible.
## lda Report Workflow Demo
Start the prepared workflow RPC server from the repository root:
```powershell
uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765
```
Start the web console:
```powershell
pnpm --dir web dev
```
Open `http://127.0.0.1:5173/`, connect to
`http://127.0.0.1:8765/rpc`, then use the `lda report workflow demo`
panel. The panel expects `lda_report_case_study.default` to already exist
in the connected store. If it is missing, the panel displays the exact
product CLI setup commands.
+31 -8
View File
@@ -74,6 +74,16 @@ afterEach(() => {
cleanup(); cleanup();
}); });
const lifecycleOk: RpcResponse = {
ok: true,
operation: "workflow.artifacts.list",
label: "List artifacts",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact list",
durationMs: 5,
};
describe("App", () => { describe("App", () => {
it("shows source inventory errors from rejected source refreshes", async () => { it("shows source inventory errors from rejected source refreshes", async () => {
mockedConnectToServer.mockResolvedValue( mockedConnectToServer.mockResolvedValue(
@@ -130,14 +140,26 @@ describe("App", () => {
mockedConnectToServer.mockResolvedValue( mockedConnectToServer.mockResolvedValue(
successfulConnection("http://127.0.0.1:8765/rpc"), successfulConnection("http://127.0.0.1:8765/rpc"),
); );
mockedCallOperation.mockResolvedValue({ mockedCallOperation.mockImplementation((op: string) => {
ok: true, if (op === "workflow.sources.list") {
operation: "workflow.sources.list", return Promise.resolve({
label: "List sources", ok: true,
interpreted: { sources: [], total: 0, nextCursor: null }, operation: "workflow.sources.list",
exchange: { request: {}, response: {} }, label: "List sources",
equivalentCli: "uv run wf source list", interpreted: { sources: [], total: 0, nextCursor: null },
durationMs: 5, exchange: { request: {}, response: {} },
equivalentCli: "uv run wf source list",
durationMs: 5,
});
}
if (op === "workflow.deployments.inspect") {
return Promise.resolve({
ok: false,
error: { code: "rpc_remote_error", message: "not found" },
exchange: { request: {}, response: {} },
});
}
return Promise.resolve(lifecycleOk);
}); });
render(<App />); render(<App />);
@@ -146,5 +168,6 @@ describe("App", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("lifecycle-explorer")).toBeInTheDocument(); expect(screen.getByTestId("lifecycle-explorer")).toBeInTheDocument();
}); });
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
}); });
}); });
+4
View File
@@ -10,6 +10,8 @@ import { ConnectionHeader } from "../components/ConnectionHeader.js";
import { SourceInventory } from "../components/SourceInventory.js"; import { SourceInventory } from "../components/SourceInventory.js";
import { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js"; import { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js"; import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js";
import { LdaReportDemoPanel } from "../demo/LdaReportDemoPanel.js";
import { useLdaReportDemo } from "../demo/useLdaReportDemo.js";
const parseSources = ( const parseSources = (
data: unknown, data: unknown,
@@ -54,6 +56,7 @@ export const App = () => {
); );
const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence); const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence);
const demoController = useLdaReportDemo(connectedTarget, recordEvidence);
const loadSources = useCallback( const loadSources = useCallback(
async (target: string) => { async (target: string) => {
@@ -160,6 +163,7 @@ export const App = () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onDraftChange={(value) => dispatch({ type: "draft_changed", value })} onDraftChange={(value) => dispatch({ type: "draft_changed", value })}
/> />
{connectedTarget && <LdaReportDemoPanel controller={demoController} />}
<SourceInventory <SourceInventory
sources={state.sources} sources={state.sources}
loading={state.sourcesLoading} loading={state.sourcesLoading}
@@ -49,6 +49,8 @@ const OperationNameSchema = v.union([
v.literal("workflow.deployments.validate"), v.literal("workflow.deployments.validate"),
v.literal("workflow.runs.list"), v.literal("workflow.runs.list"),
v.literal("workflow.runs.inspect"), v.literal("workflow.runs.inspect"),
v.literal("workflow.runs.start"),
v.literal("workflow.runs.resume"),
v.literal("workflow.runs.trace"), v.literal("workflow.runs.trace"),
]); ]);
@@ -0,0 +1,143 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { LdaReportDemoPanel } from "./LdaReportDemoPanel.js";
const baseState = {
message: null as string | null,
runId: null as string | null,
interruptPayload: null as null,
output: null as null,
trace: null as null,
};
describe("LdaReportDemoPanel", () => {
it("shows setup commands when the prepared deployment is missing", () => {
render(
<LdaReportDemoPanel
controller={{
state: { ...baseState, phase: "missing" },
refresh: vi.fn(),
startRun: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}}
/>,
);
expect(screen.getByText(/prepared demo deployment is missing/i)).toBeInTheDocument();
expect(screen.getByText(/wf-rpc-server --config examples\/lda_report_workflow\/wf.config.json/i)).toBeInTheDocument();
});
it("starts the demo when ready", async () => {
const startRun = vi.fn();
render(
<LdaReportDemoPanel
controller={{
state: { ...baseState, phase: "ready" },
refresh: vi.fn(),
startRun,
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /start demo run/i }));
expect(startRun).toHaveBeenCalledOnce();
});
it("disables refresh during interrupt to preserve runId", () => {
const { container } = render(
<LdaReportDemoPanel
controller={{
state: {
...baseState,
phase: "interrupted",
runId: "run_demo",
interruptPayload: {
report_markdown: "# Report",
proposed_issues: [],
},
},
refresh: vi.fn(),
startRun: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}}
/>,
);
const buttons = container.querySelectorAll("button");
const refreshButton = Array.from(buttons).find(
(b) => b.textContent?.includes("Refresh demo state"),
);
expect(refreshButton).toBeDefined();
expect(refreshButton).toBeDisabled();
});
it("allows refresh after completion so the demo can run again", () => {
const view = render(
<LdaReportDemoPanel
controller={{
state: {
...baseState,
phase: "completed",
output: {
approved: true,
markdown: "# Report",
created_issues: [],
selected_issue_ids: [],
comment: null,
},
},
refresh: vi.fn(),
startRun: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}}
/>,
);
expect(
within(view.container).getByRole("button", { name: /refresh demo state/i }),
).toBeEnabled();
});
it("displays trace frames in completed view", () => {
render(
<LdaReportDemoPanel
controller={{
state: {
...baseState,
phase: "completed",
output: {
approved: true,
markdown: "# Report",
created_issues: [{ id: "ISSUE-001", title: "Demo", url: "local://issues/ISSUE-001" }],
selected_issue_ids: ["demo-issue-1"],
comment: "Create it.",
},
trace: {
frames: [
{ nodeId: "generate", stepType: "tool", outcome: "completed", resolvedInput: {}, output: {}, stateChanges: {} },
{ nodeId: "review", stepType: "interrupt", outcome: "submitted", resolvedInput: {}, output: {}, stateChanges: {} },
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
},
},
refresh: vi.fn(),
startRun: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}}
/>,
);
expect(screen.getByText("Execution trace (2 frames)")).toBeInTheDocument();
expect(screen.getByText("generate")).toBeInTheDocument();
expect(screen.getByText("review")).toBeInTheDocument();
});
});
@@ -0,0 +1,150 @@
import { useMemo, useState } from "react";
import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js";
import type { useLdaReportDemo } from "./useLdaReportDemo.js";
type Controller = ReturnType<typeof useLdaReportDemo>;
export const LdaReportDemoPanel = ({ controller }: { readonly controller: Controller }) => {
const { state } = controller;
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
const [comment, setComment] = useState("Create selected issues before the defense.");
const proposedIssues = state.interruptPayload?.proposed_issues ?? [];
const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]);
const runInProgress =
state.phase === "starting" ||
state.phase === "interrupted" ||
state.phase === "resuming";
return (
<section aria-label="lda report workflow demo" className="demo-panel">
<div className="demo-panel__header">
<div>
<h2>lda report workflow demo</h2>
<p>
Prepared workflow: start run, stop at typed issue review,
resume, then inspect trace and generated issues.
</p>
</div>
<button onClick={controller.refresh} disabled={runInProgress}>
Refresh demo state
</button>
</div>
{state.phase === "missing" && (
<div className="demo-panel__missing" role="status">
<h3>Prepared demo deployment is missing</h3>
<p>Run the example RPC server/store setup outside the UI, then refresh.</p>
<pre><code>{ldaReportSetupCommands.join("\n")}</code></pre>
</div>
)}
{(state.phase === "ready" || state.phase === "checking") && (
<button
onClick={controller.startRun}
disabled={state.phase === "checking"}
>
Start demo run
</button>
)}
{(state.phase === "starting" || state.phase === "resuming") && (
<p role="status">Demo workflow is {state.phase}.</p>
)}
{state.phase === "interrupted" && state.interruptPayload && (
<div className="demo-panel__review">
<h3>Typed interrupt: issue_review</h3>
<p>Run id: <code>{state.runId}</code></p>
<div className="demo-panel__markdown">
<h4>Generated report preview</h4>
<pre><code>{state.interruptPayload.report_markdown}</code></pre>
</div>
<fieldset>
<legend>Select issues to create</legend>
{proposedIssues.map((issue) => (
<label key={issue.id} className="demo-panel__issue">
<input
type="checkbox"
checked={selectedIds.has(issue.id)}
onChange={(event) => {
const next = new Set(selectedIds);
if (event.currentTarget.checked) {
next.add(issue.id);
} else {
next.delete(issue.id);
}
setSelectedIds(next);
}}
/>
<span>
<strong>{issue.title}</strong>
<small>{issue.id} · {issue.severity}</small>
<span>{issue.body}</span>
</span>
</label>
))}
</fieldset>
<label>
Review comment
<textarea value={comment} onChange={(event) => setComment(event.currentTarget.value)} />
</label>
<div className="demo-panel__actions">
<button
onClick={() => controller.submitSelectedIssues(selectedIssueIds, comment)}
disabled={selectedIssueIds.length === 0}
>
Resume and create selected issues
</button>
<button onClick={() => controller.cancelReview(comment)}>
Cancel review
</button>
</div>
</div>
)}
{state.phase === "completed" && state.output && (
<div className="demo-panel__complete">
<h3>Completed: {state.output.approved ? "issues created" : "revision requested"}</h3>
<p>Created issues: {state.output.created_issues.length}</p>
<ul>
{state.output.created_issues.map((issue) => (
<li key={issue.id}>
<strong>{issue.id}</strong> {issue.title}
</li>
))}
</ul>
<h4>Final markdown</h4>
<pre><code>{state.output.markdown}</code></pre>
<h4>Execution trace ({state.trace?.frames.length ?? 0} frames)</h4>
{state.trace && state.trace.frames.length > 0 ? (
<table>
<thead>
<tr>
<th>Node</th>
<th>Step</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{state.trace.frames.map((frame, i) => (
<tr key={i}>
<td><code>{frame.nodeId}</code></td>
<td>{frame.stepType}</td>
<td>{frame.outcome}</td>
</tr>
))}
</tbody>
</table>
) : (
<p>No trace frames available.</p>
)}
</div>
)}
{state.phase === "error" && state.message && (
<p role="alert">{state.message}</p>
)}
</section>
);
};
@@ -0,0 +1,20 @@
/** Constants for the lda report demo workflow panel. */
export const LDA_REPORT_DEPLOYMENT_ID = "lda_report_case_study.default";
export const LDA_REPORT_INTERRUPT_KIND = "issue_review";
export const ldaReportDemoInput = {
selected_documents: [
"project-brief.md",
"architecture-notes.md",
"evaluation-findings.md",
"risk-register.md",
"roadmap.md",
],
board_path: "issue-board.json",
} as const;
export const ldaReportSetupCommands = [
"uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765",
"uv run wf --config examples/lda_report_workflow/wf.config.json --local artifact create-from-plan examples/lda_report_workflow/workflow.plan.json --artifact lda_report_case_study --version 1 --title \"lda.chat Report Case Study\" --outcome completed --outcome cancelled --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board",
"uv run wf --config examples/lda_report_workflow/wf.config.json --local deploy save lda_report_case_study.default --artifact lda_report_case_study --version 1 --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board",
] as const;
@@ -0,0 +1,37 @@
import * as v from "valibot";
export const ProposedIssueSchema = v.object({
id: v.string(),
title: v.string(),
body: v.string(),
severity: v.optional(v.string(), "medium"),
});
export const CreatedIssueSchema = v.object({
id: v.string(),
title: v.string(),
url: v.string(),
});
export const LdaReportInterruptPayloadSchema = v.object({
report_markdown: v.string(),
proposed_issues: v.array(ProposedIssueSchema),
});
export const LdaReportOutputSchema = v.object({
approved: v.boolean(),
markdown: v.string(),
created_issues: v.array(CreatedIssueSchema),
selected_issue_ids: v.array(v.string()),
comment: v.nullish(v.string(), null),
});
export type ProposedIssue = v.InferOutput<typeof ProposedIssueSchema>;
export type LdaReportInterruptPayload = v.InferOutput<typeof LdaReportInterruptPayloadSchema>;
export type LdaReportOutput = v.InferOutput<typeof LdaReportOutputSchema>;
export const parseLdaReportInterruptPayload = (value: unknown): LdaReportInterruptPayload =>
v.parse(LdaReportInterruptPayloadSchema, value);
export const parseLdaReportOutput = (value: unknown): LdaReportOutput =>
v.parse(LdaReportOutputSchema, value);
@@ -0,0 +1,204 @@
import { renderHook, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useLdaReportDemo } from "./useLdaReportDemo.js";
import { callOperation } from "../connection/api.js";
vi.mock("../connection/api.js", () => ({
callOperation: vi.fn(),
}));
const mockedCallOperation = vi.mocked(callOperation);
beforeEach(() => {
mockedCallOperation.mockReset();
});
const inspectResult = {
ok: true as const,
operation: "workflow.deployments.inspect" as const,
label: "Inspect deployment",
interpreted: {
id: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
durationMs: 5,
};
const startResult = {
ok: true as const,
operation: "workflow.runs.start" as const,
label: "Start run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
interrupt: {
kind: "issue_review",
payload: {
report_markdown: "# lda.chat Thesis And Project Readiness Report",
proposed_issues: [
{
id: "demo-issue-1",
title: "Prepare demo script",
body: "Write the defense walkthrough.",
severity: "medium",
},
],
},
outcomes: ["submitted", "cancelled"],
},
outcome: null,
error: null,
output: null,
diagnostics: [],
traceCount: 1,
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: "wf.workflow.resume_run",
reason: "run is interrupted",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
durationMs: 10,
};
const resumeResult = {
ok: true as const,
operation: "workflow.runs.resume" as const,
label: "Resume run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "completed",
error: null,
output: {
approved: true,
markdown: "# lda.chat Thesis And Project Readiness Report",
created_issues: [
{
id: "ISSUE-001",
title: "Prepare demo script",
url: "local://issues/ISSUE-001",
},
],
selected_issue_ids: ["demo-issue-1"],
comment: "Create selected issues.",
},
diagnostics: [],
traceCount: 4,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run resume run_demo --payload '<json>'",
durationMs: 12,
};
const traceResult = {
ok: true as const,
operation: "workflow.runs.trace" as const,
label: "Read run trace",
interpreted: {
runId: "run_demo",
status: "completed",
frames: [
{
nodeId: "generate",
stepType: "tool",
outcome: "completed",
resolvedInput: {},
output: {},
stateChanges: {},
},
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run trace run_demo --from 0 --limit 50",
durationMs: 8,
};
describe("useLdaReportDemo", () => {
it("reports missing deployment without trying to create it", async () => {
mockedCallOperation.mockResolvedValue({
ok: false,
error: { code: "rpc_remote_error", message: "not found" },
exchange: { request: {}, response: {} },
});
const { result } = renderHook(() =>
useLdaReportDemo("http://127.0.0.1:8765/rpc", vi.fn()),
);
await waitFor(() => {
expect(result.current.state.phase).toBe("missing");
});
expect(mockedCallOperation).toHaveBeenCalledWith(
"workflow.deployments.inspect",
"http://127.0.0.1:8765/rpc",
{ deployment_id: "lda_report_case_study.default" },
);
expect(mockedCallOperation).not.toHaveBeenCalledWith(
"workflow.runs.start",
expect.anything(),
expect.anything(),
);
});
it("transitions through interrupted start and completed resume", async () => {
mockedCallOperation
.mockResolvedValueOnce(inspectResult)
.mockResolvedValueOnce(startResult)
.mockResolvedValueOnce(resumeResult)
.mockResolvedValueOnce(traceResult);
const { result } = renderHook(() =>
useLdaReportDemo("http://127.0.0.1:8765/rpc", vi.fn()),
);
await waitFor(() => {
expect(result.current.state.phase).toBe("ready");
});
await act(async () => {
await result.current.startRun();
});
expect(result.current.state.phase).toBe("interrupted");
expect(result.current.state.interruptPayload?.proposed_issues[0]?.id).toBe("demo-issue-1");
await act(async () => {
await result.current.submitSelectedIssues(["demo-issue-1"], "Create it.");
});
expect(result.current.state.phase).toBe("completed");
expect(result.current.state.output?.created_issues[0]?.id).toBe("ISSUE-001");
expect(result.current.state.trace?.frames.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import { callOperation } from "../connection/api.js";
import type { EvidenceRecord } from "../app/state.js";
import {
LDA_REPORT_DEPLOYMENT_ID,
LDA_REPORT_INTERRUPT_KIND,
ldaReportDemoInput,
} from "./ldaReportDemoConfig.js";
import {
parseLdaReportInterruptPayload,
parseLdaReportOutput,
type LdaReportInterruptPayload,
type LdaReportOutput,
} from "./ldaReportDemoModels.js";
import { decodeRunDetail, decodeTracePage, type TracePage } from "../lifecycle/models.js";
type DemoPhase =
| "idle"
| "checking"
| "missing"
| "ready"
| "starting"
| "interrupted"
| "resuming"
| "completed"
| "error";
type DemoState = {
readonly phase: DemoPhase;
readonly message: string | null;
readonly runId: string | null;
readonly interruptPayload: LdaReportInterruptPayload | null;
readonly output: LdaReportOutput | null;
readonly trace: TracePage | null;
};
const initialState: DemoState = {
phase: "idle",
message: null,
runId: null,
interruptPayload: null,
output: null,
trace: null,
};
type DemoAction =
| { readonly type: "checking" }
| { readonly type: "missing"; readonly message: string }
| { readonly type: "ready" }
| { readonly type: "starting" }
| { readonly type: "interrupted"; readonly runId: string; readonly payload: LdaReportInterruptPayload }
| { readonly type: "resuming" }
| { readonly type: "completed"; readonly output: LdaReportOutput; readonly trace: TracePage | null }
| { readonly type: "error"; readonly message: string };
const reducer = (state: DemoState, action: DemoAction): DemoState => {
switch (action.type) {
case "checking":
return { ...initialState, phase: "checking" };
case "missing":
return { ...initialState, phase: "missing", message: action.message };
case "ready":
return { ...initialState, phase: "ready" };
case "starting":
return { ...state, phase: "starting", message: null };
case "interrupted":
return {
...state,
phase: "interrupted",
runId: action.runId,
interruptPayload: action.payload,
output: null,
trace: null,
};
case "resuming":
return { ...state, phase: "resuming", message: null };
case "completed":
return { ...state, phase: "completed", output: action.output, trace: action.trace };
case "error":
return { ...state, phase: "error", message: action.message };
default:
return state;
}
};
type EvidenceRecorder = (record: EvidenceRecord) => void;
const recordOperationEvidence = (
recordEvidence: EvidenceRecorder,
result: Awaited<ReturnType<typeof callOperation>>,
) => {
if (!result.ok) return;
recordEvidence({
id: `demo-${result.operation}-${Date.now()}`,
operation: result.operation,
label: result.label,
equivalentCli: result.equivalentCli,
request: result.exchange.request,
response: result.exchange.response,
durationMs: result.durationMs,
});
};
export const useLdaReportDemo = (
target: string | null,
recordEvidence: EvidenceRecorder,
) => {
const [state, dispatch] = useReducer(reducer, initialState);
const recordEvidenceRef = useRef(recordEvidence);
recordEvidenceRef.current = recordEvidence;
const refresh = useCallback(async () => {
if (!target) return;
dispatch({ type: "checking" });
try {
const result = await callOperation(
"workflow.deployments.inspect",
target,
{ deployment_id: LDA_REPORT_DEPLOYMENT_ID },
);
recordOperationEvidence(recordEvidenceRef.current, result);
if (!result.ok) {
dispatch({ type: "missing", message: result.error.message });
return;
}
dispatch({ type: "ready" });
} catch (e: unknown) {
dispatch({ type: "missing", message: e instanceof Error ? e.message : "unknown error" });
}
}, [target]);
useEffect(() => {
void refresh();
}, [refresh]);
const startRun = useCallback(async () => {
if (!target) return;
dispatch({ type: "starting" });
try {
const result = await callOperation(
"workflow.runs.start",
target,
{
deployment_id: LDA_REPORT_DEPLOYMENT_ID,
workflow_input: ldaReportDemoInput,
trace_range: { start: 0, limit: 50 },
},
);
recordOperationEvidence(recordEvidenceRef.current, result);
if (!result.ok) {
dispatch({ type: "error", message: result.error.message });
return;
}
const detail = decodeRunDetail(result.interpreted);
if (detail.status !== "interrupted" || detail.interrupt?.kind !== LDA_REPORT_INTERRUPT_KIND) {
dispatch({ type: "error", message: "Demo run did not stop at issue_review interrupt." });
return;
}
dispatch({
type: "interrupted",
runId: detail.runId,
payload: parseLdaReportInterruptPayload(detail.interrupt.payload),
});
} catch (e: unknown) {
dispatch({ type: "error", message: e instanceof Error ? e.message : "unknown error" });
}
}, [target]);
const resume = useCallback(async (
resumePayload: { approved: boolean; selected_issue_ids: string[]; comment: string },
resumeOutcome: "submitted" | "cancelled",
) => {
if (!target || !state.runId) return;
dispatch({ type: "resuming" });
try {
const result = await callOperation(
"workflow.runs.resume",
target,
{
run_id: state.runId,
resume_payload: resumePayload,
resume_outcome: resumeOutcome,
trace_range: { start: 0, limit: 50 },
},
);
recordOperationEvidence(recordEvidenceRef.current, result);
if (!result.ok) {
dispatch({ type: "error", message: result.error.message });
return;
}
const detail = decodeRunDetail(result.interpreted);
const traceResult = await callOperation(
"workflow.runs.trace",
target,
{ run_id: detail.runId, trace_range: { start: 0, limit: 50 } },
);
recordOperationEvidence(recordEvidenceRef.current, traceResult);
const trace = traceResult.ok ? decodeTracePage(traceResult.interpreted) : null;
dispatch({
type: "completed",
output: parseLdaReportOutput(detail.output),
trace,
});
} catch (e: unknown) {
dispatch({ type: "error", message: e instanceof Error ? e.message : "unknown error" });
}
}, [target, state.runId]);
return {
state,
refresh,
startRun,
submitSelectedIssues: (selectedIssueIds: string[], comment: string) =>
resume(
{ approved: true, selected_issue_ids: selectedIssueIds, comment },
"submitted",
),
cancelReview: (comment: string) =>
resume(
{ approved: false, selected_issue_ids: [], comment },
"cancelled",
),
};
};
+54
View File
@@ -389,6 +389,60 @@ tbody tr:nth-child(10) { animation-delay: 270ms; }
} }
} }
/* Demo panel */
.demo-panel {
grid-column: 1 / -1;
}
.demo-panel__header,
.demo-panel__actions {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.demo-panel pre {
max-height: 18rem;
overflow: auto;
padding: 0.75rem;
background: var(--color-ink);
color: var(--color-paper);
border-radius: 3px;
}
.demo-panel fieldset {
border: 1px solid var(--color-border);
margin: 1rem 0;
padding: 0.75rem;
}
.demo-panel__issue {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
padding: 0.65rem 0;
text-transform: none;
letter-spacing: normal;
font-family: var(--font-body);
}
.demo-panel__issue span span,
.demo-panel__issue small {
display: block;
}
.demo-panel textarea {
width: 100%;
min-height: 5rem;
margin-top: 0.35rem;
padding: 0.5rem;
border: 1px solid var(--color-border);
font: inherit;
}
@media (max-width: 560px) { @media (max-width: 560px) {
form { form {
flex-direction: column; flex-direction: column;
+2
View File
@@ -21,6 +21,8 @@ export {
WorkflowDeploymentsValidate, WorkflowDeploymentsValidate,
WorkflowRunsList, WorkflowRunsList,
WorkflowRunsInspect, WorkflowRunsInspect,
WorkflowRunsStart,
WorkflowRunsResume,
WorkflowRunsTrace, WorkflowRunsTrace,
WorkflowRpcs, WorkflowRpcs,
ArtifactRefSchema, ArtifactRefSchema,
+74 -1
View File
@@ -16,6 +16,8 @@ import {
WorkflowRunsListResultSchema, WorkflowRunsListResultSchema,
WorkflowRunsInspectPayloadSchema, WorkflowRunsInspectPayloadSchema,
WorkflowRunsInspectResultSchema, WorkflowRunsInspectResultSchema,
WorkflowRunsStartPayloadSchema,
WorkflowRunsResumePayloadSchema,
WorkflowRunsTracePayloadSchema, WorkflowRunsTracePayloadSchema,
WorkflowRunsTraceResultSchema, WorkflowRunsTraceResultSchema,
} from "./rpcs.js"; } from "./rpcs.js";
@@ -24,7 +26,7 @@ export type OperationMeta = {
readonly method: string; readonly method: string;
readonly label: string; readonly label: string;
readonly explanation: string; readonly explanation: string;
readonly idempotency: "read"; readonly idempotency: "read" | "write";
readonly equivalentCli: (params: unknown) => string; readonly equivalentCli: (params: unknown) => string;
readonly interpret: (result: unknown) => unknown; readonly interpret: (result: unknown) => unknown;
}; };
@@ -68,6 +70,37 @@ const interpretNextActions = (nextActions: {
warnings: nextActions.warnings, warnings: nextActions.warnings,
}); });
/** Adapts a snake_case run detail from the server into camelCase for the browser. */
const interpretRunDetail = (decoded: {
readonly run_id: string;
readonly deployment_id: string;
readonly artifact_id: string;
readonly artifact_version: number;
readonly status: string;
readonly resume_readiness: string;
readonly interrupt: unknown;
readonly outcome: string | null;
readonly error: string | null;
readonly output: Record<string, unknown> | null;
readonly diagnostics: ReadonlyArray<unknown>;
readonly trace_count: number;
readonly next_actions: Parameters<typeof interpretNextActions>[0];
}) => ({
runId: decoded.run_id,
deploymentId: decoded.deployment_id,
artifactId: decoded.artifact_id,
artifactVersion: decoded.artifact_version,
status: decoded.status,
resumeReadiness: decoded.resume_readiness,
interrupt: decoded.interrupt,
outcome: decoded.outcome,
error: decoded.error,
output: decoded.output,
diagnostics: decoded.diagnostics,
traceCount: decoded.trace_count,
nextActions: interpretNextActions(decoded.next_actions),
});
const operationEntries: ReadonlyArray<OperationMeta> = [ const operationEntries: ReadonlyArray<OperationMeta> = [
{ {
method: "workflow.health", method: "workflow.health",
@@ -324,6 +357,46 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
}; };
}, },
}, },
{
method: "workflow.runs.start",
label: "Start run",
explanation: "Start a workflow deployment run",
idempotency: "write",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(WorkflowRunsStartPayloadSchema)(
params,
{ onExcessProperty: "error" },
);
return `uv run wf run start ${p.deployment_id} --input '<json>'`;
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
result,
{ onExcessProperty: "ignore" },
);
return interpretRunDetail(decoded);
},
},
{
method: "workflow.runs.resume",
label: "Resume run",
explanation: "Resume an interrupted workflow run",
idempotency: "write",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(WorkflowRunsResumePayloadSchema)(
params,
{ onExcessProperty: "error" },
);
return `uv run wf run resume ${p.run_id} --payload '<json>'`;
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
result,
{ onExcessProperty: "ignore" },
);
return interpretRunDetail(decoded);
},
},
{ {
method: "workflow.runs.trace", method: "workflow.runs.trace",
label: "Read run trace", label: "Read run trace",
+30
View File
@@ -242,6 +242,9 @@ const RunInterruptSchema = Schema.Struct({
kind: Schema.String, kind: Schema.String,
payload: JsonObjectSchema, payload: JsonObjectSchema,
outcomes: Schema.Array(Schema.String), outcomes: Schema.Array(Schema.String),
request_schema: Schema.optional(JsonObjectSchema),
resume_schema: Schema.optional(JsonObjectSchema),
typed: Schema.optional(Schema.Boolean),
}); });
const RunNextActionsSchema = Schema.Struct({ const RunNextActionsSchema = Schema.Struct({
@@ -275,6 +278,31 @@ export const WorkflowRunsInspect = Rpc.make("workflow.runs.inspect", {
error: Schema.Never, error: Schema.Never,
}); });
export const WorkflowRunsStartPayloadSchema = Schema.Struct({
deployment_id: Schema.String,
workflow_input: JsonObjectSchema,
trace_range: Schema.optional(Schema.NullOr(TraceRangeSchema)),
});
export const WorkflowRunsResumePayloadSchema = Schema.Struct({
run_id: Schema.String,
resume_payload: JsonObjectSchema,
resume_outcome: Schema.optional(Schema.String),
trace_range: Schema.optional(Schema.NullOr(TraceRangeSchema)),
});
export const WorkflowRunsStart = Rpc.make("workflow.runs.start", {
payload: WorkflowRunsStartPayloadSchema,
success: WorkflowRunsInspectResultSchema,
error: Schema.Never,
});
export const WorkflowRunsResume = Rpc.make("workflow.runs.resume", {
payload: WorkflowRunsResumePayloadSchema,
success: WorkflowRunsInspectResultSchema,
error: Schema.Never,
});
export const WorkflowRunsTracePayloadSchema = Schema.Struct({ export const WorkflowRunsTracePayloadSchema = Schema.Struct({
run_id: Schema.String, run_id: Schema.String,
trace_range: TraceRangeSchema, trace_range: TraceRangeSchema,
@@ -314,5 +342,7 @@ export const WorkflowRpcs = RpcGroup.make(
WorkflowDeploymentsValidate, WorkflowDeploymentsValidate,
WorkflowRunsList, WorkflowRunsList,
WorkflowRunsInspect, WorkflowRunsInspect,
WorkflowRunsStart,
WorkflowRunsResume,
WorkflowRunsTrace, WorkflowRunsTrace,
); );
+145
View File
@@ -210,6 +210,115 @@ const lifecycleCases = [
}, },
}, },
}, },
{
operation: "workflow.runs.start" as const,
params: {
deployment_id: "lda_report_case_study.default",
workflow_input: {
selected_documents: [
"project-brief.md",
"architecture-notes.md",
"evaluation-findings.md",
"risk-register.md",
"roadmap.md",
],
board_path: "issue-board.json",
},
trace_range: { start: 0, limit: 50 },
},
result: {
run_id: "run_demo",
deployment_id: "lda_report_case_study.default",
artifact_id: "lda_report_case_study",
artifact_version: 1,
status: "interrupted",
resume_readiness: "ready",
interrupt: {
kind: "issue_review",
payload: {
report_markdown: "# lda.chat Thesis And Project Readiness Report",
proposed_issues: [
{
id: "demo-issue-1",
title: "Prepare demo script",
body: "Write the defense walkthrough.",
severity: "medium",
},
],
},
outcomes: ["submitted", "cancelled"],
request_schema: {
type: "object",
required: ["report_markdown", "proposed_issues"],
},
resume_schema: {
type: "object",
required: ["approved", "selected_issue_ids"],
},
typed: true,
},
outcome: null,
error: null,
output: null,
diagnostics: [],
trace_count: 1,
next_actions: {
can_continue: true,
can_save_now: null,
recommended_next_tool: "wf.workflow.resume_run",
reason: "run is interrupted",
patch_examples: [],
warnings: [],
},
},
},
{
operation: "workflow.runs.resume" as const,
params: {
run_id: "run_demo",
resume_payload: {
approved: true,
selected_issue_ids: ["demo-issue-1"],
comment: "Create selected issues.",
},
resume_outcome: "submitted",
trace_range: { start: 0, limit: 50 },
},
result: {
run_id: "run_demo",
deployment_id: "lda_report_case_study.default",
artifact_id: "lda_report_case_study",
artifact_version: 1,
status: "completed",
resume_readiness: "not_applicable",
interrupt: null,
outcome: "completed",
error: null,
output: {
approved: true,
markdown: "# lda.chat Thesis And Project Readiness Report",
created_issues: [
{
id: "ISSUE-001",
title: "Prepare demo script",
url: "local://issues/ISSUE-001",
},
],
selected_issue_ids: ["demo-issue-1"],
comment: "Create selected issues.",
},
diagnostics: [],
trace_count: 4,
next_actions: {
can_continue: false,
can_save_now: null,
recommended_next_tool: null,
reason: "Run completed.",
patch_examples: [],
warnings: [],
},
},
},
{ {
operation: "workflow.runs.trace" as const, operation: "workflow.runs.trace" as const,
params: { run_id: "run_1", trace_range: { start: 0, limit: 50 } }, params: { run_id: "run_1", trace_range: { start: 0, limit: 50 } },
@@ -472,4 +581,40 @@ describe("lifecycle operations", () => {
}); });
expect(exchange.interpreted).not.toHaveProperty("trace"); expect(exchange.interpreted).not.toHaveProperty("trace");
}); });
it("interprets typed interrupt contracts from run start", async () => {
const startCase = lifecycleCases.find(
(testCase) => testCase.operation === "workflow.runs.start",
);
expect(startCase).toBeDefined();
if (!startCase) return;
const fetch: typeof globalThis.fetch = async (input, init) => {
const request = await requestBody(input, init);
return jsonResponse({
jsonrpc: "2.0",
id: request.id,
result: startCase.result,
});
};
const exchange = await runOperation(
{ fetch },
startCase.operation as "workflow.health" | "workflow.sources.list",
startCase.params,
);
expect(exchange.interpreted).toMatchObject({
runId: "run_demo",
status: "interrupted",
interrupt: {
kind: "issue_review",
typed: true,
outcomes: ["submitted", "cancelled"],
},
nextActions: {
canContinue: true,
},
});
});
}); });
+20
View File
@@ -34,6 +34,8 @@ import {
WorkflowDeploymentsValidatePayloadSchema, WorkflowDeploymentsValidatePayloadSchema,
WorkflowRunsListPayloadSchema, WorkflowRunsListPayloadSchema,
WorkflowRunsInspectPayloadSchema, WorkflowRunsInspectPayloadSchema,
WorkflowRunsStartPayloadSchema,
WorkflowRunsResumePayloadSchema,
WorkflowRunsTracePayloadSchema, WorkflowRunsTracePayloadSchema,
} from "./rpcs.js"; } from "./rpcs.js";
import { normalizeLoopbackTarget } from "./target-policy.js"; import { normalizeLoopbackTarget } from "./target-policy.js";
@@ -51,6 +53,8 @@ export type OperationName =
| "workflow.deployments.validate" | "workflow.deployments.validate"
| "workflow.runs.list" | "workflow.runs.list"
| "workflow.runs.inspect" | "workflow.runs.inspect"
| "workflow.runs.start"
| "workflow.runs.resume"
| "workflow.runs.trace"; | "workflow.runs.trace";
export interface WorkflowRpcOptions { export interface WorkflowRpcOptions {
@@ -89,6 +93,8 @@ const isOperationName = (value: string): value is OperationName =>
value === "workflow.deployments.validate" || value === "workflow.deployments.validate" ||
value === "workflow.runs.list" || value === "workflow.runs.list" ||
value === "workflow.runs.inspect" || value === "workflow.runs.inspect" ||
value === "workflow.runs.start" ||
value === "workflow.runs.resume" ||
value === "workflow.runs.trace"; value === "workflow.runs.trace";
const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({ const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({
@@ -338,6 +344,20 @@ const executeImpl =
); );
return yield* client.workflow["runs.inspect"](payload); return yield* client.workflow["runs.inspect"](payload);
} }
case "workflow.runs.start": {
const payload = yield* decodeParams(
WorkflowRunsStartPayloadSchema,
params,
);
return yield* client.workflow["runs.start"](payload);
}
case "workflow.runs.resume": {
const payload = yield* decodeParams(
WorkflowRunsResumePayloadSchema,
params,
);
return yield* client.workflow["runs.resume"](payload);
}
case "workflow.runs.trace": { case "workflow.runs.trace": {
const payload = yield* decodeParams( const payload = yield* decodeParams(
WorkflowRunsTracePayloadSchema, WorkflowRunsTracePayloadSchema,