1146 lines
36 KiB
Markdown
1146 lines
36 KiB
Markdown
# Schema Approval Surface 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:** Replace the raw `{ "type": "object" }` interrupt proof in the defense demo with a reusable schema-aware approval surface that can be used by Scene 10, prepared-agent approval messages, and future console interrupt/resume flows.
|
|
|
|
**Architecture:** Keep runtime validation owned by `wf_core` and persisted run inspection; the web layer only projects known JSON Schema and replay payload data into display fields. Add a small pure projection module, then render it through one approval component used by the typed interrupt panel and the chat approval event. The current replay has a loose resume schema, so the UI must show outcome buttons plus the actual recorded resume payload instead of pretending fields exist.
|
|
|
|
**Tech Stack:** React 19, TypeScript, Vitest, Testing Library, Valibot for existing replay decoding, CSS modules by convention through existing presentation stylesheets.
|
|
|
|
## Global Constraints
|
|
|
|
- Do not implement JSON Schema validation in the web UI; runtime validation already lives in the workflow engine.
|
|
- Do not add a form library or component library in this slice.
|
|
- Do not replace the chat surface in this slice; make the approval component reusable by the later AI Elements/chat-library work.
|
|
- Keep Scene 10 readable at 720p and avoid adding a modal for the approval form.
|
|
- Use submitted/cancelled outcome language, not approved/denied, when describing workflow resume outcomes.
|
|
- Raw schema evidence may remain available, but it must not be the primary approval visual.
|
|
- Add comments around non-obvious fallback logic, especially when projecting fields from a loose schema plus replay payload.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create `web/apps/console/src/presentation/approval/schema-approval-model.ts`
|
|
- Pure projection helpers from schema/payload/outcomes to display rows.
|
|
- Create `web/apps/console/src/presentation/approval/schema-approval-model.test.ts`
|
|
- Unit tests for explicit schema fields, loose-object fallback, arrays, booleans, and unknown schemas.
|
|
- Create `web/apps/console/src/presentation/approval/SchemaApprovalSurface.tsx`
|
|
- Reusable approval UI: heading, outcome buttons, projected fields, payload preview, status text.
|
|
- Create `web/apps/console/src/presentation/approval/SchemaApprovalSurface.test.tsx`
|
|
- Component tests for buttons, field rendering, loose schema copy, cancelled/submitted states.
|
|
- Modify `web/apps/console/src/presentation/demo-workflow-model.ts`
|
|
- Extend the interrupt projection with request schema, resume payload preview, and resume outcome.
|
|
- Modify `web/apps/console/src/presentation/InterruptContractPreview.tsx`
|
|
- Use `SchemaApprovalSurface` for approval mode; keep raw schema compact for preview mode.
|
|
- Modify `web/apps/console/src/presentation/DemoWorkflowScene.tsx`
|
|
- Pass the `run_resume` event into the interrupt contract projection.
|
|
- Modify `web/apps/console/src/presentation/DemoWorkflowScene.test.tsx`
|
|
- Update assertions away from raw-schema-first approval and toward form/payload/outcome evidence.
|
|
- Modify `web/apps/console/src/demo/agent/events.ts`
|
|
- Add optional approval contract data to `approval-request` parts without importing presentation code.
|
|
- Modify `web/apps/console/src/demo/agent/preparedRecipeDriver.ts`
|
|
- Attach replay-derived approval contract data to the `resumeIssueReview` approval request.
|
|
- Modify `web/apps/console/src/presentation/OperatorChat.tsx`
|
|
- Render `SchemaApprovalSurface` inside approval-request parts when approval data is present; fall back to current buttons otherwise.
|
|
- Modify `web/apps/console/src/presentation/OperatorChat.test.tsx`
|
|
- Assert schema approval renders in chat and still calls approve/cancel handlers.
|
|
- Modify `web/apps/console/src/presentation/styles/demo-workflow.css`
|
|
- Add approval surface styles and remove raw-schema dominance in approval mode.
|
|
- Modify `docs/current_roadmap.md`
|
|
- Mark this schema approval slice completed when implementation is done and renumber later items.
|
|
|
|
---
|
|
|
|
### Task 1: Pure Schema Approval Projection
|
|
|
|
**Files:**
|
|
- Create: `web/apps/console/src/presentation/approval/schema-approval-model.ts`
|
|
- Create: `web/apps/console/src/presentation/approval/schema-approval-model.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
|
|
```ts
|
|
export type SchemaApprovalFieldKind = "string" | "number" | "boolean" | "array" | "object" | "unknown";
|
|
|
|
export type SchemaApprovalField = {
|
|
readonly name: string;
|
|
readonly label: string;
|
|
readonly kind: SchemaApprovalFieldKind;
|
|
readonly required: boolean;
|
|
readonly description: string | null;
|
|
readonly valuePreview: string | null;
|
|
};
|
|
|
|
export type SchemaApprovalModel = {
|
|
readonly hasExplicitFields: boolean;
|
|
readonly fields: ReadonlyArray<SchemaApprovalField>;
|
|
readonly payloadPreview: ReadonlyArray<{ readonly key: string; readonly value: string }>;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
};
|
|
|
|
export type BuildSchemaApprovalModelInput = {
|
|
readonly schema: unknown;
|
|
readonly payload: unknown;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
};
|
|
|
|
export const buildSchemaApprovalModel = (input: BuildSchemaApprovalModelInput): SchemaApprovalModel;
|
|
```
|
|
|
|
- Consumes: no project runtime APIs; this is a pure projection helper.
|
|
|
|
- [ ] **Step 1: Write the failing projection tests**
|
|
|
|
Create `web/apps/console/src/presentation/approval/schema-approval-model.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it } from "vitest";
|
|
import { buildSchemaApprovalModel } from "./schema-approval-model.js";
|
|
|
|
describe("buildSchemaApprovalModel", () => {
|
|
it("projects explicit object schema properties into approval fields", () => {
|
|
const model = buildSchemaApprovalModel({
|
|
schema: {
|
|
type: "object",
|
|
required: ["selected_issue_ids"],
|
|
properties: {
|
|
selected_issue_ids: {
|
|
type: "array",
|
|
description: "Issue ids to create",
|
|
items: { type: "string" },
|
|
},
|
|
comment: { type: "string" },
|
|
approved: { type: "boolean" },
|
|
},
|
|
},
|
|
payload: {
|
|
selected_issue_ids: ["risk-1"],
|
|
comment: "Create the selected issue.",
|
|
approved: true,
|
|
},
|
|
outcomes: ["submitted", "cancelled"],
|
|
});
|
|
|
|
expect(model.hasExplicitFields).toBe(true);
|
|
expect(model.outcomes).toEqual(["submitted", "cancelled"]);
|
|
expect(model.fields).toEqual([
|
|
{
|
|
name: "selected_issue_ids",
|
|
label: "selected issue ids",
|
|
kind: "array",
|
|
required: true,
|
|
description: "Issue ids to create",
|
|
valuePreview: "[\"risk-1\"]",
|
|
},
|
|
{
|
|
name: "comment",
|
|
label: "comment",
|
|
kind: "string",
|
|
required: false,
|
|
description: null,
|
|
valuePreview: "Create the selected issue.",
|
|
},
|
|
{
|
|
name: "approved",
|
|
label: "approved",
|
|
kind: "boolean",
|
|
required: false,
|
|
description: null,
|
|
valuePreview: "true",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses payload preview when schema is a loose object without properties", () => {
|
|
const model = buildSchemaApprovalModel({
|
|
schema: { type: "object" },
|
|
payload: {
|
|
selected_issue_ids: ["risk-1"],
|
|
comment: "Create the selected issue.",
|
|
},
|
|
outcomes: ["submitted", "cancelled"],
|
|
});
|
|
|
|
expect(model.hasExplicitFields).toBe(false);
|
|
expect(model.fields).toEqual([]);
|
|
expect(model.payloadPreview).toEqual([
|
|
{ key: "selected_issue_ids", value: "[\"risk-1\"]" },
|
|
{ key: "comment", value: "Create the selected issue." },
|
|
]);
|
|
});
|
|
|
|
it("handles non-object schema without throwing", () => {
|
|
const model = buildSchemaApprovalModel({
|
|
schema: true,
|
|
payload: null,
|
|
outcomes: [],
|
|
});
|
|
|
|
expect(model.hasExplicitFields).toBe(false);
|
|
expect(model.fields).toEqual([]);
|
|
expect(model.payloadPreview).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/presentation/approval/schema-approval-model.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `schema-approval-model.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement the projection helper**
|
|
|
|
Create `web/apps/console/src/presentation/approval/schema-approval-model.ts`:
|
|
|
|
```ts
|
|
export type SchemaApprovalFieldKind = "string" | "number" | "boolean" | "array" | "object" | "unknown";
|
|
|
|
export type SchemaApprovalField = {
|
|
readonly name: string;
|
|
readonly label: string;
|
|
readonly kind: SchemaApprovalFieldKind;
|
|
readonly required: boolean;
|
|
readonly description: string | null;
|
|
readonly valuePreview: string | null;
|
|
};
|
|
|
|
export type SchemaApprovalModel = {
|
|
readonly hasExplicitFields: boolean;
|
|
readonly fields: ReadonlyArray<SchemaApprovalField>;
|
|
readonly payloadPreview: ReadonlyArray<{ readonly key: string; readonly value: string }>;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
};
|
|
|
|
export type BuildSchemaApprovalModelInput = {
|
|
readonly schema: unknown;
|
|
readonly payload: unknown;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
};
|
|
|
|
type JsonObject = Record<string, unknown>;
|
|
|
|
const isObject = (value: unknown): value is JsonObject =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const stringArray = (value: unknown): ReadonlyArray<string> =>
|
|
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
|
|
const formatValue = (value: unknown): string | null => {
|
|
if (value === undefined) return null;
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
|
|
return JSON.stringify(value);
|
|
};
|
|
|
|
const fieldKind = (propertySchema: unknown): SchemaApprovalFieldKind => {
|
|
if (!isObject(propertySchema)) return "unknown";
|
|
const type = propertySchema.type;
|
|
if (type === "integer") return "number";
|
|
if (type === "string" || type === "number" || type === "boolean" || type === "array" || type === "object") {
|
|
return type;
|
|
}
|
|
return "unknown";
|
|
};
|
|
|
|
const labelFor = (name: string): string => name.replaceAll("_", " ");
|
|
|
|
const payloadEntries = (payload: unknown): ReadonlyArray<{ readonly key: string; readonly value: string }> => {
|
|
if (!isObject(payload)) return [];
|
|
return Object.entries(payload).map(([key, value]) => ({
|
|
key,
|
|
value: formatValue(value) ?? "undefined",
|
|
}));
|
|
};
|
|
|
|
export const buildSchemaApprovalModel = ({
|
|
schema,
|
|
payload,
|
|
outcomes,
|
|
}: BuildSchemaApprovalModelInput): SchemaApprovalModel => {
|
|
const required = isObject(schema) ? new Set(stringArray(schema.required)) : new Set<string>();
|
|
const properties = isObject(schema) && isObject(schema.properties) ? schema.properties : null;
|
|
const payloadObject = isObject(payload) ? payload : {};
|
|
|
|
// This is projection, not validation. For loose schemas the product should
|
|
// still show the recorded resume payload instead of inventing absent fields.
|
|
const fields = properties
|
|
? Object.entries(properties).map(([name, propertySchema]): SchemaApprovalField => ({
|
|
name,
|
|
label: labelFor(name),
|
|
kind: fieldKind(propertySchema),
|
|
required: required.has(name),
|
|
description: isObject(propertySchema) && typeof propertySchema.description === "string"
|
|
? propertySchema.description
|
|
: null,
|
|
valuePreview: formatValue(payloadObject[name]),
|
|
}))
|
|
: [];
|
|
|
|
return {
|
|
hasExplicitFields: fields.length > 0,
|
|
fields,
|
|
payloadPreview: payloadEntries(payload),
|
|
outcomes,
|
|
};
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run the projection tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/presentation/approval/schema-approval-model.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/apps/console/src/presentation/approval/schema-approval-model.ts web/apps/console/src/presentation/approval/schema-approval-model.test.ts
|
|
git commit -m "feat: project schema approval fields"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Reusable Schema Approval Surface
|
|
|
|
**Files:**
|
|
- Create: `web/apps/console/src/presentation/approval/SchemaApprovalSurface.tsx`
|
|
- Create: `web/apps/console/src/presentation/approval/SchemaApprovalSurface.test.tsx`
|
|
- Modify: `web/apps/console/src/presentation/styles/demo-workflow.css`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `buildSchemaApprovalModel(input: BuildSchemaApprovalModelInput): SchemaApprovalModel`
|
|
- Produces:
|
|
|
|
```ts
|
|
export type SchemaApprovalSurfaceProps = {
|
|
readonly title: string;
|
|
readonly schema: unknown;
|
|
readonly payload: unknown;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
readonly runId: string | null;
|
|
readonly state?: "ready" | "submitted" | "cancelled";
|
|
readonly onSubmit?: (() => void) | undefined;
|
|
readonly onCancel?: (() => void) | undefined;
|
|
};
|
|
|
|
export const SchemaApprovalSurface: (props: SchemaApprovalSurfaceProps) => JSX.Element;
|
|
```
|
|
|
|
- [ ] **Step 1: Write the failing component tests**
|
|
|
|
Create `web/apps/console/src/presentation/approval/SchemaApprovalSurface.test.tsx`:
|
|
|
|
```tsx
|
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { SchemaApprovalSurface } from "./SchemaApprovalSurface.js";
|
|
|
|
describe("SchemaApprovalSurface", () => {
|
|
it("renders explicit schema fields and outcome actions", () => {
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
|
|
render(
|
|
<SchemaApprovalSurface
|
|
title="Issue review resume"
|
|
schema={{
|
|
type: "object",
|
|
required: ["selected_issue_ids"],
|
|
properties: {
|
|
selected_issue_ids: { type: "array", description: "Issue ids to create" },
|
|
comment: { type: "string" },
|
|
},
|
|
}}
|
|
payload={{ selected_issue_ids: ["risk-1"], comment: "Create the selected issue." }}
|
|
outcomes={["submitted", "cancelled"]}
|
|
runId="run_recorded_lda_report"
|
|
onSubmit={onSubmit}
|
|
onCancel={onCancel}
|
|
/>,
|
|
);
|
|
|
|
const surface = screen.getByRole("group", { name: /issue review resume/i });
|
|
expect(within(surface).getByText("selected issue ids")).toBeInTheDocument();
|
|
expect(within(surface).getByText("required")).toBeInTheDocument();
|
|
expect(within(surface).getByText("[\"risk-1\"]")).toBeInTheDocument();
|
|
expect(within(surface).getByText("run_recorded_lda_report")).toBeInTheDocument();
|
|
|
|
fireEvent.click(within(surface).getByRole("button", { name: /submit/i }));
|
|
fireEvent.click(within(surface).getByRole("button", { name: /cancel/i }));
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders payload preview for loose object schemas", () => {
|
|
render(
|
|
<SchemaApprovalSurface
|
|
title="Issue review resume"
|
|
schema={{ type: "object" }}
|
|
payload={{ selected_issue_ids: ["risk-1"], comment: "Create the selected issue." }}
|
|
outcomes={["submitted", "cancelled"]}
|
|
runId="run_recorded_lda_report"
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("No additional resume fields are declared by this schema.")).toBeInTheDocument();
|
|
expect(screen.getByText("selected_issue_ids")).toBeInTheDocument();
|
|
expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows submitted and cancelled states without active actions", () => {
|
|
const { rerender } = render(
|
|
<SchemaApprovalSurface
|
|
title="Issue review resume"
|
|
schema={{ type: "object" }}
|
|
payload={{}}
|
|
outcomes={["submitted", "cancelled"]}
|
|
runId={null}
|
|
state="submitted"
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Outcome: submitted")).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<SchemaApprovalSurface
|
|
title="Issue review resume"
|
|
schema={{ type: "object" }}
|
|
payload={{}}
|
|
outcomes={["submitted", "cancelled"]}
|
|
runId={null}
|
|
state="cancelled"
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Outcome: cancelled")).toBeInTheDocument();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/presentation/approval/SchemaApprovalSurface.test.tsx
|
|
```
|
|
|
|
Expected: FAIL because `SchemaApprovalSurface.tsx` does not exist.
|
|
|
|
- [ ] **Step 3: Implement the component**
|
|
|
|
Create `web/apps/console/src/presentation/approval/SchemaApprovalSurface.tsx`:
|
|
|
|
```tsx
|
|
import { buildSchemaApprovalModel } from "./schema-approval-model.js";
|
|
|
|
export type SchemaApprovalSurfaceProps = {
|
|
readonly title: string;
|
|
readonly schema: unknown;
|
|
readonly payload: unknown;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
readonly runId: string | null;
|
|
readonly state?: "ready" | "submitted" | "cancelled";
|
|
readonly onSubmit?: (() => void) | undefined;
|
|
readonly onCancel?: (() => void) | undefined;
|
|
};
|
|
|
|
export const SchemaApprovalSurface = ({
|
|
title,
|
|
schema,
|
|
payload,
|
|
outcomes,
|
|
runId,
|
|
state = "ready",
|
|
onSubmit,
|
|
onCancel,
|
|
}: SchemaApprovalSurfaceProps) => {
|
|
const model = buildSchemaApprovalModel({ schema, payload, outcomes });
|
|
const isResolved = state !== "ready";
|
|
|
|
return (
|
|
<section className="schema-approval-surface" role="group" aria-label={title} data-state={state}>
|
|
<header className="schema-approval-surface__header">
|
|
<span>Schema-backed decision</span>
|
|
<strong>{title}</strong>
|
|
<code>{runId ?? "run unavailable"}</code>
|
|
</header>
|
|
|
|
<div className="schema-approval-surface__body">
|
|
{model.hasExplicitFields ? (
|
|
<dl className="schema-approval-surface__fields">
|
|
{model.fields.map((field) => (
|
|
<div key={field.name} className="schema-approval-surface__field" data-kind={field.kind}>
|
|
<dt>
|
|
<span>{field.label}</span>
|
|
{field.required ? <small>required</small> : <small>optional</small>}
|
|
</dt>
|
|
<dd>
|
|
<code>{field.valuePreview ?? "not provided"}</code>
|
|
{field.description ? <p>{field.description}</p> : null}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
) : (
|
|
<div className="schema-approval-surface__loose">
|
|
<p>No additional resume fields are declared by this schema.</p>
|
|
<dl>
|
|
{model.payloadPreview.map((entry) => (
|
|
<div key={entry.key}>
|
|
<dt>{entry.key}</dt>
|
|
<dd><code>{entry.value}</code></dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="schema-approval-surface__actions">
|
|
{isResolved ? (
|
|
<strong>Outcome: {state}</strong>
|
|
) : (
|
|
<>
|
|
<button type="button" onClick={onSubmit} disabled={!onSubmit}>
|
|
Submit
|
|
</button>
|
|
<button type="button" onClick={onCancel} disabled={!onCancel}>
|
|
Cancel
|
|
</button>
|
|
</>
|
|
)}
|
|
<span>{model.outcomes.join(" / ")}</span>
|
|
</footer>
|
|
</section>
|
|
);
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Add minimal presentation CSS**
|
|
|
|
Append to `web/apps/console/src/presentation/styles/demo-workflow.css`:
|
|
|
|
```css
|
|
.schema-approval-surface {
|
|
display: grid;
|
|
gap: 0.75rem;
|
|
min-width: 0;
|
|
padding: 0.85rem;
|
|
border: 1px solid color-mix(in oklch, var(--accent-amber) 48%, var(--stage-line));
|
|
border-radius: 0.85rem;
|
|
background:
|
|
linear-gradient(135deg, color-mix(in oklch, var(--accent-amber) 16%, transparent), transparent 48%),
|
|
var(--stage-surface);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.schema-approval-surface__header,
|
|
.schema-approval-surface__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.schema-approval-surface__header span,
|
|
.schema-approval-surface__actions span,
|
|
.schema-approval-surface__field small {
|
|
color: var(--text-muted);
|
|
font: 700 0.68rem/1 var(--font-mono);
|
|
}
|
|
|
|
.schema-approval-surface__header strong {
|
|
font: 700 1rem/1.1 var(--font-display);
|
|
}
|
|
|
|
.schema-approval-surface__header code,
|
|
.schema-approval-surface__field code,
|
|
.schema-approval-surface__loose code {
|
|
font: 600 0.72rem/1.35 var(--font-mono);
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.schema-approval-surface__fields,
|
|
.schema-approval-surface__loose dl {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.schema-approval-surface__field,
|
|
.schema-approval-surface__loose dl > div {
|
|
display: grid;
|
|
grid-template-columns: minmax(8rem, 0.55fr) minmax(0, 1fr);
|
|
gap: 0.75rem;
|
|
align-items: start;
|
|
padding: 0.55rem;
|
|
border: 1px solid color-mix(in oklch, var(--stage-line) 70%, transparent);
|
|
border-radius: 0.6rem;
|
|
background: color-mix(in oklch, var(--stage-inset) 82%, transparent);
|
|
}
|
|
|
|
.schema-approval-surface__field dt {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.schema-approval-surface__field dd,
|
|
.schema-approval-surface__loose dd {
|
|
display: grid;
|
|
gap: 0.3rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.schema-approval-surface__field p,
|
|
.schema-approval-surface__loose p {
|
|
margin: 0;
|
|
color: var(--text-muted);
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.schema-approval-surface__actions button {
|
|
border: 1px solid var(--stage-line);
|
|
border-radius: 0.55rem;
|
|
padding: 0.48rem 0.7rem;
|
|
background: color-mix(in oklch, var(--stage-surface) 78%, var(--accent-cyan));
|
|
color: var(--text-primary);
|
|
font: 700 0.78rem/1 var(--font-mono);
|
|
}
|
|
|
|
.schema-approval-surface__actions button:disabled {
|
|
opacity: 0.55;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the component tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/presentation/approval
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add web/apps/console/src/presentation/approval web/apps/console/src/presentation/styles/demo-workflow.css
|
|
git commit -m "feat: add schema approval surface"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Wire Scene 10 Approval To Replay Payload
|
|
|
|
**Files:**
|
|
- Modify: `web/apps/console/src/presentation/demo-workflow-model.ts`
|
|
- Modify: `web/apps/console/src/presentation/InterruptContractPreview.tsx`
|
|
- Modify: `web/apps/console/src/presentation/DemoWorkflowScene.tsx`
|
|
- Modify: `web/apps/console/src/presentation/DemoWorkflowScene.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `SchemaApprovalSurface`
|
|
- Produces: `InterruptContractPresentation` with request schema and resume payload.
|
|
|
|
- [ ] **Step 1: Write failing scene assertions**
|
|
|
|
Modify `web/apps/console/src/presentation/DemoWorkflowScene.test.tsx`:
|
|
|
|
```tsx
|
|
it("shows a schema approval surface for the approval beat instead of raw schema as the primary visual", () => {
|
|
render(<DemoWorkflowScene beatId="approval" recording={recording} />);
|
|
|
|
const approval = screen.getByRole("group", { name: /issue review resume/i });
|
|
expect(within(approval).getByText("Schema-backed decision")).toBeInTheDocument();
|
|
expect(within(approval).getByText("selected_issue_ids")).toBeInTheDocument();
|
|
expect(within(approval).getByText("[\"risk-1\"]")).toBeInTheDocument();
|
|
expect(within(approval).getByRole("button", { name: /submit/i })).toBeDisabled();
|
|
expect(within(approval).getByRole("button", { name: /cancel/i })).toBeDisabled();
|
|
});
|
|
|
|
it("keeps raw resume schema visible only in interrupt preview mode", () => {
|
|
const { rerender } = render(<DemoWorkflowScene beatId="interrupt" recording={recording} />);
|
|
expect(screen.getByText("Resume schema")).toBeInTheDocument();
|
|
|
|
rerender(<DemoWorkflowScene beatId="approval" recording={recording} />);
|
|
expect(screen.queryByText("Resume schema")).not.toBeInTheDocument();
|
|
});
|
|
```
|
|
|
|
Make sure this test imports `within` from `@testing-library/react`.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/presentation/DemoWorkflowScene.test.tsx
|
|
```
|
|
|
|
Expected: FAIL because approval mode still renders `InterruptContractPreview` with raw schema only.
|
|
|
|
- [ ] **Step 3: Extend the presentation contract**
|
|
|
|
Modify `web/apps/console/src/presentation/demo-workflow-model.ts`:
|
|
|
|
```ts
|
|
const InterruptProjectionSchema = v.looseObject({
|
|
kind: v.string(),
|
|
outcomes: v.optional(v.array(v.string())),
|
|
request_schema: v.optional(v.unknown()),
|
|
resume_schema: v.optional(v.unknown()),
|
|
});
|
|
|
|
const ResumeParamsSchema = v.looseObject({
|
|
resume_payload: v.optional(v.unknown()),
|
|
resume_outcome: v.optional(v.string()),
|
|
});
|
|
```
|
|
|
|
Update `InterruptContractPresentation`:
|
|
|
|
```ts
|
|
export type InterruptContractPresentation = {
|
|
readonly kind: string;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
readonly requestSchema: unknown;
|
|
readonly resumeSchema: unknown;
|
|
readonly resumePayloadPreview: unknown;
|
|
readonly resumeOutcome: string | null;
|
|
readonly runId: string | null;
|
|
};
|
|
```
|
|
|
|
Update `projectInterruptContract`:
|
|
|
|
```ts
|
|
export const projectInterruptContract = (
|
|
event: DemoEvent,
|
|
resumeEvent?: DemoEvent | null,
|
|
): InterruptContractPresentation | null => {
|
|
const interrupt = decodeInterpretation(event)?.interrupt;
|
|
if (!interrupt || !interrupt.outcomes || interrupt.resume_schema === undefined) return null;
|
|
const decodedResumeParams = v.safeParse(ResumeParamsSchema, resumeEvent?.params);
|
|
const resumeParams = decodedResumeParams.success ? decodedResumeParams.output : null;
|
|
return {
|
|
kind: interrupt.kind,
|
|
outcomes: interrupt.outcomes,
|
|
requestSchema: interrupt.request_schema ?? null,
|
|
resumeSchema: interrupt.resume_schema,
|
|
resumePayloadPreview: resumeParams?.resume_payload ?? null,
|
|
resumeOutcome: resumeParams?.resume_outcome ?? null,
|
|
runId: event.resultingIds.runId,
|
|
};
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Pass the resume event from the demo scene**
|
|
|
|
Modify `web/apps/console/src/presentation/DemoWorkflowScene.tsx`:
|
|
|
|
```ts
|
|
const runStart = recording.events.find((event) => event.stage === "run_start") ?? null;
|
|
const runResume = recording.events.find((event) => event.stage === "run_resume") ?? null;
|
|
const contract = runStart ? projectInterruptContract(runStart, runResume) : null;
|
|
```
|
|
|
|
- [ ] **Step 5: Render approval surface in approval mode**
|
|
|
|
Modify `web/apps/console/src/presentation/InterruptContractPreview.tsx`:
|
|
|
|
```tsx
|
|
import { SchemaApprovalSurface } from "./approval/SchemaApprovalSurface.js";
|
|
```
|
|
|
|
Inside the component body, before the raw schema block:
|
|
|
|
```tsx
|
|
{mode === "approval" ? (
|
|
<SchemaApprovalSurface
|
|
title={`${contract.kind} resume`}
|
|
schema={contract.resumeSchema}
|
|
payload={contract.resumePayloadPreview}
|
|
outcomes={contract.outcomes}
|
|
runId={contract.runId}
|
|
/>
|
|
) : (
|
|
<div className="interrupt-contract-preview__schema">
|
|
<span>Resume schema</span>
|
|
<pre><code>{formatJson(contract.resumeSchema)}</code></pre>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
Keep the header and persisted-run summary. The approval mode can show both the surrounding interrupt metadata and the approval surface, but not the raw schema block as the main visual.
|
|
|
|
- [ ] **Step 6: Run scene tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/presentation/DemoWorkflowScene.test.tsx src/presentation/InterruptContractPreview.test.tsx
|
|
```
|
|
|
|
If `InterruptContractPreview.test.tsx` does not exist, run only `DemoWorkflowScene.test.tsx`.
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add web/apps/console/src/presentation/demo-workflow-model.ts web/apps/console/src/presentation/InterruptContractPreview.tsx web/apps/console/src/presentation/DemoWorkflowScene.tsx web/apps/console/src/presentation/DemoWorkflowScene.test.tsx
|
|
git commit -m "feat: show schema approval in demo interrupt"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Attach Approval Data To Chat Approval Requests
|
|
|
|
**Files:**
|
|
- Modify: `web/apps/console/src/demo/agent/events.ts`
|
|
- Modify: `web/apps/console/src/demo/agent/events.test.ts`
|
|
- Modify: `web/apps/console/src/demo/agent/preparedRecipeDriver.ts`
|
|
- Modify: `web/apps/console/src/demo/agent/preparedRecipeDriver.test.ts`
|
|
- Modify: `web/apps/console/src/presentation/OperatorChat.tsx`
|
|
- Modify: `web/apps/console/src/presentation/OperatorChat.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Produces:
|
|
|
|
```ts
|
|
export type AgentApprovalContract = {
|
|
readonly kind: string;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
readonly resumeSchema: unknown;
|
|
readonly resumePayloadPreview: unknown;
|
|
readonly runId: string | null;
|
|
};
|
|
```
|
|
|
|
Then:
|
|
|
|
```ts
|
|
| {
|
|
readonly type: "approval-request";
|
|
readonly callId: string;
|
|
readonly name: AgentToolName;
|
|
readonly prompt: string;
|
|
readonly contract?: AgentApprovalContract | undefined;
|
|
}
|
|
```
|
|
|
|
- Consumes: `SchemaApprovalSurface`.
|
|
|
|
- [ ] **Step 1: Add event tests for optional approval contract**
|
|
|
|
Modify `web/apps/console/src/demo/agent/events.test.ts`:
|
|
|
|
```ts
|
|
it("creates approval request parts with optional contract data", () => {
|
|
const part = approvalRequestPart(
|
|
"call-1",
|
|
"resumeIssueReview",
|
|
"Approve?",
|
|
{
|
|
kind: "issue_review",
|
|
outcomes: ["submitted", "cancelled"],
|
|
resumeSchema: { type: "object" },
|
|
resumePayloadPreview: { selected_issue_ids: ["risk-1"] },
|
|
runId: "run_recorded_lda_report",
|
|
},
|
|
);
|
|
|
|
expect(part).toMatchObject({
|
|
type: "approval-request",
|
|
contract: {
|
|
kind: "issue_review",
|
|
runId: "run_recorded_lda_report",
|
|
},
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Update event types and constructor**
|
|
|
|
Modify `web/apps/console/src/demo/agent/events.ts`:
|
|
|
|
```ts
|
|
export type AgentApprovalContract = {
|
|
readonly kind: string;
|
|
readonly outcomes: ReadonlyArray<string>;
|
|
readonly resumeSchema: unknown;
|
|
readonly resumePayloadPreview: unknown;
|
|
readonly runId: string | null;
|
|
};
|
|
```
|
|
|
|
Update the union member and constructor:
|
|
|
|
```ts
|
|
| {
|
|
readonly type: "approval-request";
|
|
readonly callId: string;
|
|
readonly name: AgentToolName;
|
|
readonly prompt: string;
|
|
readonly contract?: AgentApprovalContract | undefined;
|
|
}
|
|
```
|
|
|
|
```ts
|
|
export const approvalRequestPart = (
|
|
callId: string,
|
|
name: AgentToolName,
|
|
prompt: string,
|
|
contract?: AgentApprovalContract,
|
|
): AgentMessagePart => ({
|
|
type: "approval-request",
|
|
callId,
|
|
name,
|
|
prompt,
|
|
contract,
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Attach replay-derived contract in prepared driver**
|
|
|
|
Modify `web/apps/console/src/demo/agent/preparedRecipeDriver.ts` near the `runStart` / `resume` lookup:
|
|
|
|
```ts
|
|
const interrupt = runStart?.interpreted && typeof runStart.interpreted === "object"
|
|
&& "interrupt" in runStart.interpreted
|
|
? runStart.interpreted.interrupt
|
|
: null;
|
|
const resumePayload = resume?.params && typeof resume.params === "object" && "resume_payload" in resume.params
|
|
? resume.params.resume_payload
|
|
: null;
|
|
```
|
|
|
|
Add a local helper if type-checking needs narrowing:
|
|
|
|
```ts
|
|
const approvalContract = isObject(interrupt) && Array.isArray(interrupt.outcomes)
|
|
? {
|
|
kind: typeof interrupt.kind === "string" ? interrupt.kind : "issue_review",
|
|
outcomes: interrupt.outcomes.filter((entry): entry is string => typeof entry === "string"),
|
|
resumeSchema: "resume_schema" in interrupt ? interrupt.resume_schema : { type: "object" },
|
|
resumePayloadPreview: resumePayload,
|
|
runId,
|
|
}
|
|
: undefined;
|
|
```
|
|
|
|
Then update the approval request:
|
|
|
|
```ts
|
|
approvalRequestPart(
|
|
callId,
|
|
step.toolName,
|
|
"Approve resuming the workflow run with the selected issues?",
|
|
approvalContract,
|
|
),
|
|
```
|
|
|
|
Add a small `isObject` helper in this file if needed:
|
|
|
|
```ts
|
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
```
|
|
|
|
- [ ] **Step 4: Add driver test assertion**
|
|
|
|
Modify `web/apps/console/src/demo/agent/preparedRecipeDriver.test.ts` in the approval-request test:
|
|
|
|
```ts
|
|
const approvalPart = messages
|
|
.flatMap((message) => message.parts)
|
|
.find((part) => part.type === "approval-request");
|
|
expect(approvalPart).toMatchObject({
|
|
type: "approval-request",
|
|
contract: {
|
|
kind: "issue_review",
|
|
runId: "run_recorded_lda_report",
|
|
},
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Render schema approval inside chat**
|
|
|
|
Modify `web/apps/console/src/presentation/OperatorChat.tsx`:
|
|
|
|
```tsx
|
|
import { SchemaApprovalSurface } from "./approval/SchemaApprovalSurface.js";
|
|
```
|
|
|
|
Replace the approval-request branch with:
|
|
|
|
```tsx
|
|
case "approval-request":
|
|
return (
|
|
<div key={key} className="chat-tool-part chat-tool-part--approval">
|
|
<span>Approval required</span>
|
|
<code>{part.name}</code>
|
|
<p>{part.prompt}</p>
|
|
{part.contract ? (
|
|
<SchemaApprovalSurface
|
|
title={`${part.contract.kind} resume`}
|
|
schema={part.contract.resumeSchema}
|
|
payload={part.contract.resumePayloadPreview}
|
|
outcomes={part.contract.outcomes}
|
|
runId={part.contract.runId}
|
|
onSubmit={onApprove}
|
|
onCancel={onDeny}
|
|
/>
|
|
) : (
|
|
<div className="chat-approval-actions">
|
|
<button type="button" onClick={onApprove} disabled={!onApprove}>Approve</button>
|
|
<button type="button" onClick={onDeny} disabled={!onDeny}>Deny</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 6: Update chat tests**
|
|
|
|
Modify `web/apps/console/src/presentation/OperatorChat.test.tsx` approval test to include `contract` and expect submit/cancel:
|
|
|
|
```tsx
|
|
expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
|
expect(onApprove).toHaveBeenCalledTimes(1);
|
|
expect(onDeny).toHaveBeenCalledTimes(1);
|
|
```
|
|
|
|
Keep a fallback test for approval requests without `contract`, because live AI SDK drivers may not provide this immediately.
|
|
|
|
- [ ] **Step 7: Run focused agent/chat tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/demo/agent src/presentation/OperatorChat.test.tsx src/presentation/approval
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add web/apps/console/src/demo/agent web/apps/console/src/presentation/OperatorChat.tsx web/apps/console/src/presentation/OperatorChat.test.tsx web/apps/console/src/presentation/approval
|
|
git commit -m "feat: render schema approval in chat"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Verification, Roadmap, And Visual Smoke
|
|
|
|
**Files:**
|
|
- Modify: `docs/current_roadmap.md`
|
|
- Move after completion: `docs/superpowers/plans/2026-07-09-schema-approval-surface.md` -> `docs/historical/superpowers/plans/2026-07-09-schema-approval-surface.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes all previous task outputs.
|
|
- Produces final documented slice status.
|
|
|
|
- [ ] **Step 1: Run presentation-focused tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @lda/console test -- src/presentation src/demo/agent
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Run broad web verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --dir web test
|
|
pnpm --dir web typecheck
|
|
pnpm --dir web build
|
|
```
|
|
|
|
Expected:
|
|
- Tests pass.
|
|
- Typecheck clean.
|
|
- Build passes with only the known Vite chunk-size warning if it appears.
|
|
|
|
- [ ] **Step 3: Capture visual smoke screenshots**
|
|
|
|
With `pnpm dev` running, capture these routes at `1280x720`:
|
|
|
|
```text
|
|
http://127.0.0.1:5173/present#scene/interrupt-evidence/approval
|
|
http://127.0.0.1:5173/present#scene/interrupt-evidence/resume
|
|
http://127.0.0.1:5173/present#scene/workflow-demo/interrupt
|
|
```
|
|
|
|
Expected:
|
|
- Approval beat shows the schema approval surface and recorded resume payload.
|
|
- Interrupt beat still shows raw resume schema proof.
|
|
- Buttons are visible but disabled in Scene 10 unless the chat approval event owns handlers.
|
|
|
|
- [ ] **Step 4: Update roadmap**
|
|
|
|
In `docs/current_roadmap.md`:
|
|
- Change implementation order item 15 from future to completed.
|
|
- Add implementation link to `historical/superpowers/plans/2026-07-09-schema-approval-surface.md`.
|
|
- Keep chat surface replacement as a later item unless it was fully replaced; this slice only improves approval rendering inside existing chat.
|
|
|
|
Use wording like:
|
|
|
|
```md
|
|
15. Completed: schema approval surface for typed interrupt/resume decisions.
|
|
Scene 10 and prepared-agent approval requests now render schema/payload
|
|
approval UI instead of raw `{ "type": "object" }` as the primary product
|
|
proof. Implementation:
|
|
[`schema approval surface`](historical/superpowers/plans/2026-07-09-schema-approval-surface.md).
|
|
```
|
|
|
|
- [ ] **Step 5: Archive the plan**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git mv docs/superpowers/plans/2026-07-09-schema-approval-surface.md docs/historical/superpowers/plans/2026-07-09-schema-approval-surface.md
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-09-schema-approval-surface.md
|
|
git commit -m "docs: complete schema approval surface"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist
|
|
|
|
- [ ] No task asks the worker to implement JSON Schema validation in React.
|
|
- [ ] Scene 10 approval no longer makes raw `{ "type": "object" }` the primary visual.
|
|
- [ ] Loose-schema fallback uses actual recorded resume payload and has a code comment explaining why.
|
|
- [ ] Chat approval request still works without contract data.
|
|
- [ ] The component has explicit submit/cancel labels, but workflow outcome copy remains submitted/cancelled.
|
|
- [ ] Tests cover projection, component rendering, Scene 10 integration, and chat integration.
|
|
- [ ] Roadmap points to `historical/**` after completion.
|