feat: browse authoring contract paths

This commit is contained in:
lda
2026-08-14 18:19:29 +07:00 Verified
parent 804946531a
commit 50a462f927
9 changed files with 1268 additions and 0 deletions
+118
View File
@@ -2023,6 +2023,124 @@ tbody tr:hover {
min-width: 0;
}
.authoring-path-picker {
display: grid;
min-width: 0;
gap: 0.45rem;
}
.authoring-path-picker h3 {
margin: 0;
font-size: 0.92rem;
}
.authoring-path-picker > label,
.authoring-path-picker__advanced > label {
color: var(--color-slate);
font-size: 0.78rem;
}
.authoring-path-picker__options {
min-width: 0;
max-height: min(20rem, 48dvh);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.15rem;
border: 1px solid var(--color-border);
background: var(--color-paper);
}
.authoring-path-picker__group {
min-width: 0;
margin: 0;
padding: 0.55rem;
border: 0;
}
.authoring-path-picker__group + .authoring-path-picker__group {
border-top: 1px solid var(--color-border);
}
.authoring-path-picker__group legend {
padding: 0 0.2rem;
color: var(--color-ink);
font-family: var(--font-heading);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.authoring-path-picker__list {
display: grid;
gap: 0.35rem;
}
.authoring-path-picker__option {
display: grid;
width: 100%;
min-width: 0;
gap: 0.12rem;
padding: 0.5rem;
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-ink);
text-align: left;
text-transform: none;
letter-spacing: normal;
}
.authoring-path-picker__option:hover,
.authoring-path-picker__option:focus-visible,
.authoring-path-picker__option[aria-pressed="true"] {
border-color: var(--color-signal-green);
background: #f6fbf7;
}
.authoring-path-picker__option:disabled {
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-slate);
cursor: not-allowed;
opacity: 0.72;
}
.authoring-path-picker__option code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--color-slate);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.authoring-path-picker__option span,
.authoring-path-picker__option small {
color: var(--color-slate);
font-size: 0.72rem;
}
.authoring-path-picker__option small {
overflow-wrap: anywhere;
}
.authoring-path-picker__advanced {
display: grid;
gap: 0.45rem;
padding-top: 0.25rem;
border-top: 1px solid var(--color-border);
}
.authoring-path-picker__advanced summary {
width: fit-content;
color: var(--color-ink);
cursor: pointer;
font-family: var(--font-heading);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Motion */
@keyframes fadeIn {
from {
@@ -0,0 +1,163 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
AuthoringPathOption,
AuthoringPathUse,
} from "../domain/authoring-contract-models.js";
import { AuthoringPathPicker } from "./AuthoringPathPicker.js";
afterEach(() => {
cleanup();
});
const option = (
path: string,
label: string,
origin: AuthoringPathOption["origin"],
uses: ReadonlyArray<AuthoringPathUse>,
extras: Partial<AuthoringPathOption> = {},
): AuthoringPathOption => ({
path,
label,
origin,
schema: { type: "string" },
required: false,
availability: "available",
uses,
...extras,
});
const options: ReadonlyArray<AuthoringPathOption> = [
option("input.title", "Title", "workflow_input", ["step_input"], {
description: "The report title.",
required: true,
}),
option("state.report", "Report state", "workflow_state", ["step_input"]),
option("step_output.markdown", "Markdown", "step_output", ["step_output_source"], {
description: "Rendered markdown content.",
}),
option("context.viewer_id", "Viewer ID", "runtime_context", ["step_input"], {
availability: "conditional",
reason: "Available when the selected step runs in a viewer frame.",
}),
option("output.report", "Final report", "workflow_output", ["workflow_output"]),
option("input.customer.name", "Customer name", "workflow_input", ["step_input"]),
];
describe("AuthoringPathPicker", () => {
it("groups options by semantic origin and shows canonical paths secondarily", () => {
render(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options}
uses="step_input"
value=""
/>,
);
expect(screen.getByRole("group", { name: "Workflow input" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "State" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Step output" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Runtime context" })).toBeInTheDocument();
expect(screen.getByText("input.title")).toBeInTheDocument();
expect(screen.getByText("Title")).toBeInTheDocument();
});
it("searches labels, canonical paths, and descriptions", async () => {
const user = userEvent.setup();
render(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options}
uses="step_input"
value=""
/>,
);
await user.type(screen.getByRole("searchbox", { name: "Search Source path" }), "rendered");
expect(screen.getByRole("button", { name: /Markdown/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Title/ })).not.toBeInTheDocument();
});
it("explains conditional options and hides an empty runtime context group", () => {
const { rerender } = render(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options}
uses="step_input"
value=""
/>,
);
expect(
screen.getByText("Available when the selected step runs in a viewer frame."),
).toBeInTheDocument();
rerender(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options.filter((entry) => entry.origin !== "runtime_context")}
uses="step_input"
value=""
/>,
);
expect(screen.queryByRole("group", { name: "Runtime context" })).not.toBeInTheDocument();
});
it("disables incompatible entries and emits only a canonical path", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<AuthoringPathPicker
label="Source path"
onChange={onChange}
options={options}
uses="step_input"
value=""
/>,
);
const incompatible = screen.getByRole("button", { name: /Final report/ });
expect(incompatible).toBeDisabled();
await user.click(incompatible);
await user.click(screen.getByRole("button", { name: /Title/ }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("input.title");
});
it("keeps nested choices keyboard reachable and preserves normal choices in Advanced mode", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<AuthoringPathPicker
allowCustom
label="Source path"
onChange={onChange}
options={options}
uses="step_input"
value=""
/>,
);
const nested = screen.getByRole("button", { name: /Customer name/ });
nested.focus();
await user.keyboard("{Enter}");
expect(onChange).toHaveBeenCalledWith("input.customer.name");
await user.click(screen.getByText("Advanced"));
expect(screen.getByRole("textbox", { name: "Custom Source path" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Title/ })).toBeInTheDocument();
await user.clear(screen.getByRole("textbox", { name: "Custom Source path" }));
await user.type(screen.getByRole("textbox", { name: "Custom Source path" }), "context.future");
expect(onChange).toHaveBeenLastCalledWith("context.future");
});
});
@@ -0,0 +1,135 @@
import { useEffect, useId, useState } from "react";
import type {
AuthoringPathOption,
AuthoringPathOrigin,
AuthoringPathUse,
} from "../domain/authoring-contract-models.js";
export type AuthoringPathPickerProps = {
readonly options: ReadonlyArray<AuthoringPathOption>;
readonly uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>;
readonly value: string;
readonly onChange: (value: string) => void;
readonly label: string;
readonly allowCustom?: boolean;
};
type OptionGroup = {
readonly origin: AuthoringPathOrigin;
readonly label: string;
};
const OPTION_GROUPS: ReadonlyArray<OptionGroup> = [
{ origin: "workflow_input", label: "Workflow input" },
{ origin: "workflow_state", label: "State" },
{ origin: "step_output", label: "Step output" },
{ origin: "runtime_context", label: "Runtime context" },
{ origin: "workflow_output", label: "Workflow output" },
{ origin: "step_input", label: "Step input" },
];
const safeId = (value: string): string => value.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
const optionText = (option: AuthoringPathOption): string =>
[option.label, option.path, option.description].filter(Boolean).join(" ").toLocaleLowerCase();
const normalizedUses = (
uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>,
): ReadonlySet<AuthoringPathUse> => new Set(Array.isArray(uses) ? uses : [uses]);
export const AuthoringPathPicker = ({
options,
uses,
value,
onChange,
label,
allowCustom = false,
}: AuthoringPathPickerProps) => {
const id = safeId(useId());
const searchId = `${id}-search`;
const customId = `${id}-custom`;
const [search, setSearch] = useState("");
const [customValue, setCustomValue] = useState(value);
const requestedUses = normalizedUses(uses);
const normalizedSearch = search.trim().toLocaleLowerCase();
useEffect(() => {
setCustomValue(value);
}, [value]);
const visibleOptions = options.filter((option) =>
normalizedSearch === "" || optionText(option).includes(normalizedSearch),
);
return (
<section aria-labelledby={`${id}-heading`} className="authoring-path-picker">
<h3 id={`${id}-heading`}>{label}</h3>
<label htmlFor={searchId}>Search {label}</label>
<input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search labels or paths"
type="search"
value={search}
/>
<div aria-label={`${label} options`} className="authoring-path-picker__options">
{OPTION_GROUPS.map((group) => {
const groupOptions = visibleOptions.filter((option) => option.origin === group.origin);
if (groupOptions.length === 0) return null;
return (
<fieldset className="authoring-path-picker__group" key={group.origin}>
<legend>{group.label}</legend>
<div className="authoring-path-picker__list">
{groupOptions.map((option) => {
const compatible = option.uses.some((use) => requestedUses.has(use));
const reasonId = `${id}-${safeId(option.path)}-reason`;
return (
<button
aria-describedby={
option.reason !== undefined || !compatible ? reasonId : undefined
}
aria-pressed={option.path === value}
className="authoring-path-picker__option"
disabled={!compatible}
key={option.path}
onClick={() => {
setCustomValue(option.path);
onChange(option.path);
}}
type="button"
>
<strong>{option.label}</strong>
<code>{option.path}</code>
{option.description !== undefined && <span>{option.description}</span>}
{option.required && <small>Required</small>}
{option.availability === "conditional" && option.reason !== undefined && (
<small id={reasonId}>{option.reason}</small>
)}
{!compatible && <small id={reasonId}>Not available for this field.</small>}
</button>
);
})}
</div>
</fieldset>
);
})}
{visibleOptions.length === 0 && <p>No matching paths.</p>}
</div>
{allowCustom && (
<details className="authoring-path-picker__advanced">
<summary>Advanced</summary>
<label htmlFor={customId}>Custom {label}</label>
<input
id={customId}
onChange={(event) => {
setCustomValue(event.target.value);
onChange(event.target.value);
}}
type="text"
value={customValue}
/>
</details>
)}
</section>
);
};
@@ -0,0 +1,196 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionState } from "../../app/state.js";
import { useConsoleWorkspace } from "../context.js";
import {
createAuthoringContractClient,
type AuthoringContractClient,
} from "../domain/authoring-contract-client.js";
import type {
AuthoringContractInventory,
AuthoringPathOption,
} from "../domain/authoring-contract-models.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
import { useAuthoringContract } from "./useAuthoringContract.js";
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
vi.mock("../domain/authoring-contract-client.js", async () => {
const actual = await vi.importActual<typeof import("../domain/authoring-contract-client.js")>(
"../domain/authoring-contract-client.js",
);
return { ...actual, createAuthoringContractClient: vi.fn() };
});
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCreateAuthoringContractClient = vi.mocked(createAuthoringContractClient);
const connectedState = {
phase: "connected",
connectedTarget: "http://workflow.example/rpc",
} as ConnectionState;
const disconnectedState = {
phase: "not_configured",
connectedTarget: null,
} as ConnectionState;
const option: AuthoringPathOption = {
path: "input.title",
label: "Title",
origin: "workflow_input",
schema: { type: "string" },
required: true,
availability: "available",
uses: ["step_input"],
};
const inventory = (revision: number, selectedStepId: string | null): AuthoringContractInventory => ({
workspaceId: "draft-report",
revision,
selectedStepId,
readableSources: [option],
stepInputTargets: [],
stepOutputSources: [],
stateTargets: [],
workflowOutputTargets: [],
entrySteps: [],
workflowOutcomes: ["ok"],
warnings: [],
});
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const client = {
inspect: vi.fn<AuthoringContractClient["inspect"]>(),
} satisfies AuthoringContractClient;
const readExecutor = {} as ConsoleReadExecutor;
beforeEach(() => {
client.inspect.mockReset();
mockedCreateAuthoringContractClient.mockReset();
mockedCreateAuthoringContractClient.mockReturnValue(client);
mockedUseConsoleWorkspace.mockReturnValue({
connection: connectedState,
connectedTarget: connectedState.connectedTarget,
recordEvidence: vi.fn(),
readExecutor,
writeExecutor: null,
});
});
describe("useAuthoringContract", () => {
it("stays disconnected without a read executor", () => {
mockedUseConsoleWorkspace.mockReturnValue({
connection: disconnectedState,
connectedTarget: null,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: null,
});
const { result } = renderHook(() =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
);
expect(result.current.phase).toBe("disconnected");
expect(client.inspect).not.toHaveBeenCalled();
});
it("loads the contract projection with a null selected step", async () => {
client.inspect.mockResolvedValue(inventory(7, null));
const { result } = renderHook(() =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
);
await waitFor(() => expect(result.current.phase).toBe("ready"));
expect(client.inspect).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 7,
selectedStepId: null,
});
expect(result.current.inventory?.selectedStepId).toBeNull();
});
it("passes the executable step id and ignores a stale selection response", async () => {
const first = deferred<AuthoringContractInventory>();
const second = deferred<AuthoringContractInventory>();
client.inspect.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { result, rerender } = renderHook(
({ selectedStepId }: { readonly selectedStepId: string | null }) =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId }),
{ initialProps: { selectedStepId: "read" } },
);
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(1));
rerender({ selectedStepId: "render" });
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(2));
first.resolve(inventory(7, "read"));
second.resolve(inventory(7, "render"));
await waitFor(() => expect(result.current.phase).toBe("ready"));
expect(result.current.inventory?.selectedStepId).toBe("render");
expect(client.inspect).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 7,
selectedStepId: "render",
});
});
it("retains the last matching inventory when a refresh fails", async () => {
client.inspect
.mockResolvedValueOnce(inventory(7, "read"))
.mockRejectedValueOnce(new Error("inspection failed"));
const { result } = renderHook(() =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: "read" }),
);
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.refresh());
await waitFor(() => expect(result.current.phase).toBe("error"));
expect(result.current.inventory?.selectedStepId).toBe("read");
expect(result.current.message).toBe("inspection failed");
});
it("reloads when the revision changes and on manual refresh", async () => {
client.inspect
.mockResolvedValueOnce(inventory(7, "read"))
.mockResolvedValueOnce(inventory(8, "read"))
.mockResolvedValueOnce(inventory(8, "read"));
const { result, rerender } = renderHook(
({ revision }: { readonly revision: number }) =>
useAuthoringContract({ workspaceId: "draft-report", revision, selectedStepId: "read" }),
{ initialProps: { revision: 7 } },
);
await waitFor(() => expect(result.current.inventory?.revision).toBe(7));
rerender({ revision: 8 });
await waitFor(() => expect(result.current.inventory?.revision).toBe(8));
act(() => result.current.refresh());
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(3));
expect(client.inspect).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
selectedStepId: "read",
});
});
});
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConsoleWorkspace } from "../context.js";
import {
createAuthoringContractClient,
type AuthoringContractClient,
} from "../domain/authoring-contract-client.js";
import type { AuthoringContractInventory } from "../domain/authoring-contract-models.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
export type AuthoringContractPhase =
| "disconnected"
| "idle"
| "loading"
| "ready"
| "error";
export type UseAuthoringContractOptions = {
readonly workspaceId: string | null;
readonly revision: number | null;
readonly selectedStepId?: string | null;
};
export type AuthoringContractController = {
readonly phase: AuthoringContractPhase;
readonly inventory: AuthoringContractInventory | null;
readonly message: string | null;
readonly refresh: () => void;
};
type RequestIdentity = {
readonly readExecutor: ConsoleReadExecutor;
readonly connectedTarget: string;
readonly workspaceId: string;
readonly revision: number;
readonly selectedStepId: string | null;
};
type StoredInventory = {
readonly request: RequestIdentity;
readonly inventory: AuthoringContractInventory;
};
type AuthoringContractState = {
readonly phase: AuthoringContractPhase;
readonly stored: StoredInventory | null;
readonly message: string | null;
};
const initialState: AuthoringContractState = {
phase: "disconnected",
stored: null,
message: null,
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const sameRequest = (left: RequestIdentity, right: RequestIdentity): boolean =>
left.readExecutor === right.readExecutor &&
left.connectedTarget === right.connectedTarget &&
left.workspaceId === right.workspaceId &&
left.revision === right.revision &&
left.selectedStepId === right.selectedStepId;
export const useAuthoringContract = ({
workspaceId,
revision,
selectedStepId,
}: UseAuthoringContractOptions): AuthoringContractController => {
const { connectedTarget, readExecutor } = useConsoleWorkspace();
const client = useMemo<AuthoringContractClient | null>(
() => (readExecutor === null ? null : createAuthoringContractClient(readExecutor)),
[readExecutor],
);
const request = useMemo<RequestIdentity | null>(
() =>
readExecutor !== null &&
connectedTarget !== null &&
workspaceId !== null &&
revision !== null
? {
readExecutor,
connectedTarget,
workspaceId,
revision,
selectedStepId: selectedStepId ?? null,
}
: null,
[connectedTarget, readExecutor, revision, selectedStepId, workspaceId],
);
const [state, setState] = useState<AuthoringContractState>(initialState);
const generationRef = useRef(0);
const inspect = useCallback(
(): void => {
if (client === null || request === null) return;
const generation = ++generationRef.current;
const requested = request;
setState((current) => ({
phase: "loading",
stored:
current.stored !== null && sameRequest(current.stored.request, requested)
? current.stored
: null,
message: null,
}));
void client
.inspect({
workspaceId: requested.workspaceId,
revision: requested.revision,
selectedStepId: requested.selectedStepId,
})
.then((inventory) => {
if (generation !== generationRef.current) return;
setState({
phase: "ready",
stored: { request: requested, inventory },
message: null,
});
})
.catch((error: unknown) => {
if (generation !== generationRef.current) return;
setState((current) => ({
...current,
phase: "error",
message: errorMessage(error),
}));
});
}, [client, request]);
useEffect(() => {
if (request === null || client === null) {
generationRef.current++;
setState({
phase: client === null || connectedTarget === null ? "disconnected" : "idle",
stored: null,
message: null,
});
return;
}
inspect();
}, [client, connectedTarget, inspect, request]);
const refresh = useCallback((): void => {
inspect();
}, [inspect]);
const currentInventory =
request !== null && state.stored !== null && sameRequest(state.stored.request, request)
? state.stored.inventory
: null;
const phase =
request !== null &&
(state.stored === null || !sameRequest(state.stored.request, request))
? "loading"
: request === null
? client === null || connectedTarget === null
? "disconnected"
: "idle"
: state.phase;
return {
phase,
inventory: currentInventory,
message: currentInventory === null && phase === "loading" ? null : state.message,
refresh,
};
};
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { OperationName } from "../../connection/contracts.js";
import {
decodeAuthoringContractInventory,
} from "./authoring-contract-models.js";
import { createAuthoringContractClient } from "./authoring-contract-client.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
const wireInventory = {
workspace_id: "draft-report",
revision: 7,
selected_step_id: null,
readable_sources: [],
step_input_targets: [],
step_output_sources: [],
state_targets: [],
workflow_output_targets: [],
entry_steps: [],
workflow_outcomes: ["ok"],
warnings: [],
};
type RunCall = {
readonly operation: OperationName;
readonly params: unknown;
readonly decode: (value: unknown) => unknown;
};
const runWith = (
response: unknown,
): { readonly executor: ConsoleReadExecutor; readonly calls: RunCall[] } => {
const calls: RunCall[] = [];
const run: ConsoleReadExecutor["run"] = async <T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T> => {
calls.push({ operation, params, decode });
return decode(response);
};
return { executor: { run }, calls };
};
describe("AuthoringContractClient", () => {
it("sends the exact inspection params and decodes the response", async () => {
const { executor, calls } = runWith(wireInventory);
const client = createAuthoringContractClient(executor);
const result = await client.inspect({
workspaceId: " draft-report ",
revision: 7,
selectedStepId: "render",
});
expect(calls[0]?.operation).toBe("workflow.draft_workspaces.inspect_authoring_contract");
expect(calls[0]?.params).toEqual({
workspace_id: "draft-report",
revision: 7,
selected_step_id: "render",
});
expect(calls[0]?.decode).toBe(decodeAuthoringContractInventory);
expect(result.workspaceId).toBe("draft-report");
});
it("passes null when no step is selected", async () => {
const { executor, calls } = runWith(wireInventory);
const client = createAuthoringContractClient(executor);
await client.inspect({ workspaceId: "draft-report", revision: 7, selectedStepId: null });
expect(calls[0]?.operation).toBe("workflow.draft_workspaces.inspect_authoring_contract");
expect(calls[0]?.params).toEqual({
workspace_id: "draft-report",
revision: 7,
selected_step_id: null,
});
expect(calls[0]?.decode).toBe(decodeAuthoringContractInventory);
});
it.each([
["workspaceId", { workspace_id: "other" }],
["revision", { revision: 8 }],
])("rejects a response with a mismatched %s", async (_field, replacement) => {
const { executor } = runWith({ ...wireInventory, ...replacement });
const client = createAuthoringContractClient(executor);
await expect(
client.inspect({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
).rejects.toThrow("does not match inspection request");
});
});
@@ -0,0 +1,51 @@
import type { OperationName } from "../../connection/contracts.js";
import {
decodeAuthoringContractInventory,
type AuthoringContractInventory,
} from "./authoring-contract-models.js";
import { ConsoleClientError } from "./errors.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
export type AuthoringContractInspectionInput = {
readonly workspaceId: string;
readonly revision: number;
readonly selectedStepId?: string | null;
};
export interface AuthoringContractClient {
inspect(input: AuthoringContractInspectionInput): Promise<AuthoringContractInventory>;
}
const invalidInput = (operation: OperationName, message: string): ConsoleClientError =>
new ConsoleClientError("operation", operation, message);
export const createAuthoringContractClient = (
executor: ConsoleReadExecutor,
): AuthoringContractClient => ({
inspect: async (input) => {
const workspaceId = input.workspaceId.trim();
if (!workspaceId) {
throw invalidInput(
"workflow.draft_workspaces.inspect_authoring_contract",
"workspace id must not be blank",
);
}
const selectedStepId = input.selectedStepId?.trim() || null;
const inventory = await executor.run(
"workflow.draft_workspaces.inspect_authoring_contract",
{
workspace_id: workspaceId,
revision: input.revision,
selected_step_id: selectedStepId,
},
decodeAuthoringContractInventory,
);
if (
inventory.workspaceId !== workspaceId ||
inventory.revision !== input.revision
) {
throw new Error("authoring contract response does not match inspection request");
}
return inventory;
},
});
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
decodeAuthoringContractInventory,
type AuthoringContractInventory,
} from "./authoring-contract-models.js";
const pathOption = {
path: "input.title",
label: "Title",
origin: "workflow_input",
schema: { type: "string", description: "A report title." },
required: true,
availability: "available",
uses: ["step_input", "workflow_output"],
description: "A report title.",
};
const inventory = {
workspace_id: "draft-report",
revision: 7,
selected_step_id: "render",
readable_sources: [pathOption],
step_input_targets: [
{
...pathOption,
path: "step_input.title",
origin: "step_input",
uses: ["step_input"],
},
],
step_output_sources: [
{
...pathOption,
path: "step_output.markdown",
origin: "step_output",
uses: ["step_output_source"],
},
],
state_targets: [
{
...pathOption,
path: "state.report",
origin: "workflow_state",
uses: ["state_target", "workflow_output"],
},
],
workflow_output_targets: [
{
...pathOption,
path: "output.report",
origin: "workflow_output",
uses: ["workflow_output"],
},
],
entry_steps: [
{
step_id: "render",
label: "Render report",
description: "Render the report.",
input_targets: [],
output_sources: [],
outcomes: ["ok", "error"],
},
],
workflow_outcomes: ["ok", "error"],
warnings: ["Context is conditional."],
} satisfies Record<string, unknown>;
describe("authoring contract models", () => {
it("decodes the complete inventory into camelCase browser fields", () => {
const decoded = decodeAuthoringContractInventory(inventory);
expect(decoded.workspaceId).toBe("draft-report");
expect(decoded.selectedStepId).toBe("render");
expect(decoded.readableSources[0]?.origin).toBe("workflow_input");
expect(decoded.readableSources[0]?.schema).toEqual(pathOption.schema);
expect(decoded.stepInputTargets[0]?.path).toBe("step_input.title");
expect(decoded.entrySteps[0]?.stepId).toBe("render");
expect(decoded.entrySteps[0]?.inputTargets).toEqual([]);
});
it.each([
["origin", { origin: "unknown" }],
["availability", { availability: "maybe" }],
["use", { uses: ["unknown"] }],
["schema", { schema: "not an object" }],
])("rejects an unknown or malformed path option %s", (_field, replacement) => {
const malformed = {
...inventory,
readable_sources: [{ ...pathOption, ...replacement }],
};
expect(() => decodeAuthoringContractInventory(malformed)).toThrow(
"AuthoringContractInventory is malformed",
);
});
it("rejects a malformed inventory envelope", () => {
expect(() => decodeAuthoringContractInventory({ ...inventory, revision: "7" })).toThrow(
"AuthoringContractInventory is malformed",
);
});
const _typeCheck: AuthoringContractInventory | null = null;
void _typeCheck;
});
@@ -0,0 +1,239 @@
import * as v from "valibot";
export type AuthoringPathOrigin =
| "workflow_input"
| "workflow_state"
| "runtime_context"
| "step_input"
| "step_output"
| "workflow_output";
export type AuthoringPathAvailability = "available" | "conditional";
export type AuthoringPathUse =
| "step_input"
| "step_output_source"
| "state_target"
| "workflow_output";
export type AuthoringPathOption = {
readonly path: string;
readonly label: string;
readonly origin: AuthoringPathOrigin;
readonly schema: Readonly<Record<string, unknown>>;
readonly required: boolean;
readonly availability: AuthoringPathAvailability;
readonly uses: ReadonlyArray<AuthoringPathUse>;
readonly description?: string | undefined;
readonly reason?: string | undefined;
};
export type AuthoringStepContract = {
readonly stepId: string;
readonly label: string;
readonly description?: string | undefined;
readonly inputTargets?: ReadonlyArray<AuthoringPathOption> | undefined;
readonly outputSources?: ReadonlyArray<AuthoringPathOption> | undefined;
readonly outcomes?: ReadonlyArray<string> | undefined;
};
export type AuthoringContractInventory = {
readonly workspaceId: string;
readonly revision: number;
readonly selectedStepId: string | null;
readonly readableSources: ReadonlyArray<AuthoringPathOption>;
readonly stepInputTargets: ReadonlyArray<AuthoringPathOption>;
readonly stepOutputSources: ReadonlyArray<AuthoringPathOption>;
readonly stateTargets: ReadonlyArray<AuthoringPathOption>;
readonly workflowOutputTargets: ReadonlyArray<AuthoringPathOption>;
readonly entrySteps: ReadonlyArray<AuthoringStepContract>;
readonly workflowOutcomes: ReadonlyArray<string>;
readonly warnings: ReadonlyArray<string>;
};
const JsonObjectSchema = v.record(v.string(), v.unknown());
const AuthoringPathOriginSchema = v.union([
v.literal("workflow_input"),
v.literal("workflow_state"),
v.literal("runtime_context"),
v.literal("step_input"),
v.literal("step_output"),
v.literal("workflow_output"),
]);
const AuthoringPathAvailabilitySchema = v.union([
v.literal("available"),
v.literal("conditional"),
]);
const AuthoringPathUseSchema = v.union([
v.literal("step_input"),
v.literal("step_output_source"),
v.literal("state_target"),
v.literal("workflow_output"),
]);
const AuthoringPathOptionWireSchema = v.object({
path: v.string(),
label: v.string(),
origin: AuthoringPathOriginSchema,
schema: JsonObjectSchema,
required: v.boolean(),
availability: AuthoringPathAvailabilitySchema,
uses: v.array(AuthoringPathUseSchema),
description: v.optional(v.string()),
reason: v.optional(v.string()),
});
const AuthoringStepContractWireSchema = v.object({
step_id: v.string(),
label: v.string(),
description: v.optional(v.string()),
input_targets: v.optional(v.array(AuthoringPathOptionWireSchema)),
output_sources: v.optional(v.array(AuthoringPathOptionWireSchema)),
outcomes: v.optional(v.array(v.string())),
});
const AuthoringContractInventoryWireSchema = v.object({
workspace_id: v.string(),
revision: v.number(),
selected_step_id: v.nullable(v.string()),
readable_sources: v.array(AuthoringPathOptionWireSchema),
step_input_targets: v.array(AuthoringPathOptionWireSchema),
step_output_sources: v.array(AuthoringPathOptionWireSchema),
state_targets: v.array(AuthoringPathOptionWireSchema),
workflow_output_targets: v.array(AuthoringPathOptionWireSchema),
entry_steps: v.array(AuthoringStepContractWireSchema),
workflow_outcomes: v.array(v.string()),
warnings: v.array(v.string()),
});
const AuthoringPathOptionBrowserSchema = v.object({
path: v.string(),
label: v.string(),
origin: AuthoringPathOriginSchema,
schema: JsonObjectSchema,
required: v.boolean(),
availability: AuthoringPathAvailabilitySchema,
uses: v.array(AuthoringPathUseSchema),
description: v.optional(v.string()),
reason: v.optional(v.string()),
});
const AuthoringStepContractBrowserSchema = v.object({
stepId: v.string(),
label: v.string(),
description: v.optional(v.string()),
inputTargets: v.optional(v.array(AuthoringPathOptionBrowserSchema)),
outputSources: v.optional(v.array(AuthoringPathOptionBrowserSchema)),
outcomes: v.optional(v.array(v.string())),
});
const AuthoringContractInventoryBrowserSchema = v.object({
workspaceId: v.string(),
revision: v.number(),
selectedStepId: v.nullable(v.string()),
readableSources: v.array(AuthoringPathOptionBrowserSchema),
stepInputTargets: v.array(AuthoringPathOptionBrowserSchema),
stepOutputSources: v.array(AuthoringPathOptionBrowserSchema),
stateTargets: v.array(AuthoringPathOptionBrowserSchema),
workflowOutputTargets: v.array(AuthoringPathOptionBrowserSchema),
entrySteps: v.array(AuthoringStepContractBrowserSchema),
workflowOutcomes: v.array(v.string()),
warnings: v.array(v.string()),
});
type AuthoringPathOptionWire = v.InferOutput<typeof AuthoringPathOptionWireSchema>;
type AuthoringStepContractWire = v.InferOutput<typeof AuthoringStepContractWireSchema>;
type AuthoringContractInventoryWire = v.InferOutput<
typeof AuthoringContractInventoryWireSchema
>;
type AuthoringContractInventoryBrowser = v.InferOutput<
typeof AuthoringContractInventoryBrowserSchema
>;
const decode = <T>(
label: string,
schema: v.GenericSchema<unknown, T>,
value: unknown,
): T => {
const result = v.safeParse(schema, value);
if (result.success) return result.output;
throw new Error(
`${label} is malformed: ${result.issues[0]?.message ?? "unknown issue"}`,
);
};
const mapPathOption = (option: AuthoringPathOptionWire): AuthoringPathOption => ({
path: option.path,
label: option.label,
origin: option.origin,
schema: option.schema,
required: option.required,
availability: option.availability,
uses: option.uses,
...(option.description === undefined ? {} : { description: option.description }),
...(option.reason === undefined ? {} : { reason: option.reason }),
});
const mapStepContract = (step: AuthoringStepContractWire): AuthoringStepContract => ({
stepId: step.step_id,
label: step.label,
...(step.description === undefined ? {} : { description: step.description }),
...(step.input_targets === undefined
? {}
: { inputTargets: step.input_targets.map(mapPathOption) }),
...(step.output_sources === undefined
? {}
: { outputSources: step.output_sources.map(mapPathOption) }),
...(step.outcomes === undefined ? {} : { outcomes: step.outcomes }),
});
const mapInventory = (
inventory: AuthoringContractInventoryWire,
): AuthoringContractInventory => ({
workspaceId: inventory.workspace_id,
revision: inventory.revision,
selectedStepId: inventory.selected_step_id,
readableSources: inventory.readable_sources.map(mapPathOption),
stepInputTargets: inventory.step_input_targets.map(mapPathOption),
stepOutputSources: inventory.step_output_sources.map(mapPathOption),
stateTargets: inventory.state_targets.map(mapPathOption),
workflowOutputTargets: inventory.workflow_output_targets.map(mapPathOption),
entrySteps: inventory.entry_steps.map(mapStepContract),
workflowOutcomes: inventory.workflow_outcomes,
warnings: inventory.warnings,
});
const mapBrowserInventory = (
inventory: AuthoringContractInventoryBrowser,
): AuthoringContractInventory => ({
workspaceId: inventory.workspaceId,
revision: inventory.revision,
selectedStepId: inventory.selectedStepId,
readableSources: inventory.readableSources,
stepInputTargets: inventory.stepInputTargets,
stepOutputSources: inventory.stepOutputSources,
stateTargets: inventory.stateTargets,
workflowOutputTargets: inventory.workflowOutputTargets,
entrySteps: inventory.entrySteps,
workflowOutcomes: inventory.workflowOutcomes,
warnings: inventory.warnings,
});
const AuthoringContractResponseSchema = v.union([
AuthoringContractInventoryWireSchema,
AuthoringContractInventoryBrowserSchema,
]);
export const decodeAuthoringContractInventory = (
value: unknown,
): AuthoringContractInventory => {
const decoded = decode(
"AuthoringContractInventory",
AuthoringContractResponseSchema,
value,
);
return "workspace_id" in decoded ? mapInventory(decoded) : mapBrowserInventory(decoded);
};