Files
lda-wf/docs/historical/superpowers/plans/2026-07-02-lda-report-demo-panel.md
T

40 KiB

lda Report Demo Panel Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a working console demo panel that operates an already-prepared lda_report_case_study.default deployment through start, typed interrupt review, resume, trace, and final output inspection.

Architecture: The Python JSON-RPC server already exposes workflow.runs.start and workflow.runs.resume; this plan only brings those methods into the web RPC package and adds a React panel that uses the public console backend. The panel does not create artifacts, save deployments, or run hidden setup commands. If the prepared deployment is missing, it shows explicit setup instructions and stops.

Tech Stack: TypeScript, React 19, Valibot, Effect RPC schemas in @lda/workflow-rpc, existing console /api/rpc operation bridge, existing Python wf-rpc-server.

Global Constraints

  • Do not add workflow authoring/setup buttons to the UI in this slice.
  • The prepared deployment id is exactly lda_report_case_study.default.
  • The demo start input is the checked-in example shape: selected documents plus board_path: "issue-board.json".
  • The expected interrupt kind is exactly issue_review.
  • The resume form supports the two real outcomes: submitted and cancelled.
  • Keep all demo-specific UI under web/apps/console/src/demo/.
  • Preserve raw request/response evidence by going through callOperation() only; do not call fetch() directly from the demo panel.
  • Before implementation, keep or commit the current graph-console fixes; this plan assumes the console already has a readable graph view.

File Structure

  • Modify web/packages/rpc/src/rpcs.ts
    • Add schemas/RPC definitions for workflow.runs.start and workflow.runs.resume.
    • Extend the run interrupt schema with typed, request_schema, and resume_schema.
  • Modify web/packages/rpc/src/service.ts
    • Allow and dispatch the two new run operations.
  • Modify web/packages/rpc/src/method-registry.ts
    • Add metadata, CLI strings, and interpreted DTOs for start/resume.
  • Modify web/packages/rpc/src/index.ts
    • Export the new schemas/RPC definitions if needed by tests.
  • Modify web/packages/rpc/src/service.test.ts
    • Add operation cases for start/resume and assert typed interrupt fields survive interpretation.
  • Modify web/apps/console/src/connection/contracts.ts
    • Add the two operation literals so the browser can call them.
  • Create web/apps/console/src/demo/ldaReportDemoConfig.ts
    • Constants for deployment id, default input, setup commands, and expected interrupt kind.
  • Create web/apps/console/src/demo/ldaReportDemoModels.ts
    • Valibot decoders for the demo-specific interrupt payload and final output.
  • Create web/apps/console/src/demo/useLdaReportDemo.ts
    • Hook that drives deployment discovery, run start, resume, inspect, and trace.
  • Create web/apps/console/src/demo/LdaReportDemoPanel.tsx
    • Working UI for demo status, start button, interrupt payload, resume form, final output, trace summary, and missing-demo instructions.
  • Create web/apps/console/src/demo/useLdaReportDemo.test.tsx
    • Hook tests for missing deployment, interrupted start, submitted resume, and cancelled resume.
  • Create web/apps/console/src/demo/LdaReportDemoPanel.test.tsx
    • Component tests for the main user paths.
  • Modify web/apps/console/src/app/App.tsx
    • Mount the demo panel after connection, above or beside the generic lifecycle explorer.
  • Modify web/apps/console/src/app/App.test.tsx
    • Assert the demo panel mounts after connect and does not break existing source/lifecycle loading.
  • Modify web/apps/console/src/styles/global.css
    • Add minimal demo panel styles; prioritize clarity over visual polish.
  • Modify web/README.md
    • Add the manual demo run instructions.
  • Modify docs/current_roadmap.md
    • Mark this demo panel as completed only after implementation and verification.

Task 1: Add Web RPC Support For Run Start And Resume

Files:

  • Modify: web/packages/rpc/src/rpcs.ts
  • Modify: web/packages/rpc/src/service.ts
  • Modify: web/packages/rpc/src/method-registry.ts
  • Modify: web/packages/rpc/src/index.ts
  • Modify: web/packages/rpc/src/service.test.ts
  • Modify: web/apps/console/src/connection/contracts.ts

Interfaces:

  • Consumes: Python JSON-RPC methods workflow.runs.start and workflow.runs.resume.

  • Produces:

    • Operation names accepted by callOperation().
    • Interpreted start/resume results shaped like existing RunDetail, with camelCase nextActions, interrupt contract fields, and no trace frames in inspect/start/resume payloads.
  • Step 1: Add failing service tests for start and resume

    In web/packages/rpc/src/service.test.ts, extend lifecycleCases with these two cases:

    {
      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: [],
        },
      },
    }
    

    Add this assertion after the generic lifecycle loop:

    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,
        },
      });
    });
    
  • Step 2: Run the failing tests

    Run:

    pnpm --dir web --filter @lda/workflow-rpc test -- src/service.test.ts
    

    Expected: FAIL because workflow.runs.start and workflow.runs.resume are not accepted operation names in the web package.

  • Step 3: Add Effect RPC schemas

    In web/packages/rpc/src/rpcs.ts, add:

    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)),
    });
    

    Replace RunInterruptSchema with:

    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),
    });
    

    After WorkflowRunsInspect, add:

    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,
    });
    

    Add both RPCs to WorkflowRpcs.

  • Step 4: Dispatch the operations

    In web/packages/rpc/src/service.ts, extend the operation union and isKnownOperation:

    | "workflow.runs.start"
    | "workflow.runs.resume"
    

    Import the two new payload schemas and add switch cases:

    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);
    }
    
  • Step 5: Add operation metadata

    In web/packages/rpc/src/method-registry.ts, add a helper:

    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),
    });
    

    Use it for existing workflow.runs.inspect, then add entries:

    {
      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);
      },
    },
    
  • Step 6: Add browser operation literals

    In web/apps/console/src/connection/contracts.ts, add:

    v.literal("workflow.runs.start"),
    v.literal("workflow.runs.resume"),
    
  • Step 7: Run tests

    Run:

    pnpm --dir web --filter @lda/workflow-rpc test -- src/service.test.ts
    pnpm --dir web --filter @lda/workflow-rpc typecheck
    pnpm --dir web --filter @lda/console typecheck
    

    Expected: all pass.

  • Step 8: Commit

    git add web/packages/rpc/src/rpcs.ts web/packages/rpc/src/service.ts web/packages/rpc/src/method-registry.ts web/packages/rpc/src/index.ts web/packages/rpc/src/service.test.ts web/apps/console/src/connection/contracts.ts
    git commit -m "feat: expose run start and resume in console rpc"
    

Task 2: Add Demo Hook And Models

Files:

  • Create: web/apps/console/src/demo/ldaReportDemoConfig.ts
  • Create: web/apps/console/src/demo/ldaReportDemoModels.ts
  • Create: web/apps/console/src/demo/useLdaReportDemo.ts
  • Create: web/apps/console/src/demo/useLdaReportDemo.test.tsx

Interfaces:

  • Consumes: callOperation(operation, target, params) and Task 1 operation names.

  • Produces:

    • useLdaReportDemo(target, recordEvidence) hook.
    • Controller with refresh, startRun, submitSelectedIssues, and cancelReview.
  • Step 1: Create demo constants

    Create web/apps/console/src/demo/ldaReportDemoConfig.ts:

    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;
    
  • Step 2: Create demo-specific decoders

    Create web/apps/console/src/demo/ldaReportDemoModels.ts:

    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);
    
  • Step 3: Write hook tests first

    Create web/apps/console/src/demo/useLdaReportDemo.test.tsx.

    Test the missing deployment path:

    import { renderHook, act, waitFor } 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();
    });
    
    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(),
        );
      });
    });
    

    Add tests for start/resume after the hook implementation in Step 5.

  • Step 4: Run the failing hook test

    Run:

    pnpm --dir web --filter @lda/console test -- src/demo/useLdaReportDemo.test.tsx
    

    Expected: FAIL because useLdaReportDemo.ts does not exist.

  • Step 5: Implement the hook

    Create web/apps/console/src/demo/useLdaReportDemo.ts:

    import { useCallback, useEffect, useReducer } 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 refresh = useCallback(async () => {
        if (!target) return;
        dispatch({ type: "checking" });
        const result = await callOperation(
          "workflow.deployments.inspect",
          target,
          { deployment_id: LDA_REPORT_DEPLOYMENT_ID },
        );
        recordOperationEvidence(recordEvidence, result);
        if (!result.ok) {
          dispatch({ type: "missing", message: result.error.message });
          return;
        }
        dispatch({ type: "ready" });
      }, [target, recordEvidence]);
    
      useEffect(() => {
        void refresh();
      }, [refresh]);
    
      const startRun = useCallback(async () => {
        if (!target) return;
        dispatch({ type: "starting" });
        const result = await callOperation(
          "workflow.runs.start",
          target,
          {
            deployment_id: LDA_REPORT_DEPLOYMENT_ID,
            workflow_input: ldaReportDemoInput,
            trace_range: { start: 0, limit: 50 },
          },
        );
        recordOperationEvidence(recordEvidence, 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),
        });
      }, [target, recordEvidence]);
    
      const resume = useCallback(async (
        resumePayload: { approved: boolean; selected_issue_ids: string[]; comment: string },
        resumeOutcome: "submitted" | "cancelled",
      ) => {
        if (!target || !state.runId) return;
        dispatch({ type: "resuming" });
        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(recordEvidence, 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(recordEvidence, traceResult);
        const trace = traceResult.ok ? decodeTracePage(traceResult.interpreted) : null;
        dispatch({
          type: "completed",
          output: parseLdaReportOutput(detail.output),
          trace,
        });
      }, [target, state.runId, recordEvidence]);
    
      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",
          ),
      };
    };
    
  • Step 6: Add hook tests for interrupted start and resume

    In useLdaReportDemo.test.tsx, add tests that mock:

    • workflow.deployments.inspect -> ok.
    • workflow.runs.start -> interrupted result with issue payload.
    • workflow.runs.resume -> completed result with final output.
    • workflow.runs.trace -> trace page.

    Assert:

    expect(result.current.state.phase).toBe("interrupted");
    expect(result.current.state.interruptPayload?.proposed_issues[0]?.id).toBe("demo-issue-1");
    

    After submitSelectedIssues(["demo-issue-1"], "Create it."), assert:

    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);
    
  • Step 7: Run hook tests

    Run:

    pnpm --dir web --filter @lda/console test -- src/demo/useLdaReportDemo.test.tsx
    

    Expected: all pass.

  • Step 8: Commit

    git add web/apps/console/src/demo/ldaReportDemoConfig.ts web/apps/console/src/demo/ldaReportDemoModels.ts web/apps/console/src/demo/useLdaReportDemo.ts web/apps/console/src/demo/useLdaReportDemo.test.tsx
    git commit -m "feat: add lda report demo workflow controller"
    

Task 3: Add Working Demo Panel UI

Files:

  • Create: web/apps/console/src/demo/LdaReportDemoPanel.tsx
  • Create: web/apps/console/src/demo/LdaReportDemoPanel.test.tsx
  • Modify: web/apps/console/src/app/App.tsx
  • Modify: web/apps/console/src/app/App.test.tsx
  • Modify: web/apps/console/src/styles/global.css

Interfaces:

  • Consumes: useLdaReportDemo(target, recordEvidence).

  • Produces: visible operator panel for prepared report workflow demo.

  • Step 1: Write panel tests first

    Create web/apps/console/src/demo/LdaReportDemoPanel.test.tsx with mocked hook state cases:

    import { render, screen } from "@testing-library/react";
    import userEvent from "@testing-library/user-event";
    import { describe, it, expect, vi } from "vitest";
    import { LdaReportDemoPanel } from "./LdaReportDemoPanel.js";
    
    describe("LdaReportDemoPanel", () => {
      it("shows setup commands when the prepared deployment is missing", () => {
        render(
          <LdaReportDemoPanel
            controller={{
              state: {
                phase: "missing",
                message: "not found",
                runId: null,
                interruptPayload: null,
                output: null,
                trace: null,
              },
              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: {
                phase: "ready",
                message: null,
                runId: null,
                interruptPayload: null,
                output: null,
                trace: null,
              },
              refresh: vi.fn(),
              startRun,
              submitSelectedIssues: vi.fn(),
              cancelReview: vi.fn(),
            }}
          />,
        );
    
        await userEvent.click(screen.getByRole("button", { name: /start demo run/i }));
        expect(startRun).toHaveBeenCalledOnce();
      });
    });
    
  • Step 2: Run failing panel tests

    Run:

    pnpm --dir web --filter @lda/console test -- src/demo/LdaReportDemoPanel.test.tsx
    

    Expected: FAIL because the component does not exist.

  • Step 3: Implement the panel

    Create web/apps/console/src/demo/LdaReportDemoPanel.tsx:

    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]);
    
      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}>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>
              <p>Trace frames: {state.trace?.frames.length ?? 0}</p>
            </div>
          )}
    
          {state.phase === "error" && state.message && (
            <p role="alert">{state.message}</p>
          )}
        </section>
      );
    };
    
  • Step 4: Mount panel in the app

    In web/apps/console/src/app/App.tsx, import:

    import { LdaReportDemoPanel } from "../demo/LdaReportDemoPanel.js";
    import { useLdaReportDemo } from "../demo/useLdaReportDemo.js";
    

    Add:

    const demoController = useLdaReportDemo(connectedTarget, recordEvidence);
    

    Render after ConnectionHeader and before SourceInventory:

    {connectedTarget && <LdaReportDemoPanel controller={demoController} />}
    
  • Step 5: Add minimal styles

    In web/apps/console/src/styles/global.css, add:

    .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;
    }
    
  • Step 6: Add app tests

    In web/apps/console/src/app/App.test.tsx, add an assertion in the existing “mounts lifecycle explorer after connect” test:

    expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
    

    If the demo hook causes additional mocked calls, extend mockedCallOperation to return an ok: false deployment inspect result for workflow.deployments.inspect and the existing list response for all lifecycle list calls.

  • Step 7: Run component tests

    Run:

    pnpm --dir web --filter @lda/console test -- src/demo/LdaReportDemoPanel.test.tsx src/app/App.test.tsx
    

    Expected: all pass.

  • Step 8: Commit

    git add web/apps/console/src/demo/LdaReportDemoPanel.tsx web/apps/console/src/demo/LdaReportDemoPanel.test.tsx web/apps/console/src/app/App.tsx web/apps/console/src/app/App.test.tsx web/apps/console/src/styles/global.css
    git commit -m "feat: add lda report workflow demo panel"
    

Task 4: Add Docs, Smoke Test, And Roadmap Note

Files:

  • Modify: web/README.md
  • Modify: docs/current_roadmap.md

Interfaces:

  • Consumes: implemented demo panel from Task 3.

  • Produces: operator instructions and final verification.

  • Step 1: Document how to run the demo

    In web/README.md, add:

    ## 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.
    
  • Step 2: Update roadmap

    In docs/current_roadmap.md, add a completed bullet under the console/demo area:

    - 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.
    
  • Step 3: Run full web verification

    Run:

    pnpm --dir web test
    pnpm --dir web typecheck
    pnpm --dir web build
    

    Expected: all pass.

  • Step 4: Live smoke with prepared example server

    In one terminal, run:

    uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765
    

    In another terminal, run:

    pnpm --dir web dev
    

    Manual smoke:

    1. Open http://127.0.0.1:5173/.
    2. Connect to http://127.0.0.1:8765/rpc.
    3. If the demo panel says missing deployment, run the setup commands it shows and refresh.
    4. Click Start demo run.
    5. Confirm the panel shows issue_review with proposed issues.
    6. Select one issue.
    7. Click Resume and create selected issues.
    8. Confirm final markdown and created issue count render.
    9. Confirm Raw evidence includes workflow.runs.start, workflow.runs.resume, and workflow.runs.trace.
  • Step 5: Commit

    git add web/README.md docs/current_roadmap.md
    git commit -m "docs: document lda report console demo"
    

Self-Review

Spec coverage: The plan covers the requirement that the UI uses an already-prepared deployment, supports running the example workflow, exposes the typed interrupt, resumes with selected issues, shows final output/trace, and avoids hidden setup commands.

Placeholder scan: No placeholder task remains. The setup commands and deployment id are concrete.

Type consistency: The web RPC operation names match Python JSON-RPC methods: workflow.runs.start, workflow.runs.resume, workflow.runs.inspect, and workflow.runs.trace. Demo-specific payload names match examples/lda_report_workflow/build_workflow.py: report_markdown, proposed_issues, approved, selected_issue_ids, and comment.