diff --git a/console/Cargo.lock b/console/Cargo.lock index 6afb7d07..3477f0dd 100644 --- a/console/Cargo.lock +++ b/console/Cargo.lock @@ -257,7 +257,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "console" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-trait", diff --git a/console/web/docs/custom-function-components.md b/console/web/docs/custom-function-components.md new file mode 100644 index 00000000..1a7c9ca1 --- /dev/null +++ b/console/web/docs/custom-function-components.md @@ -0,0 +1,488 @@ +# Custom function components + +How to add bespoke UI for `function-call` messages in the console chat, instead of the default request/response JSON panes. + +**Reference implementation:** `src/components/chat/sandbox/` (15 `sandbox::*` tools, terminal + raw JSON tabs, approval previews, unified error handling). + +**Definition of done:** A custom renderer is not complete until it ships with **both** dev surfaces below — static cards on **Examples** and at least one interactive **Playground** scenario. Do not merge UI-only changes without playground coverage. + +--- + +## How it works today + +Every tool invocation becomes a `FunctionCallMessage` (`src/types/chat.ts`). The shell component `FunctionCallMessage.tsx` renders: + +1. **Header** — status dot, `permission to run` / `running` / `ran`, function id, duration. +2. **Body** — depends on lifecycle and whether a custom renderer returned a node. +3. **Pending bar** — approve/deny (unchanged by custom renderers). + +Default body = two `ValuePane`s (request + response) with `JsonHighlight`. + +Custom renderers opt in by returning a React node from `tryRender` / `tryRenderPreview`. If they return `null`, the UI falls back to JSON silently. + +```mermaid +flowchart TB + subgraph host [FunctionCallMessage.tsx] + H[Header + optional custom label] + P[Pending preview slot] + R[Running body slot] + D[Done: Tabs terminal + raw json OR JSON only] + A[Approve/deny bar] + end + subgraph plugin [Your module e.g. sandbox/] + ID[isFunctionId] + TR[tryRender] + TP[tryRenderPreview] + HL[optional FunctionIdLabel] + end + H --> HL + P --> TP + R --> TR + D --> TR + TR -->|null| JSON[ValuePane JSON fallback] +``` + +**Important:** Only `sandbox` is wired today. `FunctionCallMessage.tsx` imports `SandboxToolView` and `SandboxFunctionIdLabel` directly. Adding another family requires either more imports in FCM or a small registry (see [Scale beyond one family](#scale-beyond-one-family)). + +--- + +## Message contract + +```typescript +// src/types/chat.ts +interface FunctionCallMessage extends BaseMessage { + role: 'function-call' + functionId: string // e.g. "sandbox::exec", "shell::run" + input: unknown + output?: unknown + durationMs?: number + running?: boolean + pendingApproval?: boolean + functionCallId?: string // for approval::resolve + sessionId?: string +} +``` + +| State | Flags | What the custom UI should do | +|-------|--------|------------------------------| +| Pending approval | `pendingApproval: true` | Show `tryRenderPreview` only; return `null` from `tryRender`. Keep approve/deny bar as-is. | +| Running | `running: true`, not pending | `tryRender` with `running` prop or equivalent; hide request JSON when you own the body. | +| Done | neither flag | `tryRender` for success/error; FCM adds **terminal** (default) + **raw json** tabs when non-null. | +| Failed | `output` set, parse as error | Return error UI from `tryRender` before success parsers (see sandbox `parseSandboxErrorDisplay`). | + +Wire shapes come from the harness/engine. They are not normalized in the UI layer except inside your parsers. + +--- + +## Payload shapes to plan for + +### 1. Raw handler JSON + +What the Rust/Python handler returns, e.g. sandbox `ExecResponse`: + +```json +{ "stdout": "...", "stderr": "", "exit_code": 0, "duration_ms": 41 } +``` + +### 2. Harness agent envelope + +Added by `workers/harness/src/turn-orchestrator/agent-trigger.ts` for many agent turns: + +```json +{ + "content": [{ "type": "text", "text": "..." }], + "details": { /* actual payload */ }, + "terminate": true +} +``` + +Always unwrap before Zod parsing. Sandbox helper: + +```typescript +// sandbox/parsers.ts — unwrapEnvelope(value) +``` + +### 3. Structured sandbox errors (`SandboxErrorWire`) + +Flat object inside `details` (or raw output): + +```json +{ + "type": "exec_timeout", + "code": "S200", + "message": "...", + "docs_url": "https://...", + "retryable": true, + "fix": { ... }, + "fix_note": "..." +} +``` + +### 4. Transport / gate / `function_error` wrapper + +What you see when invocation fails before the handler body is parsed, e.g. `gate_unavailable`: + +```json +{ + "error": { + "kind": "function_error", + "message": "trigger_failed: ... {\"code\":\"S220\",...}", + "details": { + "status": "denied", + "denied_by": "gate_unavailable", + "function_id": "sandbox::fs::write", + "reason": "..." + }, + "content": [{ "type": "text", "text": "..." }] + } +} +``` + +Sandbox centralizes this in `parseSandboxErrorDisplay()` → `SandboxErrorView` (`ErrorView.tsx`). Reuse or mirror for your domain if the same translate layer is used. + +--- + +## Recommended module layout + +Mirror `sandbox/` for a new function family (example: `myfeature/`): + +``` +src/components/chat/myfeature/ + index.tsx # dispatcher: isMyFeature, tryRender, tryRenderPreview, MyFeatureFunctionIdLabel + parsers.ts # Zod schemas + unwrapEnvelope + safeParseRequest/Response + error parsing + format.ts # display helpers (bytes, paths, durations) — optional + ErrorView.tsx # domain errors — optional if you reuse a shared error module + SomeToolView.tsx # one component per function_id (or grouped by shape) + shared.tsx # Chip, MetaRow, StatusPill — or import from sandbox/shared.tsx + __tests__/ + parsers.test.ts # envelope unwrap + every schema + error cases +``` + +### Dispatcher API (`index.tsx`) + +Export a single object (same shape as `SandboxToolView`): + +| Method | When called | Contract | +|--------|-------------|----------| +| `isMyFeature(functionId)` | Optional; FCM/registry routing | Explicit `Set` of ids — avoid broad regex. | +| `tryRender(message)` | Not pending; running or done | `ReactNode \| null`. Check errors first, then `switch (functionId)`. | +| `tryRenderPreview(message)` | `pendingApproval` | Compact approval UI; `null` → request JSON shown. | +| `MyFeatureFunctionIdLabel` | Header | Optional muted prefix (`myfeature::` + tail). | + +Inside `tryRender`: + +1. Return `null` if `!isMyFeature(message.functionId)` or `message.pendingApproval`. +2. Parse errors from **raw** `message.output` (before unwrap) if your errors sit outside `details`. +3. `const input = unwrapEnvelope(message.input)`. +4. `const output = message.output != null ? unwrapEnvelope(message.output) : undefined`. +5. `safeParseResponse(schema, rawOutput)` when the schema applies to wrapped output (sandbox pattern). + +Per-tool views should accept `{ input, output?, running? }` and return `null` internally if parse fails (dispatcher already returned null for unknown ids). + +### Parsers (`parsers.ts`) + +- One Zod schema per request/response struct (non-strict `.object({...})` for forward compatibility). +- `safeParseRequest` / `safeParseResponse` that unwrap then parse. +- Document wire sources (Rust file paths) in comments like sandbox does. +- Export `SANDBOX_FUNCTION_IDS` equivalent: `MY_FEATURE_FUNCTION_IDS` as `as const` + `Set`. + +### Views + +- Reuse design tokens: `border-rule`, `bg-paper-2`, `text-ink`, `text-warn`, `font-mono`, `Badge`, `Cell`, `EmptyState`. +- Shared terminal chrome: copy `sandbox/terminal/Terminal.tsx` + `AnsiOutput.tsx` for command-like tools. +- Code blocks: `CodeHighlight` from `src/lib/syntax.tsx` (or `sandbox/CodeHighlight.tsx`). +- Running state: same shell as done, body shows muted `executing…` (see `ExecView`). + +--- + +## Checklist: add a new custom function family + +### 1. Inventory function IDs + +List every `function_id` the agent can call (from engine catalog, skills, or `functions-catalog.ts`). Add them to an explicit allowlist in `index.tsx`. + +### 2. Define Zod schemas + +Align with handler JSON in the worker/engine repo. Add tests with: + +- Raw payload +- Harness-wrapped payload (`wrapHarness` helper like `sandbox-fixtures.ts`) +- Error/gate fixtures + +### 3. Implement views + +One renderer per tool (or per response shape). Include: + +- Success path +- `running` prop +- Optional `*Preview` for approval (high-value for destructive or costly ops) + +### 4. Wire into `FunctionCallMessage.tsx` + +**Today (minimal):** + +```tsx +import { MyFeatureToolView, MyFeatureFunctionIdLabel } from '@/components/chat/myfeature' + +// Header: branch on functionId prefix or registry +function FunctionIdLabel({ functionId }: { functionId: string }) { + if (MyFeatureToolView.isMyFeature(functionId)) + return + if (SandboxToolView.isSandboxFunction(functionId)) + return + return {functionId} +} + +const preview = + SandboxToolView.tryRenderPreview(message) ?? + MyFeatureToolView.tryRenderPreview(message) + +const terminal = + !pending + ? (SandboxToolView.tryRender(message) ?? MyFeatureToolView.tryRender(message)) + : null +``` + +Rename tab labels if "terminal" is wrong for your UX (`custom` / `preview` / keep generic **preview** + **raw json**). + +### 5. Console playground (required) + +Ship **two** dev-only surfaces. Both are gated by `VITE_PLAYGROUND` (on in `.env.development`). Run `pnpm dev` in `console/web` and use the header toggle **chat / playground / examples**. + +| Surface | Route | Purpose | +|---------|-------|---------| +| **Examples** | `#/examples` | Static spec sheet — every variant visible at once, no send button. Best for pixel-polishing a single card (pending, running, done, errors). | +| **Playground** | `#/playground` | Live chat driven by a `ChatBackend` scenario — exercises the streaming contract (`fcall-start` → `fcall-end`) and the event log rail. Best for lifecycle and regression before a real backend. | + +See [`PLAYGROUND.md`](../PLAYGROUND.md) for the `StreamEvent` contract. + +#### 5a. Examples — one card per tool (required) + +Create `src/pages/Examples/sections/myfeature-fixtures.ts` with a `base()` factory (copy `sandbox-fixtures.ts`). Export: + +- One **done** fixture per `function_id` (mix envelope-wrapped and raw payloads). +- Extra fixtures for states your renderer cares about: **pending** (with `pendingApproval: true`), **running**, **error** / gate denial, edge cases (empty output, truncated grep, etc.). + +Register in `src/pages/Examples/sections/message-variants.tsx`: + +```tsx +import { myfeatureFixtures } from './myfeature-fixtures' + +{myfeatureFixtures.map((fixture) => ( + + + +))} +``` + +Open `#/examples` and confirm the **terminal** tab (default) and **raw json** tab for each card. + +**Sandbox reference:** `sandbox-fixtures.ts` + the `sandboxFixtures.map(...)` block at the bottom of `message-variants.tsx`. + +#### 5b. Playground — at least one scenario (required) + +Add an interactive scenario under `src/pages/Playground/scenarios/`. Every new function family needs **at least one** scenario registered in `scenarios/index.ts` so `#/playground` can exercise it end-to-end. + +1. **Create** `myfeature-hero.ts` (name as you like) using `makeBackend` + `streamFcall` from `scenarios/helpers.ts`: + +```ts +import { makeBackend, streamAssistant, streamFcall, streamThought } from './helpers' +// Reuse wire payloads from Examples when possible: +import { sandboxExecDone } from '@/pages/Examples/sections/sandbox-fixtures' + +export const myfeatureHero = makeBackend( + 'myfeature-hero', + async function* (prompt, _mode, _model, opts) { + const signal = opts?.signal + yield* streamThought('calling myfeature…', { signal }) + yield* streamFcall({ + functionId: 'myfeature::do_thing', + input: sandboxExecDone.input, // or inline realistic JSON + output: sandboxExecDone.output, + waitMs: 700, + signal, + }) + yield* streamAssistant('done.', { signal }) + }, +) +``` + +2. **Register** in `scenarios/index.ts`: + +```ts +import { myfeatureHero } from './myfeature-hero' + +// Inside SCENARIOS: +{ + id: 'myfeature-hero', + label: 'myfeature · hero', + description: 'one myfeature:: call with realistic request/response payloads.', + group: 'agent', // or add a new ScenarioGroup + preferredMode: 'agent', + backend: myfeatureHero, +}, +``` + +3. **Verify:** `pnpm dev` → `#/playground` → pick your scenario → send any message → confirm the custom card renders (not JSON-only) and the right-hand event log shows `fcall-start` / `fcall-end`. + +**Coverage guidance:** + +| Renderer feature | Examples fixture | Playground scenario | +|------------------|------------------|---------------------| +| Success / done | per `function_id` | at least one `streamFcall` with success output | +| Pending approval | `pendingApproval: true` | `streamFcall({ pendingApproval: true, approvalWaitMs: … })` — see `pending-approval.ts` | +| Running shimmer | `running: true` | shorten `waitMs` and watch mid-flight (or dedicated scenario) | +| Error / gate | error fixture on Examples | `output: { error: … }` — see `error-on-fcall.ts` or `sandboxFsWriteGateError` payloads | + +When you add a **new** `function_id` to an existing family, add an Examples `VariantCard` **and** extend a Playground scenario (or add a focused scenario) so the picker still covers it. + +**Sandbox gap:** Examples already list all 15 tools; Playground does not yet have a dedicated `sandbox::*` scenario — add `sandbox-exec.ts` (or similar) when touching sandbox as a template for others. + +### 6. Tests + +```bash +cd console/web +pnpm test -- src/components/chat/myfeature +pnpm typecheck +pnpm build +``` + +### 7. Lint touched files + +```bash +pnpm exec biome check --write \ + src/components/chat/myfeature \ + src/components/chat/FunctionCallMessage.tsx \ + src/pages/Examples/sections/myfeature-fixtures.ts \ + src/pages/Playground/scenarios/myfeature-hero.ts \ + src/pages/Playground/scenarios/index.ts +``` + +### 8. Pre-merge smoke (required) + +```bash +cd console/web && pnpm dev +``` + +- `#/examples` — scroll your new `VariantCard`s; toggle **terminal** / **raw json**. +- `#/playground` — run your new scenario; approve a pending call if applicable. + +--- + +## `FunctionCallMessage` body logic (reference) + +Custom renderers interact with these flags (from `FunctionCallMessage.tsx`): + +```tsx +const sandboxPreview = SandboxToolView.tryRenderPreview(message) +const sandboxTerminal = !pending ? SandboxToolView.tryRender(message) : null +const hasSandboxTerminal = sandboxTerminal != null + +const showRequestPaneAbove = + !(pending && sandboxPreview) && + !(running && hasSandboxTerminal) && + !(!pending && !running && hasSandboxTerminal) +``` + +| Case | Request pane above | Running slot | Done body | +|------|-------------------|--------------|-----------| +| Pending + preview | Hidden | — | — | +| Pending, no preview | Shown | — | — | +| Running + custom | Hidden | Custom | — | +| Running, no custom | Shown | Response JSON | — | +| Done + custom | Hidden | — | Tabs: custom + raw json | +| Done, no custom | Shown | — | Request + response JSON | + +Approve/deny handlers are props on `FunctionCallMessage`; custom modules do not implement approval themselves. + +--- + +## Scale beyond one family + +Duplicating `SandboxToolView` imports in FCM does not scale. Suggested refactor (not implemented yet): + +``` +src/components/chat/function-plugins/ + types.ts # FunctionCallRenderer interface + registry.ts # ordered list of plugins + index.ts # resolvePreview(message), resolveTerminal(message), resolveLabel(functionId) +``` + +```typescript +export interface FunctionCallRenderer { + id: string + isMatch: (functionId: string) => boolean + tryRender: (message: FunctionCallMessage) => React.ReactNode | null + tryRenderPreview?: (message: FunctionCallMessage) => React.ReactNode | null + FunctionIdLabel?: (props: { functionId: string }) => React.ReactNode + /** Tab label when this renderer wins; default "preview" */ + primaryTabLabel?: string +} +``` + +FCM becomes: + +```typescript +const terminal = !pending ? resolveTerminal(message) : null +const preview = resolvePreview(message) +``` + +Register `sandboxPlugin` and `myFeaturePlugin` in `registry.ts`. First non-null win, or explicit priority field. + +Until that exists, follow the **minimal wiring** in step 4 above. + +--- + +## Shared utilities you can reuse + +| Utility | Location | Use for | +|---------|----------|---------| +| `unwrapEnvelope` | `sandbox/parsers.ts` | Harness `{ content, details, terminate }` | +| `parseSandboxErrorDisplay` | `sandbox/parsers.ts` | Only if your tools emit the same error shapes | +| `Chip`, `MetaRow`, `ActionLine` | `sandbox/shared.tsx` | Metadata rows | +| `Terminal`, `AnsiOutput` | `sandbox/terminal/` | Exec/run-style output | +| `JsonHighlight` / `CodeHighlight` | `src/lib/syntax.tsx` | JSON / code blocks | +| `wrapHarness` | `sandbox-fixtures.ts` | Test/fixture envelope | +| UI primitives | `src/components/ui/*` | `Badge`, `Button`, `Tabs`, `Cell`, `EmptyState` | + +Consider extracting `unwrapEnvelope` and a generic `parseFunctionErrorDisplay` to `src/components/chat/function-plugins/` when a second family needs the same error wrappers. + +--- + +## Backend / catalog alignment + +- Function ids must match what the engine registers (`::` separator per AGENTS.md). +- UI catalog: `src/lib/functions-catalog.ts` and `use-functions-catalog.ts` (mentions, slash commands) are separate from renderers — update both if you want discoverability in the composer. +- Events → messages: `src/lib/backend/translate.ts` maps agent events to `FunctionCallMessage`; custom UI does not change translation, only display. + +--- + +## Out of scope (by design) + +- Streaming partial stdout into the card (sandbox exec is buffered upstream). +- Interactive terminal / PTY (`xterm.js`). +- Full ANSI color parsing in output (stdout/stderr two-tone only). +- Persisting terminal vs json tab choice across messages. +- Re-run or edit from the function card. + +--- + +## Quick reference: sandbox files + +| File | Role | +|------|------| +| `sandbox/index.tsx` | Dispatcher + `SandboxFunctionIdLabel` | +| `sandbox/parsers.ts` | Zod + envelope + errors | +| `sandbox/format.ts` | Formatting helpers | +| `sandbox/ErrorView.tsx` | `SandboxErrorView` / invocation errors | +| `sandbox/*View.tsx` | Per-tool UI | +| `sandbox/__tests__/parsers.test.ts` | Unit tests | +| `pages/Examples/sections/sandbox-fixtures.ts` | Examples fixtures (required) | +| `pages/Examples/sections/message-variants.tsx` | Registers Examples cards | +| `pages/Playground/scenarios/*.ts` | Playground `ChatBackend` scenarios (required) | +| `pages/Playground/scenarios/index.ts` | Scenario picker registry | +| `pages/Playground/scenarios/helpers.ts` | `streamFcall`, `streamThought`, `makeBackend` | +| `PLAYGROUND.md` | Streaming contract for scenarios | +| `FunctionCallMessage.tsx` | Host integration | + +Use this table as a copy-paste checklist when adding the next family. **Always** include the Examples + Playground rows before calling the work done. diff --git a/console/web/package.json b/console/web/package.json index 37369e5f..5bcdfd2a 100644 --- a/console/web/package.json +++ b/console/web/package.json @@ -6,6 +6,7 @@ "packageManager": "pnpm@10.18.2", "scripts": { "dev": "vite", + "dev:playground": "VITE_PLAYGROUND=1 vite", "build": "tsc -b && vite build", "preview": "vite preview", "typecheck": "tsc -b --noEmit", diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index 05a6e112..6b6a9d9b 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -28,6 +28,7 @@ import type { } from '@/types/chat' import { Composer, type ComposerSubmitPayload } from './Composer' import { ContextUsage } from './ContextUsage' +import { ExportSessionButton } from './ExportSessionButton' import { MessageList } from './MessageList' function isAbortError(err: unknown): boolean { @@ -541,6 +542,12 @@ export function ChatView({ messages={conversation.messages} contextWindow={contextWindow} /> + + announcer.announce(`session exported as ${filename}`) + } + />
void + className?: string +} + +/** + * Header-cluster button that downloads the current chat session as a + * markdown file. Designed for the "paste into another AI to analyse the + * skills" workflow — the file is self-contained text, attachments are + * referenced by name only (no base64 payload). + * + * Visual style mirrors the existing session-id copy button in + * `ChatView` (`ChatView.tsx:517-537`): font-mono, 11px, uppercase, + * `text-ink-faint hover:text-ink`, with a 1200ms "exported" label flip + * after a successful download. + */ +export function ExportSessionButton({ + conversation, + onExported, + className, +}: ExportSessionButtonProps) { + const [exported, setExported] = useState(false) + const disabled = conversation.messages.length === 0 + + const handleClick = useCallback(() => { + if (disabled) return + try { + const filename = downloadConversationAsMarkdown(conversation) + setExported(true) + window.setTimeout(() => setExported(false), 1200) + onExported?.(filename) + } catch { + // Browsers without Blob/URL support are far outside our target; + // swallow rather than crash the header. + } + }, [conversation, disabled, onExported]) + + return ( + + ) +} diff --git a/console/web/src/components/chat/FunctionCallMessage.tsx b/console/web/src/components/chat/FunctionCallMessage.tsx index 4b564e4b..f5e60aad 100644 --- a/console/web/src/components/chat/FunctionCallMessage.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.tsx @@ -1,8 +1,15 @@ import { useEffect, useState } from 'react' +import { + DirectoryFunctionIdLabel, + DirectoryToolView, +} from '@/components/chat/directory' +import { EngineFunctionIdLabel, EngineToolView } from '@/components/chat/engine' import { SandboxFunctionIdLabel, SandboxToolView, } from '@/components/chat/sandbox' +import { WebFunctionIdLabel, WebToolView } from '@/components/chat/web' +import { WorkerFunctionIdLabel, WorkerToolView } from '@/components/chat/worker' import { AlwaysAllowButton } from '@/components/permissions/AlwaysAllowButton' import { Button } from '@/components/ui/Button' import { StatusDot } from '@/components/ui/StatusDot' @@ -86,6 +93,30 @@ function formatPrimitive(v: Primitive): string { return String(v) } +/** + * Branch the function-id label across registered renderer families. New + * families slot in here — no other change to FCM is needed. The default + * (unbranded) span keeps unknown ids readable. + */ +function FunctionIdLabel({ functionId }: { functionId: string }) { + if (DirectoryToolView.isDirectoryFunction(functionId)) { + return + } + if (EngineToolView.isEngineListFunction(functionId)) { + return + } + if (WorkerToolView.isWorkerFunction(functionId)) { + return + } + if (WebToolView.isWebFunction(functionId)) { + return + } + if (SandboxToolView.isSandboxFunction(functionId)) { + return + } + return {functionId} +} + export function FunctionCallMessage({ message, defaultOpen, @@ -103,13 +134,24 @@ export function FunctionCallMessage({ >(null) const [submitError, setSubmitError] = useState(null) - const sandboxPreview = SandboxToolView.tryRenderPreview(message) - const sandboxTerminal = !pending ? SandboxToolView.tryRender(message) : null - const hasSandboxTerminal = sandboxTerminal != null + const customPreview = + SandboxToolView.tryRenderPreview(message) ?? + EngineToolView.tryRenderPreview(message) ?? + DirectoryToolView.tryRenderPreview(message) ?? + WorkerToolView.tryRenderPreview(message) ?? + WebToolView.tryRenderPreview(message) + const customTerminal = !pending + ? (SandboxToolView.tryRender(message) ?? + EngineToolView.tryRender(message) ?? + DirectoryToolView.tryRender(message) ?? + WorkerToolView.tryRender(message) ?? + WebToolView.tryRender(message)) + : null + const hasCustomTerminal = customTerminal != null const showRequestPaneAbove = - !(pending && sandboxPreview) && - !(running && hasSandboxTerminal) && - !(!pending && !running && hasSandboxTerminal) + !(pending && customPreview) && + !(running && hasCustomTerminal) && + !(!pending && !running && hasCustomTerminal) const runResolve = async (kind: 'approve' | 'deny' | 'always_allow') => { const handler = @@ -165,7 +207,7 @@ export function FunctionCallMessage({ <>ran )} ƒ{' '} - + {!pending && !running && typeof message.durationMs === 'number' ? ( {' '} @@ -188,20 +230,20 @@ export function FunctionCallMessage({ {open ? (
- {pending && sandboxPreview ? ( -
{sandboxPreview}
+ {pending && customPreview ? ( +
{customPreview}
) : showRequestPaneAbove ? ( ) : null} {running && !pending ? ( - hasSandboxTerminal ? ( -
{sandboxTerminal}
+ hasCustomTerminal ? ( +
{customTerminal}
) : ( ) ) : null} {!pending && !running ? ( - hasSandboxTerminal ? ( + hasCustomTerminal ? ( setTab(v as 'terminal' | 'json')} @@ -211,7 +253,7 @@ export function FunctionCallMessage({ terminal raw json - {sandboxTerminal} + {customTerminal} diff --git a/console/web/src/components/chat/directory/DownloadView.tsx b/console/web/src/components/chat/directory/DownloadView.tsx new file mode 100644 index 00000000..728ce401 --- /dev/null +++ b/console/web/src/components/chat/directory/DownloadView.tsx @@ -0,0 +1,150 @@ +import type { ReactNode } from 'react' +import { + ActionLine, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { + type SkillsDownloadRequest, + safeParseRequest, + safeParseResponse, + skillsDownloadRequestSchema, + skillsDownloadResponseSchema, +} from './parsers' +import { KvChip } from './shared' + +interface ViewProps { + input: unknown + output: unknown + running?: boolean +} + +/** Classifies the request shape into the two valid source modes (repo or + * registry). Only the active source's chips render — `null` means the + * request was malformed and the dispatcher will fall back to JSON. */ +function classifySource(req: SkillsDownloadRequest): + | { kind: 'repo'; repo: string; skill: string; branch: string } + | { + kind: 'registry' + worker: string + spec: string + } + | null { + if (req.repo && req.skill) { + return { + kind: 'repo', + repo: req.repo, + skill: req.skill, + branch: req.branch ?? 'main', + } + } + if (req.worker) { + const spec = req.version + ? `v${req.version}` + : req.tag + ? `${req.tag}` + : 'latest' + return { kind: 'registry', worker: req.worker, spec } + } + return null +} + +export function SkillsDownloadView({ input, output, running }: ViewProps) { + const req = safeParseRequest(skillsDownloadRequestSchema, input) + if (!req) return null + const source = classifySource(req) + if (!source) return null + + if (running) { + return ( +
+ + + {sourceChips(source)} + + + {describeSource(source)} + +
+ · cloning + writing skills… +
+
+ ) + } + + const resp = safeParseResponse(skillsDownloadResponseSchema, output) + if (!resp) return null + + const skillsCount = resp.skills_written.length + const promptsCount = resp.prompts_written.length + + return ( +
+ + + {resp.namespace} + {skillsCount} + {promptsCount} + + + {describeSource(source)} + + + +
+ ) +} + +function WrittenList({ label, names }: { label: string; names: string[] }) { + return ( +
+
+ {label} · {names.length} +
+ {names.length === 0 ? ( +
+ · none +
+ ) : ( +
    + {names.map((n) => ( +
  • + {n} +
  • + ))} +
+ )} +
+ ) +} + +function sourceChips( + source: ReturnType & object, +): ReactNode { + if (source.kind === 'repo') { + return ( + <> + repo + {source.branch} + + ) + } + return ( + <> + registry + {source.spec} + + ) +} + +function describeSource( + source: ReturnType & object, +): string { + if (source.kind === 'repo') { + return `${source.repo} › skills/${source.skill}@${source.branch}` + } + return `registry: ${source.worker}@${source.spec}` +} diff --git a/console/web/src/components/chat/directory/PromptsViews.tsx b/console/web/src/components/chat/directory/PromptsViews.tsx new file mode 100644 index 00000000..b322b1d9 --- /dev/null +++ b/console/web/src/components/chat/directory/PromptsViews.tsx @@ -0,0 +1,125 @@ +import { + ActionLine, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { + promptsGetRequestSchema, + promptsGetResponseSchema, + promptsListResponseSchema, + safeParseRequest, + safeParseResponse, +} from './parsers' +import { formatRelativeTime, KvChip, MarkdownPane } from './shared' + +interface ViewProps { + input: unknown + output: unknown + running?: boolean +} + +/* ---------------- directory::prompts::list ---------------- */ + +export function PromptsListView({ output, running }: ViewProps) { + if (running) { + return ( +
+ + + +
+ · scanning prompts folder… +
+
+ ) + } + + const resp = safeParseResponse(promptsListResponseSchema, output) + if (!resp) return null + + const label = + resp.prompts.length === 0 + ? 'no prompts' + : `${resp.prompts.length} ${ + resp.prompts.length === 1 ? 'prompt' : 'prompts' + }` + + return ( +
+ + + + {resp.prompts.length === 0 ? ( +
+ · no prompts found +
+ ) : ( +
    + {resp.prompts.map((p) => ( +
  • + + {p.name} + + {p.description ? ( +
    + {p.description} +
    + ) : null} + + {formatRelativeTime(p.modified_at)} + +
  • + ))} +
+ )} +
+ ) +} + +/* ---------------- directory::prompts::get ---------------- */ + +export function PromptsGetView({ input, output, running }: ViewProps) { + const req = safeParseRequest(promptsGetRequestSchema, input) + + if (running) { + return ( +
+ + + {req ? {req.name} : null} + +
+ · fetching prompt… +
+
+ ) + } + + const resp = safeParseResponse(promptsGetResponseSchema, output) + if (!resp) return null + + return ( +
+ + + {formatRelativeTime(resp.modified_at)} + + +
+ + {resp.name} + + {resp.description ? ( + + {resp.description} + + ) : null} +
+
+ +
+ ) +} diff --git a/console/web/src/components/chat/directory/RegistryViews.tsx b/console/web/src/components/chat/directory/RegistryViews.tsx new file mode 100644 index 00000000..462a7f0b --- /dev/null +++ b/console/web/src/components/chat/directory/RegistryViews.tsx @@ -0,0 +1,326 @@ +import type { ReactNode } from 'react' +import { + ActionLine, + Chip, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { + type ApiReferenceShape, + registryWorkerInfoRequestSchema, + registryWorkerInfoResponseSchema, + registryWorkersListRequestSchema, + registryWorkersListResponseSchema, + type SkillsTreeShape, + safeParseRequest, + safeParseResponse, +} from './parsers' +import { KvChip, MarkdownPane } from './shared' + +interface ViewProps { + input: unknown + output: unknown + running?: boolean +} + +/* ---------------- directory::registry::workers::list ---------------- */ + +export function RegistryWorkersListView({ input, output, running }: ViewProps) { + const req = safeParseRequest(registryWorkersListRequestSchema, input) + + if (running) { + return ( +
+ + + {req?.search ? {req.search} : null} + {req?.cursor ? ·next page· : null} + +
+ · querying registry… +
+
+ ) + } + + const resp = safeParseResponse(registryWorkersListResponseSchema, output) + if (!resp) return null + + return ( +
+ + + {req?.search ? {req.search} : null} + {resp.pagination.page_size ? ( + {resp.pagination.page_size} + ) : null} + + {resp.workers.length === 0 ? ( +
+ · no published workers match +
+ ) : ( +
    + {resp.workers.map((w) => ( +
  • +
    + + {w.name} + + {w.version ? ( + + v{w.version} + + ) : null} + {w.type ? {w.type} : null} + {typeof w.total_downloads === 'number' && + w.total_downloads > 0 ? ( + + {formatCount(w.total_downloads)} + + ) : null} + {w.author?.verified ? ( + + + verified + + + ) : null} +
    + {w.description ? ( +
    + {w.description} +
    + ) : null} +
    + {w.author?.name ? by {w.author.name} : null} + {w.repo ? {w.repo} : null} + {w.image ? image: {w.image} : null} + {w.supported_targets && w.supported_targets.length > 0 ? ( + targets: {w.supported_targets.join(', ')} + ) : null} +
    +
  • + ))} +
+ )} + {resp.pagination.has_more && resp.pagination.next_cursor ? ( +
+ next cursor available — pass back to fetch more +
+ ) : null} +
+ ) +} + +/* ---------------- directory::registry::workers::info ---------------- */ + +export function RegistryWorkerInfoView({ input, output, running }: ViewProps) { + const req = safeParseRequest(registryWorkerInfoRequestSchema, input) + + if (running) { + return ( +
+ + + {req ? {req.name} : null} + {req?.version ? {req.version} : null} + {req?.tag ? {req.tag} : null} + +
+ · fetching worker manifest… +
+
+ ) + } + + const resp = safeParseResponse(registryWorkerInfoResponseSchema, output) + if (!resp) return null + const { worker, readme, api_reference, skills_tree } = resp + + return ( +
+ + + {worker.version ? ( + v{worker.version} + ) : null} + {worker.type ? {worker.type} : null} + {typeof worker.total_downloads === 'number' && + worker.total_downloads > 0 ? ( + + {formatCount(worker.total_downloads)} + + ) : null} + {worker.author?.verified ? ( + + verified + + ) : null} + + +
+ + {worker.name} + + {worker.description ? ( + + {worker.description} + + ) : null} +
+
+ + + + {readme ? ( +
+
+ readme +
+ +
+ ) : null} +
+ ) +} + +function IdentityRow({ + worker, +}: { + worker: { + repo?: string | null + image?: string | null + supported_targets?: string[] + dependencies?: { name: string; version: string }[] + author?: { name?: string | null; verified?: boolean } | null + } +}) { + const bits: string[] = [] + if (worker.author?.name) bits.push(`by ${worker.author.name}`) + if (worker.repo) bits.push(worker.repo) + if (worker.image) bits.push(`image: ${worker.image}`) + if (worker.supported_targets && worker.supported_targets.length > 0) { + bits.push(`targets: ${worker.supported_targets.join(', ')}`) + } + if (worker.dependencies && worker.dependencies.length > 0) { + bits.push( + `deps: ${worker.dependencies + .map((d) => `${d.name}@${d.version}`) + .join(', ')}`, + ) + } + if (bits.length === 0) return null + return ( +
+ {bits.map((b) => ( + + {b} + + ))} +
+ ) +} + +function ApiReferenceSection({ api }: { api: ApiReferenceShape }) { + const fns = api.functions ?? [] + const triggers = api.triggers ?? [] + if (fns.length === 0 && triggers.length === 0) return null + return ( +
+
+ api · {fns.length} fns · {triggers.length} triggers +
+ {renderRefList('functions', fns)} + {renderRefList('triggers', triggers)} +
+ ) +} + +function renderRefList( + label: string, + items: { name: string; description?: string | null }[], +): ReactNode { + if (items.length === 0) return null + return ( +
+
+ {label} +
+
    + {items.map((it) => ( +
  • + + {it.name} + + {it.description ? ( + + {it.description} + + ) : null} +
  • + ))} +
+
+ ) +} + +function SkillsTreeSection({ tree }: { tree: SkillsTreeShape }) { + const skills = tree.skills ?? [] + const prompts = tree.prompts ?? [] + if (skills.length === 0 && prompts.length === 0) return null + return ( +
+
+ skills tree · {skills.length} skills · {prompts.length} prompts +
+ {skills.length > 0 ? ( +
    + {skills.map((s) => ( +
  • + {s.path} +
  • + ))} +
+ ) : null} + {prompts.length > 0 ? ( +
+
+ prompts +
+
    + {prompts.map((p) => ( +
  • + + {p.name} + + {p.description ? ( + + {p.description} + + ) : null} +
  • + ))} +
+
+ ) : null} +
+ ) +} + +function formatCount(n: number): string { + if (n < 1000) return `${n}` + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k` + return `${(n / 1_000_000).toFixed(1)}M` +} diff --git a/console/web/src/components/chat/directory/SkillsViews.tsx b/console/web/src/components/chat/directory/SkillsViews.tsx new file mode 100644 index 00000000..685745d6 --- /dev/null +++ b/console/web/src/components/chat/directory/SkillsViews.tsx @@ -0,0 +1,256 @@ +import type { ReactNode } from 'react' +import { + ActionLine, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { + type SkillsListRequest, + safeParseRequest, + safeParseResponse, + skillsGetRequestSchema, + skillsGetResponseSchema, + skillsIndexResponseSchema, + skillsListRequestSchema, + skillsListResponseSchema, +} from './parsers' +import { formatBytes, formatRelativeTime, KvChip, MarkdownPane } from './shared' + +interface ViewProps { + input: unknown + output: unknown + running?: boolean +} + +/* ---------------- directory::skills::list ---------------- */ + +export function SkillsListView({ input, output, running }: ViewProps) { + const req = safeParseRequest(skillsListRequestSchema, input) + + if (running) { + return ( + } + running + /> + ) + } + + const resp = safeParseResponse(skillsListResponseSchema, output) + if (!resp) return null + + return ( + } + > + {resp.skills.length === 0 ? ( + + ) : ( +
    + {resp.skills.map((s) => ( +
  • +
    + + {s.id} + + {s.type ? {s.type} : null} + {s.function_id ? ( + {s.function_id} + ) : null} +
    +
    + {s.title} +
    + {s.description ? ( +
    + {s.description} +
    + ) : null} +
    + {formatBytes(s.bytes)} · {formatRelativeTime(s.modified_at)} +
    +
  • + ))} +
+ )} +
+ ) +} + +/* ---------------- directory::skills::get ---------------- */ + +export function SkillsGetView({ input, output, running }: ViewProps) { + const req = safeParseRequest(skillsGetRequestSchema, input) + + if (running) { + return ( +
+ + + {req ? {req.id} : null} + +
+ · fetching skill… +
+
+ ) + } + + const resp = safeParseResponse(skillsGetResponseSchema, output) + if (!resp) return null + + return ( +
+ + + {resp.type ? {resp.type} : null} + {resp.function_id ? ( + {resp.function_id} + ) : null} + {formatRelativeTime(resp.modified_at)} + + +
+ + {resp.id} + + + {resp.title} + +
+
+ +
+ ) +} + +/* ---------------- directory::skills::index ---------------- */ + +export function SkillsIndexView({ output, running }: ViewProps) { + if (running) { + return ( +
+ + + +
+ · building index… +
+
+ ) + } + + const resp = safeParseResponse(skillsIndexResponseSchema, output) + if (!resp) return null + + return ( +
+ + + + {resp.workers_count === 0 ? ( + + ) : ( + + )} +
+ ) +} + +/* ---------------- shared bits ---------------- */ + +interface ListShellProps { + count: number | null + noun: string + filters?: ReactNode + running?: boolean + children?: ReactNode +} + +function ListShell({ + count, + noun, + filters, + running, + children, +}: ListShellProps) { + const label = + running || count === null + ? `listing ${noun}…` + : count === 0 + ? `no ${noun} match` + : `${count} ${count === 1 ? noun.replace(/s$/, '') : noun}` + const pillVariant: 'accent' | 'warn' | 'default' = running + ? 'default' + : count === 0 + ? 'warn' + : 'accent' + return ( +
+ + + {filters ? ( + {filters} + ) : null} + + {running ? ( +
+ · scanning skills folder… +
+ ) : ( + children + )} +
+ ) +} + +function EmptyRow({ label }: { label: string }) { + return ( +
+ · {label} +
+ ) +} + +function RequestFilters({ req }: { req?: SkillsListRequest }) { + if (!req) return null + const chips: ReactNode[] = [] + if (req.prefix) { + chips.push( + + {req.prefix} + , + ) + } + if (req.type) { + chips.push( + + {req.type} + , + ) + } + if (req.search) { + chips.push( + + {req.search} + , + ) + } + if (req.include_description === false) { + chips.push( + + on + , + ) + } + return chips.length > 0 ? <>{chips} : null +} diff --git a/console/web/src/components/chat/directory/__tests__/parsers.test.ts b/console/web/src/components/chat/directory/__tests__/parsers.test.ts new file mode 100644 index 00000000..750ffd93 --- /dev/null +++ b/console/web/src/components/chat/directory/__tests__/parsers.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest' +import { + DIRECTORY_FUNCTION_IDS, + isDirectoryFunction, + promptsGetResponseSchema, + promptsListResponseSchema, + registryWorkerInfoRequestSchema, + registryWorkerInfoResponseSchema, + registryWorkersListRequestSchema, + registryWorkersListResponseSchema, + safeParseRequest, + safeParseResponse, + skillsDownloadRequestSchema, + skillsDownloadResponseSchema, + skillsGetRequestSchema, + skillsGetResponseSchema, + skillsIndexResponseSchema, + skillsListRequestSchema, + skillsListResponseSchema, + unwrapEnvelope, +} from '../parsers' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +describe('isDirectoryFunction', () => { + it('matches every id in the allowlist', () => { + for (const id of DIRECTORY_FUNCTION_IDS) { + expect(isDirectoryFunction(id)).toBe(true) + } + }) + + it('rejects unrelated ids', () => { + expect(isDirectoryFunction('directory::')).toBe(false) + expect(isDirectoryFunction('directory::skills')).toBe(false) + expect(isDirectoryFunction('engine::workers::list')).toBe(false) + }) +}) + +describe('directory::skills::list', () => { + it('parses an empty request', () => { + expect(safeParseRequest(skillsListRequestSchema, {})).toEqual({}) + }) + + it('parses a prefix+type filter', () => { + expect( + safeParseRequest(skillsListRequestSchema, { + prefix: 'sandbox/', + type: 'how-to', + include_description: false, + }), + ).toEqual({ + prefix: 'sandbox/', + type: 'how-to', + include_description: false, + }) + }) + + it('parses a wrapped response payload', () => { + const payload = { + skills: [ + { + id: 'sandbox/skills/sandbox/create', + title: 'sandbox::create', + type: 'how-to', + function_id: 'sandbox::create', + description: '…', + bytes: 1840, + modified_at: '2026-05-26T10:00:00Z', + }, + ], + } + const parsed = safeParseResponse(skillsListResponseSchema, wrap(payload)) + expect(parsed?.skills[0].function_id).toBe('sandbox::create') + }) + + it('parses an entry with null type + function_id', () => { + expect( + safeParseResponse(skillsListResponseSchema, { + skills: [ + { + id: 'sandbox/index', + title: 'sandbox', + type: null, + function_id: null, + description: '', + bytes: 100, + modified_at: '', + }, + ], + }), + ).toBeTruthy() + }) +}) + +describe('directory::skills::get', () => { + it('parses the request payload', () => { + expect(safeParseRequest(skillsGetRequestSchema, { id: 'a/b' })).toEqual({ + id: 'a/b', + }) + }) + + it('parses a wrapped get response', () => { + const parsed = safeParseResponse(skillsGetResponseSchema, { + id: 'a/b', + title: 'A', + type: 'how-to', + function_id: null, + body: '# A', + modified_at: '2026-05-26T10:00:00Z', + }) + expect(parsed?.body).toBe('# A') + }) + + it('rejects a response missing body', () => { + expect( + safeParseResponse(skillsGetResponseSchema, { + id: 'x', + title: 't', + modified_at: '', + }), + ).toBeNull() + }) +}) + +describe('directory::skills::index', () => { + it('parses the wrapped response', () => { + const parsed = safeParseResponse( + skillsIndexResponseSchema, + wrap({ body: '## a', workers_count: 1 }), + ) + expect(parsed?.workers_count).toBe(1) + }) +}) + +describe('directory::skills::download', () => { + it('parses both source variants in the request', () => { + expect( + safeParseRequest(skillsDownloadRequestSchema, { + repo: 'https://x', + skill: 'a', + branch: 'main', + }), + ).toBeTruthy() + expect( + safeParseRequest(skillsDownloadRequestSchema, { + worker: 'pdfkit', + version: '1.0.0', + }), + ).toBeTruthy() + }) + + it('parses the wrapped response', () => { + const parsed = safeParseResponse(skillsDownloadResponseSchema, { + namespace: 'sandbox', + skills_written: ['sandbox/index'], + prompts_written: [], + source: { kind: 'repo' }, + }) + expect(parsed?.namespace).toBe('sandbox') + }) +}) + +describe('directory::prompts::list / get', () => { + it('parses the list payload', () => { + expect( + safeParseResponse(promptsListResponseSchema, wrap({ prompts: [] })), + ).toEqual({ prompts: [] }) + }) + + it('parses the get payload', () => { + const parsed = safeParseResponse(promptsGetResponseSchema, { + name: 'a', + description: 'b', + body: '# a', + modified_at: '', + }) + expect(parsed?.body).toBe('# a') + }) +}) + +describe('directory::registry::workers::list', () => { + it('parses an empty request', () => { + expect(safeParseRequest(registryWorkersListRequestSchema, {})).toEqual({}) + }) + + it('parses a search+cursor request', () => { + expect( + safeParseRequest(registryWorkersListRequestSchema, { + search: 'pdf', + cursor: 'abc', + }), + ).toEqual({ search: 'pdf', cursor: 'abc' }) + }) + + it('parses a wrapped page with workers + pagination', () => { + const parsed = safeParseResponse( + registryWorkersListResponseSchema, + wrap({ + workers: [ + { + name: 'pdfkit', + description: 'pdf renderer', + type: 'binary', + version: '1.0.0', + total_downloads: 4823, + author: { name: 'iii', verified: true }, + }, + ], + pagination: { + next_cursor: 'xyz', + has_more: true, + page_size: 20, + }, + }), + ) + expect(parsed?.workers[0].author?.verified).toBe(true) + expect(parsed?.pagination.has_more).toBe(true) + }) +}) + +describe('directory::registry::workers::info', () => { + it('parses the request shape', () => { + expect( + safeParseRequest(registryWorkerInfoRequestSchema, { + name: 'pdfkit', + tag: 'latest', + }), + ).toEqual({ name: 'pdfkit', tag: 'latest' }) + }) + + it('parses a wrapped detail response with api reference + skills tree', () => { + const parsed = safeParseResponse( + registryWorkerInfoResponseSchema, + wrap({ + worker: { + name: 'pdfkit', + description: 'pdf renderer', + type: 'binary', + version: '1.0.0', + }, + readme: '# pdfkit', + api_reference: { + functions: [ + { name: 'pdfkit::render', description: 'render html→pdf' }, + ], + triggers: [], + }, + skills_tree: { + skills: [{ path: 'pdfkit/index' }], + prompts: [], + }, + }), + ) + expect(parsed?.api_reference.functions).toHaveLength(1) + expect(parsed?.skills_tree.skills).toHaveLength(1) + }) + + it('rejects a response missing worker', () => { + expect( + safeParseResponse(registryWorkerInfoResponseSchema, { + api_reference: { functions: [], triggers: [] }, + skills_tree: { skills: [], prompts: [] }, + }), + ).toBeNull() + }) +}) + +describe('unwrapEnvelope re-export', () => { + it('peels the harness envelope', () => { + const inner = { skills: [] } + expect(unwrapEnvelope(wrap(inner))).toEqual(inner) + }) +}) diff --git a/console/web/src/components/chat/directory/index.tsx b/console/web/src/components/chat/directory/index.tsx new file mode 100644 index 00000000..c98197df --- /dev/null +++ b/console/web/src/components/chat/directory/index.tsx @@ -0,0 +1,100 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import type { FunctionCallMessage } from '@/types/chat' +import { SkillsDownloadView } from './DownloadView' +import { PromptsGetView, PromptsListView } from './PromptsViews' +import { isDirectoryFunction, unwrapEnvelope } from './parsers' +import { + RegistryWorkerInfoView, + RegistryWorkersListView, +} from './RegistryViews' +import { SkillsGetView, SkillsIndexView, SkillsListView } from './SkillsViews' + +export function DirectoryFunctionIdLabel({ + functionId, +}: { + functionId: string +}) { + if (!functionId.startsWith('directory::')) { + return {functionId} + } + const tail = functionId.slice('directory::'.length) + return ( + <> + directory:: + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isDirectoryFunction(message.functionId)) return null + if (message.pendingApproval) return null + + const input = unwrapEnvelope(message.input) + const rawOutput = message.output + const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined + const running = !!message.running + + // Reuse the sandbox error parser for the shared `function_error` + // / gate-denial envelope — same translate layer. + const errorDisplay = + !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null + if (errorDisplay) { + return + } + + switch (message.functionId) { + case 'directory::skills::list': + return + case 'directory::skills::get': + return + case 'directory::skills::index': + return + case 'directory::skills::download': + return ( + + ) + case 'directory::prompts::list': + return + case 'directory::prompts::get': + return + case 'directory::registry::workers::list': + return ( + + ) + case 'directory::registry::workers::info': + return ( + + ) + default: + return null + } +} + +/** + * Directory reads are non-destructive and don't go through the approval + * gate. `download` could in principle (it touches disk), but the daemon + * doesn't gate it today — returning `null` lets the default request JSON + * pane handle the pending-state if it ever surfaces. + */ +function tryRenderPreview( + _message: FunctionCallMessage, +): React.ReactNode | null { + return null +} + +export const DirectoryToolView = { + isDirectoryFunction, + tryRender, + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/directory/parsers.ts b/console/web/src/components/chat/directory/parsers.ts new file mode 100644 index 00000000..d70e8c8c --- /dev/null +++ b/console/web/src/components/chat/directory/parsers.ts @@ -0,0 +1,271 @@ +/** + * Zod schemas for the `directory::*` namespace (iii-directory worker). + * + * Wire source: `iii-directory/src/functions/*.rs` + * - skills.rs — directory::skills::list / get / index + * - download.rs — directory::skills::download + * - prompts.rs — directory::prompts::list / get + * - registry.rs — directory::registry::workers::list / info + */ +import { z } from 'zod' +import { unwrapEnvelope } from '@/components/chat/sandbox/parsers' + +export { unwrapEnvelope } + +export const DIRECTORY_FUNCTION_IDS = [ + 'directory::skills::list', + 'directory::skills::get', + 'directory::skills::index', + 'directory::skills::download', + 'directory::prompts::list', + 'directory::prompts::get', + 'directory::registry::workers::list', + 'directory::registry::workers::info', +] as const + +export type DirectoryFunctionId = (typeof DIRECTORY_FUNCTION_IDS)[number] + +const DIRECTORY_FUNCTION_ID_SET: ReadonlySet = new Set( + DIRECTORY_FUNCTION_IDS, +) + +export function isDirectoryFunction(id: string): id is DirectoryFunctionId { + return DIRECTORY_FUNCTION_ID_SET.has(id) +} + +/* ---------------- skills::list ---------------- */ + +export const skillsListRequestSchema = z.object({ + search: z.string().optional(), + prefix: z.string().optional(), + type: z.string().optional(), + include_description: z.boolean().optional(), +}) +export type SkillsListRequest = z.infer + +export const skillEntrySchema = z.object({ + id: z.string(), + title: z.string(), + type: z.string().nullable().optional(), + function_id: z.string().nullable().optional(), + description: z.string(), + bytes: z.number(), + modified_at: z.string(), +}) +export type SkillEntry = z.infer + +export const skillsListResponseSchema = z.object({ + skills: z.array(skillEntrySchema), +}) +export type SkillsListResponse = z.infer + +/* ---------------- skills::get ---------------- */ + +export const skillsGetRequestSchema = z.object({ + id: z.string(), +}) +export type SkillsGetRequest = z.infer + +export const skillsGetResponseSchema = z.object({ + id: z.string(), + title: z.string(), + type: z.string().nullable().optional(), + function_id: z.string().nullable().optional(), + body: z.string(), + modified_at: z.string(), +}) +export type SkillsGetResponse = z.infer + +/* ---------------- skills::index ---------------- */ + +export const skillsIndexRequestSchema = z.object({}) +export type SkillsIndexRequest = z.infer + +export const skillsIndexResponseSchema = z.object({ + body: z.string(), + workers_count: z.number(), +}) +export type SkillsIndexResponse = z.infer + +/* ---------------- skills::download ---------------- */ + +export const skillsDownloadRequestSchema = z.object({ + repo: z.string().nullable().optional(), + skill: z.string().nullable().optional(), + branch: z.string().nullable().optional(), + worker: z.string().nullable().optional(), + version: z.string().nullable().optional(), + tag: z.string().nullable().optional(), +}) +export type SkillsDownloadRequest = z.infer + +export const skillsDownloadResponseSchema = z.object({ + namespace: z.string(), + skills_written: z.array(z.string()), + prompts_written: z.array(z.string()), + source: z.unknown(), +}) +export type SkillsDownloadResponse = z.infer< + typeof skillsDownloadResponseSchema +> + +/* ---------------- prompts::list ---------------- */ + +export const promptsListRequestSchema = z.object({}) +export type PromptsListRequest = z.infer + +export const promptEntrySchema = z.object({ + name: z.string(), + description: z.string(), + modified_at: z.string(), +}) +export type PromptEntry = z.infer + +export const promptsListResponseSchema = z.object({ + prompts: z.array(promptEntrySchema), +}) +export type PromptsListResponse = z.infer + +/* ---------------- prompts::get ---------------- */ + +export const promptsGetRequestSchema = z.object({ + name: z.string(), +}) +export type PromptsGetRequest = z.infer + +export const promptsGetResponseSchema = z.object({ + name: z.string(), + description: z.string(), + body: z.string(), + modified_at: z.string(), +}) +export type PromptsGetResponse = z.infer + +/* ---------------- registry::workers::list ---------------- */ + +export const registryWorkersListRequestSchema = z.object({ + search: z.string().optional(), + cursor: z.string().nullable().optional(), +}) +export type RegistryWorkersListRequest = z.infer< + typeof registryWorkersListRequestSchema +> + +export const registryWorkerAuthorSchema = z.object({ + name: z.string().nullable().optional(), + pfp: z.string().nullable().optional(), + verified: z.boolean().optional(), +}) +export type RegistryWorkerAuthor = z.infer + +export const registryDependencySchema = z.object({ + name: z.string(), + version: z.string(), +}) +export type RegistryDependency = z.infer + +export const registryWorkerSchema = z.object({ + name: z.string(), + description: z.string().nullable().optional(), + type: z.string().nullable().optional(), + version: z.string().nullable().optional(), + repo: z.string().nullable().optional(), + config: z.unknown().optional(), + supported_targets: z.array(z.string()).optional(), + image: z.string().nullable().optional(), + total_downloads: z.number().optional(), + dependencies: z.array(registryDependencySchema).optional(), + author: registryWorkerAuthorSchema.nullable().optional(), +}) +export type RegistryWorker = z.infer + +export const registryPaginationSchema = z.object({ + next_cursor: z.string().nullable().optional(), + has_more: z.boolean().optional(), + page_size: z.number().optional(), +}) +export type RegistryPagination = z.infer + +export const registryWorkersListResponseSchema = z.object({ + workers: z.array(registryWorkerSchema), + pagination: registryPaginationSchema, +}) +export type RegistryWorkersListResponse = z.infer< + typeof registryWorkersListResponseSchema +> + +/* ---------------- registry::workers::info ---------------- */ + +export const registryWorkerInfoRequestSchema = z.object({ + name: z.string(), + version: z.string().nullable().optional(), + tag: z.string().nullable().optional(), +}) +export type RegistryWorkerInfoRequest = z.infer< + typeof registryWorkerInfoRequestSchema +> + +export const apiReferenceFunctionSchema = z.object({ + name: z.string(), + description: z.string().nullable().optional(), + request_schema: z.unknown().nullable().optional(), + response_schema: z.unknown().nullable().optional(), + metadata: z.unknown().nullable().optional(), +}) +export type ApiReferenceFunction = z.infer + +export const apiReferenceTriggerSchema = z.object({ + name: z.string(), + description: z.string().nullable().optional(), + invocation_schema: z.unknown().nullable().optional(), + return_schema: z.unknown().nullable().optional(), + metadata: z.unknown().nullable().optional(), +}) +export type ApiReferenceTrigger = z.infer + +export const apiReferenceSchema = z.object({ + functions: z.array(apiReferenceFunctionSchema).optional(), + triggers: z.array(apiReferenceTriggerSchema).optional(), +}) +export type ApiReferenceShape = z.infer + +export const skillsTreeSkillSchema = z.object({ + path: z.string(), +}) +export const skillsTreePromptSchema = z.object({ + name: z.string(), + description: z.string().nullable().optional(), +}) +export const skillsTreeSchema = z.object({ + skills: z.array(skillsTreeSkillSchema).optional(), + prompts: z.array(skillsTreePromptSchema).optional(), +}) +export type SkillsTreeShape = z.infer + +export const registryWorkerInfoResponseSchema = z.object({ + worker: registryWorkerSchema, + readme: z.string().nullable().optional(), + api_reference: apiReferenceSchema, + skills_tree: skillsTreeSchema, +}) +export type RegistryWorkerInfoResponse = z.infer< + typeof registryWorkerInfoResponseSchema +> + +/* ---------------- generic helpers ---------------- */ + +export function safeParseRequest( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} + +export function safeParseResponse( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(unwrapEnvelope(value)) + return parsed.success ? parsed.data : null +} diff --git a/console/web/src/components/chat/directory/shared.tsx b/console/web/src/components/chat/directory/shared.tsx new file mode 100644 index 00000000..ca491196 --- /dev/null +++ b/console/web/src/components/chat/directory/shared.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' +import { Chip } from '@/components/chat/sandbox/shared' +import { Markdown } from '@/lib/markdown' + +interface KvChipProps { + label: string + children: ReactNode +} + +/** Two-tone chip with a small uppercase label and a value. Reused across + * every directory view for filter + metadata badges. */ +export function KvChip({ label, children }: KvChipProps) { + return ( + + + {label} + + {children} + + ) +} + +/** + * Render a markdown body (skill `.md`, prompt body, worker README) as actual + * markdown — headings, fenced code, lists — via the same `` renderer + * the chat transcript uses, rather than the raw monospace `
` it used to be.
+ */
+export function MarkdownPane({
+  body,
+  className,
+}: {
+  body: string
+  className?: string
+}) {
+  return (
+    
+ {body} +
+ ) +} + +export function formatRelativeTime(value: string): string { + if (!value) return '' + const ts = Date.parse(value) + if (Number.isNaN(ts)) return value + const diffMs = Date.now() - ts + if (diffMs < 0) return new Date(ts).toLocaleString() + if (diffMs < 60_000) return `${Math.floor(diffMs / 1000)}s ago` + if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago` + if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago` + if (diffMs < 7 * 86_400_000) return `${Math.floor(diffMs / 86_400_000)}d ago` + return new Date(ts).toLocaleDateString() +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} diff --git a/console/web/src/components/chat/engine/FunctionInfoView.tsx b/console/web/src/components/chat/engine/FunctionInfoView.tsx new file mode 100644 index 00000000..c02449ba --- /dev/null +++ b/console/web/src/components/chat/engine/FunctionInfoView.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react' +import { + ActionLine, + Chip, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { CodeHighlight } from '@/lib/syntax' +import { cn } from '@/lib/utils' +import { + type FunctionDetail, + functionDetailSchema, + functionInfoRequestSchema, + safeParseRequest, + safeParseResponse, +} from './parsers' + +interface FunctionInfoViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function FunctionInfoView({ + input, + output, + running, +}: FunctionInfoViewProps) { + const req = safeParseRequest(functionInfoRequestSchema, input) + + if (running) { + return ( +
+ + + {req ? ( + + + function + + {req.function_id} + + ) : null} + +
+ · inspecting function… +
+
+ ) + } + + const detail = safeParseResponse(functionDetailSchema, output) + if (!detail) return null + + return ( +
+ + + + + worker + + {detail.worker_name} + + + + triggers + + + {detail.registered_triggers.length} + + + + + + {detail.function_id} + + + {detail.description ? ( +
+ {detail.description} +
+ ) : null} + + + {detail.metadata !== undefined ? ( + + ) : null} + +
+ ) +} + +function RegisteredTriggers({ + triggers, +}: { + triggers: FunctionDetail['registered_triggers'] +}) { + if (triggers.length === 0) { + return ( +
+ · no registered triggers +
+ ) + } + return ( +
+
+ registered triggers · {triggers.length} +
+
    + {triggers.map((t) => ( +
  • +
    + + {shortenId(t.id)} + + + {t.trigger_type} + +
    + +
  • + ))} +
+
+ ) +} + +function TriggerConfig({ config }: { config: unknown }) { + if (config === undefined || config === null) return null + if ( + typeof config === 'object' && + Object.keys(config as object).length === 0 + ) { + return ( +
· no config
+ ) + } + let pretty: string + try { + pretty = JSON.stringify(config, null, 2) + } catch { + return null + } + return ( + + ) +} + +interface SchemaSectionProps { + label: string + schema: unknown + treatEmptyAsAny?: boolean +} + +function SchemaSection({ + label, + schema, + treatEmptyAsAny = true, +}: SchemaSectionProps) { + const [open, setOpen] = useState(false) + if (schema === undefined || schema === null) { + return ( +
+ {label} · none +
+ ) + } + const isAny = treatEmptyAsAny && isAnySchema(schema) + const pretty = tryStringifyJson(schema) + return ( +
+ + {open && pretty != null ? ( + + ) : null} +
+ ) +} + +function isAnySchema(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const obj = value as Record + // JsonSchema's `AnyValue` placeholder ships `$schema` + `title: "AnyValue"` + // with no constraints, which the harness wraps around `Value` request / + // response payloads where the engine has no static schema. + if (obj.title === 'AnyValue') return true + const keys = Object.keys(obj) + if (keys.length === 0) return true + if (keys.every((k) => k === '$schema' || k === 'title')) return true + return false +} + +function tryStringifyJson(value: unknown): string | null { + try { + return JSON.stringify(value, null, 2) + } catch { + return null + } +} + +function shortenId(id: string): string { + if (id.length <= 14) return id + return `${id.slice(0, 8)}…${id.slice(-4)}` +} diff --git a/console/web/src/components/chat/engine/FunctionsListView.tsx b/console/web/src/components/chat/engine/FunctionsListView.tsx new file mode 100644 index 00000000..fdeb715b --- /dev/null +++ b/console/web/src/components/chat/engine/FunctionsListView.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from 'react' +import { + type FunctionsListRequest, + functionsListRequestSchema, + functionsListResponseSchema, + safeParseRequest, + safeParseResponse, +} from './parsers' +import { FilterChip, InternalChip, ListHeader } from './shared' + +interface FunctionsListViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function FunctionsListView({ + input, + output, + running, +}: FunctionsListViewProps) { + const req = safeParseRequest(functionsListRequestSchema, input) + + if (running) { + return ( +
+ } + /> +
+ · listing functions… +
+
+ ) + } + + const resp = safeParseResponse(functionsListResponseSchema, output) + if (!resp) return null + + return ( +
+ } + /> + {resp.functions.length === 0 ? ( +
+ · no functions returned +
+ ) : ( +
    + {resp.functions.map((fn) => ( +
  • +
    + + {fn.function_id} + + + {fn.worker_name} + +
    + {fn.description ? ( +
    + {fn.description} +
    + ) : null} +
  • + ))} +
+ )} +
+ ) +} + +function RequestFilters({ req }: { req?: FunctionsListRequest }) { + if (!req) return null + const chips: ReactNode[] = [] + if (req.prefix) { + chips.push() + } + if (req.worker) { + chips.push() + } + if (req.search) { + chips.push() + } + if (req.include_internal) { + chips.push() + } + return chips.length > 0 ? <>{chips} : null +} diff --git a/console/web/src/components/chat/engine/RegisteredTriggersListView.tsx b/console/web/src/components/chat/engine/RegisteredTriggersListView.tsx new file mode 100644 index 00000000..28dd79d8 --- /dev/null +++ b/console/web/src/components/chat/engine/RegisteredTriggersListView.tsx @@ -0,0 +1,153 @@ +import type { ReactNode } from 'react' +import { CodeHighlight } from '@/lib/syntax' +import { + type RegisteredTriggersListRequest, + registeredTriggersListRequestSchema, + registeredTriggersListResponseSchema, + safeParseRequest, + safeParseResponse, +} from './parsers' +import { FilterChip, InternalChip, ListHeader } from './shared' + +interface RegisteredTriggersListViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function RegisteredTriggersListView({ + input, + output, + running, +}: RegisteredTriggersListViewProps) { + const req = safeParseRequest(registeredTriggersListRequestSchema, input) + + if (running) { + return ( +
+ } + /> +
+ · listing registered triggers… +
+
+ ) + } + + const resp = safeParseResponse(registeredTriggersListResponseSchema, output) + if (!resp) return null + + return ( +
+ } + /> + {resp.registered_triggers.length === 0 ? ( +
+ · no registered triggers returned +
+ ) : ( +
    + {resp.registered_triggers.map((t) => ( +
  • +
    + + {shortenId(t.id)} + + + {t.worker_name} + +
    +
    + + {t.trigger_type} + + + + {t.function_id} + +
    + +
  • + ))} +
+ )} +
+ ) +} + +function shortenId(id: string): string { + if (id.length <= 14) return id + return `${id.slice(0, 8)}…${id.slice(-4)}` +} + +function ConfigSummary({ text }: { text: string }) { + if (!text) return null + const trimmed = text.trim() + if (!trimmed) return null + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return ( + + ) + } + return ( +
+ {trimmed} +
+ ) +} + +function prettyJsonIfPossible(text: string): string { + try { + return JSON.stringify(JSON.parse(text), null, 2) + } catch { + return text + } +} + +function RequestFilters({ req }: { req?: RegisteredTriggersListRequest }) { + if (!req) return null + const chips: ReactNode[] = [] + if (req.function_id) { + chips.push( + , + ) + } + if (req.trigger_type) { + chips.push( + , + ) + } + if (req.worker) { + chips.push() + } + if (req.search) { + chips.push() + } + if (req.include_internal) { + chips.push() + } + return chips.length > 0 ? <>{chips} : null +} diff --git a/console/web/src/components/chat/engine/TriggersListView.tsx b/console/web/src/components/chat/engine/TriggersListView.tsx new file mode 100644 index 00000000..1085a101 --- /dev/null +++ b/console/web/src/components/chat/engine/TriggersListView.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from 'react' +import { + safeParseRequest, + safeParseResponse, + type TriggersListRequest, + triggersListRequestSchema, + triggersListResponseSchema, +} from './parsers' +import { FilterChip, InternalChip, ListHeader } from './shared' + +interface TriggersListViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function TriggersListView({ + input, + output, + running, +}: TriggersListViewProps) { + const req = safeParseRequest(triggersListRequestSchema, input) + + if (running) { + return ( +
+ } + /> +
+ · listing triggers… +
+
+ ) + } + + const resp = safeParseResponse(triggersListResponseSchema, output) + if (!resp) return null + + return ( +
+ } + /> + {resp.triggers.length === 0 ? ( +
+ · no triggers returned +
+ ) : ( +
    + {resp.triggers.map((t) => ( +
  • +
    + + {t.id} + + + {t.worker_name} + +
    +
    + {t.description} +
    +
  • + ))} +
+ )} +
+ ) +} + +function RequestFilters({ req }: { req?: TriggersListRequest }) { + if (!req) return null + const chips: ReactNode[] = [] + if (req.prefix) { + chips.push() + } + if (req.worker) { + chips.push() + } + if (req.search) { + chips.push() + } + if (req.include_internal) { + chips.push() + } + return chips.length > 0 ? <>{chips} : null +} diff --git a/console/web/src/components/chat/engine/WorkerInfoView.tsx b/console/web/src/components/chat/engine/WorkerInfoView.tsx new file mode 100644 index 00000000..56a5a060 --- /dev/null +++ b/console/web/src/components/chat/engine/WorkerInfoView.tsx @@ -0,0 +1,275 @@ +import { + ActionLine, + Chip, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { + safeParseRequest, + safeParseResponse, + type WorkerInfoResponse, + type WorkerMetrics, + workerInfoRequestSchema, + workerInfoResponseSchema, +} from './parsers' + +interface WorkerInfoViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function WorkerInfoView({ + input, + output, + running, +}: WorkerInfoViewProps) { + const req = safeParseRequest(workerInfoRequestSchema, input) + + if (running) { + return ( +
+ + + {req ? ( + + + worker + + {req.name} + + ) : null} + +
+ · inspecting worker… +
+
+ ) + } + + const resp = safeParseResponse(workerInfoResponseSchema, output) + if (!resp) return null + + const { worker, functions, trigger_types, registered_triggers } = resp + + return ( +
+ + + {worker.runtime ? ( + + + runtime + + {worker.runtime} + + ) : null} + {worker.version ? ( + + + version + + {worker.version} + + ) : null} + {worker.os ? ( + + + os + + {worker.os} + + ) : null} + {worker.internal ? ( + + + internal + + + ) : null} + + + + {worker.name ?? worker.id} + + + {worker.description ? ( +
+ {worker.description} +
+ ) : null} + + {worker.latest_metrics ? ( + + ) : null} + ({ + key: fn.function_id, + left: fn.function_id, + right: fn.description ?? null, + }))} + /> + ({ + key: t.id, + left: t.id, + right: t.description, + }))} + /> + ({ + key: t.id, + left: `${t.trigger_type} → ${t.function_id}`, + right: t.config_summary, + }))} + /> +
+ ) +} + +function IdentityRow({ worker }: { worker: WorkerInfoResponse['worker'] }) { + const bits: string[] = [] + bits.push(`id: ${worker.id}`) + if (worker.ip_address) bits.push(worker.ip_address) + if (worker.isolation) bits.push(`isolation: ${worker.isolation}`) + if (typeof worker.pid === 'number') bits.push(`pid: ${worker.pid}`) + bits.push(`active: ${worker.active_invocations}`) + bits.push(`fns: ${worker.function_count}`) + bits.push(formatConnectedFor(worker.connected_at_ms)) + return ( +
+ {bits.map((b) => ( + {b} + ))} +
+ ) +} + +function MetricsRow({ metrics }: { metrics: WorkerMetrics }) { + const cells: { key: string; label: string; value: string }[] = [] + if (typeof metrics.memory_heap_used === 'number') { + cells.push({ + key: 'heap', + label: 'heap', + value: formatBytes(metrics.memory_heap_used), + }) + } + if (typeof metrics.memory_rss === 'number') { + cells.push({ + key: 'rss', + label: 'rss', + value: formatBytes(metrics.memory_rss), + }) + } + if (typeof metrics.cpu_percent === 'number') { + cells.push({ + key: 'cpu', + label: 'cpu', + value: `${metrics.cpu_percent.toFixed(1)}%`, + }) + } + if (typeof metrics.event_loop_lag_ms === 'number') { + cells.push({ + key: 'lag', + label: 'loop lag', + value: `${metrics.event_loop_lag_ms.toFixed(1)}ms`, + }) + } + if (typeof metrics.uptime_seconds === 'number') { + cells.push({ + key: 'uptime', + label: 'uptime', + value: formatUptime(metrics.uptime_seconds), + }) + } + if (cells.length === 0) return null + return ( +
+ {cells.map((c) => ( + + + {c.label} + + {c.value} + + ))} +
+ ) +} + +interface SubListProps { + title: string + empty: string + items: { key: string; left: string; right: string | null }[] +} + +function SubList({ title, empty, items }: SubListProps) { + return ( +
+
+ {title} · {items.length} +
+ {items.length === 0 ? ( +
+ · {empty} +
+ ) : ( +
    + {items.map((it) => ( +
  • + + {it.left} + + {it.right ? ( + + {it.right} + + ) : null} +
  • + ))} +
+ )} +
+ ) +} + +function statusVariant( + status: string, +): 'accent' | 'default' | 'warn' | 'alert' { + const s = status.toLowerCase() + if (s === 'connected' || s === 'active' || s === 'ready') return 'accent' + if (s === 'disconnected' || s === 'stopped' || s === 'down') return 'default' + return 'warn' +} + +function formatConnectedFor(connectedAtMs: number): string { + const ms = Math.max(0, Date.now() - connectedAtMs) + if (ms < 60_000) return `${Math.floor(ms / 1000)}s up` + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m up` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h up` + return `${Math.floor(ms / 86_400_000)}d up` +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB` +} + +function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h` + return `${Math.floor(seconds / 86400)}d` +} diff --git a/console/web/src/components/chat/engine/WorkersListView.tsx b/console/web/src/components/chat/engine/WorkersListView.tsx new file mode 100644 index 00000000..a9042f83 --- /dev/null +++ b/console/web/src/components/chat/engine/WorkersListView.tsx @@ -0,0 +1,181 @@ +import type { ReactNode } from 'react' +import { + safeParseRequest, + safeParseResponse, + type WorkerSummary, + type WorkersListRequest, + workersListRequestSchema, + workersListResponseSchema, +} from './parsers' +import { FilterChip, ListHeader } from './shared' + +interface WorkersListViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function WorkersListView({ + input, + output, + running, +}: WorkersListViewProps) { + const req = safeParseRequest(workersListRequestSchema, input) + + if (running) { + return ( +
+ } + /> +
+ · listing workers… +
+
+ ) + } + + const resp = safeParseResponse(workersListResponseSchema, output) + if (!resp) return null + + return ( +
+ } + /> + {resp.workers.length === 0 ? ( +
+ · no workers connected +
+ ) : ( +
    + {resp.workers.map((w) => ( + + ))} +
+ )} +
+ ) +} + +function WorkerRow({ worker }: { worker: WorkerSummary }) { + return ( +
  • +
    + + {worker.name ?? worker.id} + + + {worker.runtime ? ( + + + runtime + + {worker.runtime} + + ) : null} + {worker.os ? ( + + + os + + {worker.os} + + ) : null} + {worker.isolation ? ( + + + isolation + + {worker.isolation} + + ) : null} +
    +
    + id: {shortenId(worker.id)} + {worker.version ? · v{worker.version} : null} + + · {worker.function_count}{' '} + {worker.function_count === 1 ? 'fn' : 'fns'} + + {worker.active_invocations > 0 ? ( + + · {worker.active_invocations}{' '} + active + + ) : null} + {worker.ip_address ? · {worker.ip_address} : null} + · {formatConnectedFor(worker.connected_at_ms)} +
    + {worker.description ? ( +
    + {worker.description} +
    + ) : null} +
  • + ) +} + +function StatusBadge({ status }: { status: string }) { + const variant = statusToVariant(status) + return ( + + {status} + + ) +} + +function statusToVariant(status: string): string { + const s = status.toLowerCase() + if (s === 'connected' || s === 'active' || s === 'ready') { + return 'text-accent border-accent/40 bg-paper-2' + } + if (s === 'disconnected' || s === 'stopped' || s === 'down') { + return 'text-ink-faint border-rule-2 bg-paper-2' + } + return 'text-warn border-warn/40 bg-paper-2' +} + +function SmallChip({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function shortenId(id: string): string { + if (id.length <= 14) return id + return `${id.slice(0, 8)}…${id.slice(-4)}` +} + +function formatConnectedFor(connectedAtMs: number): string { + const now = Date.now() + const ms = Math.max(0, now - connectedAtMs) + if (ms < 60_000) return `${Math.floor(ms / 1000)}s up` + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m up` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h up` + return `${Math.floor(ms / 86_400_000)}d up` +} + +function RequestFilters({ req }: { req?: WorkersListRequest }) { + if (!req) return null + const chips: ReactNode[] = [] + if (req.runtime) { + chips.push() + } + if (req.status) { + chips.push() + } + if (req.search) { + chips.push() + } + return chips.length > 0 ? <>{chips} : null +} diff --git a/console/web/src/components/chat/engine/WorkersRegisterView.tsx b/console/web/src/components/chat/engine/WorkersRegisterView.tsx new file mode 100644 index 00000000..662cd4c6 --- /dev/null +++ b/console/web/src/components/chat/engine/WorkersRegisterView.tsx @@ -0,0 +1,95 @@ +import { + ActionLine, + Chip, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { + safeParseRequest, + safeParseResponse, + workersRegisterRequestSchema, + workersRegisterResponseSchema, +} from './parsers' + +interface WorkersRegisterViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function WorkersRegisterView({ + input, + output, + running, +}: WorkersRegisterViewProps) { + const req = safeParseRequest(workersRegisterRequestSchema, input) + if (!req) return null + + const headerLabel = running + ? 'registering…' + : (() => { + const resp = safeParseResponse(workersRegisterResponseSchema, output) + if (resp == null) return 'register failed' + return resp.success ? 'registered' : 'register failed' + })() + const headerVariant: 'accent' | 'warn' | 'default' = running + ? 'default' + : headerLabel === 'registered' + ? 'accent' + : 'warn' + + return ( +
    + + + {req.runtime ? ( + + + runtime + + {req.runtime} + + ) : null} + {req.version ? ( + + + version + + {req.version} + + ) : null} + {req.os ? ( + + + os + + {req.os} + + ) : null} + + + + {req.name ?? req._caller_worker_id} + + +
    + + id: {shortenId(req._caller_worker_id)} + + {req.telemetry?.install_kind ? ( + install: {req.telemetry.install_kind} + ) : null} + {req.telemetry?.device_id ? ( + + device: {shortenId(req.telemetry.device_id)} + + ) : null} +
    +
    + ) +} + +function shortenId(id: string): string { + if (id.length <= 14) return id + return `${id.slice(0, 8)}…${id.slice(-4)}` +} diff --git a/console/web/src/components/chat/engine/__tests__/parsers.test.ts b/console/web/src/components/chat/engine/__tests__/parsers.test.ts new file mode 100644 index 00000000..50a37341 --- /dev/null +++ b/console/web/src/components/chat/engine/__tests__/parsers.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest' +import { + ENGINE_FUNCTION_IDS, + functionDetailSchema, + functionInfoRequestSchema, + functionsListRequestSchema, + functionsListResponseSchema, + isEngineListFunction, + registeredTriggersListRequestSchema, + registeredTriggersListResponseSchema, + safeParseRequest, + safeParseResponse, + triggersListRequestSchema, + triggersListResponseSchema, + unwrapEnvelope, + workerInfoRequestSchema, + workerInfoResponseSchema, + workersListRequestSchema, + workersListResponseSchema, + workersRegisterRequestSchema, + workersRegisterResponseSchema, +} from '../parsers' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +describe('isEngineListFunction', () => { + it('matches every id in the explicit allowlist', () => { + for (const id of ENGINE_FUNCTION_IDS) { + expect(isEngineListFunction(id)).toBe(true) + } + }) + + it('rejects unrelated ids', () => { + expect(isEngineListFunction('engine::functions')).toBe(false) + expect(isEngineListFunction('sandbox::exec')).toBe(false) + expect(isEngineListFunction('engine::triggers::create')).toBe(false) + }) +}) + +describe('engine::functions::list', () => { + it('parses an empty request payload', () => { + const req = safeParseRequest(functionsListRequestSchema, {}) + expect(req).toEqual({}) + }) + + it('parses a filter-applied request', () => { + const req = safeParseRequest(functionsListRequestSchema, { + prefix: 'todo::', + include_internal: true, + }) + expect(req).toEqual({ prefix: 'todo::', include_internal: true }) + }) + + it('parses a raw response payload', () => { + const resp = safeParseResponse(functionsListResponseSchema, { + functions: [ + { + function_id: 'todo::list', + worker_name: 'todo-app', + description: 'List all todos', + }, + ], + }) + expect(resp?.functions).toHaveLength(1) + expect(resp?.functions[0].function_id).toBe('todo::list') + }) + + it('parses a harness-wrapped response payload', () => { + const payload = { + functions: [ + { function_id: 'todo::create', worker_name: 'todo-app' }, + { + function_id: 'todo::delete', + worker_name: 'todo-app', + description: null, + }, + ], + } + const resp = safeParseResponse(functionsListResponseSchema, wrap(payload)) + expect(resp?.functions).toHaveLength(2) + }) + + it('parses an empty list', () => { + expect( + safeParseResponse(functionsListResponseSchema, wrap({ functions: [] })), + ).toEqual({ functions: [] }) + }) +}) + +describe('engine::functions::info', () => { + it('parses the request payload', () => { + expect( + safeParseRequest(functionInfoRequestSchema, { + function_id: 'sandbox::fs::write', + }), + ).toEqual({ function_id: 'sandbox::fs::write' }) + }) + + it('rejects a request missing function_id', () => { + expect(safeParseRequest(functionInfoRequestSchema, {})).toBeNull() + }) + + it('parses a wrapped AnyValue-schema detail (mirrors the screenshot)', () => { + const detail = { + description: 'Stream-upload a file into a sandbox', + function_id: 'sandbox::fs::write', + registered_triggers: [], + request_schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'AnyValue', + }, + response_schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'AnyValue', + }, + worker_name: 'iii-sandbox', + } + const parsed = safeParseResponse(functionDetailSchema, wrap(detail)) + expect(parsed?.function_id).toBe('sandbox::fs::write') + expect(parsed?.registered_triggers).toEqual([]) + }) + + it('parses a rich payload with registered triggers + schemas', () => { + const detail = { + function_id: 'todo::create', + worker_name: 'todo-app', + description: 'Create a new todo', + request_schema: { type: 'object' }, + response_schema: { type: 'object' }, + registered_triggers: [ + { + id: '8c7e9d12-2a4f-4b5c-9d3e-1f2a3b4c5d6e', + trigger_type: 'http', + config: { api_path: '/todos', http_method: 'POST' }, + }, + ], + } + const parsed = safeParseResponse(functionDetailSchema, detail) + expect(parsed?.registered_triggers).toHaveLength(1) + expect(parsed?.registered_triggers[0].trigger_type).toBe('http') + }) + + it('rejects a payload missing registered_triggers', () => { + const parsed = safeParseResponse(functionDetailSchema, { + function_id: 'x', + worker_name: 'y', + }) + expect(parsed).toBeNull() + }) +}) + +describe('engine::triggers::list', () => { + it('parses a wrapped success payload', () => { + const payload = { + triggers: [ + { id: 'http', worker_name: 'http', description: 'HTTP API trigger' }, + { + id: 'log', + worker_name: 'log', + description: 'Log event trigger', + }, + ], + } + const resp = safeParseResponse(triggersListResponseSchema, wrap(payload)) + expect(resp?.triggers).toHaveLength(2) + }) + + it('parses a search-applied request', () => { + expect( + safeParseRequest(triggersListRequestSchema, { search: 'http' }), + ).toEqual({ search: 'http' }) + }) + + it('rejects payloads missing the `triggers` array', () => { + const resp = safeParseResponse(triggersListResponseSchema, { foo: [] }) + expect(resp).toBeNull() + }) +}) + +describe('engine::registered-triggers::list', () => { + it('parses a wrapped registered-triggers payload', () => { + const payload = { + registered_triggers: [ + { + id: '6ebe7d64-3717-4acc-a02d-3f050fb86df2', + trigger_type: 'http', + function_id: 'todo::html', + worker_name: 'todo-app', + config_summary: '{"api_path":"/todos/html","http_method":"GET"}', + }, + ], + } + const resp = safeParseResponse( + registeredTriggersListResponseSchema, + wrap(payload), + ) + expect(resp?.registered_triggers).toHaveLength(1) + }) + + it('parses a function_id filter request', () => { + expect( + safeParseRequest(registeredTriggersListRequestSchema, { + function_id: 'todo::html', + }), + ).toEqual({ function_id: 'todo::html' }) + }) +}) + +describe('engine::workers::list', () => { + it('parses an empty request payload', () => { + expect(safeParseRequest(workersListRequestSchema, {})).toEqual({}) + }) + + it('parses a runtime+status filter', () => { + expect( + safeParseRequest(workersListRequestSchema, { + runtime: 'rust', + status: 'connected', + }), + ).toEqual({ runtime: 'rust', status: 'connected' }) + }) + + it('parses a wrapped success payload', () => { + const payload = { + workers: [ + { + id: 'w-1', + name: 'todo-app', + description: null, + version: '0.4.7', + runtime: 'node', + os: 'darwin', + status: 'connected', + function_count: 6, + connected_at_ms: 1_700_000_000_000, + active_invocations: 0, + isolation: 'process', + ip_address: '127.0.0.1', + }, + ], + } + const parsed = safeParseResponse(workersListResponseSchema, wrap(payload)) + expect(parsed?.workers).toHaveLength(1) + expect(parsed?.workers[0].runtime).toBe('node') + }) + + it('parses an empty workers list', () => { + expect( + safeParseResponse(workersListResponseSchema, { workers: [] }), + ).toEqual({ workers: [] }) + }) +}) + +describe('engine::workers::info', () => { + it('parses the request payload', () => { + expect( + safeParseRequest(workerInfoRequestSchema, { name: 'todo-app' }), + ).toEqual({ name: 'todo-app' }) + }) + + it('rejects a request missing name', () => { + expect(safeParseRequest(workerInfoRequestSchema, {})).toBeNull() + }) + + it('parses a wrapped full detail payload', () => { + const payload = { + worker: { + id: 'w-1', + name: 'todo-app', + description: 'demo', + version: '0.4.7', + runtime: 'node', + os: 'darwin', + status: 'connected', + function_count: 1, + connected_at_ms: 1_700_000_000_000, + active_invocations: 0, + isolation: 'process', + ip_address: null, + internal: false, + latest_metrics: { + memory_heap_used: 1024, + cpu_percent: 1.2, + timestamp_ms: 1_700_000_001_000, + runtime: 'node', + }, + }, + functions: [{ function_id: 'todo::list', worker_name: 'todo-app' }], + trigger_types: [], + registered_triggers: [], + } + const parsed = safeParseResponse(workerInfoResponseSchema, wrap(payload)) + expect(parsed?.worker.internal).toBe(false) + expect(parsed?.functions).toHaveLength(1) + expect(parsed?.worker.latest_metrics?.cpu_percent).toBe(1.2) + }) + + it('parses an internal worker without optional fields', () => { + const parsed = safeParseResponse(workerInfoResponseSchema, { + worker: { + id: 'engine-fns', + name: 'iii-engine-functions', + description: null, + version: null, + runtime: 'rust', + os: 'darwin', + status: 'connected', + function_count: 7, + connected_at_ms: 0, + active_invocations: 0, + isolation: 'embedded', + ip_address: null, + internal: true, + }, + functions: [], + trigger_types: [], + registered_triggers: [], + }) + expect(parsed?.worker.internal).toBe(true) + }) +}) + +describe('engine::workers::register', () => { + it('parses a full request with telemetry', () => { + const parsed = safeParseRequest(workersRegisterRequestSchema, { + _caller_worker_id: 'w-1', + name: 'todo-app', + runtime: 'node', + version: '0.4.7', + os: 'darwin', + telemetry: { device_id: 'dev-1', install_kind: 'npm' }, + }) + expect(parsed?._caller_worker_id).toBe('w-1') + expect(parsed?.telemetry?.install_kind).toBe('npm') + }) + + it('rejects a request missing the _caller_worker_id', () => { + expect( + safeParseRequest(workersRegisterRequestSchema, { name: 'no-id' }), + ).toBeNull() + }) + + it('parses the success envelope', () => { + expect( + safeParseResponse(workersRegisterResponseSchema, wrap({ success: true })), + ).toEqual({ success: true }) + }) +}) + +describe('unwrapEnvelope re-export', () => { + it('peels the harness envelope', () => { + const inner = { functions: [] } + expect(unwrapEnvelope(wrap(inner))).toEqual(inner) + }) +}) diff --git a/console/web/src/components/chat/engine/index.tsx b/console/web/src/components/chat/engine/index.tsx new file mode 100644 index 00000000..99916212 --- /dev/null +++ b/console/web/src/components/chat/engine/index.tsx @@ -0,0 +1,98 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import type { FunctionCallMessage } from '@/types/chat' +import { FunctionInfoView } from './FunctionInfoView' +import { FunctionsListView } from './FunctionsListView' +import { isEngineListFunction, unwrapEnvelope } from './parsers' +import { RegisteredTriggersListView } from './RegisteredTriggersListView' +import { TriggersListView } from './TriggersListView' +import { WorkerInfoView } from './WorkerInfoView' +import { WorkersListView } from './WorkersListView' +import { WorkersRegisterView } from './WorkersRegisterView' + +/** + * Header label for `engine::*::list` ids. Mirrors `SandboxFunctionIdLabel`: + * dims the namespace prefix so the tail is readable in the FCM header. + */ +export function EngineFunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith('engine::')) { + return {functionId} + } + const tail = functionId.slice('engine::'.length) + return ( + <> + engine:: + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isEngineListFunction(message.functionId)) return null + if (message.pendingApproval) return null + + const input = unwrapEnvelope(message.input) + const rawOutput = message.output + const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined + const running = !!message.running + + // Reuse the sandbox error parser for gate/transport-level errors + // — the `function_error` envelope is shared infra, not sandbox-specific. + const errorDisplay = + !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null + if (errorDisplay) { + return + } + + switch (message.functionId) { + case 'engine::functions::list': + return ( + + ) + case 'engine::functions::info': + return ( + + ) + case 'engine::triggers::list': + return ( + + ) + case 'engine::registered-triggers::list': + return ( + + ) + case 'engine::workers::list': + return + case 'engine::workers::info': + return + case 'engine::workers::register': + return ( + + ) + default: + return null + } +} + +/** + * Engine list calls are read-only and don't go through the approval gate, + * so there's nothing meaningful to preview. Returning `null` lets the + * default request JSON pane render if a `pendingApproval` message ever + * reaches the renderer. + */ +function tryRenderPreview( + _message: FunctionCallMessage, +): React.ReactNode | null { + return null +} + +export const EngineToolView = { + isEngineListFunction, + tryRender, + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/engine/parsers.ts b/console/web/src/components/chat/engine/parsers.ts new file mode 100644 index 00000000..313061fd --- /dev/null +++ b/console/web/src/components/chat/engine/parsers.ts @@ -0,0 +1,258 @@ +/** + * Zod schemas + envelope helpers for the three `engine::*::list` catalogue + * tools. Mirrors the sandbox `parsers.ts` shape: non-strict schemas (so + * unknown fields pass through), a re-exported `unwrapEnvelope`, and + * `safeParseRequest` / `safeParseResponse` that unwrap the harness + * `{ content, details, terminate }` envelope before parsing. + * + * Wire source: `motia/engine/src/workers/engine_fn/mod.rs` — + * FunctionsListInput / FunctionSummary + * TriggersListInput / TriggerTypeSummary + * RegisteredTriggersListInput / RegisteredTriggerSummary + */ +import { z } from 'zod' +import { unwrapEnvelope } from '@/components/chat/sandbox/parsers' + +export { unwrapEnvelope } + +export const ENGINE_FUNCTION_IDS = [ + 'engine::functions::list', + 'engine::functions::info', + 'engine::triggers::list', + 'engine::registered-triggers::list', + 'engine::workers::list', + 'engine::workers::info', + 'engine::workers::register', +] as const + +export type EngineFunctionId = (typeof ENGINE_FUNCTION_IDS)[number] + +const ENGINE_FUNCTION_ID_SET: ReadonlySet = new Set( + ENGINE_FUNCTION_IDS, +) + +/** + * Predicate for both the list catalogues and the singular `info` lookup. + * Name kept for backwards compatibility with the FCM wiring; the family + * now covers every renderer in this module. + */ +export function isEngineListFunction(id: string): id is EngineFunctionId { + return ENGINE_FUNCTION_ID_SET.has(id) +} + +/* ---------------- engine::functions::list ---------------- */ + +export const functionsListRequestSchema = z.object({ + search: z.string().optional(), + prefix: z.string().optional(), + worker: z.string().optional(), + include_internal: z.boolean().optional(), +}) +export type FunctionsListRequest = z.infer + +export const functionSummarySchema = z.object({ + function_id: z.string(), + worker_name: z.string(), + description: z.string().nullable().optional(), +}) +export type FunctionSummary = z.infer + +export const functionsListResponseSchema = z.object({ + functions: z.array(functionSummarySchema), +}) +export type FunctionsListResponse = z.infer + +/* ---------------- engine::functions::info ---------------- */ + +export const functionInfoRequestSchema = z.object({ + function_id: z.string(), +}) +export type FunctionInfoRequest = z.infer + +/** Inline registered trigger payload from `FunctionDetail`. Different shape + * than `RegisteredTriggerSummary` (used by `engine::registered-triggers::list`): + * `config` is the raw JSON object, not a stringified `config_summary`. */ +export const registeredTriggerRefSchema = z.object({ + id: z.string(), + trigger_type: z.string(), + config: z.unknown(), +}) +export type RegisteredTriggerRef = z.infer + +export const functionDetailSchema = z.object({ + function_id: z.string(), + worker_name: z.string(), + description: z.string().nullable().optional(), + request_schema: z.unknown().optional(), + response_schema: z.unknown().optional(), + metadata: z.unknown().optional(), + registered_triggers: z.array(registeredTriggerRefSchema), +}) +export type FunctionDetail = z.infer + +/* ---------------- engine::triggers::list ---------------- */ + +export const triggersListRequestSchema = z.object({ + search: z.string().optional(), + prefix: z.string().optional(), + worker: z.string().optional(), + include_internal: z.boolean().optional(), +}) +export type TriggersListRequest = z.infer + +export const triggerTypeSummarySchema = z.object({ + id: z.string(), + worker_name: z.string(), + description: z.string(), +}) +export type TriggerTypeSummary = z.infer + +export const triggersListResponseSchema = z.object({ + triggers: z.array(triggerTypeSummarySchema), +}) +export type TriggersListResponse = z.infer + +/* ------------- engine::registered-triggers::list ------------- */ + +export const registeredTriggersListRequestSchema = z.object({ + search: z.string().optional(), + trigger_type: z.string().optional(), + function_id: z.string().optional(), + worker: z.string().optional(), + include_internal: z.boolean().optional(), +}) +export type RegisteredTriggersListRequest = z.infer< + typeof registeredTriggersListRequestSchema +> + +export const registeredTriggerSummarySchema = z.object({ + id: z.string(), + trigger_type: z.string(), + function_id: z.string(), + worker_name: z.string(), + config_summary: z.string(), +}) +export type RegisteredTriggerSummary = z.infer< + typeof registeredTriggerSummarySchema +> + +export const registeredTriggersListResponseSchema = z.object({ + registered_triggers: z.array(registeredTriggerSummarySchema), +}) +export type RegisteredTriggersListResponse = z.infer< + typeof registeredTriggersListResponseSchema +> + +/* ---------------- engine::workers::list ---------------- */ + +export const workersListRequestSchema = z.object({ + search: z.string().optional(), + runtime: z.string().optional(), + status: z.string().optional(), +}) +export type WorkersListRequest = z.infer + +export const workerSummarySchema = z.object({ + id: z.string(), + name: z.string().nullable().optional(), + description: z.string().nullable().optional(), + version: z.string().nullable().optional(), + runtime: z.string().nullable().optional(), + os: z.string().nullable().optional(), + status: z.string(), + function_count: z.number(), + connected_at_ms: z.number(), + active_invocations: z.number(), + isolation: z.string().nullable().optional(), + ip_address: z.string().nullable().optional(), +}) +export type WorkerSummary = z.infer + +export const workersListResponseSchema = z.object({ + workers: z.array(workerSummarySchema), +}) +export type WorkersListResponse = z.infer + +/* ---------------- engine::workers::info ---------------- */ + +export const workerInfoRequestSchema = z.object({ + name: z.string(), +}) +export type WorkerInfoRequest = z.infer + +export const workerMetricsSchema = z.object({ + memory_heap_used: z.number().optional(), + memory_heap_total: z.number().optional(), + memory_rss: z.number().optional(), + memory_external: z.number().optional(), + cpu_user_micros: z.number().optional(), + cpu_system_micros: z.number().optional(), + cpu_percent: z.number().optional(), + event_loop_lag_ms: z.number().optional(), + uptime_seconds: z.number().optional(), + timestamp_ms: z.number(), + runtime: z.string(), +}) +export type WorkerMetrics = z.infer + +export const workerDetailEnvelopeSchema = workerSummarySchema.extend({ + pid: z.number().optional(), + internal: z.boolean(), + latest_metrics: workerMetricsSchema.nullable().optional(), +}) +export type WorkerDetailEnvelope = z.infer + +/** `functions` inside `workers::info` only carries `function_id` + + * `worker_name` (no description). The `functionSummarySchema` already + * marks `description` optional/nullable, so we reuse it here. */ +export const workerInfoResponseSchema = z.object({ + worker: workerDetailEnvelopeSchema, + functions: z.array(functionSummarySchema), + trigger_types: z.array(triggerTypeSummarySchema), + registered_triggers: z.array(registeredTriggerSummarySchema), +}) +export type WorkerInfoResponse = z.infer + +/* ---------------- engine::workers::register ---------------- */ + +export const workerTelemetryMetaSchema = z.object({ + device_id: z.string().optional(), + install_kind: z.string().optional(), +}) + +export const workersRegisterRequestSchema = z.object({ + _caller_worker_id: z.string(), + runtime: z.string().nullable().optional(), + version: z.string().nullable().optional(), + name: z.string().nullable().optional(), + os: z.string().nullable().optional(), + telemetry: workerTelemetryMetaSchema.nullable().optional(), +}) +export type WorkersRegisterRequest = z.infer< + typeof workersRegisterRequestSchema +> + +export const workersRegisterResponseSchema = z.object({ + success: z.boolean(), +}) +export type WorkersRegisterResponse = z.infer< + typeof workersRegisterResponseSchema +> + +/* ---------------- generic helpers ---------------- */ + +export function safeParseRequest( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} + +export function safeParseResponse( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(unwrapEnvelope(value)) + return parsed.success ? parsed.data : null +} diff --git a/console/web/src/components/chat/engine/shared.tsx b/console/web/src/components/chat/engine/shared.tsx new file mode 100644 index 00000000..3c9af761 --- /dev/null +++ b/console/web/src/components/chat/engine/shared.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { Chip, MetaRow, StatusPill } from '@/components/chat/sandbox/shared' + +/** + * Header row shared by all three engine list views. Renders a status pill + * (count) on the left and a flexible chip row on the right for the active + * request filters. Keeps the three list views visually consistent. + */ +interface ListHeaderProps { + count: number + noun: string + filters?: ReactNode + tone?: 'default' | 'accent' | 'warn' +} + +export function ListHeader({ + count, + noun, + filters, + tone = 'accent', +}: ListHeaderProps) { + const label = + count === 0 + ? `no ${noun} match` + : `${count} ${count === 1 ? noun.replace(/s$/, '') : noun}` + const pillVariant: 'default' | 'accent' | 'warn' = + count === 0 ? 'warn' : tone === 'accent' ? 'accent' : tone + return ( + + + {filters ? ( + {filters} + ) : null} + + ) +} + +interface FilterChipProps { + label: string + value: ReactNode +} + +export function FilterChip({ label, value }: FilterChipProps) { + return ( + + + {label} + + {value} + + ) +} + +export function InternalChip() { + return ( + + internal + + ) +} diff --git a/console/web/src/components/chat/web/FetchView.tsx b/console/web/src/components/chat/web/FetchView.tsx new file mode 100644 index 00000000..7f8beea1 --- /dev/null +++ b/console/web/src/components/chat/web/FetchView.tsx @@ -0,0 +1,366 @@ +import { useState } from 'react' +import { + ActionLine, + Chip, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { JsonHighlight } from '@/lib/syntax' +import { cn } from '@/lib/utils' +import { + type FetchRequest, + type FetchResult, + fetchErrorVariantLabel, + fetchRequestSchema, + fetchResultSchema, + safeParseRequest, + safeParseResponse, +} from './parsers' + +interface FetchViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function FetchView({ input, output, running }: FetchViewProps) { + const req = safeParseRequest(fetchRequestSchema, input) + if (!req) return null + + const method = (req.method ?? 'GET').toUpperCase() + + if (running) { + return ( +
    + + + {method} + + + {req.url} + +
    + · waiting for response… +
    +
    + ) + } + + const result = safeParseResponse(fetchResultSchema, output) + if (!result) return null + + if (!result.ok) { + return + } + return +} + +/** Compact preview rendered while a `web::fetch` call sits in the + approval gate. Read-only summary of method + URL + payload counts. */ +export function FetchPreview({ input }: { input: unknown }) { + const req = safeParseRequest(fetchRequestSchema, input) + if (!req) return null + const method = (req.method ?? 'GET').toUpperCase() + const headerCount = req.headers ? Object.keys(req.headers).length : 0 + const hasBody = req.body != null || req.json !== undefined + return ( +
    + + + {method} + {req.response_format ? ( + + + format + + {req.response_format} + + ) : null} + + + {req.url} + +
    + + + headers + + {headerCount} + + {hasBody ? ( + + + body + + + {req.json !== undefined ? 'json' : 'text'} + + + ) : null} + {typeof req.timeout_ms === 'number' ? ( + + + timeout + + + {req.timeout_ms}ms + + + ) : null} + {req.follow_redirects === false ? ( + + + no-redirect + + + ) : null} +
    +
    + ) +} + +/* ---------------- success pane ---------------- */ + +interface FetchSuccessPaneProps { + req: FetchRequest + method: string + result: Extract +} + +function FetchSuccessPane({ req, method, result }: FetchSuccessPaneProps) { + const contentType = result.headers['content-type'] + const statusVariant = statusToVariant(result.status) + return ( +
    + + + {method} + {contentType ? ( + + + type + + {contentType.split(';')[0]} + + ) : null} + + + format + + {result.response_format} + + {result.bytes_truncated ? ( + + truncated + + ) : null} + {result.redirect_chain && result.redirect_chain.length > 0 ? ( + + + redirects + + + {result.redirect_chain.length} + + + ) : null} + + + {req.url} + + {result.parse_error ? ( + + parse error: {result.parse_error} + + ) : null} + {result.redirect_chain && result.redirect_chain.length > 0 ? ( + + ) : null} + + +
    + ) +} + +function RedirectChain({ chain }: { chain: string[] }) { + return ( +
    +
    + redirect chain +
    + {chain.map((url) => ( +
    + → {url} +
    + ))} +
    + ) +} + +function ResponseHeaders({ headers }: { headers: Record }) { + const [open, setOpen] = useState(false) + const entries = Object.entries(headers) + if (entries.length === 0) return null + return ( +
    + + {open ? ( + + + {entries.map(([key, value]) => ( + + + + + ))} + +
    + {key} + {value}
    + ) : null} +
    + ) +} + +function Body({ result }: { result: Extract }) { + if (result.response_format === 'base64') { + return ( +
    +
    + + base64 + + + {result.body.length} chars + +
    +
    +          {truncateString(result.body, 600)}
    +        
    +
    + ) + } + + const jsonString = jsonStringFromResult(result) + if (jsonString != null) { + return + } + + if (result.body.length === 0) { + return ( +
    + · empty body +
    + ) + } + + return ( +
    +      {result.body}
    +    
    + ) +} + +function jsonStringFromResult( + result: Extract, +): string | null { + if (result.response_format === 'json' && result.json !== undefined) { + try { + return JSON.stringify(result.json, null, 2) + } catch { + // fall through to body parse below + } + } + const contentType = result.headers['content-type'] + if (contentType?.toLowerCase().includes('json') && result.body.length > 0) { + try { + return JSON.stringify(JSON.parse(result.body), null, 2) + } catch { + return null + } + } + return null +} + +function truncateString(value: string, max: number): string { + if (value.length <= max) return value + return `${value.slice(0, max)}…` +} + +function statusToVariant( + status: number, +): 'accent' | 'default' | 'warn' | 'alert' { + if (status >= 500) return 'alert' + if (status >= 400) return 'warn' + if (status >= 300) return 'default' + if (status >= 200) return 'accent' + return 'default' +} + +/* ---------------- error pane ---------------- */ + +interface FetchErrorPaneProps { + req: FetchRequest + method: string + error: Extract +} + +function FetchErrorPane({ req, method, error }: FetchErrorPaneProps) { + return ( +
    + + + {method} + {typeof error.status === 'number' ? ( + + + status + + {error.status} + + ) : null} + + + code + + {error.error} + + + + {req.url} + +
    +
    +          {error.message}
    +        
    +
    +
    + ) +} diff --git a/console/web/src/components/chat/web/__tests__/parsers.test.ts b/console/web/src/components/chat/web/__tests__/parsers.test.ts new file mode 100644 index 00000000..64f29c7e --- /dev/null +++ b/console/web/src/components/chat/web/__tests__/parsers.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + fetchErrorSchema, + fetchErrorVariantLabel, + fetchRequestSchema, + fetchResultSchema, + fetchSuccessSchema, + isWebFunction, + safeParseRequest, + safeParseResponse, + unwrapEnvelope, + WEB_FUNCTION_IDS, +} from '../parsers' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +describe('isWebFunction', () => { + it('matches every id in the explicit allowlist', () => { + for (const id of WEB_FUNCTION_IDS) { + expect(isWebFunction(id)).toBe(true) + } + }) + + it('rejects unrelated ids', () => { + expect(isWebFunction('web::')).toBe(false) + expect(isWebFunction('sandbox::exec')).toBe(false) + expect(isWebFunction('webfetch')).toBe(false) + }) +}) + +describe('fetchRequestSchema', () => { + it('accepts a minimal request', () => { + const r = safeParseRequest(fetchRequestSchema, { + url: 'https://example.com', + }) + expect(r?.url).toBe('https://example.com') + }) + + it('accepts a fully-populated POST request', () => { + const r = safeParseRequest(fetchRequestSchema, { + url: 'https://example.com/api', + method: 'POST', + headers: { 'content-type': 'application/json' }, + json: { hello: 'world' }, + timeout_ms: 5_000, + max_bytes: 1_000_000, + follow_redirects: false, + response_format: 'json', + }) + expect(r?.method).toBe('POST') + expect(r?.response_format).toBe('json') + }) + + it('rejects requests without a url', () => { + expect(safeParseRequest(fetchRequestSchema, { method: 'GET' })).toBeNull() + }) +}) + +describe('fetchSuccessSchema', () => { + const base = { + ok: true as const, + status: 200, + status_text: 'OK', + headers: { 'content-type': 'application/json' }, + body: '{"ok":true,"todos":[]}', + response_format: 'text' as const, + bytes_truncated: false, + } + + it('parses a raw text-format success', () => { + expect(fetchSuccessSchema.safeParse(base).success).toBe(true) + }) + + it('parses a wrapped json-format success with json + redirect_chain', () => { + const payload = { + ...base, + response_format: 'json' as const, + json: { ok: true, todos: [] }, + redirect_chain: ['https://old.example.com/path'], + } + expect(safeParseResponse(fetchSuccessSchema, wrap(payload))).toMatchObject({ + response_format: 'json', + redirect_chain: ['https://old.example.com/path'], + }) + }) + + it('parses a base64 binary success', () => { + expect( + safeParseResponse(fetchSuccessSchema, { + ...base, + response_format: 'base64' as const, + body: 'aGVsbG8=', + }), + ).toBeTruthy() + }) +}) + +describe('fetchErrorSchema', () => { + it.each([ + 'invalid_payload', + 'invalid_url', + 'blocked_host', + 'timeout', + 'too_large', + 'too_many_redirects', + 'transport_error', + ] as const)('accepts the %s variant', (variant) => { + const parsed = fetchErrorSchema.safeParse({ + ok: false, + error: variant, + message: 'something went wrong', + }) + expect(parsed.success).toBe(true) + }) + + it('rejects an unknown error variant', () => { + expect( + fetchErrorSchema.safeParse({ + ok: false, + error: 'unknown_kind', + message: 'nope', + }).success, + ).toBe(false) + }) + + it('accepts an optional null status', () => { + expect( + fetchErrorSchema.safeParse({ + ok: false, + error: 'timeout', + message: 'request timed out', + status: null, + }).success, + ).toBe(true) + }) +}) + +describe('fetchResultSchema union', () => { + it('discriminates success vs error by `ok`', () => { + const successParsed = fetchResultSchema.safeParse({ + ok: true, + status: 200, + status_text: 'OK', + headers: {}, + body: '', + response_format: 'text', + bytes_truncated: false, + }) + expect(successParsed.success).toBe(true) + + const errorParsed = fetchResultSchema.safeParse({ + ok: false, + error: 'transport_error', + message: 'ECONNRESET', + }) + expect(errorParsed.success).toBe(true) + }) +}) + +describe('fetchErrorVariantLabel', () => { + it('maps every variant to a human-readable label', () => { + expect(fetchErrorVariantLabel('timeout')).toBe('Request timed out') + expect(fetchErrorVariantLabel('blocked_host')).toBe('Blocked host') + }) +}) + +describe('unwrapEnvelope re-export', () => { + it('peels the harness envelope', () => { + const inner = { ok: true } + expect(unwrapEnvelope(wrap(inner))).toEqual(inner) + }) +}) diff --git a/console/web/src/components/chat/web/index.tsx b/console/web/src/components/chat/web/index.tsx new file mode 100644 index 00000000..ebf7ba4e --- /dev/null +++ b/console/web/src/components/chat/web/index.tsx @@ -0,0 +1,64 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import type { FunctionCallMessage } from '@/types/chat' +import { FetchPreview, FetchView } from './FetchView' +import { isWebFunction, unwrapEnvelope } from './parsers' + +export function WebFunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith('web::')) { + return {functionId} + } + const tail = functionId.slice('web::'.length) + return ( + <> + web:: + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isWebFunction(message.functionId)) return null + if (message.pendingApproval) return null + + const input = unwrapEnvelope(message.input) + const rawOutput = message.output + const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined + const running = !!message.running + + // Reuse the sandbox error parser for transport-level / gate denials. + // `web::fetch`'s own handler-level errors (`{ ok: false, ... }`) are + // handled inside FetchView's success/error union, not here. + const errorDisplay = + !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null + if (errorDisplay) { + return + } + + switch (message.functionId) { + case 'web::fetch': + return + default: + return null + } +} + +function tryRenderPreview( + message: FunctionCallMessage, +): React.ReactNode | null { + if (!isWebFunction(message.functionId)) return null + const input = unwrapEnvelope(message.input) + switch (message.functionId) { + case 'web::fetch': + return + default: + return null + } +} + +export const WebToolView = { + isWebFunction, + tryRender, + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/web/parsers.ts b/console/web/src/components/chat/web/parsers.ts new file mode 100644 index 00000000..6b0b259e --- /dev/null +++ b/console/web/src/components/chat/web/parsers.ts @@ -0,0 +1,129 @@ +/** + * Zod schemas + envelope helpers for `web::fetch`. + * + * Wire source: + * workers/harness/src/web/schemas.ts -> FetchPayload (request) + * -> FetchResult (response union) + * workers/harness/src/web/fetch.ts -> handler returns FetchResult + * + * The response is a discriminated union on `ok`: + * { ok: true, status, status_text, headers, body, response_format, + * bytes_truncated, json?, parse_error?, redirect_chain? } + * { ok: false, error: , message, status? } + * + * Schemas are non-strict so additive wire fields don't break the UI. + */ +import { z } from 'zod' +import { unwrapEnvelope } from '@/components/chat/sandbox/parsers' + +export { unwrapEnvelope } + +export const WEB_FUNCTION_IDS = ['web::fetch'] as const +export type WebFunctionId = (typeof WEB_FUNCTION_IDS)[number] + +const WEB_FUNCTION_ID_SET: ReadonlySet = new Set( + WEB_FUNCTION_IDS, +) + +export function isWebFunction(id: string): id is WebFunctionId { + return WEB_FUNCTION_ID_SET.has(id) +} + +/* ---------------- request ---------------- */ + +export const fetchMethodSchema = z.enum([ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', +]) +export type FetchMethod = z.infer + +export const fetchResponseFormatSchema = z.enum(['text', 'base64', 'json']) +export type FetchResponseFormat = z.infer + +export const fetchRequestSchema = z.object({ + url: z.string(), + method: fetchMethodSchema.optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.string().optional(), + json: z.unknown().optional(), + timeout_ms: z.number().optional(), + max_bytes: z.number().optional(), + follow_redirects: z.boolean().optional(), + response_format: fetchResponseFormatSchema.optional(), +}) +export type FetchRequest = z.infer + +/* ---------------- response ---------------- */ + +export const fetchSuccessSchema = z.object({ + ok: z.literal(true), + status: z.number(), + status_text: z.string(), + headers: z.record(z.string(), z.string()), + body: z.string(), + response_format: fetchResponseFormatSchema, + bytes_truncated: z.boolean(), + json: z.unknown().optional(), + parse_error: z.string().optional(), + redirect_chain: z.array(z.string()).optional(), +}) +export type FetchSuccess = z.infer + +export const fetchErrorVariantSchema = z.enum([ + 'invalid_payload', + 'invalid_url', + 'blocked_host', + 'timeout', + 'too_large', + 'too_many_redirects', + 'transport_error', +]) +export type FetchErrorVariant = z.infer + +export const fetchErrorSchema = z.object({ + ok: z.literal(false), + error: fetchErrorVariantSchema, + message: z.string(), + status: z.number().nullable().optional(), +}) +export type FetchError = z.infer + +export const fetchResultSchema = z.union([fetchSuccessSchema, fetchErrorSchema]) +export type FetchResult = z.infer + +/* ---------------- helpers ---------------- */ + +export function safeParseRequest( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} + +export function safeParseResponse( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(unwrapEnvelope(value)) + return parsed.success ? parsed.data : null +} + +const ERROR_VARIANT_LABEL: Record = { + invalid_payload: 'Invalid payload', + invalid_url: 'Invalid URL', + blocked_host: 'Blocked host', + timeout: 'Request timed out', + too_large: 'Response too large', + too_many_redirects: 'Too many redirects', + transport_error: 'Transport error', +} + +export function fetchErrorVariantLabel(variant: FetchErrorVariant): string { + return ERROR_VARIANT_LABEL[variant] +} diff --git a/console/web/src/components/chat/worker/WorkerListView.tsx b/console/web/src/components/chat/worker/WorkerListView.tsx new file mode 100644 index 00000000..b3b988df --- /dev/null +++ b/console/web/src/components/chat/worker/WorkerListView.tsx @@ -0,0 +1,120 @@ +import { Chip, MetaRow, StatusPill } from '@/components/chat/sandbox/shared' +import { + safeParseRequest, + safeParseResponse, + type WorkerEntry, + workerListRequestSchema, + workerListResponseSchema, +} from './parsers' + +interface WorkerListViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function WorkerListView({ + input, + output, + running, +}: WorkerListViewProps) { + const req = safeParseRequest(workerListRequestSchema, input) + + if (running) { + return ( +
    + + + {req?.running_only ? ( + + + running only + + + ) : null} + +
    + · enumerating workers… +
    +
    + ) + } + + const resp = safeParseResponse(workerListResponseSchema, output) + if (!resp) return null + + const runningCount = resp.workers.filter((w) => w.running).length + const label = + resp.workers.length === 0 + ? 'no workers' + : `${resp.workers.length} ${ + resp.workers.length === 1 ? 'worker' : 'workers' + } · ${runningCount} running` + const pillVariant: 'accent' | 'warn' = + resp.workers.length === 0 ? 'warn' : 'accent' + + return ( +
    + + + {req?.running_only ? ( + + + running only + + + ) : null} + + {resp.workers.length === 0 ? ( +
    + · no workers found +
    + ) : ( +
      + {resp.workers.map((w) => ( + + ))} +
    + )} +
    + ) +} + +function WorkerRow({ entry }: { entry: WorkerEntry }) { + return ( +
  • +
    + + {entry.name} + + + {entry.version ? ( + + v{entry.version} + + ) : null} +
    +
    + {typeof entry.pid === 'number' + ? `pid ${entry.pid}` + : entry.running + ? 'pid —' + : 'stopped'} +
    +
  • + ) +} + +function RunningBadge({ running }: { running: boolean }) { + return ( + + {running ? 'running' : 'stopped'} + + ) +} diff --git a/console/web/src/components/chat/worker/WorkerOpView.tsx b/console/web/src/components/chat/worker/WorkerOpView.tsx new file mode 100644 index 00000000..489137f5 --- /dev/null +++ b/console/web/src/components/chat/worker/WorkerOpView.tsx @@ -0,0 +1,455 @@ +import type { ReactNode } from 'react' +import { + ActionLine, + Chip, + MetaRow, + StatusPill, +} from '@/components/chat/sandbox/shared' +import { + safeParseRequest, + safeParseResponse, + type WorkerSource, + workerAddRequestSchema, + workerAddResponseSchema, + workerClearRequestSchema, + workerClearResponseSchema, + workerRemoveRequestSchema, + workerRemoveResponseSchema, + workerStartRequestSchema, + workerStartResponseSchema, + workerStopRequestSchema, + workerStopResponseSchema, + workerUpdateRequestSchema, + workerUpdateResponseSchema, +} from './parsers' + +interface WorkerOpViewProps { + input: unknown + output: unknown + running?: boolean +} + +/* ---------------- worker::start ---------------- */ + +export function WorkerStartView({ input, output, running }: WorkerOpViewProps) { + const req = safeParseRequest(workerStartRequestSchema, input) + if (!req) return null + + if (running) { + return ( + + + + ) + } + + const resp = safeParseResponse(workerStartResponseSchema, output) + if (!resp) return null + + return ( + + ) : null, + typeof resp.port === 'number' ? portChip(resp.port) : null, + ]} + /> + ) +} + +/* ---------------- worker::stop ---------------- */ + +export function WorkerStopView({ input, output, running }: WorkerOpViewProps) { + const req = safeParseRequest(workerStopRequestSchema, input) + if (!req) return null + + if (running) { + return ( + + + + ) + } + + const resp = safeParseResponse(workerStopResponseSchema, output) + if (!resp) return null + + return ( + + ) +} + +/* ---------------- worker::add ---------------- */ + +export function WorkerAddView({ input, output, running }: WorkerOpViewProps) { + const req = safeParseRequest(workerAddRequestSchema, input) + if (!req) return null + const sourceLabel = describeSource(req.source) + + if (running) { + return ( + + + source + + {req.source.kind} + , + req.force ? : null, + req.reset_config ? ( + + ) : null, + ]} + > + + {sourceLabel} + + + + ) + } + + const resp = safeParseResponse(workerAddResponseSchema, output) + if (!resp) return null + + return ( + + + version + + {resp.version} + + ) : null, + resp.awaited_ready ? : null, + ]} + > + + {sourceLabel} + + + + ) +} + +/* ---------------- worker::remove ---------------- */ + +export function WorkerRemoveView({ + input, + output, + running, +}: WorkerOpViewProps) { + const req = safeParseRequest(workerRemoveRequestSchema, input) + if (!req) return null + + if (running) { + return ( + : null, + req.yes ? : null, + ]} + > + + + ) + } + + const resp = safeParseResponse(workerRemoveResponseSchema, output) + if (!resp) return null + + return ( + + + + ) +} + +/* ---------------- worker::update ---------------- */ + +export function WorkerUpdateView({ + input, + output, + running, +}: WorkerOpViewProps) { + const req = safeParseRequest(workerUpdateRequestSchema, input) + if (!req) return null + const target = + req.names && req.names.length > 0 ? req.names.join(', ') : 'all installed' + + if (running) { + return ( + + + + ) + } + + const resp = safeParseResponse(workerUpdateResponseSchema, output) + if (!resp) return null + + return ( + + {resp.updated.length === 0 ? ( +
    + · everything was already at the latest version +
    + ) : ( +
      + {resp.updated.map((u) => ( +
    • + + {u.name} + + + v{u.from_version} → v{u.to_version} + +
    • + ))} +
    + )} +
    + ) +} + +/* ---------------- worker::clear ---------------- */ + +export function WorkerClearView({ input, output, running }: WorkerOpViewProps) { + const req = safeParseRequest(workerClearRequestSchema, input) + if (!req) return null + const target = + req.names && req.names.length > 0 + ? req.names.join(', ') + : req.all + ? 'all artifacts' + : 'no target' + + if (running) { + return ( + + + + ) + } + + const resp = safeParseResponse(workerClearResponseSchema, output) + if (!resp) return null + + return ( + + + + ) +} + +/* ---------------- shared shell ---------------- */ + +interface OpShellProps { + statusLabel: string + statusVariant: 'default' | 'accent' | 'warn' | 'alert' + title: string + chips?: (ReactNode | null)[] + children?: ReactNode +} + +function OpShell({ + statusLabel, + statusVariant, + title, + chips, + children, +}: OpShellProps) { + const renderedChips = (chips ?? []).filter(Boolean) as ReactNode[] + return ( +
    + + + {renderedChips} + + + + {title} + + + {children} +
    + ) +} + +function RunningHint({ label }: { label: string }) { + return ( +
    + · {label} +
    + ) +} + +function ConfigPathRow({ path }: { path: string }) { + return ( +
    + + config + + {path} +
    + ) +} + +function NameList({ names, empty }: { names: string[]; empty: string }) { + if (names.length === 0) { + return ( +
    + {empty} +
    + ) + } + return ( +
      + {names.map((n) => ( +
    • + {n} +
    • + ))} +
    + ) +} + +function PidChip({ pid }: { pid: number }) { + return ( + + pid + {pid} + + ) +} + +function portChip(port: number) { + return ( + + port + {port} + + ) +} + +function configChip(path: string) { + return ( + + config + + {path} + + + ) +} + +function FlagChip({ label }: { label: string }) { + return ( + + + {label} + + + ) +} + +/* ---------------- helpers ---------------- */ + +function describeSource(source: WorkerSource): string { + switch (source.kind) { + case 'registry': + return source.version + ? `registry:${source.name}@${source.version}` + : `registry:${source.name}` + case 'oci': + return `oci:${source.reference}` + case 'local': + return `local:${source.path}` + } +} + +function sourceTitle(source: WorkerSource): string { + switch (source.kind) { + case 'registry': + return source.name + case 'oci': + return source.reference.split('/').pop() ?? source.reference + case 'local': + return source.path.split('/').pop() ?? source.path + } +} + +function removeTargetTitle(req: { names?: string[]; all?: boolean }): string { + if (req.all) return 'all installed' + if (req.names && req.names.length > 0) return req.names.join(', ') + return '—' +} + +function statusVariantForAddStatus( + status: 'installed' | 'already_current' | 'repaired' | 'replaced', +): 'accent' | 'default' | 'warn' { + if (status === 'installed' || status === 'repaired' || status === 'replaced') + return 'accent' + return 'default' +} diff --git a/console/web/src/components/chat/worker/__tests__/parsers.test.ts b/console/web/src/components/chat/worker/__tests__/parsers.test.ts new file mode 100644 index 00000000..14e78b86 --- /dev/null +++ b/console/web/src/components/chat/worker/__tests__/parsers.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + isWorkerFunction, + safeParseRequest, + safeParseResponse, + unwrapEnvelope, + WORKER_FUNCTION_IDS, + workerAddRequestSchema, + workerAddResponseSchema, + workerClearResponseSchema, + workerListRequestSchema, + workerListResponseSchema, + workerRemoveResponseSchema, + workerStartRequestSchema, + workerStartResponseSchema, + workerStopRequestSchema, + workerStopResponseSchema, + workerUpdateResponseSchema, +} from '../parsers' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +describe('isWorkerFunction', () => { + it('matches every id in the explicit allowlist', () => { + for (const id of WORKER_FUNCTION_IDS) { + expect(isWorkerFunction(id)).toBe(true) + } + }) + + it('rejects engine::workers::* (different family)', () => { + expect(isWorkerFunction('engine::workers::list')).toBe(false) + expect(isWorkerFunction('engine::workers::info')).toBe(false) + }) + + it('rejects unrelated ids', () => { + expect(isWorkerFunction('sandbox::exec')).toBe(false) + expect(isWorkerFunction('worker::')).toBe(false) + }) +}) + +describe('worker::list', () => { + it('parses an empty request', () => { + expect(safeParseRequest(workerListRequestSchema, {})).toEqual({}) + }) + + it('parses a running_only filter', () => { + expect( + safeParseRequest(workerListRequestSchema, { running_only: true }), + ).toEqual({ running_only: true }) + }) + + it('parses the wrapped multi-worker payload from the screenshot', () => { + const payload = { + workers: [ + { name: 'iii-worker-manager', pid: null, running: true }, + { name: 'iii-directory', pid: 19052, running: true, version: '0.5.2' }, + { name: 'iii-stream', pid: null, running: true, version: '0.11.6' }, + ], + } + const parsed = safeParseResponse(workerListResponseSchema, wrap(payload)) + expect(parsed?.workers).toHaveLength(3) + expect(parsed?.workers[1].pid).toBe(19052) + expect(parsed?.workers[0].version).toBeUndefined() + }) + + it('parses an empty list', () => { + expect( + safeParseResponse(workerListResponseSchema, { workers: [] }), + ).toEqual({ workers: [] }) + }) +}) + +describe('worker::start', () => { + it('parses a request', () => { + expect( + safeParseRequest(workerStartRequestSchema, { name: 'pdfkit' }), + ).toEqual({ name: 'pdfkit' }) + }) + + it('rejects a request missing name', () => { + expect(safeParseRequest(workerStartRequestSchema, {})).toBeNull() + }) + + it('parses a response with null pid + port (engine builtin)', () => { + expect( + safeParseResponse(workerStartResponseSchema, { + name: 'iii-stream', + pid: null, + port: null, + }), + ).toMatchObject({ name: 'iii-stream', pid: null, port: null }) + }) + + it('parses a response with real pid + port', () => { + expect( + safeParseResponse(workerStartResponseSchema, { + name: 'pdfkit', + pid: 28943, + port: 4101, + }), + ).toEqual({ name: 'pdfkit', pid: 28943, port: 4101 }) + }) +}) + +describe('worker::stop', () => { + it('accepts the request shape', () => { + expect( + safeParseRequest(workerStopRequestSchema, { name: 'pdfkit', yes: true }), + ).toEqual({ name: 'pdfkit', yes: true }) + }) + + it('parses stopped=true and stopped=false outcomes', () => { + expect( + safeParseResponse(workerStopResponseSchema, { + name: 'pdfkit', + stopped: true, + }), + ).toEqual({ name: 'pdfkit', stopped: true }) + expect( + safeParseResponse(workerStopResponseSchema, { + name: 'pdfkit', + stopped: false, + }), + ).toEqual({ name: 'pdfkit', stopped: false }) + }) +}) + +describe('worker::add', () => { + it('accepts every WorkerSource variant', () => { + expect( + safeParseRequest(workerAddRequestSchema, { + source: { kind: 'registry', name: 'pdfkit', version: '1.0.0' }, + })?.source.kind, + ).toBe('registry') + expect( + safeParseRequest(workerAddRequestSchema, { + source: { kind: 'oci', reference: 'ghcr.io/iii-hq/node:latest' }, + })?.source.kind, + ).toBe('oci') + expect( + safeParseRequest(workerAddRequestSchema, { + source: { kind: 'local', path: '/tmp/worker' }, + })?.source.kind, + ).toBe('local') + }) + + it('rejects an unknown source kind', () => { + expect( + safeParseRequest(workerAddRequestSchema, { + source: { kind: 'magic', name: 'x' }, + }), + ).toBeNull() + }) + + it.each([ + 'installed', + 'already_current', + 'repaired', + 'replaced', + ] as const)('parses the %s status', (status) => { + const parsed = safeParseResponse(workerAddResponseSchema, { + name: 'pdfkit', + status, + awaited_ready: true, + config_path: '/x/iii.config.yaml', + }) + expect(parsed?.status).toBe(status) + }) +}) + +describe('worker::remove / clear / update', () => { + it('parses remove outcomes (full + empty)', () => { + expect( + safeParseResponse(workerRemoveResponseSchema, { + removed: ['a', 'b'], + }), + ).toEqual({ removed: ['a', 'b'] }) + expect( + safeParseResponse(workerRemoveResponseSchema, { removed: [] }), + ).toEqual({ removed: [] }) + }) + + it('parses clear outcomes', () => { + expect( + safeParseResponse(workerClearResponseSchema, { + cleared: ['pdfkit'], + }), + ).toEqual({ cleared: ['pdfkit'] }) + }) + + it('parses update outcomes including version pairs', () => { + const parsed = safeParseResponse(workerUpdateResponseSchema, { + updated: [{ name: 'pdfkit', from_version: '1.0.0', to_version: '1.1.0' }], + }) + expect(parsed?.updated[0].to_version).toBe('1.1.0') + }) +}) + +describe('unwrapEnvelope re-export', () => { + it('peels the harness envelope', () => { + const inner = { workers: [] } + expect(unwrapEnvelope(wrap(inner))).toEqual(inner) + }) +}) diff --git a/console/web/src/components/chat/worker/index.tsx b/console/web/src/components/chat/worker/index.tsx new file mode 100644 index 00000000..7e0ff7b0 --- /dev/null +++ b/console/web/src/components/chat/worker/index.tsx @@ -0,0 +1,89 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import type { FunctionCallMessage } from '@/types/chat' +import { isWorkerFunction, unwrapEnvelope } from './parsers' +import { WorkerListView } from './WorkerListView' +import { + WorkerAddView, + WorkerClearView, + WorkerRemoveView, + WorkerStartView, + WorkerStopView, + WorkerUpdateView, +} from './WorkerOpView' + +export function WorkerFunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith('worker::')) { + return {functionId} + } + const tail = functionId.slice('worker::'.length) + return ( + <> + worker:: + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isWorkerFunction(message.functionId)) return null + if (message.pendingApproval) return null + + const input = unwrapEnvelope(message.input) + const rawOutput = message.output + const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined + const running = !!message.running + + const errorDisplay = + !running && rawOutput != null ? parseSandboxErrorDisplay(rawOutput) : null + if (errorDisplay) { + return + } + + switch (message.functionId) { + case 'worker::list': + return + case 'worker::start': + return + case 'worker::stop': + return + case 'worker::add': + return + case 'worker::remove': + return ( + + ) + case 'worker::update': + return ( + + ) + case 'worker::clear': + return + case 'worker::schema': + // Schema introspection — wire shape is opaque per-call, defer to + // default JSON pane until we have a concrete UX. + return null + default: + return null + } +} + +/** + * Destructive lifecycle ops (start/stop/add/remove/update/clear) are + * approval-gated by the daemon, but the pending-state payload echoes the + * caller request — so the default request JSON pane is already a fine + * preview. Returning `null` keeps the implementation focused on the + * done/running paths. + */ +function tryRenderPreview( + _message: FunctionCallMessage, +): React.ReactNode | null { + return null +} + +export const WorkerToolView = { + isWorkerFunction, + tryRender, + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/worker/parsers.ts b/console/web/src/components/chat/worker/parsers.ts new file mode 100644 index 00000000..8671b1b7 --- /dev/null +++ b/console/web/src/components/chat/worker/parsers.ts @@ -0,0 +1,203 @@ +/** + * Zod schemas for the `worker::*` namespace (iii-worker manager daemon). + * + * Wire source: `motia/crates/iii-worker/src/core/types.rs` + * - AddOptions / AddOutcome + * - RemoveOptions / RemoveOutcome + * - UpdateOptions / UpdateOutcome + * - StartOptions / StartOutcome + * - StopOptions / StopOutcome + * - ListOptions / ListOutcome + * - ClearOptions / ClearOutcome + * + * Distinct from `engine::workers::*` (engine catalogue surface): this + * namespace is the worker-manager CLI/daemon and uses different ids and + * shapes — singular `worker::` prefix, focused on lifecycle ops. + */ +import { z } from 'zod' +import { unwrapEnvelope } from '@/components/chat/sandbox/parsers' + +export { unwrapEnvelope } + +export const WORKER_FUNCTION_IDS = [ + 'worker::add', + 'worker::remove', + 'worker::update', + 'worker::start', + 'worker::stop', + 'worker::list', + 'worker::clear', + 'worker::schema', +] as const + +export type WorkerFunctionId = (typeof WORKER_FUNCTION_IDS)[number] + +const WORKER_FUNCTION_ID_SET: ReadonlySet = new Set( + WORKER_FUNCTION_IDS, +) + +export function isWorkerFunction(id: string): id is WorkerFunctionId { + return WORKER_FUNCTION_ID_SET.has(id) +} + +/* ---------------- shared building blocks ---------------- */ + +export const workerSourceSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('registry'), + name: z.string(), + version: z.string().optional(), + }), + z.object({ + kind: z.literal('oci'), + reference: z.string(), + }), + z.object({ + kind: z.literal('local'), + path: z.string(), + }), +]) +export type WorkerSource = z.infer + +/* ---------------- worker::list ---------------- */ + +export const workerListRequestSchema = z.object({ + running_only: z.boolean().optional(), +}) +export type WorkerListRequest = z.infer + +export const workerEntrySchema = z.object({ + name: z.string(), + version: z.string().nullable().optional(), + running: z.boolean(), + pid: z.number().nullable().optional(), +}) +export type WorkerEntry = z.infer + +export const workerListResponseSchema = z.object({ + workers: z.array(workerEntrySchema), +}) +export type WorkerListResponse = z.infer + +/* ---------------- worker::add ---------------- */ + +export const workerAddRequestSchema = z.object({ + source: workerSourceSchema, + force: z.boolean().optional(), + reset_config: z.boolean().optional(), + wait: z.boolean().optional(), +}) +export type WorkerAddRequest = z.infer + +export const workerAddStatusSchema = z.enum([ + 'installed', + 'already_current', + 'repaired', + 'replaced', +]) +export type WorkerAddStatus = z.infer + +export const workerAddResponseSchema = z.object({ + name: z.string(), + version: z.string().nullable().optional(), + status: workerAddStatusSchema, + awaited_ready: z.boolean(), + config_path: z.string(), +}) +export type WorkerAddResponse = z.infer + +/* ---------------- worker::remove ---------------- */ + +export const workerRemoveRequestSchema = z.object({ + names: z.array(z.string()).optional(), + all: z.boolean().optional(), + yes: z.boolean().optional(), +}) +export type WorkerRemoveRequest = z.infer + +export const workerRemoveResponseSchema = z.object({ + removed: z.array(z.string()), +}) +export type WorkerRemoveResponse = z.infer + +/* ---------------- worker::update ---------------- */ + +export const workerUpdateRequestSchema = z.object({ + names: z.array(z.string()).optional(), +}) +export type WorkerUpdateRequest = z.infer + +export const workerUpdateEntrySchema = z.object({ + name: z.string(), + from_version: z.string(), + to_version: z.string(), +}) +export type WorkerUpdateEntry = z.infer + +export const workerUpdateResponseSchema = z.object({ + updated: z.array(workerUpdateEntrySchema), +}) +export type WorkerUpdateResponse = z.infer + +/* ---------------- worker::start ---------------- */ + +export const workerStartRequestSchema = z.object({ + name: z.string(), + port: z.number().optional(), + config: z.string().optional(), + wait: z.boolean().optional(), +}) +export type WorkerStartRequest = z.infer + +export const workerStartResponseSchema = z.object({ + name: z.string(), + pid: z.number().nullable().optional(), + port: z.number().nullable().optional(), +}) +export type WorkerStartResponse = z.infer + +/* ---------------- worker::stop ---------------- */ + +export const workerStopRequestSchema = z.object({ + name: z.string(), + yes: z.boolean().optional(), +}) +export type WorkerStopRequest = z.infer + +export const workerStopResponseSchema = z.object({ + name: z.string(), + stopped: z.boolean(), +}) +export type WorkerStopResponse = z.infer + +/* ---------------- worker::clear ---------------- */ + +export const workerClearRequestSchema = z.object({ + names: z.array(z.string()).optional(), + all: z.boolean().optional(), + yes: z.boolean().optional(), +}) +export type WorkerClearRequest = z.infer + +export const workerClearResponseSchema = z.object({ + cleared: z.array(z.string()), +}) +export type WorkerClearResponse = z.infer + +/* ---------------- generic helpers ---------------- */ + +export function safeParseRequest( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} + +export function safeParseResponse( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(unwrapEnvelope(value)) + return parsed.success ? parsed.data : null +} diff --git a/console/web/src/lib/export-session.test.ts b/console/web/src/lib/export-session.test.ts new file mode 100644 index 00000000..f1d01c0b --- /dev/null +++ b/console/web/src/lib/export-session.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import type { + AssistantMessage, + Conversation, + FunctionCallMessage, + Message, + SystemMessage, + ThoughtMessage, + UserMessage, +} from '@/types/chat' +import { buildExportFilename, conversationToMarkdown } from './export-session' + +function baseConversation(messages: Message[] = []): Conversation { + return { + id: 'conv-12345678-abcd', + title: 'Test session', + model: 'openai::gpt-5', + mode: 'agent', + messages, + createdAt: Date.UTC(2025, 0, 1, 12, 0, 0), + updatedAt: Date.UTC(2025, 0, 1, 12, 30, 0), + } +} + +describe('conversationToMarkdown', () => { + it('renders a header for an empty conversation with no-messages marker', () => { + const out = conversationToMarkdown(baseConversation()) + expect(out).toMatch(/^# Session: Test session/) + expect(out).toContain('- ID: `conv-12345678-abcd`') + expect(out).toContain('- Model: `openai::gpt-5`') + expect(out).toContain('- Mode: `agent`') + expect(out).toContain('- Message count: 0') + expect(out).toContain('_(no messages)_') + }) + + it('renders one of each message type with correct headings', () => { + const user: UserMessage = { + id: 'u1', + role: 'user', + content: 'hello', + createdAt: 1, + } + const assistant: AssistantMessage = { + id: 'a1', + role: 'assistant', + content: 'world', + model: 'openai::gpt-5', + mode: 'agent', + createdAt: 2, + } + const thought: ThoughtMessage = { + id: 't1', + role: 'thought', + content: 'thinking…', + durationMs: 100, + createdAt: 3, + } + const fcallNoOutput: FunctionCallMessage = { + id: 'f1', + role: 'function-call', + functionId: 'search', + input: { query: 'foo' }, + createdAt: 4, + } + const fcallWithOutput: FunctionCallMessage = { + id: 'f2', + role: 'function-call', + functionId: 'search', + input: { query: 'bar' }, + output: { hits: 3 }, + createdAt: 5, + } + const sys: SystemMessage = { + id: 's1', + role: 'system', + content: 'compacted', + tone: 'info', + kind: 'compaction', + createdAt: 6, + } + + const out = conversationToMarkdown( + baseConversation([ + user, + assistant, + thought, + fcallNoOutput, + fcallWithOutput, + sys, + ]), + ) + + expect(out).toContain('## User\nhello') + expect(out).toContain('## Assistant (openai::gpt-5, agent)\nworld') + expect(out).toContain('## Thought\nthinking…') + expect(out).toContain('## Tool call — search') + expect(out).toContain('"query": "foo"') + expect(out).toContain('"query": "bar"') + expect(out).toContain('"hits": 3') + expect(out).toContain('## System — info (compaction)\ncompacted') + }) + + it('omits the Output block when output is undefined', () => { + const fcall: FunctionCallMessage = { + id: 'f1', + role: 'function-call', + functionId: 'search', + input: { q: 'x' }, + createdAt: 1, + } + const out = conversationToMarkdown(baseConversation([fcall])) + expect(out).toContain('**Input:**') + expect(out).not.toContain('**Output:**') + }) + + it('lists attachments by metadata without leaking dataUrl base64', () => { + const user: UserMessage = { + id: 'u1', + role: 'user', + content: 'see image', + createdAt: 1, + attachments: [ + { + id: 'att1', + name: 'screenshot.png', + size: 2048, + type: 'image/png', + dataUrl: 'data:image/png;base64,AAAABBBBCCCCDDDD', + }, + ], + } + const out = conversationToMarkdown(baseConversation([user])) + expect(out).toContain( + '**Attachments:** `screenshot.png` (image/png, 2.0 KB)', + ) + expect(out).not.toContain('AAAABBBBCCCCDDDD') + expect(out).not.toContain('data:image/png') + }) + + it('falls back to String(value) for non-serialisable tool input', () => { + const circular: { self?: unknown } = {} + circular.self = circular + const fcall: FunctionCallMessage = { + id: 'f1', + role: 'function-call', + functionId: 'broken', + input: circular, + createdAt: 1, + } + const out = conversationToMarkdown(baseConversation([fcall])) + expect(out).toContain('## Tool call — broken') + // Either '[object Object]' from the toString fallback or the markdown + // simply didn't throw — both signal graceful handling. + expect(out).toContain('[object Object]') + }) + + it('preserves message order', () => { + const m1: UserMessage = { + id: 'u1', + role: 'user', + content: 'first', + createdAt: 1, + } + const m2: AssistantMessage = { + id: 'a1', + role: 'assistant', + content: 'second', + createdAt: 2, + } + const m3: UserMessage = { + id: 'u2', + role: 'user', + content: 'third', + createdAt: 3, + } + const out = conversationToMarkdown(baseConversation([m1, m2, m3])) + const firstIdx = out.indexOf('first') + const secondIdx = out.indexOf('second') + const thirdIdx = out.indexOf('third') + expect(firstIdx).toBeGreaterThan(-1) + expect(secondIdx).toBeGreaterThan(firstIdx) + expect(thirdIdx).toBeGreaterThan(secondIdx) + }) + + it('annotates a pending-approval tool call in the heading', () => { + const fcall: FunctionCallMessage = { + id: 'f1', + role: 'function-call', + functionId: 'delete_file', + input: { path: '/tmp/x' }, + pendingApproval: true, + createdAt: 1, + } + const out = conversationToMarkdown(baseConversation([fcall])) + expect(out).toContain('## Tool call — delete_file (pending approval)') + }) +}) + +describe('buildExportFilename', () => { + it('uses the first 8 chars of the conversation id', () => { + const filename = buildExportFilename(baseConversation()) + expect(filename).toMatch(/^iii-session-conv-123-\d{8}-\d{4}\.md$/) + }) + + it('falls back to `session` when the id is empty', () => { + const conv = { ...baseConversation(), id: '' } + const filename = buildExportFilename(conv) + expect(filename).toMatch(/^iii-session-session-\d{8}-\d{4}\.md$/) + }) +}) diff --git a/console/web/src/lib/export-session.ts b/console/web/src/lib/export-session.ts new file mode 100644 index 00000000..96ce0fff --- /dev/null +++ b/console/web/src/lib/export-session.ts @@ -0,0 +1,160 @@ +/** + * Renders a `Conversation` as a markdown transcript and triggers a + * browser download. Designed so the resulting `.md` file can be pasted + * into another AI for analysis — role-prefixed messages, tool calls as + * fenced JSON, attachments listed by metadata only (no base64 payload). + */ + +import type { + Attachment, + Conversation, + FunctionCallMessage, + Message, +} from '@/types/chat' + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return `${bytes} B` + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function formatTimestamp(ms: number): string { + try { + return new Date(ms).toISOString() + } catch { + return String(ms) + } +} + +/** Best-effort pretty JSON; falls back to `String(value)` on circular refs / BigInt. */ +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function renderAttachments(attachments: readonly Attachment[]): string { + // dataUrl is intentionally omitted — base64 payloads would balloon the + // markdown and aren't useful for an AI doing transcript analysis. + const items = attachments.map( + (a) => `\`${a.name}\` (${a.type || 'unknown'}, ${formatBytes(a.size)})`, + ) + return `**Attachments:** ${items.join(', ')}` +} + +function renderMessage(message: Message): string { + switch (message.role) { + case 'user': { + const parts: string[] = ['## User', message.content || '_(empty)_'] + if (message.attachments && message.attachments.length > 0) { + parts.push('', renderAttachments(message.attachments)) + } + return parts.join('\n') + } + case 'assistant': { + const meta: string[] = [] + if (message.model) meta.push(message.model) + if (message.mode) meta.push(message.mode) + const header = + meta.length > 0 ? `## Assistant (${meta.join(', ')})` : '## Assistant' + return `${header}\n${message.content || '_(empty)_'}` + } + case 'thought': { + return `## Thought\n${message.content || '_(empty)_'}` + } + case 'function-call': { + return renderFunctionCall(message) + } + case 'system': { + const tone = message.tone ? ` — ${message.tone}` : '' + const kind = message.kind === 'compaction' ? ' (compaction)' : '' + return `## System${tone}${kind}\n${message.content || '_(empty)_'}` + } + } +} + +function renderFunctionCall(message: FunctionCallMessage): string { + const status = message.pendingApproval + ? ' (pending approval)' + : message.running + ? ' (running)' + : '' + const parts: string[] = [ + `## Tool call — ${message.functionId}${status}`, + '**Input:**', + '```json', + formatJson(message.input), + '```', + ] + if (message.output !== undefined) { + parts.push('**Output:**', '```json', formatJson(message.output), '```') + } + return parts.join('\n') +} + +export function conversationToMarkdown(conversation: Conversation): string { + const header: string[] = [ + `# Session: ${conversation.title}`, + '', + `- ID: \`${conversation.id}\``, + `- Created: ${formatTimestamp(conversation.createdAt)}`, + `- Updated: ${formatTimestamp(conversation.updatedAt)}`, + `- Model: \`${conversation.model}\``, + `- Mode: \`${conversation.mode}\``, + `- Message count: ${conversation.messages.length}`, + '', + '---', + '', + ] + + if (conversation.messages.length === 0) { + return `${header.join('\n')}_(no messages)_\n` + } + + const body = conversation.messages.map(renderMessage).join('\n\n') + return `${header.join('\n')}${body}\n` +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n) +} + +function timestampSlug(now: Date = new Date()): string { + const y = now.getFullYear() + const m = pad2(now.getMonth() + 1) + const d = pad2(now.getDate()) + const hh = pad2(now.getHours()) + const mm = pad2(now.getMinutes()) + return `${y}${m}${d}-${hh}${mm}` +} + +export function buildExportFilename(conversation: Conversation): string { + const shortId = conversation.id.slice(0, 8) || 'session' + return `iii-session-${shortId}-${timestampSlug()}.md` +} + +/** + * Triggers a browser download of the rendered markdown. + * Returns the filename so callers can announce it (e.g. via a live region). + */ +export function downloadConversationAsMarkdown( + conversation: Conversation, +): string { + const markdown = conversationToMarkdown(conversation) + const filename = buildExportFilename(conversation) + const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.rel = 'noopener' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + // Revoke on the next tick so Safari has time to honour the click. + window.setTimeout(() => URL.revokeObjectURL(url), 0) + return filename +} diff --git a/console/web/src/pages/Examples/index.tsx b/console/web/src/pages/Examples/index.tsx index 2e57adc9..07db44cb 100644 --- a/console/web/src/pages/Examples/index.tsx +++ b/console/web/src/pages/Examples/index.tsx @@ -1,6 +1,7 @@ import { Prompt } from '@/components/ui/Prompt' import { ColorTokensSection } from './sections/color-tokens' import { ComposerVariantsSection } from './sections/composer-variants' +import { CustomFunctionViewsSection } from './sections/custom-function-views' import { LoadingStatesSection } from './sections/loading-states' import { MessageVariantsSection } from './sections/message-variants' import { PrimitivesSection } from './sections/primitives' @@ -9,6 +10,7 @@ import { TypographySection } from './sections/typography' const TOC: { id: string; label: string }[] = [ { id: 'composer-variants', label: 'composer' }, { id: 'message-variants', label: 'messages' }, + { id: 'custom-function-views', label: 'function views' }, { id: 'loading-states', label: 'loading' }, { id: 'primitives', label: 'primitives' }, { id: 'typography', label: 'typography' }, @@ -49,6 +51,7 @@ export function Examples() { + diff --git a/console/web/src/pages/Examples/sections/custom-function-views.tsx b/console/web/src/pages/Examples/sections/custom-function-views.tsx new file mode 100644 index 00000000..2cad1236 --- /dev/null +++ b/console/web/src/pages/Examples/sections/custom-function-views.tsx @@ -0,0 +1,53 @@ +import { FunctionCallMessage } from '@/components/chat/FunctionCallMessage' +import type { FunctionCallMessage as FCallType } from '@/types/chat' +import { Section, VariantCard } from '../Section' +import { directoryFixtures } from './directory-fixtures' +import { engineFixtures } from './engine-fixtures' +import { webFixtures } from './web-fixtures' +import { workerFixtures } from './worker-fixtures' + +/** + * Namespace-specific function-call renderers, one card per fixture. Mirrors + * the sandbox block in `message-variants` but groups the newer families + * (directory / engine / web / worker) under their own headings so the spec + * sheet stays scannable as more views land. Every card is `defaultOpen` so + * the custom terminal/preview pane renders, not just the collapsed header. + */ +const FAMILIES: { family: string; fixtures: readonly FCallType[] }[] = [ + { family: 'directory', fixtures: directoryFixtures }, + { family: 'engine', fixtures: engineFixtures }, + { family: 'web', fixtures: webFixtures }, + { family: 'worker', fixtures: workerFixtures }, +] + +export function CustomFunctionViewsSection() { + return ( +
    +
    + {FAMILIES.map(({ family, fixtures }) => ( +
    +

    + {family} · {fixtures.length} view + {fixtures.length === 1 ? '' : 's'} +

    +
    + {fixtures.map((fixture) => ( + + + + ))} +
    +
    + ))} +
    +
    + ) +} diff --git a/console/web/src/pages/Examples/sections/directory-fixtures.ts b/console/web/src/pages/Examples/sections/directory-fixtures.ts new file mode 100644 index 00000000..77ee4553 --- /dev/null +++ b/console/web/src/pages/Examples/sections/directory-fixtures.ts @@ -0,0 +1,316 @@ +import type { FunctionCallMessage } from '@/types/chat' +import { wrapHarness } from './sandbox-fixtures' + +const now = Date.now() +const NOW_ISO = new Date(now).toISOString() +const YESTERDAY_ISO = new Date(now - 86_400_000).toISOString() +const LAST_WEEK_ISO = new Date(now - 7 * 86_400_000).toISOString() + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + output, + durationMs: 28, + createdAt: now, + ...extra, + } +} + +/* ---------------- directory::skills::list ---------------- */ + +export const directorySkillsListDone = base( + 'dir-skills-list', + 'directory::skills::list', + { prefix: 'sandbox/' }, + wrapHarness({ + skills: [ + { + id: 'sandbox/skills/sandbox/create', + title: 'sandbox::create', + type: 'how-to', + function_id: 'sandbox::create', + description: + 'Create a fresh sandbox VM. Pair with sandbox::exec to run commands inside it.', + bytes: 1840, + modified_at: YESTERDAY_ISO, + }, + { + id: 'sandbox/skills/sandbox/exec', + title: 'sandbox::exec', + type: 'how-to', + function_id: 'sandbox::exec', + description: 'Run a single command in an existing sandbox.', + bytes: 1602, + modified_at: YESTERDAY_ISO, + }, + { + id: 'sandbox/index', + title: 'Sandbox worker', + type: 'index', + function_id: null, + description: + 'Read-write VMs for the agent. Use create to provision one, exec/run to interact.', + bytes: 920, + modified_at: LAST_WEEK_ISO, + }, + ], + }), +) + +export const directorySkillsListEmpty = base( + 'dir-skills-list-empty', + 'directory::skills::list', + { search: 'no-such-skill' }, + { skills: [] }, +) + +export const directorySkillsListRunning = base( + 'dir-skills-list-running', + 'directory::skills::list', + {}, + undefined, + { running: true }, +) + +/* ---------------- directory::skills::get ---------------- */ + +export const directorySkillsGetDone = base( + 'dir-skills-get', + 'directory::skills::get', + { id: 'sandbox/skills/sandbox/create' }, + wrapHarness({ + id: 'sandbox/skills/sandbox/create', + title: 'sandbox::create', + type: 'how-to', + function_id: 'sandbox::create', + body: `# sandbox::create + +Create a fresh sandbox VM. + +## Example + +\`\`\`json +{ + "image": "motia/node:20", + "cpus": 2, + "memory_mb": 512 +} +\`\`\` +`, + modified_at: YESTERDAY_ISO, + }), +) + +/* ---------------- directory::skills::index ---------------- */ + +export const directorySkillsIndexDone = base( + 'dir-skills-index', + 'directory::skills::index', + {}, + wrapHarness({ + body: `## sandbox + +Read-write VMs for the agent. Use \`create\` to provision one. + +[Read more](sandbox/index.md) + +## todo-app + +Demo todo app with create/list/get/update/delete + an HTML page. + +[Read more](todo-app/index.md) +`, + workers_count: 2, + }), +) + +/* ---------------- directory::skills::download ---------------- */ + +export const directorySkillsDownloadRepo = base( + 'dir-skills-download-repo', + 'directory::skills::download', + { + repo: 'https://github.com/iii-hq/sandbox', + skill: 'sandbox', + branch: 'main', + }, + wrapHarness({ + namespace: 'sandbox', + skills_written: [ + 'sandbox/index', + 'sandbox/skills/sandbox/create', + 'sandbox/skills/sandbox/exec', + 'sandbox/skills/sandbox/stop', + ], + prompts_written: ['sandbox/prompts/getting-started'], + source: { + kind: 'repo', + repo: 'https://github.com/iii-hq/sandbox', + skill: 'sandbox', + branch: 'main', + }, + }), +) + +export const directorySkillsDownloadRegistry = base( + 'dir-skills-download-reg', + 'directory::skills::download', + { worker: 'pdfkit', version: '1.0.0' }, + { + namespace: 'pdfkit', + skills_written: ['pdfkit/index', 'pdfkit/skills/pdfkit/render'], + prompts_written: [], + source: { + kind: 'registry', + worker: 'pdfkit', + spec: { kind: 'version', value: '1.0.0' }, + }, + }, +) + +/* ---------------- directory::prompts::list ---------------- */ + +export const directoryPromptsListDone = base( + 'dir-prompts-list', + 'directory::prompts::list', + {}, + wrapHarness({ + prompts: [ + { + name: 'sandbox/getting-started', + description: 'Walks the agent through creating + using a sandbox VM.', + modified_at: YESTERDAY_ISO, + }, + { + name: 'todo-app/quickstart', + description: 'Set up the demo todo worker and exercise every function.', + modified_at: LAST_WEEK_ISO, + }, + ], + }), +) + +/* ---------------- directory::prompts::get ---------------- */ + +export const directoryPromptsGetDone = base( + 'dir-prompts-get', + 'directory::prompts::get', + { name: 'sandbox/getting-started' }, + wrapHarness({ + name: 'sandbox/getting-started', + description: 'Walks the agent through creating + using a sandbox VM.', + body: `Hello! Here's how to spin up a sandbox: + +1. Call \`sandbox::create\` to provision a VM. +2. Use \`sandbox::exec\` to run shell commands inside it. +3. When done, call \`sandbox::stop\` to release resources. +`, + modified_at: NOW_ISO, + }), +) + +/* ---------------- directory::registry::workers::list ---------------- */ + +export const directoryRegistryListDone = base( + 'dir-reg-list', + 'directory::registry::workers::list', + { search: 'pdf' }, + wrapHarness({ + workers: [ + { + name: 'pdfkit', + description: 'Render HTML to PDF with WeasyPrint.', + type: 'binary', + version: '1.0.0', + repo: 'https://github.com/iii-hq/pdfkit', + config: {}, + supported_targets: ['darwin-arm64', 'linux-x86_64'], + total_downloads: 4823, + dependencies: [], + author: { name: 'iii-hq', verified: true }, + }, + { + name: 'pdfsign', + description: 'Cryptographically sign PDFs.', + type: 'image', + version: '0.4.2', + image: 'ghcr.io/iii-hq/pdfsign:0.4.2', + config: {}, + supported_targets: [], + total_downloads: 412, + dependencies: [{ name: 'pdfkit', version: '>=1.0.0' }], + author: { name: 'jane', verified: false }, + }, + ], + pagination: { + next_cursor: 'eyJjcz0yfQ==', + has_more: true, + page_size: 20, + }, + }), +) + +/* ---------------- directory::registry::workers::info ---------------- */ + +export const directoryRegistryInfoDone = base( + 'dir-reg-info', + 'directory::registry::workers::info', + { name: 'pdfkit', tag: 'latest' }, + wrapHarness({ + worker: { + name: 'pdfkit', + description: 'Render HTML to PDF with WeasyPrint.', + type: 'binary', + version: '1.0.0', + repo: 'https://github.com/iii-hq/pdfkit', + config: {}, + supported_targets: ['darwin-arm64', 'linux-x86_64'], + total_downloads: 4823, + dependencies: [], + author: { name: 'iii-hq', verified: true }, + }, + readme: '# pdfkit\n\nRender HTML → PDF. Powered by WeasyPrint.\n', + api_reference: { + functions: [ + { + name: 'pdfkit::render', + description: 'Render an HTML string to PDF bytes.', + request_schema: null, + response_schema: null, + metadata: null, + }, + ], + triggers: [], + }, + skills_tree: { + skills: [ + { path: 'pdfkit/index' }, + { path: 'pdfkit/skills/pdfkit/render' }, + ], + prompts: [], + }, + }), +) + +export const directoryFixtures = [ + directorySkillsListDone, + directorySkillsListEmpty, + directorySkillsListRunning, + directorySkillsGetDone, + directorySkillsIndexDone, + directorySkillsDownloadRepo, + directorySkillsDownloadRegistry, + directoryPromptsListDone, + directoryPromptsGetDone, + directoryRegistryListDone, + directoryRegistryInfoDone, +] as const diff --git a/console/web/src/pages/Examples/sections/engine-fixtures.ts b/console/web/src/pages/Examples/sections/engine-fixtures.ts new file mode 100644 index 00000000..7cb26f54 --- /dev/null +++ b/console/web/src/pages/Examples/sections/engine-fixtures.ts @@ -0,0 +1,583 @@ +import type { FunctionCallMessage } from '@/types/chat' +import { wrapHarness } from './sandbox-fixtures' + +const now = Date.now() + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + output, + durationMs: 2, + createdAt: now, + ...extra, + } +} + +/* ---------------- engine::functions::list ---------------- */ + +export const engineFunctionsListDone = base( + 'engine-fn-list', + 'engine::functions::list', + { prefix: 'todo::' }, + wrapHarness({ + functions: [ + { + function_id: 'todo::create', + worker_name: 'todo-app', + description: 'Create a new todo', + }, + { + function_id: 'todo::delete', + worker_name: 'todo-app', + description: 'Delete a todo by ID', + }, + { + function_id: 'todo::get', + worker_name: 'todo-app', + description: 'Get a single todo by ID', + }, + { + function_id: 'todo::html', + worker_name: 'todo-app', + description: 'Serve the Todo App HTML page', + }, + { + function_id: 'todo::list', + worker_name: 'todo-app', + description: 'List all todos', + }, + { + function_id: 'todo::update', + worker_name: 'todo-app', + description: 'Update a todo by ID', + }, + ], + }), +) + +export const engineFunctionsListEmpty = base( + 'engine-fn-list-empty', + 'engine::functions::list', + { worker: 'iii-directory', search: 'nonexistent' }, + { functions: [] }, +) + +export const engineFunctionsListRaw = base( + 'engine-fn-list-raw', + 'engine::functions::list', + {}, + { + functions: [ + { + function_id: 'directory::skills::download', + worker_name: 'iii-directory', + description: 'Download skill bundle from the registry', + }, + { + function_id: 'directory::skills::list', + worker_name: 'iii-directory', + // description intentionally omitted for visual variance + }, + ], + }, +) + +/* ---------------- engine::functions::info ---------------- */ + +const ANY_VALUE_SCHEMA = { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'AnyValue', +} + +/** Mirrors the screenshot: `sandbox::fs::write` with AnyValue placeholders + * and no registered triggers. The view should collapse the schema slots + * to "· any" and surface the empty-trigger state cleanly. */ +export const engineFunctionInfoAnyValue = base( + 'engine-fn-info-any', + 'engine::functions::info', + { function_id: 'sandbox::fs::write' }, + wrapHarness({ + description: 'Stream-upload a file into a sandbox', + function_id: 'sandbox::fs::write', + registered_triggers: [], + request_schema: ANY_VALUE_SCHEMA, + response_schema: ANY_VALUE_SCHEMA, + worker_name: 'iii-sandbox', + }), +) + +/** Rich payload with real JSON Schemas + one registered trigger so the + * view's expandable schema panes and config block both fire. */ +export const engineFunctionInfoRich = base( + 'engine-fn-info-rich', + 'engine::functions::info', + { function_id: 'todo::create' }, + { + function_id: 'todo::create', + worker_name: 'todo-app', + description: 'Create a new todo and persist it to the store.', + request_schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'CreateTodoInput', + type: 'object', + required: ['title'], + properties: { + title: { type: 'string', minLength: 1 }, + done: { type: 'boolean', default: false }, + }, + }, + response_schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'Todo', + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + done: { type: 'boolean' }, + }, + }, + registered_triggers: [ + { + id: '8c7e9d12-2a4f-4b5c-9d3e-1f2a3b4c5d6e', + trigger_type: 'http', + config: { api_path: '/todos', http_method: 'POST' }, + }, + ], + }, +) + +export const engineFunctionInfoNotFound = base( + 'engine-fn-info-notfound', + 'engine::functions::info', + { function_id: 'nope::missing' }, + { + error: { + kind: 'function_error', + message: "Function 'nope::missing' is not registered.", + details: { + code: 'NOT_FOUND', + message: "Function 'nope::missing' is not registered.", + }, + content: [ + { + type: 'text', + text: "Function 'nope::missing' is not registered.", + }, + ], + }, + }, +) + +/* ---------------- engine::triggers::list ---------------- */ + +export const engineTriggersListDone = base( + 'engine-tr-list', + 'engine::triggers::list', + {}, + wrapHarness({ + triggers: [ + { + id: 'database::row-change', + worker_name: 'Andersons-MBP.localdomain:28943', + description: + 'Postgres logical replication. Stubbed in v1.0 pending tokio-postgres replication slot support.', + }, + { + id: 'directory::prompts::on-change', + worker_name: 'iii-directory', + description: + 'Fires after every successful directory::skills::download that wrote at least one prompt.', + }, + { + id: 'directory::skills::on-change', + worker_name: 'iii-directory', + description: + 'Fires after every successful directory::skills::download that wrote at least one skill.', + }, + { id: 'http', worker_name: 'http', description: 'HTTP API trigger' }, + { id: 'log', worker_name: 'log', description: 'Log event trigger' }, + { id: 'state', worker_name: 'state', description: 'State trigger' }, + { id: 'stream', worker_name: 'stream', description: 'Stream trigger' }, + ], + }), +) + +export const engineTriggersListFiltered = base( + 'engine-tr-list-filter', + 'engine::triggers::list', + { search: 'http', include_internal: true }, + { + triggers: [ + { id: 'http', worker_name: 'http', description: 'HTTP API trigger' }, + ], + }, +) + +/* ---------------- engine::registered-triggers::list ---------------- */ + +export const engineRegisteredTriggersListDone = base( + 'engine-reg-tr', + 'engine::registered-triggers::list', + { function_id: 'todo::html' }, + wrapHarness({ + registered_triggers: [ + { + id: '6ebe7d64-3717-4acc-a02d-3f050fb86df2', + trigger_type: 'http', + function_id: 'todo::html', + worker_name: 'todo-app', + config_summary: '{"api_path":"/todos/html","http_method":"GET"}', + }, + { + id: 'f3c6b688-6840-48e8-8e7f-fec48d8cffbf', + trigger_type: 'http', + function_id: 'todo::html', + worker_name: 'todo-app', + config_summary: '{"api_path":"/todos/html","http_method":"GET"}', + }, + ], + }), +) + +export const engineRegisteredTriggersListPlainSummary = base( + 'engine-reg-tr-plain', + 'engine::registered-triggers::list', + { worker: 'iii-directory' }, + { + registered_triggers: [ + { + id: 'a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d', + trigger_type: 'log', + function_id: 'directory::log::watch', + worker_name: 'iii-directory', + config_summary: 'level=info, source=stdout', + }, + ], + }, +) + +/* ---------------- error fixture ---------------- */ + +export const engineFunctionsListGateError = base( + 'engine-fn-list-gate', + 'engine::functions::list', + { prefix: 'todo::' }, + { + error: { + kind: 'function_error', + message: + 'trigger_failed: IIIInvocationError: invocation_failed: handler error', + details: { + schema_version: 1, + status: 'denied', + denied_by: 'gate_unavailable', + function_id: 'engine::functions::list', + reason: 'trigger_failed: directory engine unreachable', + }, + content: [ + { + type: 'text', + text: 'trigger_failed: directory engine unreachable', + }, + ], + }, + }, +) + +export const engineRunning = base( + 'engine-running', + 'engine::triggers::list', + {}, + undefined, + { running: true }, +) + +/* ---------------- engine::workers::list ---------------- */ + +const tenMinutesAgo = now - 10 * 60 * 1000 + +export const engineWorkersListDone = base( + 'engine-wrk-list', + 'engine::workers::list', + {}, + wrapHarness({ + workers: [ + { + id: '7fa8e8a4-1c3d-44b2-9a5f-1234567890ab', + name: 'todo-app', + description: null, + version: '0.4.7', + runtime: 'node', + os: 'darwin', + status: 'connected', + function_count: 6, + connected_at_ms: tenMinutesAgo, + active_invocations: 0, + isolation: 'process', + ip_address: '127.0.0.1', + }, + { + id: '00000000-0000-0000-0000-000000000aaa', + name: 'iii-directory', + description: null, + version: '0.1.5', + runtime: 'rust', + os: 'darwin', + status: 'connected', + function_count: 12, + connected_at_ms: now - 2 * 3600 * 1000, + active_invocations: 1, + isolation: 'embedded', + ip_address: null, + }, + { + id: '11111111-2222-3333-4444-555555555555', + name: 'iii-sandbox', + description: null, + version: '0.4.7', + runtime: 'rust', + os: 'linux', + status: 'disconnected', + function_count: 15, + connected_at_ms: now - 24 * 3600 * 1000, + active_invocations: 0, + isolation: 'libkrun', + ip_address: null, + }, + ], + }), +) + +export const engineWorkersListFiltered = base( + 'engine-wrk-list-filter', + 'engine::workers::list', + { runtime: 'rust', status: 'connected' }, + { + workers: [ + { + id: '00000000-0000-0000-0000-000000000aaa', + name: 'iii-directory', + description: null, + version: '0.1.5', + runtime: 'rust', + os: 'darwin', + status: 'connected', + function_count: 12, + connected_at_ms: now - 2 * 3600 * 1000, + active_invocations: 0, + isolation: 'embedded', + ip_address: null, + }, + ], + }, +) + +export const engineWorkersListEmpty = base( + 'engine-wrk-list-empty', + 'engine::workers::list', + { search: 'no-such-worker' }, + { workers: [] }, +) + +/* ---------------- engine::workers::info ---------------- */ + +export const engineWorkerInfoDone = base( + 'engine-wrk-info', + 'engine::workers::info', + { name: 'todo-app' }, + wrapHarness({ + worker: { + id: '7fa8e8a4-1c3d-44b2-9a5f-1234567890ab', + name: 'todo-app', + description: 'Demo todo app worker.', + version: '0.4.7', + runtime: 'node', + os: 'darwin', + status: 'connected', + function_count: 6, + connected_at_ms: tenMinutesAgo, + active_invocations: 0, + isolation: 'process', + ip_address: '127.0.0.1', + pid: 28943, + internal: false, + latest_metrics: { + memory_heap_used: 28 * 1024 * 1024, + memory_heap_total: 64 * 1024 * 1024, + memory_rss: 102 * 1024 * 1024, + cpu_percent: 1.2, + event_loop_lag_ms: 0.8, + uptime_seconds: 612, + timestamp_ms: now, + runtime: 'node', + }, + }, + functions: [ + { function_id: 'todo::create', worker_name: 'todo-app' }, + { function_id: 'todo::delete', worker_name: 'todo-app' }, + { function_id: 'todo::get', worker_name: 'todo-app' }, + { function_id: 'todo::html', worker_name: 'todo-app' }, + { function_id: 'todo::list', worker_name: 'todo-app' }, + { function_id: 'todo::update', worker_name: 'todo-app' }, + ], + trigger_types: [], + registered_triggers: [ + { + id: '6ebe7d64-3717-4acc-a02d-3f050fb86df2', + trigger_type: 'http', + function_id: 'todo::html', + worker_name: 'todo-app', + config_summary: '{"api_path":"/todos/html","http_method":"GET"}', + }, + { + id: 'aa11bb22-cc33-dd44-ee55-ff6677889900', + trigger_type: 'http', + function_id: 'todo::list', + worker_name: 'todo-app', + config_summary: '{"api_path":"/todos","http_method":"GET"}', + }, + ], + }), +) + +export const engineWorkerInfoInternal = base( + 'engine-wrk-info-internal', + 'engine::workers::info', + { name: 'iii-engine-functions' }, + { + worker: { + id: '00000000-0000-0000-0000-deadbeefcafe', + name: 'iii-engine-functions', + description: null, + version: null, + runtime: 'rust', + os: 'darwin', + status: 'connected', + function_count: 7, + connected_at_ms: now - 5 * 60 * 1000, + active_invocations: 0, + isolation: 'embedded', + ip_address: null, + internal: true, + }, + functions: [ + { + function_id: 'engine::functions::list', + worker_name: 'iii-engine-functions', + }, + { + function_id: 'engine::functions::info', + worker_name: 'iii-engine-functions', + }, + { + function_id: 'engine::triggers::list', + worker_name: 'iii-engine-functions', + }, + { + function_id: 'engine::registered-triggers::list', + worker_name: 'iii-engine-functions', + }, + { + function_id: 'engine::workers::list', + worker_name: 'iii-engine-functions', + }, + { + function_id: 'engine::workers::info', + worker_name: 'iii-engine-functions', + }, + { + function_id: 'engine::workers::register', + worker_name: 'iii-engine-functions', + }, + ], + trigger_types: [], + registered_triggers: [], + }, +) + +export const engineWorkerInfoNotFound = base( + 'engine-wrk-info-notfound', + 'engine::workers::info', + { name: 'no-such-worker' }, + { + error: { + kind: 'function_error', + message: "Worker 'no-such-worker' is not connected.", + details: { + code: 'NOT_FOUND', + message: "Worker 'no-such-worker' is not connected.", + }, + content: [ + { + type: 'text', + text: "Worker 'no-such-worker' is not connected.", + }, + ], + }, + }, +) + +/* ---------------- engine::workers::register ---------------- */ + +export const engineWorkerRegisterDone = base( + 'engine-wrk-register', + 'engine::workers::register', + { + _caller_worker_id: '7fa8e8a4-1c3d-44b2-9a5f-1234567890ab', + name: 'todo-app', + runtime: 'node', + version: '0.4.7', + os: 'darwin', + telemetry: { + device_id: 'dev-9a8b7c6d5e4f3a2b1c0d', + install_kind: 'npm', + }, + }, + wrapHarness({ success: true }), +) + +export const engineWorkerRegisterRunning = base( + 'engine-wrk-register-running', + 'engine::workers::register', + { + _caller_worker_id: '00000000-0000-0000-0000-000000000000', + name: 'new-worker', + runtime: 'python', + version: '0.1.0', + os: 'linux', + }, + undefined, + { running: true }, +) + +export const engineFixtures = [ + engineFunctionsListDone, + engineFunctionsListRaw, + engineFunctionsListEmpty, + engineFunctionInfoAnyValue, + engineFunctionInfoRich, + engineFunctionInfoNotFound, + engineTriggersListDone, + engineTriggersListFiltered, + engineRegisteredTriggersListDone, + engineRegisteredTriggersListPlainSummary, + engineWorkersListDone, + engineWorkersListFiltered, + engineWorkersListEmpty, + engineWorkerInfoDone, + engineWorkerInfoInternal, + engineWorkerInfoNotFound, + engineWorkerRegisterDone, + engineWorkerRegisterRunning, + engineRunning, + engineFunctionsListGateError, +] as const diff --git a/console/web/src/pages/Examples/sections/web-fixtures.ts b/console/web/src/pages/Examples/sections/web-fixtures.ts new file mode 100644 index 00000000..188900e8 --- /dev/null +++ b/console/web/src/pages/Examples/sections/web-fixtures.ts @@ -0,0 +1,261 @@ +import type { FunctionCallMessage } from '@/types/chat' +import { wrapHarness } from './sandbox-fixtures' + +const now = Date.now() + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + output, + durationMs: 134, + createdAt: now, + ...extra, + } +} + +const todosBody = JSON.stringify({ ok: true, todos: [] }) + +/** Realistic JSON success that mirrors the screenshot's curl-against-localhost + * shape. Body is text by default and parsed to pretty JSON by content-type. */ +export const webFetchJsonSuccess = base( + 'web-fetch-json', + 'web::fetch', + { method: 'GET', url: 'http://127.0.0.1:3111/todos' }, + wrapHarness({ + ok: true, + status: 200, + status_text: 'OK', + headers: { + 'access-control-allow-origin': '*', + 'content-length': '22', + 'content-type': 'application/json', + date: 'Wed, 27 May 2026 21:56:43 GMT', + vary: 'origin, access-control-request-method, access-control-request-headers', + }, + body: todosBody, + response_format: 'text', + bytes_truncated: false, + }), +) + +export const webFetchJsonExplicitFormat = base( + 'web-fetch-json-explicit', + 'web::fetch', + { + method: 'GET', + url: 'https://api.example.com/v1/items', + response_format: 'json', + timeout_ms: 5_000, + }, + { + ok: true, + status: 200, + status_text: 'OK', + headers: { 'content-type': 'application/json' }, + body: '{"items":[{"id":1,"name":"a"},{"id":2,"name":"b"}]}', + response_format: 'json', + bytes_truncated: false, + json: { + items: [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ], + }, + redirect_chain: ['https://example.com/v1/items'], + }, +) + +export const webFetchTextHtml = base( + 'web-fetch-html', + 'web::fetch', + { method: 'GET', url: 'https://example.com/' }, + wrapHarness({ + ok: true, + status: 200, + status_text: 'OK', + headers: { + 'content-type': 'text/html; charset=UTF-8', + 'content-length': '1256', + }, + body: '\n\n Example\n

    Example Domain

    \n\n', + response_format: 'text', + bytes_truncated: false, + }), +) + +export const webFetchTruncated = base( + 'web-fetch-truncated', + 'web::fetch', + { + method: 'GET', + url: 'https://feeds.example.com/huge.json', + max_bytes: 1024, + }, + { + ok: true, + status: 200, + status_text: 'OK', + headers: { 'content-type': 'application/json' }, + body: '{"items":[…', + response_format: 'text', + bytes_truncated: true, + }, +) + +export const webFetchBase64 = base( + 'web-fetch-base64', + 'web::fetch', + { + method: 'GET', + url: 'https://cdn.example.com/icon.png', + response_format: 'base64', + }, + { + ok: true, + status: 200, + status_text: 'OK', + headers: { + 'content-type': 'image/png', + 'content-length': '512', + }, + body: 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', + response_format: 'base64', + bytes_truncated: false, + }, +) + +export const webFetchNotFound = base( + 'web-fetch-404', + 'web::fetch', + { method: 'GET', url: 'https://api.example.com/missing' }, + { + ok: true, + status: 404, + status_text: 'Not Found', + headers: { 'content-type': 'application/json' }, + body: '{"error":"not_found"}', + response_format: 'text', + bytes_truncated: false, + }, +) + +export const webFetchServerError = base( + 'web-fetch-500', + 'web::fetch', + { method: 'POST', url: 'https://api.example.com/orders' }, + { + ok: true, + status: 502, + status_text: 'Bad Gateway', + headers: { 'content-type': 'text/plain' }, + body: 'upstream timed out', + response_format: 'text', + bytes_truncated: false, + }, +) + +export const webFetchPending = base( + 'web-fetch-pending', + 'web::fetch', + { + method: 'POST', + url: 'https://api.example.com/orders', + headers: { 'content-type': 'application/json' }, + json: { sku: 'A-12', qty: 3 }, + timeout_ms: 8_000, + }, + undefined, + { pendingApproval: true }, +) + +export const webFetchRunning = base( + 'web-fetch-running', + 'web::fetch', + { method: 'GET', url: 'https://example.com/' }, + undefined, + { running: true }, +) + +export const webFetchTimeoutError = base( + 'web-fetch-err-timeout', + 'web::fetch', + { method: 'GET', url: 'https://slow.example.com/', timeout_ms: 1_000 }, + { + ok: false, + error: 'timeout', + message: + 'request timed out after 1000ms (raise timeout_ms or shrink response with smaller max_bytes)', + }, +) + +export const webFetchBlockedHostError = base( + 'web-fetch-err-blocked', + 'web::fetch', + { method: 'GET', url: 'http://169.254.169.254/' }, + { + ok: false, + error: 'blocked_host', + message: + 'host 169.254.169.254 resolves to a link-local address blocked by the SSRF policy', + }, +) + +export const webFetchTooLargeError = base( + 'web-fetch-err-too-large', + 'web::fetch', + { method: 'GET', url: 'https://example.com/big', max_bytes: 1024 }, + { + ok: false, + error: 'too_large', + message: 'response exceeded max_bytes (1024) before completion', + status: 200, + }, +) + +export const webFetchTransportError = base( + 'web-fetch-err-transport', + 'web::fetch', + { method: 'GET', url: 'https://no-such-host.example.invalid/' }, + { + ok: false, + error: 'transport_error', + message: 'dns lookup failed for host', + }, +) + +export const webFetchInvalidUrlError = base( + 'web-fetch-err-invalid-url', + 'web::fetch', + { method: 'GET', url: 'not a url' }, + { + ok: false, + error: 'invalid_url', + message: 'url must be an absolute http(s):// URL', + }, +) + +export const webFixtures = [ + webFetchJsonSuccess, + webFetchJsonExplicitFormat, + webFetchTextHtml, + webFetchTruncated, + webFetchBase64, + webFetchNotFound, + webFetchServerError, + webFetchPending, + webFetchRunning, + webFetchTimeoutError, + webFetchBlockedHostError, + webFetchTooLargeError, + webFetchTransportError, + webFetchInvalidUrlError, +] as const diff --git a/console/web/src/pages/Examples/sections/worker-fixtures.ts b/console/web/src/pages/Examples/sections/worker-fixtures.ts new file mode 100644 index 00000000..94581dc3 --- /dev/null +++ b/console/web/src/pages/Examples/sections/worker-fixtures.ts @@ -0,0 +1,258 @@ +import type { FunctionCallMessage } from '@/types/chat' +import { wrapHarness } from './sandbox-fixtures' + +const now = Date.now() + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + output, + durationMs: 769, + createdAt: now, + ...extra, + } +} + +/* ---------------- worker::list ---------------- */ + +/** Mirrors the user's screenshot: 7+ workers, mix of running engine builtins + * (null pid, null version) and a managed iii-directory with a real pid. */ +export const workerListDone = base( + 'worker-list', + 'worker::list', + {}, + wrapHarness({ + workers: [ + { name: 'iii-worker-manager', pid: null, running: true }, + { name: 'iii-pubsub', pid: null, running: true }, + { name: 'iii-observability', pid: null, running: true }, + { name: 'iii-directory', pid: 19052, running: true, version: '0.5.2' }, + { name: 'iii-queue', pid: null, running: true, version: '0.11.6' }, + { name: 'iii-state', pid: null, running: true, version: '0.11.6' }, + { name: 'iii-stream', pid: null, running: true, version: '0.11.6' }, + { name: 'iii-http', pid: null, running: false, version: '0.11.6' }, + ], + }), +) + +export const workerListRunningOnly = base( + 'worker-list-running', + 'worker::list', + { running_only: true }, + { + workers: [ + { name: 'iii-directory', pid: 19052, running: true, version: '0.5.2' }, + { name: 'iii-queue', pid: null, running: true, version: '0.11.6' }, + ], + }, +) + +export const workerListEmpty = base( + 'worker-list-empty', + 'worker::list', + { running_only: true }, + { workers: [] }, +) + +export const workerListRunning = base( + 'worker-list-loading', + 'worker::list', + {}, + undefined, + { running: true }, +) + +/* ---------------- worker::start ---------------- */ + +export const workerStartDone = base( + 'worker-start', + 'worker::start', + { name: 'pdfkit', wait: true }, + wrapHarness({ name: 'pdfkit', pid: 28943, port: 4101 }), +) + +export const workerStartNoPid = base( + 'worker-start-no-pid', + 'worker::start', + { name: 'iii-stream' }, + { name: 'iii-stream', pid: null, port: null }, +) + +/* ---------------- worker::stop ---------------- */ + +export const workerStopDone = base( + 'worker-stop', + 'worker::stop', + { name: 'pdfkit', yes: true }, + wrapHarness({ name: 'pdfkit', stopped: true }), +) + +export const workerStopFailed = base( + 'worker-stop-failed', + 'worker::stop', + { name: 'pdfkit', yes: true }, + { name: 'pdfkit', stopped: false }, +) + +/* ---------------- worker::add ---------------- */ + +export const workerAddDone = base( + 'worker-add', + 'worker::add', + { + source: { kind: 'registry', name: 'pdfkit', version: '1.0.0' }, + force: false, + reset_config: false, + wait: true, + }, + wrapHarness({ + name: 'pdfkit', + version: '1.0.0', + status: 'installed', + awaited_ready: true, + config_path: '/Users/anderson/code/demo/iii.config.yaml', + }), +) + +export const workerAddOci = base( + 'worker-add-oci', + 'worker::add', + { + source: { kind: 'oci', reference: 'ghcr.io/iii-hq/node:latest' }, + force: true, + wait: true, + }, + { + name: 'node', + version: null, + status: 'replaced', + awaited_ready: true, + config_path: '/Users/anderson/code/demo/iii.config.yaml', + }, +) + +export const workerAddAlreadyCurrent = base( + 'worker-add-current', + 'worker::add', + { + source: { kind: 'registry', name: 'pdfkit' }, + wait: true, + }, + { + name: 'pdfkit', + version: '1.0.0', + status: 'already_current', + awaited_ready: true, + config_path: '/Users/anderson/code/demo/iii.config.yaml', + }, +) + +/* ---------------- worker::remove ---------------- */ + +export const workerRemoveDone = base( + 'worker-remove', + 'worker::remove', + { names: ['pdfkit', 'old-worker'], yes: true }, + wrapHarness({ removed: ['pdfkit', 'old-worker'] }), +) + +export const workerRemoveAll = base( + 'worker-remove-all', + 'worker::remove', + { all: true, yes: true }, + { removed: ['pdfkit', 'iii-stream', 'todo-app'] }, +) + +export const workerRemoveNothing = base( + 'worker-remove-empty', + 'worker::remove', + { names: ['gone'], yes: true }, + { removed: [] }, +) + +/* ---------------- worker::update ---------------- */ + +export const workerUpdateDone = base( + 'worker-update', + 'worker::update', + { names: ['pdfkit', 'iii-stream'] }, + wrapHarness({ + updated: [ + { name: 'pdfkit', from_version: '1.0.0', to_version: '1.1.0' }, + { name: 'iii-stream', from_version: '0.11.6', to_version: '0.12.0' }, + ], + }), +) + +export const workerUpdateAlreadyCurrent = base( + 'worker-update-current', + 'worker::update', + {}, + { updated: [] }, +) + +/* ---------------- worker::clear ---------------- */ + +export const workerClearDone = base( + 'worker-clear', + 'worker::clear', + { all: true, yes: true }, + wrapHarness({ cleared: ['pdfkit', 'iii-stream'] }), +) + +/* ---------------- error fixture ---------------- */ + +export const workerStopGateError = base( + 'worker-stop-gate', + 'worker::stop', + { name: 'pdfkit' }, + { + error: { + kind: 'function_error', + message: 'trigger_failed: W104: ConsentRequired', + details: { + schema_version: 1, + status: 'denied', + denied_by: 'permissions', + function_id: 'worker::stop', + reason: 'destructive op requires yes=true', + }, + content: [ + { + type: 'text', + text: 'trigger_failed: destructive op requires yes=true', + }, + ], + }, + }, +) + +export const workerFixtures = [ + workerListDone, + workerListRunningOnly, + workerListEmpty, + workerListRunning, + workerStartDone, + workerStartNoPid, + workerStopDone, + workerStopFailed, + workerAddDone, + workerAddOci, + workerAddAlreadyCurrent, + workerRemoveDone, + workerRemoveAll, + workerRemoveNothing, + workerUpdateDone, + workerUpdateAlreadyCurrent, + workerClearDone, + workerStopGateError, +] as const