diff --git a/docs/canvases.md b/docs/canvases.md new file mode 100644 index 000000000..fa4aed7ca --- /dev/null +++ b/docs/canvases.md @@ -0,0 +1,120 @@ +# Canvases + +Canvases are interactive artifacts rendered next to the terminal in the session detail view. Instead of scrolling through terminal scrollback to find a diff, a test summary, or PR status, agents and plugins emit structured artifacts that the dashboard renders as standalone panels. + +This is AO's answer to Cursor's [canvases](https://cursor.com/docs/agent/tools/canvas), shaped for an open-source plugin ecosystem. + +## Design principles + +1. **Expressive data, constrained UI.** Producers supply data in one of a fixed set of renderer types. They do not ship React components. This keeps the dashboard install one-step, avoids running third-party JS in the supervisor, and keeps the rendered surface consistent across every AO instance. +2. **No new plugin slot.** Canvases are session output, not infrastructure. Existing plugins (agent, SCM, tracker) opt in by implementing `CanvasProducer`. Core also synthesizes canvases from PR / CI / git data so the feature works without per-agent integration. +3. **Workspace is the source of truth.** Agents write canvases to `{workspacePath}/.ao/canvases/{id}.json` — same pattern as `activity.jsonl`. Core reads, validates, size-caps, and exposes through the dashboard API. The dashboard never reads agent files directly. +4. **Pull, then push.** v0.1 polls a REST endpoint on the session detail page. Live updates ride on the existing mux WebSocket later — no new SSE channel. + +## Renderer types + +Defined in [`packages/core/src/types.ts`](../packages/core/src/types.ts) as `CanvasArtifact`. Current set: + +| Type | Use for | Payload shape | +|------|---------|---------------| +| `markdown` | Notes, summaries, READMEs, plain text reports | `{ markdown: string }` | +| `diff` | File changes, patches, reviews | `{ files: CanvasDiffFile[] }` | +| `table` | Test results, dependency lists, anything tabular | `{ columns, rows }` | +| `stats` | Cost, token counts, durations, pass/fail counts | `{ metrics: CanvasStatMetric[] }` | + +If your data doesn't fit, **shape it to fit one of these** before reaching for a new type. A "test results" canvas is a `table`. A "deployment status" canvas is `stats` plus `markdown`. Adding a new renderer type requires a core PR — propose it as an issue first with at least two real use cases. + +## Producing canvases + +### From an agent (file-based) + +Write a JSON file to `{workspacePath}/.ao/canvases/{id}.json` matching `CanvasArtifact`. Use a stable `id` if you want to overwrite the same canvas across runs; use a fresh `id` to append. + +```json +{ + "version": 1, + "id": "test-results", + "type": "table", + "title": "Test results", + "createdAt": "2026-05-04T17:00:00Z", + "updatedAt": "2026-05-04T17:00:00Z", + "source": "agent", + "payload": { + "columns": [ + { "key": "name", "label": "Test" }, + { "key": "status", "label": "Status" }, + { "key": "duration_ms", "label": "Duration", "align": "right" } + ], + "rows": [ + { "name": "auth.test.ts", "status": "pass", "duration_ms": 142 }, + { "name": "billing.test.ts", "status": "fail", "duration_ms": 89 } + ] + } +} +``` + +The directory is gitignored and travels with the worktree. + +### From a plugin (programmatic) + +Implement `CanvasProducer` on any existing plugin (agent, SCM, tracker). Core calls `listCanvases` when the session detail view loads. + +```ts +import type { CanvasProducer, CanvasArtifact, Session, ProjectConfig } from "@aoagents/ao-core"; + +const producer: CanvasProducer = { + async listCanvases(session: Session, project: ProjectConfig): Promise { + return [ + { + version: 1, + id: "pr-status", + type: "stats", + title: "PR status", + createdAt: session.createdAt, + updatedAt: new Date().toISOString(), + source: "scm-github", + payload: { + metrics: [ + { label: "CI", value: "passing", tone: "good" }, + { label: "Reviews", value: 2 }, + { label: "Mergeable", value: "yes", tone: "good" }, + ], + }, + }, + ]; + }, +}; +``` + +Returned artifacts are validated and size-capped by core before reaching the dashboard. + +## Validation rules + +Canvases that fail validation are dropped silently and logged. Rules: + +- `version` must be `1`. +- `type` must be a known `CanvasType`. +- `id` must be `[a-z0-9-]{1,64}`. +- The `core-` id prefix is reserved for canvases synthesized by AO core (e.g. `core-git-diff`). File canvases using this prefix are dropped — pick a different id. +- Total serialized size capped at 256 KB per canvas. +- Per-session count capped at 32 canvases (oldest by `updatedAt` evicted). +- Payload must structurally match the type. + +## Storage layout + +``` +{workspacePath}/.ao/canvases/ + test-results.json + diff-summary.json +``` + +Synthesized canvases (PR, CI, cost) are computed on read and not persisted. If a canvas needs to survive workspace cleanup, persist it via the session metadata directory in core — not from the producer. + +## Roadmap + +- **v0.1 (shipped)** — file reader, `GET /api/sessions/[id]/canvases`, four built-in renderers, `core.git-diff` synthesized canvas, right-rail in `SessionDetail` (auto-expands when canvases exist), 5s REST poll with visibility-aware pause. +- **v0.2** — `CanvasProducer` invoked on agent / SCM / tracker plugins. +- **v0.3** — mux topic for live updates, replacing poll. +- **Later, only if justified** — sandboxed iframe escape hatch for custom UI, build-time allowlisted renderer packages. + +Out of scope indefinitely: dynamic React imports from third-party plugins, write APIs from the dashboard back into canvases, action buttons that mutate session state. diff --git a/packages/core/src/__tests__/canvas-log.test.ts b/packages/core/src/__tests__/canvas-log.test.ts new file mode 100644 index 000000000..8e6048575 --- /dev/null +++ b/packages/core/src/__tests__/canvas-log.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + readCanvases, + parseUnifiedDiff, + getCanvasDir, + CANVAS_MAX_FILE_BYTES, + CANVAS_MAX_PER_SESSION, +} from "../canvas-log.js"; +import { CanvasArtifactSchema } from "../canvas-schema.js"; +import type { CanvasArtifact } from "../types.js"; + +let dir: string; +let canvasDir: string; + +const validMarkdown: CanvasArtifact = { + version: 1, + id: "hello", + type: "markdown", + title: "Hello", + createdAt: "2026-05-05T00:00:00Z", + updatedAt: "2026-05-05T00:00:00Z", + payload: { markdown: "hi" }, +}; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "canvas-test-")); + canvasDir = getCanvasDir(dir); + await mkdir(canvasDir, { recursive: true }); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(dir, { recursive: true, force: true }); +}); + +describe("readCanvases", () => { + it("returns empty array when canvas dir does not exist", async () => { + const fresh = await mkdtemp(join(tmpdir(), "canvas-empty-")); + expect(await readCanvases(fresh)).toEqual([]); + await rm(fresh, { recursive: true, force: true }); + }); + + it("reads valid canvases", async () => { + await writeFile(join(canvasDir, "hello.json"), JSON.stringify(validMarkdown)); + const result = await readCanvases(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("hello"); + }); + + it("drops invalid JSON without throwing", async () => { + await writeFile(join(canvasDir, "bad.json"), "{ not json"); + await writeFile(join(canvasDir, "good.json"), JSON.stringify(validMarkdown)); + const result = await readCanvases(dir); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe("hello"); + }); + + it("drops canvases with unknown type", async () => { + const broken = { ...validMarkdown, type: "unknown" }; + await writeFile(join(canvasDir, "broken.json"), JSON.stringify(broken)); + expect(await readCanvases(dir)).toEqual([]); + }); + + it("drops canvases with missing required fields", async () => { + const broken = { version: 1, type: "markdown", id: "x" }; + await writeFile(join(canvasDir, "broken.json"), JSON.stringify(broken)); + expect(await readCanvases(dir)).toEqual([]); + }); + + it("drops files larger than the per-file cap", async () => { + const oversized = "x".repeat(CANVAS_MAX_FILE_BYTES + 100); + await writeFile(join(canvasDir, "big.json"), oversized); + expect(await readCanvases(dir)).toEqual([]); + }); + + it("caps to CANVAS_MAX_PER_SESSION, evicting oldest by updatedAt", async () => { + for (let i = 0; i < CANVAS_MAX_PER_SESSION + 5; i++) { + const c: CanvasArtifact = { + ...validMarkdown, + id: `c-${i}`, + updatedAt: new Date(2026, 0, 1, 0, 0, i).toISOString(), + }; + await writeFile(join(canvasDir, `c-${i}.json`), JSON.stringify(c)); + } + const result = await readCanvases(dir); + expect(result).toHaveLength(CANVAS_MAX_PER_SESSION); + expect(result[0]?.id).toBe(`c-${CANVAS_MAX_PER_SESSION + 4}`); + }); + + it("drops file canvases that use the reserved core- prefix", async () => { + const reserved: CanvasArtifact = { ...validMarkdown, id: "core-git-diff" }; + await writeFile(join(canvasDir, "evil.json"), JSON.stringify(reserved)); + expect(await readCanvases(dir)).toEqual([]); + }); + + it("ignores non-json files", async () => { + await writeFile(join(canvasDir, "notes.txt"), "ignore me"); + await writeFile(join(canvasDir, "good.json"), JSON.stringify(validMarkdown)); + const result = await readCanvases(dir); + expect(result).toHaveLength(1); + }); +}); + +describe("CanvasArtifactSchema", () => { + it("accepts a valid table canvas", () => { + const ok = CanvasArtifactSchema.safeParse({ + version: 1, + id: "tests", + type: "table", + title: "Tests", + createdAt: "2026-05-05T00:00:00Z", + updatedAt: "2026-05-05T00:00:00Z", + payload: { + columns: [{ key: "name", label: "Name" }], + rows: [{ name: "auth.test.ts" }], + }, + }); + expect(ok.success).toBe(true); + }); + + it("rejects unknown payload fields (strict)", () => { + const result = CanvasArtifactSchema.safeParse({ + ...validMarkdown, + payload: { markdown: "hi", extra: "no" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects bad id format", () => { + const result = CanvasArtifactSchema.safeParse({ ...validMarkdown, id: "BAD ID" }); + expect(result.success).toBe(false); + }); +}); + +describe("parseUnifiedDiff", () => { + it("returns no files for empty diff", () => { + expect(parseUnifiedDiff("")).toEqual({ files: [], truncated: false }); + }); + + it("parses a single-file modification with one hunk", () => { + const diff = [ + "diff --git a/foo.ts b/foo.ts", + "index abc..def 100644", + "--- a/foo.ts", + "+++ b/foo.ts", + "@@ -1,3 +1,3 @@", + " context", + "-old", + "+new", + "", + ].join("\n"); + const result = parseUnifiedDiff(diff); + expect(result.truncated).toBe(false); + expect(result.files).toHaveLength(1); + const file = result.files[0]!; + expect(file.path).toBe("foo.ts"); + expect(file.status).toBe("modified"); + expect(file.hunks[0]?.lines).toEqual([ + { kind: "context", text: "context" }, + { kind: "del", text: "old" }, + { kind: "add", text: "new" }, + ]); + }); + + it("preserves hunk lines whose content starts with +++ or ---", () => { + const diff = [ + "diff --git a/notes.md b/notes.md", + "--- a/notes.md", + "+++ b/notes.md", + "@@ -1,3 +1,3 @@", + " heading", + "---", + "+++added text", + "", + ].join("\n"); + const result = parseUnifiedDiff(diff); + expect(result.files).toHaveLength(1); + const lines = result.files[0]!.hunks[0]!.lines; + expect(lines).toEqual([ + { kind: "context", text: "heading" }, + { kind: "del", text: "--" }, + { kind: "add", text: "++added text" }, + ]); + }); + + it("ignores file-header lines emitted before the first hunk", () => { + const diff = [ + "diff --git a/foo.ts b/foo.ts", + "--- a/foo.ts", + "+++ b/foo.ts", + "@@ -1,1 +1,1 @@", + "-old", + "+new", + ].join("\n"); + const result = parseUnifiedDiff(diff); + const lines = result.files[0]!.hunks[0]!.lines; + expect(lines).toEqual([ + { kind: "del", text: "old" }, + { kind: "add", text: "new" }, + ]); + }); + + it("detects added and deleted files", () => { + const diff = [ + "diff --git a/new.ts b/new.ts", + "new file mode 100644", + "@@ -0,0 +1,1 @@", + "+hello", + "diff --git a/gone.ts b/gone.ts", + "deleted file mode 100644", + "@@ -1,1 +0,0 @@", + "-bye", + ].join("\n"); + const result = parseUnifiedDiff(diff); + expect(result.files.map((f) => f.status)).toEqual(["added", "deleted"]); + }); +}); diff --git a/packages/core/src/canvas-log.ts b/packages/core/src/canvas-log.ts new file mode 100644 index 000000000..df1db000c --- /dev/null +++ b/packages/core/src/canvas-log.ts @@ -0,0 +1,281 @@ +import { execFile } from "node:child_process"; +import { readdir, readFile, lstat } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { CanvasArtifactSchema } from "./canvas-schema.js"; +import type { + CanvasArtifact, + CanvasDiffFile, + ProjectConfig, + Session, +} from "./types.js"; + +const execFileAsync = promisify(execFile); + +export const CANVAS_MAX_FILE_BYTES = 256 * 1024; +export const CANVAS_MAX_PER_SESSION = 32; +const DIFF_MAX_FILES = 200; +const DIFF_MAX_LINES = 5000; +const UNTRACKED_FILE_LIMIT = 50; +const UNTRACKED_BYTE_BUDGET = 4 * 1024 * 1024; + +export function getCanvasDir(workspacePath: string): string { + return join(workspacePath, ".ao", "canvases"); +} + +export async function readCanvases(workspacePath: string): Promise { + const dir = getCanvasDir(workspacePath); + let entries: string[]; + try { + entries = await readdir(dir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; + throw err; + } + + const canvases: CanvasArtifact[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const filePath = join(dir, entry); + + let size: number; + try { + const st = await lstat(filePath); + // Reject anything that isn't a regular file. lstat (not stat) avoids + // following symlinks so a symlink to /dev/zero or a FIFO can't slip past + // the size check and hang the reader. + if (!st.isFile()) { + console.warn(`canvas: dropping ${entry} (not a regular file)`); + continue; + } + size = st.size; + } catch { + continue; + } + if (size > CANVAS_MAX_FILE_BYTES) { + console.warn(`canvas: dropping ${entry} (${size} bytes exceeds ${CANVAS_MAX_FILE_BYTES})`); + continue; + } + + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.warn(`canvas: dropping ${entry} (invalid JSON)`); + continue; + } + + const result = CanvasArtifactSchema.safeParse(parsed); + if (!result.success) { + console.warn(`canvas: dropping ${entry} (schema invalid: ${result.error.issues[0]?.message ?? "unknown"})`); + continue; + } + // The "core-" id prefix is reserved for canvases synthesized by core + // (e.g. core-git-diff). Dropping it here prevents an agent file canvas + // from shadowing the trusted synthesized version in API responses. + if (result.data.id.startsWith("core-")) { + console.warn(`canvas: dropping ${entry} (id "${result.data.id}" uses reserved "core-" prefix)`); + continue; + } + canvases.push(result.data); + } + + canvases.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); + return canvases.slice(0, CANVAS_MAX_PER_SESSION); +} + +export async function synthesizeGitDiffCanvas( + session: Session, + project: ProjectConfig, +): Promise { + if (!session.workspacePath) return null; + + const baseBranch = project.defaultBranch; + + // Find the merge-base so the diff doesn't misattribute upstream changes. + // `git diff ` against working tree would show commits added to main + // after the session branched as "deletions" by the agent. + // Prefer origin/ since the workspace plugin fetches and branches from + // there. Falling back to the local ref keeps offline / no-remote repos working. + const baseCandidates = [`origin/${baseBranch}`, baseBranch]; + let mergeBase = ""; + for (const candidate of baseCandidates) { + try { + const result = await execFileAsync("git", ["merge-base", candidate, "HEAD"], { + cwd: session.workspacePath, + timeout: 5_000, + }); + const sha = result.stdout.trim(); + if (sha) { + mergeBase = sha; + break; + } + } catch { + // try next candidate + } + } + if (!mergeBase) return null; + + let stdout: string; + try { + const result = await execFileAsync("git", ["diff", "--no-color", mergeBase], { + cwd: session.workspacePath, + maxBuffer: 8 * 1024 * 1024, + timeout: 5_000, + }); + stdout = result.stdout; + } catch (err) { + // On `maxBuffer` overflow, execFile still hands back the partial stdout it + // captured — keep it so parseUnifiedDiff's file/line caps can produce a + // truncated canvas instead of the user seeing the diff disappear entirely. + const e = err as { stdout?: string }; + if (typeof e.stdout === "string" && e.stdout.length > 0) { + stdout = e.stdout; + } else { + return null; + } + } + + // Append untracked files. `git diff` skips them, but they're a common case + // (agent creates a file without `git add`). For each untracked file, run + // `git diff --no-index /dev/null ` to synthesize a unified-add diff. + // Cap to UNTRACKED_FILE_LIMIT files and a total size budget so a workspace + // with thousands of generated/build artifacts can't make every poll slow. + try { + const { stdout: untrackedRaw } = await execFileAsync( + "git", + ["ls-files", "--others", "--exclude-standard", "-z"], + { cwd: session.workspacePath, maxBuffer: 1 * 1024 * 1024, timeout: 5_000 }, + ); + const untracked = untrackedRaw + .split("\0") + .filter(Boolean) + // Skip AO's own workspace files. If the host repo doesn't ignore .ao/ + // (likely, since it's not part of the project), agent-emitted canvases + // and AGENTS.md would otherwise appear as added files in the diff and + // consume the untracked-file budget. + .filter((p) => !p.startsWith(".ao/") && p !== ".ao") + .slice(0, UNTRACKED_FILE_LIMIT); + for (const path of untracked) { + if (stdout.length >= UNTRACKED_BYTE_BUDGET) break; + // Skip FIFOs, sockets, devices — `git diff --no-index` would block on + // them until our 5s timeout fires, stacking polls. + try { + const st = await lstat(join(session.workspacePath, path)); + if (!st.isFile()) continue; + } catch { + continue; + } + try { + const { stdout: addDiff } = await execFileAsync( + "git", + ["diff", "--no-color", "--no-index", "--", "/dev/null", path], + { cwd: session.workspacePath, maxBuffer: 1 * 1024 * 1024, timeout: 5_000 }, + ); + stdout += addDiff; + } catch (err) { + // `git diff --no-index` exits 1 when files differ — that's the success + // case, and execFile rejects on non-zero. Capture stdout from the error. + const e = err as { stdout?: string }; + if (typeof e.stdout === "string") stdout += e.stdout; + } + } + } catch { + // ls-files failed (not a repo, etc.) — fall through with whatever diff we have. + } + + if (!stdout.trim()) return null; + + const { files, truncated } = parseUnifiedDiff(stdout); + if (files.length === 0) return null; + + const now = new Date().toISOString(); + const fileCount = files.length; + const titleSuffix = truncated ? " (truncated)" : ""; + return { + version: 1, + id: "core-git-diff", + type: "diff", + title: `Diff vs ${baseBranch} (${fileCount} file${fileCount === 1 ? "" : "s"})${titleSuffix}`, + createdAt: now, + updatedAt: now, + source: "core", + payload: { files }, + }; +} + +export function parseUnifiedDiff(diff: string): { files: CanvasDiffFile[]; truncated: boolean } { + const lines = diff.split("\n"); + const files: CanvasDiffFile[] = []; + let current: CanvasDiffFile | null = null; + let currentHunk: CanvasDiffFile["hunks"][number] | null = null; + let totalLines = 0; + let truncated = false; + + const finalizeFile = () => { + if (current) { + if (currentHunk) current.hunks.push(currentHunk); + files.push(current); + } + current = null; + currentHunk = null; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + + if (line.startsWith("diff --git ")) { + finalizeFile(); + if (files.length >= DIFF_MAX_FILES) { + truncated = true; + break; + } + const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line); + const path = match?.[2] ?? ""; + const oldPath = match?.[1]; + current = { + path, + oldPath: oldPath !== path ? oldPath : undefined, + status: "modified", + hunks: [], + }; + continue; + } + + if (!current) continue; + + if (line.startsWith("new file mode")) current.status = "added"; + else if (line.startsWith("deleted file mode")) current.status = "deleted"; + else if (line.startsWith("rename from") || line.startsWith("rename to")) current.status = "renamed"; + else if (line.startsWith("@@")) { + if (currentHunk) current.hunks.push(currentHunk); + currentHunk = { header: line, lines: [] }; + } else if (currentHunk) { + if (line === "") continue; + const kind: "add" | "del" | "context" = line.startsWith("+") + ? "add" + : line.startsWith("-") + ? "del" + : "context"; + // Strip the unified-diff prefix character (`+`, `-`, or ` `) so the + // renderer can show its own marker column without doubling the indent. + const text = line.slice(1); + currentHunk.lines.push({ kind, text }); + totalLines++; + if (totalLines >= DIFF_MAX_LINES) { + truncated = true; + break; + } + } + } + finalizeFile(); + return { files, truncated }; +} diff --git a/packages/core/src/canvas-schema.ts b/packages/core/src/canvas-schema.ts new file mode 100644 index 000000000..c9c443cee --- /dev/null +++ b/packages/core/src/canvas-schema.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +const idPattern = /^[a-z0-9][a-z0-9-]{0,63}$/; + +const baseFields = { + version: z.literal(1), + id: z.string().regex(idPattern), + title: z.string().min(1).max(200), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + source: z.string().min(1).max(64).optional(), +}; + +const markdownPayload = z + .object({ + markdown: z.string().max(64_000), + }) + .strict(); + +const diffLine = z + .object({ + kind: z.enum(["context", "add", "del"]), + text: z.string(), + }) + .strict(); + +const diffHunk = z + .object({ + header: z.string(), + lines: z.array(diffLine), + }) + .strict(); + +const diffFile = z + .object({ + path: z.string().min(1), + oldPath: z.string().optional(), + status: z.enum(["added", "modified", "deleted", "renamed"]), + hunks: z.array(diffHunk), + }) + .strict(); + +const diffPayload = z + .object({ + files: z.array(diffFile), + }) + .strict(); + +const tableColumn = z + .object({ + key: z.string().min(1), + label: z.string(), + align: z.enum(["left", "right", "center"]).optional(), + }) + .strict(); + +const tableRow = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]), +); + +const tablePayload = z + .object({ + columns: z.array(tableColumn).min(1).max(32), + rows: z.array(tableRow).max(1000), + }) + .strict(); + +const statMetric = z + .object({ + label: z.string().min(1).max(80), + value: z.union([z.string().max(80), z.number()]), + delta: z.string().max(40).optional(), + tone: z.enum(["neutral", "good", "warn", "bad"]).optional(), + }) + .strict(); + +const statsPayload = z + .object({ + metrics: z.array(statMetric).min(1).max(32), + }) + .strict(); + +export const CanvasArtifactSchema = z.discriminatedUnion("type", [ + z.object({ ...baseFields, type: z.literal("markdown"), payload: markdownPayload }).strict(), + z.object({ ...baseFields, type: z.literal("diff"), payload: diffPayload }).strict(), + z.object({ ...baseFields, type: z.literal("table"), payload: tablePayload }).strict(), + z.object({ ...baseFields, type: z.literal("stats"), payload: statsPayload }).strict(), +]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 593b58523..69144a79e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,17 @@ // Types — everything plugins and consumers need export * from "./types.js"; +// Canvases — interactive artifacts rendered next to the terminal in SessionDetail +export { + readCanvases, + synthesizeGitDiffCanvas, + parseUnifiedDiff, + getCanvasDir, + CANVAS_MAX_FILE_BYTES, + CANVAS_MAX_PER_SESSION, +} from "./canvas-log.js"; +export { CanvasArtifactSchema } from "./canvas-schema.js"; + // Config — YAML loader + validation export { loadConfig, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b7eac5aea..32f510359 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1664,6 +1664,80 @@ export interface PluginModule { detect?(): boolean; } +// ============================================================================= +// CANVASES +// ============================================================================= + +/** + * Interactive artifacts rendered next to the terminal in SessionDetail. + * + * Two production paths: + * 1. Agents write `{workspacePath}/.ao/canvases/{id}.json` directly. + * 2. Plugins (agent, scm, tracker) implement `CanvasProducer.listCanvases` + * and return synthesized artifacts (e.g. PR status, CI summary). + * + * Renderer types are fixed in core. Third parties pick a type and supply data; + * they do not ship React components. New renderer types require a core PR. + */ +export type CanvasType = "markdown" | "diff" | "table" | "stats"; + +export interface CanvasDiffFile { + path: string; + oldPath?: string; + status: "added" | "modified" | "deleted" | "renamed"; + hunks: Array<{ + header: string; + lines: Array<{ kind: "context" | "add" | "del"; text: string }>; + }>; +} + +export interface CanvasTableColumn { + key: string; + label: string; + align?: "left" | "right" | "center"; +} + +export interface CanvasStatMetric { + label: string; + value: string | number; + delta?: string; + tone?: "neutral" | "good" | "warn" | "bad"; +} + +interface CanvasBase { + version: 1; + id: string; + type: CanvasType; + title: string; + createdAt: string; + updatedAt: string; + /** Optional source identifier (plugin name, "agent", "core"). Display only. */ + source?: string; +} + +export type CanvasArtifact = + | (CanvasBase & { type: "markdown"; payload: { markdown: string } }) + | (CanvasBase & { type: "diff"; payload: { files: CanvasDiffFile[] } }) + | (CanvasBase & { + type: "table"; + payload: { + columns: CanvasTableColumn[]; + rows: Record[]; + }; + }) + | (CanvasBase & { type: "stats"; payload: { metrics: CanvasStatMetric[] } }); + +/** + * Optional capability for plugins that can synthesize canvases from session data. + * Implemented by agent / scm / tracker plugins as needed — not a new plugin slot. + * + * Return value is treated as untrusted: core validates and size-caps before exposing + * via the dashboard API. + */ +export interface CanvasProducer { + listCanvases(session: Session, project: ProjectConfig): Promise; +} + // ============================================================================= // SESSION METADATA // ============================================================================= diff --git a/packages/web/src/app/api/sessions/[id]/canvases/route.ts b/packages/web/src/app/api/sessions/[id]/canvases/route.ts new file mode 100644 index 000000000..035761e7f --- /dev/null +++ b/packages/web/src/app/api/sessions/[id]/canvases/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest } from "next/server"; +import { validateIdentifier } from "@/lib/validation"; +import { getServices } from "@/lib/services"; +import { + readCanvases, + synthesizeGitDiffCanvas, + SessionNotFoundError, + type CanvasArtifact, +} from "@aoagents/ao-core"; +import { + getCorrelationId, + jsonWithCorrelation, + recordApiObservation, + resolveProjectIdForSessionId, +} from "@/lib/observability"; + +/** GET /api/sessions/:id/canvases — List canvas artifacts for a session */ +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const correlationId = getCorrelationId(_request); + const startedAt = Date.now(); + const { id } = await params; + const idErr = validateIdentifier(id, "id"); + if (idErr) { + return jsonWithCorrelation({ error: idErr }, { status: 400 }, correlationId); + } + + try { + const { config, sessionManager } = await getServices(); + const session = await sessionManager.get(id); + if (!session) { + return jsonWithCorrelation({ error: "Session not found" }, { status: 404 }, correlationId); + } + + // Trust the SessionManager's authoritative projectId. Falling back to + // resolveProjectIdForSessionId would mis-route sessions when two project + // prefixes share a leading substring (e.g. "app" vs "app-api"). + const projectId = session.projectId; + const project = projectId ? config.projects[projectId] : undefined; + + // A session without a workspacePath (workspace cleaned up, never created) + // is still a valid session — just one with no canvases. Return empty rather + // than 404 so the always-mounted CanvasRail shows the empty state. + const [fileCanvases, synthesized] = session.workspacePath + ? await Promise.all([ + readCanvases(session.workspacePath), + project ? synthesizeGitDiffCanvas(session, project) : Promise.resolve(null), + ]) + : [[], null]; + + const merged = new Map(); + if (synthesized) merged.set(synthesized.id, synthesized); + for (const c of fileCanvases) merged.set(c.id, c); + + const canvases = Array.from(merged.values()).sort((a, b) => + a.updatedAt < b.updatedAt ? 1 : -1, + ); + + recordApiObservation({ + config, + method: "GET", + path: "/api/sessions/[id]/canvases", + correlationId, + startedAt, + outcome: "success", + statusCode: 200, + projectId, + sessionId: id, + }); + return jsonWithCorrelation({ canvases }, { status: 200 }, correlationId); + } catch (err) { + if (err instanceof SessionNotFoundError) { + return jsonWithCorrelation({ error: err.message }, { status: 404 }, correlationId); + } + const { config } = await getServices().catch(() => ({ config: undefined })); + const projectId = config ? resolveProjectIdForSessionId(config, id) : undefined; + if (config) { + recordApiObservation({ + config, + method: "GET", + path: "/api/sessions/[id]/canvases", + correlationId, + startedAt, + outcome: "failure", + statusCode: 500, + projectId, + sessionId: id, + reason: err instanceof Error ? err.message : "Failed to list canvases", + }); + } + const msg = err instanceof Error ? err.message : "Failed to list canvases"; + return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index bb96b8a47..38bbfdb8f 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -22,6 +22,7 @@ import { } from "./SessionDetailHeader"; import { SessionEndedSummary } from "./SessionEndedSummary"; import { sessionActivityMeta } from "./session-detail-utils"; +import { CanvasRail } from "./canvas/CanvasRail"; export type { OrchestratorZones } from "./SessionDetailHeader"; @@ -194,7 +195,7 @@ export function SessionDetail({ )}
-
+
{!showTerminal ? (
@@ -220,6 +221,7 @@ export function SessionDetail({ /> )}
+ {!isMobile && }
diff --git a/packages/web/src/components/canvas/CanvasRail.tsx b/packages/web/src/components/canvas/CanvasRail.tsx new file mode 100644 index 000000000..dda197129 --- /dev/null +++ b/packages/web/src/components/canvas/CanvasRail.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { CanvasArtifact } from "@aoagents/ao-core"; +import { useSessionCanvases } from "@/hooks/useSessionCanvases"; +import { MarkdownCanvas } from "./renderers/MarkdownCanvas"; +import { DiffCanvas } from "./renderers/DiffCanvas"; +import { TableCanvas } from "./renderers/TableCanvas"; +import { StatsCanvas } from "./renderers/StatsCanvas"; + +type Props = { sessionId: string }; + +export function CanvasRail({ sessionId }: Props) { + const { canvases, loading, error } = useSessionCanvases(sessionId); + const [open, setOpen] = useState(null); + + // Auto-expand the first time a canvas appears for this session. Once the user + // explicitly toggles, `open` is no longer null and this effect is a no-op. + useEffect(() => { + if (open !== null) return; + if (canvases.length > 0) setOpen(true); + }, [open, canvases.length]); + + // Reset on session change so a new session starts in its own auto-expand state. + useEffect(() => { + setOpen(null); + }, [sessionId]); + + const isOpen = open ?? false; + + if (!isOpen) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + +function CanvasPanel({ canvas }: { canvas: CanvasArtifact }) { + return ( +
+
+

{canvas.title}

+ + {canvas.source ?? canvas.type} + +
+ +
+ ); +} + +function CanvasBody({ canvas }: { canvas: CanvasArtifact }) { + switch (canvas.type) { + case "markdown": + return ; + case "diff": + return ; + case "table": + return ; + case "stats": + return ; + } +} diff --git a/packages/web/src/components/canvas/__tests__/CanvasRail.test.tsx b/packages/web/src/components/canvas/__tests__/CanvasRail.test.tsx new file mode 100644 index 000000000..652859928 --- /dev/null +++ b/packages/web/src/components/canvas/__tests__/CanvasRail.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, act } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { CanvasArtifact } from "@aoagents/ao-core"; +import { CanvasRail } from "../CanvasRail"; + +const markdownCanvas: CanvasArtifact = { + version: 1, + id: "notes", + type: "markdown", + title: "Notes", + createdAt: "2026-05-05T00:00:00Z", + updatedAt: "2026-05-05T00:00:00Z", + payload: { markdown: "Hello canvas" }, +}; + +const statsCanvas: CanvasArtifact = { + version: 1, + id: "stats", + type: "stats", + title: "Run stats", + createdAt: "2026-05-05T00:00:00Z", + updatedAt: "2026-05-05T00:01:00Z", + payload: { metrics: [{ label: "Pass", value: 7, tone: "good" }] }, +}; + +function mockFetch(canvases: CanvasArtifact[]) { + return vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ canvases }), + }); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("CanvasRail", () => { + it("starts collapsed when no canvases exist", async () => { + vi.stubGlobal("fetch", mockFetch([])); + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByLabelText("Open canvas rail")).toBeInTheDocument(); + expect(screen.queryByText("Notes")).not.toBeInTheDocument(); + }); + + it("auto-expands and renders title when canvases arrive", async () => { + vi.stubGlobal("fetch", mockFetch([markdownCanvas])); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText("Notes")).toBeInTheDocument(); + expect(screen.getByText("Hello canvas")).toBeInTheDocument(); + }); + + it("dispatches to the stats renderer for stats canvases", async () => { + vi.stubGlobal("fetch", mockFetch([statsCanvas])); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText("Run stats")).toBeInTheDocument(); + expect(screen.getByText("Pass")).toBeInTheDocument(); + expect(screen.getByText("7")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/canvas/renderers/DiffCanvas.tsx b/packages/web/src/components/canvas/renderers/DiffCanvas.tsx new file mode 100644 index 000000000..2efe39ead --- /dev/null +++ b/packages/web/src/components/canvas/renderers/DiffCanvas.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import type { CanvasArtifact, CanvasDiffFile } from "@aoagents/ao-core"; + +type Props = { canvas: Extract }; + +export function DiffCanvas({ canvas }: Props) { + return ( +
+ {canvas.payload.files.map((file) => ( + + ))} +
+ ); +} + +function DiffFileBlock({ file }: { file: CanvasDiffFile }) { + const [open, setOpen] = useState(true); + const statusBadge = statusLabel(file.status); + return ( +
+ + {open && ( +
+ {file.hunks.map((hunk, hi) => ( +
+
+ {hunk.header} +
+ {hunk.lines.map((line, li) => ( +
+ + {line.kind === "add" ? "+" : line.kind === "del" ? "-" : " "} + + {line.text} +
+ ))} +
+ ))} +
+ )} +
+ ); +} + +function statusLabel(s: CanvasDiffFile["status"]): string { + return s === "added" ? "added" : s === "deleted" ? "deleted" : s === "renamed" ? "renamed" : "modified"; +} diff --git a/packages/web/src/components/canvas/renderers/MarkdownCanvas.tsx b/packages/web/src/components/canvas/renderers/MarkdownCanvas.tsx new file mode 100644 index 000000000..aed2d1de2 --- /dev/null +++ b/packages/web/src/components/canvas/renderers/MarkdownCanvas.tsx @@ -0,0 +1,13 @@ +"use client"; + +import type { CanvasArtifact } from "@aoagents/ao-core"; + +type Props = { canvas: Extract }; + +export function MarkdownCanvas({ canvas }: Props) { + return ( +
+      {canvas.payload.markdown}
+    
+ ); +} diff --git a/packages/web/src/components/canvas/renderers/StatsCanvas.tsx b/packages/web/src/components/canvas/renderers/StatsCanvas.tsx new file mode 100644 index 000000000..e324d7152 --- /dev/null +++ b/packages/web/src/components/canvas/renderers/StatsCanvas.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { CanvasArtifact } from "@aoagents/ao-core"; + +type Props = { canvas: Extract }; + +const toneClass: Record["payload"]["metrics"][number]["tone"]>, string> = { + good: "text-[var(--color-status-working)]", + warn: "text-[var(--color-status-pending)]", + bad: "text-[var(--color-status-error)]", + neutral: "text-[var(--color-text-primary)]", +}; + +export function StatsCanvas({ canvas }: Props) { + return ( +
+ {canvas.payload.metrics.map((m, i) => ( +
+
+ {m.label} +
+
+ {typeof m.value === "number" ? m.value.toLocaleString() : m.value} +
+ {m.delta && ( +
{m.delta}
+ )} +
+ ))} +
+ ); +} diff --git a/packages/web/src/components/canvas/renderers/TableCanvas.tsx b/packages/web/src/components/canvas/renderers/TableCanvas.tsx new file mode 100644 index 000000000..58fdf9952 --- /dev/null +++ b/packages/web/src/components/canvas/renderers/TableCanvas.tsx @@ -0,0 +1,59 @@ +"use client"; + +import type { CanvasArtifact } from "@aoagents/ao-core"; + +type Props = { canvas: Extract }; + +export function TableCanvas({ canvas }: Props) { + const { columns, rows } = canvas.payload; + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {columns.map((col) => { + const value = row[col.key]; + const display = + value === null || value === undefined + ? "" + : typeof value === "boolean" + ? value ? "yes" : "no" + : String(value); + const isNumeric = typeof value === "number"; + return ( + + ); + })} + + ))} + +
+ {col.label} +
+ {display} +
+
+ ); +} diff --git a/packages/web/src/hooks/__tests__/useSessionCanvases.test.ts b/packages/web/src/hooks/__tests__/useSessionCanvases.test.ts new file mode 100644 index 000000000..398514c85 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useSessionCanvases.test.ts @@ -0,0 +1,107 @@ +import { renderHook, waitFor, act } from "@testing-library/react"; +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { CanvasArtifact } from "@aoagents/ao-core"; +import { useSessionCanvases } from "../useSessionCanvases"; + +const sample: CanvasArtifact = { + version: 1, + id: "x", + type: "markdown", + title: "X", + createdAt: "2026-05-05T00:00:00Z", + updatedAt: "2026-05-05T00:00:00Z", + payload: { markdown: "hi" }, +}; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("useSessionCanvases", () => { + it("fetches once on mount", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ canvases: [sample] }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useSessionCanvases("ao-1")); + await waitFor(() => expect(result.current.canvases).toHaveLength(1)); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith("/api/sessions/ao-1/canvases"); + }); + + it("polls every 5 seconds", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ canvases: [] }), + }); + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers({ shouldAdvanceTime: true }); + + renderHook(() => useSessionCanvases("ao-1")); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("surfaces non-OK responses as errors", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}), + }); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useSessionCanvases("ao-1")); + await waitFor(() => expect(result.current.error).toBe("HTTP 500")); + }); + + it("returns empty when sessionId is null", () => { + const { result } = renderHook(() => useSessionCanvases(null)); + expect(result.current.canvases).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it("ignores in-flight responses from a previous sessionId", async () => { + let resolveOne: ((v: { canvases: CanvasArtifact[] }) => void) | null = null; + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes("ao-1")) { + return new Promise((resolve) => { + resolveOne = (v) => resolve({ ok: true, status: 200, json: async () => v }); + }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ canvases: [{ ...sample, id: "two" }] }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const { result, rerender } = renderHook( + ({ id }: { id: string }) => useSessionCanvases(id), + { initialProps: { id: "ao-1" } }, + ); + + rerender({ id: "ao-2" }); + await waitFor(() => expect(result.current.canvases).toEqual([{ ...sample, id: "two" }])); + + // Resolve the stale ao-1 fetch — must not overwrite ao-2's canvases. + resolveOne?.({ canvases: [{ ...sample, id: "one-stale" }] }); + await new Promise((r) => setTimeout(r, 10)); + expect(result.current.canvases).toEqual([{ ...sample, id: "two" }]); + }); +}); diff --git a/packages/web/src/hooks/useSessionCanvases.ts b/packages/web/src/hooks/useSessionCanvases.ts new file mode 100644 index 000000000..1174f8cd6 --- /dev/null +++ b/packages/web/src/hooks/useSessionCanvases.ts @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { CanvasArtifact } from "@aoagents/ao-core"; + +const POLL_INTERVAL_MS = 5000; + +export function useSessionCanvases(sessionId: string | null): { + canvases: CanvasArtifact[]; + loading: boolean; + error: string | null; +} { + const [canvases, setCanvases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + // Reset on session change so canvases from a previous session never bleed + // into the new view. + setCanvases([]); + setError(null); + + if (!sessionId) { + setLoading(false); + return; + } + setLoading(true); + + let cancelled = false; + let timer: ReturnType | null = null; + // Track in-flight requests so an older response can't overwrite a newer one + // when polls overlap (e.g. the API takes longer than POLL_INTERVAL_MS). + let nextSeq = 0; + let latestApplied = -1; + + const fetchOnce = async () => { + const seq = nextSeq++; + try { + const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/canvases`); + if (cancelled || seq < latestApplied) return; + if (!res.ok) { + latestApplied = seq; + setError(`HTTP ${res.status}`); + setLoading(false); + return; + } + const data = (await res.json()) as { canvases?: CanvasArtifact[] }; + if (cancelled || seq < latestApplied) return; + latestApplied = seq; + setCanvases(data.canvases ?? []); + setError(null); + setLoading(false); + } catch (err) { + if (cancelled || seq < latestApplied) return; + latestApplied = seq; + setError(err instanceof Error ? err.message : "fetch failed"); + setLoading(false); + } + }; + + const schedule = () => { + timer = setTimeout(async () => { + if (cancelled) return; + if (typeof document === "undefined" || document.visibilityState === "visible") { + await fetchOnce(); + } + if (!cancelled) schedule(); + }, POLL_INTERVAL_MS); + }; + + void fetchOnce(); + schedule(); + + const onVisibilityChange = () => { + if (cancelled) return; + if (typeof document !== "undefined" && document.visibilityState === "visible") { + void fetchOnce(); + } + }; + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibilityChange); + } + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", onVisibilityChange); + } + }; + }, [sessionId]); + + return { canvases, loading, error }; +}