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).
Draft workspace inspection reuses the same shell after the first vertical
path.
5. Add lifecycle autoplay, typed approval, issue-board output, and replay.
6. Add a constrained demo agent that invokes one prepared recipe macro.
7. Add an Astro presentation app and appendix routes for the 15-minute defense.
5. Completed: the web console can operate the prepared
`examples/lda_report_workflow/` deployment through run start, typed
`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
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,
deployment list, run list, graph visualization, trace frames, and raw evidence
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();
});
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", () => {
it("shows source inventory errors from rejected source refreshes", async () => {
mockedConnectToServer.mockResolvedValue(
@@ -130,14 +140,26 @@ describe("App", () => {
mockedConnectToServer.mockResolvedValue(
successfulConnection("http://127.0.0.1:8765/rpc"),
);
mockedCallOperation.mockResolvedValue({
ok: true,
operation: "workflow.sources.list",
label: "List sources",
interpreted: { sources: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf source list",
durationMs: 5,
mockedCallOperation.mockImplementation((op: string) => {
if (op === "workflow.sources.list") {
return Promise.resolve({
ok: true,
operation: "workflow.sources.list",
label: "List sources",
interpreted: { sources: [], total: 0, nextCursor: null },
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 />);
@@ -146,5 +168,6 @@ describe("App", () => {
await waitFor(() => {
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 { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js";
import { LdaReportDemoPanel } from "../demo/LdaReportDemoPanel.js";
import { useLdaReportDemo } from "../demo/useLdaReportDemo.js";
const parseSources = (
data: unknown,
@@ -54,6 +56,7 @@ export const App = () => {
);
const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence);
const demoController = useLdaReportDemo(connectedTarget, recordEvidence);
const loadSources = useCallback(
async (target: string) => {
@@ -160,6 +163,7 @@ export const App = () => {
onSubmit={onSubmit}
onDraftChange={(value) => dispatch({ type: "draft_changed", value })}
/>
{connectedTarget && <LdaReportDemoPanel controller={demoController} />}
<SourceInventory
sources={state.sources}
loading={state.sourcesLoading}
@@ -49,6 +49,8 @@ const OperationNameSchema = v.union([
v.literal("workflow.deployments.validate"),
v.literal("workflow.runs.list"),
v.literal("workflow.runs.inspect"),
v.literal("workflow.runs.start"),
v.literal("workflow.runs.resume"),
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) {
form {
flex-direction: column;
+2
View File
@@ -21,6 +21,8 @@ export {
WorkflowDeploymentsValidate,
WorkflowRunsList,
WorkflowRunsInspect,
WorkflowRunsStart,
WorkflowRunsResume,
WorkflowRunsTrace,
WorkflowRpcs,
ArtifactRefSchema,
+74 -1
View File
@@ -16,6 +16,8 @@ import {
WorkflowRunsListResultSchema,
WorkflowRunsInspectPayloadSchema,
WorkflowRunsInspectResultSchema,
WorkflowRunsStartPayloadSchema,
WorkflowRunsResumePayloadSchema,
WorkflowRunsTracePayloadSchema,
WorkflowRunsTraceResultSchema,
} from "./rpcs.js";
@@ -24,7 +26,7 @@ export type OperationMeta = {
readonly method: string;
readonly label: string;
readonly explanation: string;
readonly idempotency: "read";
readonly idempotency: "read" | "write";
readonly equivalentCli: (params: unknown) => string;
readonly interpret: (result: unknown) => unknown;
};
@@ -68,6 +70,37 @@ const interpretNextActions = (nextActions: {
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> = [
{
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",
label: "Read run trace",
+30
View File
@@ -242,6 +242,9 @@ const RunInterruptSchema = Schema.Struct({
kind: Schema.String,
payload: JsonObjectSchema,
outcomes: Schema.Array(Schema.String),
request_schema: Schema.optional(JsonObjectSchema),
resume_schema: Schema.optional(JsonObjectSchema),
typed: Schema.optional(Schema.Boolean),
});
const RunNextActionsSchema = Schema.Struct({
@@ -275,6 +278,31 @@ export const WorkflowRunsInspect = Rpc.make("workflow.runs.inspect", {
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({
run_id: Schema.String,
trace_range: TraceRangeSchema,
@@ -314,5 +342,7 @@ export const WorkflowRpcs = RpcGroup.make(
WorkflowDeploymentsValidate,
WorkflowRunsList,
WorkflowRunsInspect,
WorkflowRunsStart,
WorkflowRunsResume,
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,
params: { run_id: "run_1", trace_range: { start: 0, limit: 50 } },
@@ -472,4 +581,40 @@ describe("lifecycle operations", () => {
});
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,
WorkflowRunsListPayloadSchema,
WorkflowRunsInspectPayloadSchema,
WorkflowRunsStartPayloadSchema,
WorkflowRunsResumePayloadSchema,
WorkflowRunsTracePayloadSchema,
} from "./rpcs.js";
import { normalizeLoopbackTarget } from "./target-policy.js";
@@ -51,6 +53,8 @@ export type OperationName =
| "workflow.deployments.validate"
| "workflow.runs.list"
| "workflow.runs.inspect"
| "workflow.runs.start"
| "workflow.runs.resume"
| "workflow.runs.trace";
export interface WorkflowRpcOptions {
@@ -89,6 +93,8 @@ const isOperationName = (value: string): value is OperationName =>
value === "workflow.deployments.validate" ||
value === "workflow.runs.list" ||
value === "workflow.runs.inspect" ||
value === "workflow.runs.start" ||
value === "workflow.runs.resume" ||
value === "workflow.runs.trace";
const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({
@@ -338,6 +344,20 @@ const executeImpl =
);
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": {
const payload = yield* decodeParams(
WorkflowRunsTracePayloadSchema,