diff --git a/web/apps/console/src/styles/global.css b/web/apps/console/src/styles/global.css index 63ce19b6..3c8368ac 100644 --- a/web/apps/console/src/styles/global.css +++ b/web/apps/console/src/styles/global.css @@ -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 { diff --git a/web/apps/console/src/workspace/authoring/AuthoringPathPicker.test.tsx b/web/apps/console/src/workspace/authoring/AuthoringPathPicker.test.tsx new file mode 100644 index 00000000..ec97f9be --- /dev/null +++ b/web/apps/console/src/workspace/authoring/AuthoringPathPicker.test.tsx @@ -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, + extras: Partial = {}, +): AuthoringPathOption => ({ + path, + label, + origin, + schema: { type: "string" }, + required: false, + availability: "available", + uses, + ...extras, +}); + +const options: ReadonlyArray = [ + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect( + screen.getByText("Available when the selected step runs in a viewer frame."), + ).toBeInTheDocument(); + + rerender( + 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( + , + ); + + 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( + , + ); + + 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"); + }); +}); diff --git a/web/apps/console/src/workspace/authoring/AuthoringPathPicker.tsx b/web/apps/console/src/workspace/authoring/AuthoringPathPicker.tsx new file mode 100644 index 00000000..0b9adf54 --- /dev/null +++ b/web/apps/console/src/workspace/authoring/AuthoringPathPicker.tsx @@ -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; + readonly uses: AuthoringPathUse | ReadonlyArray; + 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 = [ + { 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, +): ReadonlySet => 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 ( +
+

{label}

+ + setSearch(event.target.value)} + placeholder="Search labels or paths" + type="search" + value={search} + /> +
+ {OPTION_GROUPS.map((group) => { + const groupOptions = visibleOptions.filter((option) => option.origin === group.origin); + if (groupOptions.length === 0) return null; + return ( +
+ {group.label} +
+ {groupOptions.map((option) => { + const compatible = option.uses.some((use) => requestedUses.has(use)); + const reasonId = `${id}-${safeId(option.path)}-reason`; + return ( + + ); + })} +
+
+ ); + })} + {visibleOptions.length === 0 &&

No matching paths.

} +
+ {allowCustom && ( +
+ Advanced + + { + setCustomValue(event.target.value); + onChange(event.target.value); + }} + type="text" + value={customValue} + /> +
+ )} +
+ ); +}; diff --git a/web/apps/console/src/workspace/authoring/useAuthoringContract.test.tsx b/web/apps/console/src/workspace/authoring/useAuthoringContract.test.tsx new file mode 100644 index 00000000..50d3353e --- /dev/null +++ b/web/apps/console/src/workspace/authoring/useAuthoringContract.test.tsx @@ -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( + "../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 = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + +const client = { + inspect: vi.fn(), +} 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(); + const second = deferred(); + 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", + }); + }); +}); diff --git a/web/apps/console/src/workspace/authoring/useAuthoringContract.ts b/web/apps/console/src/workspace/authoring/useAuthoringContract.ts new file mode 100644 index 00000000..938c08a6 --- /dev/null +++ b/web/apps/console/src/workspace/authoring/useAuthoringContract.ts @@ -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( + () => (readExecutor === null ? null : createAuthoringContractClient(readExecutor)), + [readExecutor], + ); + const request = useMemo( + () => + 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(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, + }; +}; diff --git a/web/apps/console/src/workspace/domain/authoring-contract-client.test.ts b/web/apps/console/src/workspace/domain/authoring-contract-client.test.ts new file mode 100644 index 00000000..ed4a955c --- /dev/null +++ b/web/apps/console/src/workspace/domain/authoring-contract-client.test.ts @@ -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 ( + operation: OperationName, + params: unknown, + decode: (value: unknown) => T, + ): Promise => { + 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"); + }); +}); diff --git a/web/apps/console/src/workspace/domain/authoring-contract-client.ts b/web/apps/console/src/workspace/domain/authoring-contract-client.ts new file mode 100644 index 00000000..988b0506 --- /dev/null +++ b/web/apps/console/src/workspace/domain/authoring-contract-client.ts @@ -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; +} + +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; + }, +}); diff --git a/web/apps/console/src/workspace/domain/authoring-contract-models.test.ts b/web/apps/console/src/workspace/domain/authoring-contract-models.test.ts new file mode 100644 index 00000000..9a670ead --- /dev/null +++ b/web/apps/console/src/workspace/domain/authoring-contract-models.test.ts @@ -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; + +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; +}); diff --git a/web/apps/console/src/workspace/domain/authoring-contract-models.ts b/web/apps/console/src/workspace/domain/authoring-contract-models.ts new file mode 100644 index 00000000..13b07ffa --- /dev/null +++ b/web/apps/console/src/workspace/domain/authoring-contract-models.ts @@ -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>; + readonly required: boolean; + readonly availability: AuthoringPathAvailability; + readonly uses: ReadonlyArray; + readonly description?: string | undefined; + readonly reason?: string | undefined; +}; + +export type AuthoringStepContract = { + readonly stepId: string; + readonly label: string; + readonly description?: string | undefined; + readonly inputTargets?: ReadonlyArray | undefined; + readonly outputSources?: ReadonlyArray | undefined; + readonly outcomes?: ReadonlyArray | undefined; +}; + +export type AuthoringContractInventory = { + readonly workspaceId: string; + readonly revision: number; + readonly selectedStepId: string | null; + readonly readableSources: ReadonlyArray; + readonly stepInputTargets: ReadonlyArray; + readonly stepOutputSources: ReadonlyArray; + readonly stateTargets: ReadonlyArray; + readonly workflowOutputTargets: ReadonlyArray; + readonly entrySteps: ReadonlyArray; + readonly workflowOutcomes: ReadonlyArray; + readonly warnings: ReadonlyArray; +}; + +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; +type AuthoringStepContractWire = v.InferOutput; +type AuthoringContractInventoryWire = v.InferOutput< + typeof AuthoringContractInventoryWireSchema +>; +type AuthoringContractInventoryBrowser = v.InferOutput< + typeof AuthoringContractInventoryBrowserSchema +>; + +const decode = ( + label: string, + schema: v.GenericSchema, + 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); +};