feat: add lda report workflow demo panel
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,7 +140,9 @@ 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) => {
|
||||||
|
if (op === "workflow.sources.list") {
|
||||||
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
operation: "workflow.sources.list",
|
operation: "workflow.sources.list",
|
||||||
label: "List sources",
|
label: "List sources",
|
||||||
@@ -139,6 +151,16 @@ describe("App", () => {
|
|||||||
equivalentCli: "uv run wf source list",
|
equivalentCli: "uv run wf source list",
|
||||||
durationMs: 5,
|
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 />);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export {
|
|||||||
WorkflowDeploymentsValidate,
|
WorkflowDeploymentsValidate,
|
||||||
WorkflowRunsList,
|
WorkflowRunsList,
|
||||||
WorkflowRunsInspect,
|
WorkflowRunsInspect,
|
||||||
|
WorkflowRunsStart,
|
||||||
|
WorkflowRunsResume,
|
||||||
WorkflowRunsTrace,
|
WorkflowRunsTrace,
|
||||||
WorkflowRpcs,
|
WorkflowRpcs,
|
||||||
ArtifactRefSchema,
|
ArtifactRefSchema,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user