/** * Dispatch form — pure helpers behind the Operate › Dispatch panel. * * The dispatchable targets come straight from the native manifest's flat * arrays (`actions`, `queries`) — these carry the JSON `inputSchema` that the * deep `model` action node drops (the model keeps only emits/retry). So this * reads the schema-bearing flat arrays rather than the graph nodes. * * Everything here is pure so it unit-tests without a DOM or a live wire. */ import type { Manifest } from "./manifest"; /** A handler the Dispatch panel can invoke via `POST /_nwire/dispatch`. */ export interface DispatchTarget { readonly name: string; readonly app: string; readonly kind: "action" | "query"; readonly description?: string; /** JSON-schema-shaped input (present for actions; queries usually omit it). */ readonly inputSchema?: unknown; } /** A flat, form-renderable view of one top-level schema property. */ export interface SchemaField { readonly name: string; readonly type: string; readonly required: boolean; readonly default?: unknown; readonly enum?: string[]; } interface RawEntry { name?: unknown; app?: unknown; description?: unknown; inputSchema?: unknown; } function toTargets(list: unknown, kind: DispatchTarget["kind"]): DispatchTarget[] { if (!Array.isArray(list)) return []; const out: DispatchTarget[] = []; for (const raw of list as RawEntry[]) { if (typeof raw?.name !== "string") continue; out.push({ name: raw.name, app: typeof raw.app === "string" ? raw.app : "", kind, description: typeof raw.description === "string" ? raw.description : undefined, inputSchema: raw.inputSchema, }); } return out; } /** * Every dispatchable handler in the manifest — actions first (they carry input * schemas and are the common case), then queries. Tolerant of a partial or old * manifest: missing arrays simply contribute nothing. */ export function dispatchTargets(manifest: Manifest | null | undefined): DispatchTarget[] { if (!manifest) return []; const m = manifest as unknown as { actions?: unknown; queries?: unknown }; return [...toTargets(m.actions, "action"), ...toTargets(m.queries, "query")]; } /** Free-text filter over name / app / description (case-insensitive). */ export function filterTargets(targets: readonly DispatchTarget[], query: string): DispatchTarget[] { const q = query.trim().toLowerCase(); if (!q) return [...targets]; return targets.filter( (t) => t.name.toLowerCase().includes(q) || t.app.toLowerCase().includes(q) || (t.description ?? "").toLowerCase().includes(q), ); } interface JsonSchemaProp { type?: string | string[]; default?: unknown; enum?: unknown[]; format?: string; } interface JsonSchema { properties?: Record; required?: unknown; } function asSchema(schema: unknown): JsonSchema | null { if (!schema || typeof schema !== "object") return null; const s = schema as JsonSchema; return s.properties && typeof s.properties === "object" ? s : null; } /** Flatten a JSON schema's top-level properties into form-field rows. */ export function schemaFields(schema: unknown): SchemaField[] { const s = asSchema(schema); if (!s) return []; const required = new Set(Array.isArray(s.required) ? (s.required as string[]) : []); const out: SchemaField[] = []; for (const [name, raw] of Object.entries(s.properties ?? {})) { const p = raw ?? {}; const t = Array.isArray(p.type) ? p.type.join(" | ") : (p.type ?? "any"); out.push({ name, type: t, required: required.has(name), default: p.default, enum: Array.isArray(p.enum) ? p.enum.map(String) : undefined, }); } return out; } /** Seed a sensible input object from a schema's defaults + types. */ export function scaffoldInput(schema: unknown): Record { const s = asSchema(schema); if (!s) return {}; const scaffold: Record = {}; for (const [key, prop] of Object.entries(s.properties ?? {})) { if (prop.default !== undefined) { scaffold[key] = prop.default; continue; } const t = Array.isArray(prop.type) ? prop.type[0] : prop.type; switch (t) { case "string": scaffold[key] = ""; break; case "number": case "integer": scaffold[key] = 0; break; case "boolean": scaffold[key] = false; break; case "array": scaffold[key] = []; break; case "object": scaffold[key] = {}; break; default: scaffold[key] = null; } } return scaffold; } /** Can this field render as a single inline control (vs. the JSON textarea)? */ export function isInlineRenderable(field: SchemaField): boolean { const t = field.type.split(" | ")[0]; return t === "string" || t === "number" || t === "integer" || t === "boolean"; } /** True when every field is a primitive the inline form can render. */ export function allInlineRenderable(fields: readonly SchemaField[]): boolean { return fields.length > 0 && fields.every(isInlineRenderable); }