From e3e98058b0734383732aa70b34d439ba6d046cb4 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 25 Feb 2026 11:18:53 -0800 Subject: [PATCH] feat(canvas): persist artifacts and surface UI --- docs/api/PROTOCOL.md | 2 + docs/architecture/AGENT_DIAGRAM.md | 1 + .../GATEWAY_SESSIONS_AND_QUEUE.md | 1 + docs/plans/state.json | 22 ++- src/daemon/index.ts | 2 +- src/daemon/services.ts | 6 +- src/gateway/canvas-store.test.ts | 21 +++ src/gateway/canvas-store.ts | 130 +++++++++++++++++- src/gateway/server.ts | 9 +- src/gateway/ui/pages/chat.js | 122 ++++++++++++++++ src/gateway/ui/pages/chat.test.ts | 19 +++ 11 files changed, 330 insertions(+), 5 deletions(-) diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index fd25957..0b7721b 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1229,6 +1229,8 @@ Push tokens are returned as masked previews (`tokenPreview`) and never exposed i ### Canvas Methods +Canvas artifacts are stored per session and persisted to the gateway data directory so they survive daemon restarts. + #### `canvas.put` Upsert a session-scoped canvas artifact. diff --git a/docs/architecture/AGENT_DIAGRAM.md b/docs/architecture/AGENT_DIAGRAM.md index a2865a8..976964e 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -144,6 +144,7 @@ Gateway streaming UX signals: - WebSocket `agent.send` emits `run_state` lifecycle events (`start`, `cancel_requested`, `cancelled`, `complete`, `error`) for UI/state rendering. - Routing applies reaction rules with deterministic priority/cooldown (and recursion guard) before intent routing. - Companion nodes re-register `node.*` capabilities after reconnect; runtime clients can auto-reconnect and surface connection events. +- Canvas artifacts are persisted by the gateway so session UI surfaces can recover after daemon restarts. Key files: diff --git a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md index 74bcdca..a1ce179 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -17,6 +17,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Run lifecycle/cancel intent and reaction decisions are emitted to audit logs, and aggregated into `system.metrics` counters (runStates, cancelLatencyMs, reactions) for dashboards. - Reaction matching is deterministic (priority + cooldown + recursion guard) before intent/agent routing. - Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods. +- Canvas artifacts are persisted per session under the gateway data directory for UI recovery across restarts. ## Component Map diff --git a/docs/plans/state.json b/docs/plans/state.json index 25a793a..4ce6be0 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -6717,10 +6717,30 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/cli/companion.test.ts passing" + }, + "deeper-surfaces-phase3-canvas-persistence": { + "status": "completed", + "date": "2026-02-25", + "updated": "2026-02-25", + "summary": "Added durable canvas storage backed by the gateway data directory, surfaced a lightweight canvas inspection panel in the web chat UI, and documented persistence behavior with focused tests.", + "files_modified": [ + "src/gateway/canvas-store.ts", + "src/gateway/canvas-store.test.ts", + "src/gateway/server.ts", + "src/daemon/services.ts", + "src/daemon/index.ts", + "src/gateway/ui/pages/chat.js", + "src/gateway/ui/pages/chat.test.ts", + "docs/api/PROTOCOL.md", + "docs/architecture/AGENT_DIAGRAM.md", + "docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/canvas-store.test.ts src/gateway/ui/pages/chat.test.ts passing" } }, "overall_progress": { - "total_test_count": 2020, + "total_test_count": 2021, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 1ae8789..77d6b0a 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -247,7 +247,7 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions): let channelAgents: ReturnType['agents'] | null = null; const gateway = createGateway({ - config, configPath: options?.persistConfigPath ?? options?.configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, + config, configPath: options?.persistConfigPath ?? options?.configPath, dataDir, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, memoryStore, getBackendMode: () => backendMode, setBackendMode: (mode) => { diff --git a/src/daemon/services.ts b/src/daemon/services.ts index ef051c5..45a40d8 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -282,6 +282,7 @@ export function initPairingManager(config: Config, store?: PairingStore): Pairin export interface GatewayDeps { config: Config; configPath?: string; + dataDir: string; sessionManager: SessionManager; modelRouter: ModelRouter; systemPrompt: string; @@ -301,7 +302,7 @@ export interface GatewayDeps { } export function createGateway(deps: GatewayDeps): GatewayServer { - const { config, configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, getChannelAgents } = deps; + const { config, configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, getChannelAgents, dataDir } = deps; const gateway = new GatewayServer({ port: config.server.port, @@ -358,6 +359,9 @@ export function createGateway(deps: GatewayDeps): GatewayServer { ), }, }, + canvas: { + persistDir: resolve(dataDir, 'canvas'), + }, nodes: { enabled: config.server.nodes.enabled, allowedRoles: config.server.nodes.allowed_roles, diff --git a/src/gateway/canvas-store.test.ts b/src/gateway/canvas-store.test.ts index 742b3c9..56e81f2 100644 --- a/src/gateway/canvas-store.test.ts +++ b/src/gateway/canvas-store.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; import { CanvasStore } from './canvas-store.js'; describe('CanvasStore', () => { @@ -55,4 +58,22 @@ describe('CanvasStore', () => { expect(store.get('ws:1', 'a1')).toBeUndefined(); expect(store.list('ws:1').map((a) => a.id).sort()).toEqual(['a2', 'a3']); }); + + it('persists artifacts when a persistDir is configured', () => { + const dir = mkdtempSync(resolve(tmpdir(), 'flynn-canvas-')); + try { + const store = new CanvasStore({ persistDir: dir }); + store.put('ws:1', { + id: 'a1', + type: 'note', + content: { text: 'hello' }, + }); + + const reloaded = new CanvasStore({ persistDir: dir }); + expect(reloaded.list('ws:1')).toHaveLength(1); + expect(reloaded.get('ws:1', 'a1')?.content).toEqual({ text: 'hello' }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/src/gateway/canvas-store.ts b/src/gateway/canvas-store.ts index 5c814c0..effb0d5 100644 --- a/src/gateway/canvas-store.ts +++ b/src/gateway/canvas-store.ts @@ -1,3 +1,6 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + export interface CanvasArtifact { id: string; type: string; @@ -8,6 +11,11 @@ export interface CanvasArtifact { updatedAt: number; } +export interface CanvasStoreOptions { + maxArtifactsPerSession?: number; + persistDir?: string; +} + interface CanvasPutInput { id?: string; type: string; @@ -22,10 +30,25 @@ interface CanvasPutInput { */ export class CanvasStore { private readonly sessions = new Map>(); + private readonly hydratedSessions = new Set(); + private readonly maxArtifactsPerSession: number; + private readonly persistDir?: string; - constructor(private readonly maxArtifactsPerSession = 200) {} + constructor(options: CanvasStoreOptions | number = {}) { + if (typeof options === 'number') { + this.maxArtifactsPerSession = options; + } else { + this.maxArtifactsPerSession = options.maxArtifactsPerSession ?? 200; + this.persistDir = options.persistDir; + } + + if (this.persistDir) { + mkdirSync(this.persistDir, { recursive: true }); + } + } list(sessionId: string): CanvasArtifact[] { + this.ensureHydrated(sessionId); const entries = this.sessions.get(sessionId); if (!entries) { return []; @@ -34,10 +57,12 @@ export class CanvasStore { } get(sessionId: string, artifactId: string): CanvasArtifact | undefined { + this.ensureHydrated(sessionId); return this.sessions.get(sessionId)?.get(artifactId); } put(sessionId: string, input: CanvasPutInput): CanvasArtifact { + this.ensureHydrated(sessionId); const id = sanitizeArtifactId(input.id); const now = Date.now(); let entries = this.sessions.get(sessionId); @@ -64,10 +89,12 @@ export class CanvasStore { entries.delete(oldest.id); } } + this.persistSession(sessionId); return artifact; } delete(sessionId: string, artifactId: string): boolean { + this.ensureHydrated(sessionId); const entries = this.sessions.get(sessionId); if (!entries) { return false; @@ -76,18 +103,90 @@ export class CanvasStore { if (entries.size === 0) { this.sessions.delete(sessionId); } + this.persistSession(sessionId); return removed; } clear(sessionId: string): number { + this.ensureHydrated(sessionId); const entries = this.sessions.get(sessionId); if (!entries) { return 0; } const count = entries.size; this.sessions.delete(sessionId); + this.persistSession(sessionId); return count; } + + private ensureHydrated(sessionId: string): void { + if (!this.persistDir || this.hydratedSessions.has(sessionId)) { + return; + } + this.hydratedSessions.add(sessionId); + const filePath = resolve(this.persistDir, sessionFileName(sessionId)); + + try { + const raw = readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw) as { artifacts?: CanvasArtifact[] } | null; + const artifacts = Array.isArray(parsed?.artifacts) ? parsed?.artifacts ?? [] : []; + const entries = new Map(); + const now = Date.now(); + for (const item of artifacts) { + const normalized = normalizeArtifact(item, now); + if (!normalized) { + continue; + } + entries.set(normalized.id, normalized); + } + if (entries.size > 0) { + this.trimEntries(entries); + this.sessions.set(sessionId, entries); + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code !== 'ENOENT') { + // Ignore corrupt state; we'll rebuild as new artifacts arrive. + } + } + } + + private persistSession(sessionId: string): void { + if (!this.persistDir) { + return; + } + const filePath = resolve(this.persistDir, sessionFileName(sessionId)); + const entries = this.sessions.get(sessionId); + try { + if (!entries || entries.size === 0) { + rmSync(filePath, { force: true }); + return; + } + const payload = { + sessionId, + artifacts: Array.from(entries.values()).sort((a, b) => b.updatedAt - a.updatedAt), + updatedAt: Date.now(), + }; + writeFileSync(filePath, JSON.stringify(payload, null, 2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`Failed to persist canvas session ${sessionId}: ${message}`); + } + } + + private trimEntries(entries: Map): void { + if (entries.size <= this.maxArtifactsPerSession) { + return; + } + const sorted = Array.from(entries.values()).sort((a, b) => a.updatedAt - b.updatedAt); + const excess = sorted.length - this.maxArtifactsPerSession; + for (let i = 0; i < excess; i += 1) { + const artifact = sorted[i]; + if (artifact) { + entries.delete(artifact.id); + } + } + } } function sanitizeArtifactId(raw?: string): string { @@ -97,3 +196,32 @@ function sanitizeArtifactId(raw?: string): string { } return `art_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; } + +function sessionFileName(sessionId: string): string { + const encoded = Buffer.from(sessionId).toString('base64url'); + return `canvas_${encoded}.json`; +} + +function normalizeArtifact(raw: CanvasArtifact, now: number): CanvasArtifact | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const id = typeof raw.id === 'string' ? raw.id.trim() : ''; + const type = typeof raw.type === 'string' ? raw.type.trim() : ''; + if (!id || !type) { + return null; + } + const title = typeof raw.title === 'string' && raw.title.trim().length > 0 ? raw.title.trim() : undefined; + const createdAt = Number.isFinite(raw.createdAt) ? raw.createdAt : now; + const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : createdAt; + const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : undefined; + return { + id, + type, + title, + content: raw.content, + metadata: metadata as Record | undefined, + createdAt, + updatedAt, + }; +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 9782bba..6f54286 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -106,6 +106,10 @@ export interface GatewayServerConfig { sessions?: Record>; }; }; + canvas?: { + persistDir?: string; + maxArtifactsPerSession?: number; + }; nodes?: { enabled: boolean; allowedRoles: string[]; @@ -203,7 +207,10 @@ export class GatewayServer { }); this.laneQueue = new LaneQueue(config.queue); - this.canvasStore = new CanvasStore(); + this.canvasStore = new CanvasStore({ + maxArtifactsPerSession: config.canvas?.maxArtifactsPerSession, + persistDir: config.canvas?.persistDir, + }); this.metrics = new MetricsCollector({ getQueueDepth: () => this.laneQueue.totalPending(), }); diff --git a/src/gateway/ui/pages/chat.js b/src/gateway/ui/pages/chat.js index a303421..490b6e8 100644 --- a/src/gateway/ui/pages/chat.js +++ b/src/gateway/ui/pages/chat.js @@ -16,6 +16,11 @@ let _slashPopupIndex = -1; let _elements = {}; let _pendingAttachments = []; let _sessionSort = 'recent'; +let _client = null; +let _canvasOpen = false; +let _canvasLoading = false; +let _canvasArtifacts = []; +let _canvasError = null; // ── Slash Command Definitions ─────────────────────────────── @@ -675,6 +680,101 @@ async function loadHistory(client) { } } +// ── Canvas Surface ─────────────────────────────────────────── + +function renderCanvasPanel() { + const panel = _elements.canvasPanel; + if (!panel) {return;} + + if (!_canvasOpen) { + panel.classList.add('hidden'); + panel.innerHTML = ''; + return; + } + + panel.classList.remove('hidden'); + panel.innerHTML = ` +
+
Canvas
+ +
+
+ `; + + const refreshBtn = panel.querySelector('#chat-canvas-refresh'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + void loadCanvasArtifacts(); + }); + } + + const body = panel.querySelector('#chat-canvas-body'); + if (!body) {return;} + + if (!_currentSession) { + body.innerHTML = '
Select a session to view canvas artifacts.
'; + return; + } + + if (_canvasLoading) { + body.innerHTML = '
Loading canvas artifacts...
'; + return; + } + + if (_canvasError) { + body.innerHTML = `
${escapeHtml(_canvasError)}
`; + return; + } + + if (_canvasArtifacts.length === 0) { + body.innerHTML = '
No canvas artifacts yet.
'; + return; + } + + for (const artifact of _canvasArtifacts) { + const card = document.createElement('div'); + card.className = 'border border-zinc-800 rounded-lg bg-zinc-900 px-3 py-2'; + const title = artifact.title ? escapeHtml(artifact.title) : 'Untitled'; + const type = artifact.type ? escapeHtml(artifact.type) : 'unknown'; + const updatedAt = typeof artifact.updatedAt === 'number' ? formatMessageTimestamp(artifact.updatedAt) : ''; + const contentPreview = escapeHtml(JSON.stringify(artifact.content ?? '', null, 2).slice(0, 800)); + card.innerHTML = ` +
+
${title}
+
${type}${updatedAt ? ` · ${updatedAt}` : ''}
+
+
${contentPreview}
+ `; + body.appendChild(card); + } +} + +async function loadCanvasArtifacts() { + if (!_client) {return;} + _canvasLoading = true; + _canvasError = null; + renderCanvasPanel(); + + if (!_currentSession) { + _canvasArtifacts = []; + _canvasLoading = false; + renderCanvasPanel(); + return; + } + + try { + const result = await _client.call('canvas.list', { sessionId: _currentSession }); + _canvasArtifacts = result?.artifacts ?? []; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + _canvasArtifacts = []; + _canvasError = `Failed to load canvas: ${message}`; + } finally { + _canvasLoading = false; + renderCanvasPanel(); + } +} + // ── Send Message ──────────────────────────────────────────── async function sendMessage(client, overrideText) { @@ -836,6 +936,7 @@ const EDIT_ICON = '
@@ -847,7 +948,9 @@ export const ChatPage = { +
+