From 02c891a2cad744abb3e131c660f2f65a51dcef3f Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sat, 3 Oct 2026 21:46:41 +0800 Subject: [PATCH 01/32] docs: record audio asset worker brief (MEET-1) --- _brief.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 _brief.md diff --git a/_brief.md b/_brief.md new file mode 100644 index 00000000..a03cd284 --- /dev/null +++ b/_brief.md @@ -0,0 +1,23 @@ +# MEET-1 — Audio asset support in the asset store + +You are a Worker agent on the OpenBook team. Work ONLY in this worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-1`, branch `feat/meet-1-audio-assets`). Do NOT push. Do NOT touch main. Commit incrementally with conventional commits (`feat(server,sdk): … (MEET-1)`). + +## Task +Extend the asset store to accept and serve AUDIO. Today the allowlist is images-only: `ASSET_IMAGE_MIMES` at `packages/sdk/src/importAssets.ts:55`; `safeAssetMime` at `packages/server/src/app.ts:332` rewrites everything else to `application/octet-stream`. Upload route `POST /api/assets?pageId=` at `app.ts:1581-1633`; serve route `GET /api/assets/:id` at `app.ts:1660-1690` (ETag/304, `private, immutable`, nosniff, `Content-Disposition: attachment`, `?encoding=base64` variant). Line numbers are recon-time hints — trust the code. + +## Acceptance (each maps to a test) +1. New audio allowlist in sdk (e.g. `ASSET_AUDIO_MIMES`): `audio/webm`, `audio/ogg`, `audio/mpeg`, `audio/mp4`, `audio/wav`. Audio uploads are stored AND served back with their real MIME, not octet-stream. +2. Route shapes unchanged; ETag / attachment / nosniff behavior preserved for all types. Unit tests cover an audio round-trip including the base64-JSON upload variant (desktop IPC path — see `packages/sdk/src/client.ts:1571`). +3. The 10 MiB per-asset cap (`app.ts:1485`) stays; add a code comment noting meeting-recording chunks are sized under it. +4. GC safety: `gcUnreferencedAssets` (`packages/server/src/store.ts:5610`; NOTE: store.ts contains a NUL byte — use `grep -a`) keeps an asset whose id appears in page data. Add a test proving an audio assetId stored in block props survives GC. +5. Security: audio must never be served inline-executable; keep `Content-Disposition: attachment` and nosniff. No change to access gating (default-deny, `access.ts`). + +## Definition of done +- `pnpm verify` green, run FOREGROUND in this worktree, full output in your report. (Worktree is pre-provisioned; if you see "N test FILES failed, 0 tests failed" it's build artifacts — run `pnpm --filter @book.dev/ui run build:viewer` and `pnpm --filter @book.dev/server build && pnpm --filter @book.dev/mcp build`.) +- All work committed. Do not push. +- Write `_report.md` in the worktree root AND reply with it: outcome first (PASS/FAIL), head sha, criterion→test/commit map, deltas/deviations only, open questions. Terse. + +## Rules +- Never poll/babysit anything external; deliver, report, end. +- If genuinely stuck after a real attempt, commit what you have and report where you're wedged — do not thrash. +- Don't delete or weaken ANY existing test. Reviews will check the full commit range. From 451ea1d5c53ffdeba0296c89643c8e0172a867a0 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sat, 3 Oct 2026 21:47:09 +0800 Subject: [PATCH 02/32] feat(server,sdk): support audio assets (MEET-1) --- packages/sdk/src/importAssets.ts | 30 +++++++---- packages/sdk/src/index.ts | 2 + packages/server/src/app.ts | 11 ++-- packages/server/src/assetGc.test.ts | 19 +++++++ packages/server/src/assets.test.ts | 82 +++++++++++++++++++++++++++++ packages/server/src/backups.test.ts | 27 ++++++++++ packages/server/src/store.ts | 10 ++-- 7 files changed, 162 insertions(+), 19 deletions(-) diff --git a/packages/sdk/src/importAssets.ts b/packages/sdk/src/importAssets.ts index 174522f2..385574fe 100644 --- a/packages/sdk/src/importAssets.ts +++ b/packages/sdk/src/importAssets.ts @@ -43,15 +43,7 @@ export const IMAGE_BLOCK_TYPE = 'image'; */ export const DEFAULT_MAX_ASSET_BYTES = 10 * 1024 * 1024; -/** - * Mime types an asset may be STORED and SERVED as (everything else is coerced - * to `application/octet-stream`, which — with `nosniff` + attachment - * disposition on the served response — can never execute). Single-sourced here - * (LGR-15) so the upload door (`app.ts` `safeAssetMime`) and the backup-restore - * door (`store.ts`) can never drift: an allowlist that exists twice is an - * allowlist that will eventually disagree. Grow this list (never add - * `svg+xml`, never `text/html`) if v2 serves more types. - */ +/** Raster image MIME types supported by the asset store. */ export const ASSET_IMAGE_MIMES: ReadonlySet = new Set([ 'image/png', 'image/jpeg', @@ -61,6 +53,26 @@ export const ASSET_IMAGE_MIMES: ReadonlySet = new Set([ 'image/apng', ]); +/** Audio MIME types supported by the asset store (MEET-1). */ +export const ASSET_AUDIO_MIMES: ReadonlySet = new Set([ + 'audio/webm', + 'audio/ogg', + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', +]); + +/** + * Mime types an asset may be STORED and SERVED as (everything else is coerced + * to `application/octet-stream`, which — with `nosniff` + attachment + * disposition on the served response — can never execute). Single-sourced here + * (LGR-15) so the upload door (`app.ts` `safeAssetMime`) and the backup-restore + * door (`store.ts`) can never drift: an allowlist that exists twice is an + * allowlist that will eventually disagree. Never add `image/svg+xml` or + * `text/html`: those types can execute scripts. + */ +export const ASSET_MIMES: ReadonlySet = new Set([...ASSET_IMAGE_MIMES, ...ASSET_AUDIO_MIMES]); + // ── ref classification ─────────────────────────────────────────────────────── const HTTP_RE = /^https?:\/\//i; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6ca60969..9b07afee 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -650,6 +650,8 @@ export { IMAGE_BLOCK_TYPE, DEFAULT_MAX_ASSET_BYTES, ASSET_IMAGE_MIMES, + ASSET_AUDIO_MIMES, + ASSET_MIMES, type RehydrateUrlOptions, type RehydrateStoredClient, type RehydrateStoredOptions, diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index eb9e7060..12b45052 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -9,7 +9,7 @@ import { API, AGENT_EDITS_MODES, AGENT_EDITS_POLICIES, - ASSET_IMAGE_MIMES, + ASSET_MIMES, CLIENT_HEADER, FORWARDED_HEADER, FORM_SUBMISSION_PROPERTY_ID, @@ -312,7 +312,7 @@ function denyPatPolicy(c: Context): void { } } -// The served-asset image-mime allowlist (`ASSET_IMAGE_MIMES`) is single-sourced +// The served-asset MIME allowlist (`ASSET_MIMES`) is single-sourced // in the sdk since LGR-15: the backup-restore door (`store.ts`) sanitizes // bundle-carried asset mimes against the SAME list, and an allowlist that // exists twice will eventually disagree. `image/svg+xml` stays excluded there @@ -324,7 +324,7 @@ function denyPatPolicy(c: Context): void { * the upload `Content-Type` header or the JSON `mime` field). Returns `null` when * the raw value carries a control char / CR / LF — a header-injection or * header-set-throw (500) risk — so the route rejects it (400). Otherwise the - * parameter-stripped, lowercased base type when it's an allowlisted image, else + * parameter-stripped, lowercased base type when it's an allowlisted image or audio, else * `application/octet-stream`. Because every path stores only a sanitized mime, the * `ON CONFLICT DO NOTHING` first-seen-mime dedup can never be poisoned into serving * an executable type. @@ -333,7 +333,7 @@ function safeAssetMime(raw: string): string | null { // eslint-disable-next-line no-control-regex -- intentionally rejecting control chars (CR/LF/NUL/etc) if (/[\u0000-\u001f\u007f]/.test(raw)) return null; const base = raw.split(';', 1)[0].trim().toLowerCase(); - return ASSET_IMAGE_MIMES.has(base) ? base : 'application/octet-stream'; + return ASSET_MIMES.has(base) ? base : 'application/octet-stream'; } /** Strict base64 decoder for the public form-upload envelope. */ @@ -1482,6 +1482,7 @@ export function createApp(store: PageStore, ai?: AiService, hub: PageHub = new P // 10 MiB per upload, measured on the DECODED bytes. A single embedded image / // attachment is comfortably under this; the cap stops an authed-but-hostile // writer inflating the store with one giant asset. + // Meeting-recording chunks are sized under this per-asset cap (MEET-1). const ASSET_MAX_BYTES = 10 * 1024 * 1024; // The request BODY can be base64 (the in-webview / desktop-IPC transports send // `{data: base64, mime}`), which inflates the payload ~4/3 plus a small JSON @@ -1610,7 +1611,7 @@ export function createApp(store: PageStore, ai?: AiService, hub: PageHub = new P if (bytes.byteLength > ASSET_MAX_BYTES) return c.json({error: 'request body too large'}, 413); // Stored-XSS defense: the uploader controls `mime` (the upload Content-Type or - // the JSON `mime` field). Canonicalize it to a safe, allowlisted image type (or + // the JSON `mime` field). Canonicalize it to a safe, allowlisted image or audio type (or // `application/octet-stream`) before it's stored, and reject a malformed one // (control chars / CR/LF → header-injection / 500) rather than echo it later as // a response Content-Type. Only sanitized mimes ever land in the store, so the diff --git a/packages/server/src/assetGc.test.ts b/packages/server/src/assetGc.test.ts index 6e8e0189..397e0ecd 100644 --- a/packages/server/src/assetGc.test.ts +++ b/packages/server/src/assetGc.test.ts @@ -85,6 +85,25 @@ describe('asset dedup (A1 confirmation)', () => { }); describe('gcUnreferencedAssets — the blockdoc-usage-safe GC', () => { + it('keeps audio referenced only by block props until the reference is removed', async () => { + const bytes = bytesFor(1); + const {id: assetId} = await store.putAsset(bytes, 'audio/webm'); + const page = await store.upsertPage({ + name: `meeting-${seq}`, + data: { + ...emptySnap(), + editor: 'blocks', + blockdoc: {v: 1, update: '', blocks: [{id: 'recording', type: 'audio', props: {assetId}}]}, + }, + }); + expect(await store.pagesReferencingAsset(assetId)).toEqual([]); + expect((await store.gcUnreferencedAssets({graceMs: 0})).reaped).toBe(0); + expect(await store.getAsset(assetId)).toMatchObject({mime: 'audio/webm', bytes}); + await store.upsertPage({id: page.id, name: page.name, data: emptySnap()}); + expect((await store.gcUnreferencedAssets({graceMs: 0})).ids).toEqual([assetId]); + expect(await store.getAsset(assetId)).toBeNull(); + }); + it('reaps a truly-orphaned asset (no live document references it) past the grace', async () => { const {id} = await store.putAsset(bytesFor(1), 'image/png'); // never ref'd, in no doc const {reaped, bytes, ids} = await store.gcUnreferencedAssets({graceMs: 0}); diff --git a/packages/server/src/assets.test.ts b/packages/server/src/assets.test.ts index c844c1f6..8b2dee5f 100644 --- a/packages/server/src/assets.test.ts +++ b/packages/server/src/assets.test.ts @@ -142,6 +142,65 @@ describe('asset routes — open instance', () => { expect(Array.from(roundTrip)).toEqual(Array.from(bytes)); }); + it.each(['audio/webm', 'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/wav'])( + 'round-trips %s through binary and base64-JSON uploads with safe headers and caching', + async (mime) => { + const a = app(); + const page = await store.upsertPage({name: `audio-${seq}`, data: snapshot()}); + for (const json of [false, true]) { + const bytes = new Uint8Array([0, 255, 128, 42, json ? 1 : 0]); + const inputMime = `${mime.toUpperCase()}; codecs=opus`; + const res = json + ? await a.request(`/api/assets?pageId=${page.id}`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-OpenBook-Client': '1'}, + body: JSON.stringify({data: Buffer.from(bytes).toString('base64'), mime: inputMime}), + }) + : await upload(a, page.id, bytes, inputMime); + expect(res.status).toBe(201); + const {id} = await res.json() as {id: string}; + expect(await store.getAsset(id)).toMatchObject({mime, size: bytes.length, bytes}); + expect(await store.pagesReferencingAsset(id)).toEqual([page.id]); + + const raw = await a.request(`/api/assets/${id}`); + expect(raw.status).toBe(200); + expect(raw.headers.get('Content-Type')).toBe(mime); + expect(raw.headers.get('Content-Disposition')).toBe('attachment'); + expect(raw.headers.get('X-Content-Type-Options')).toBe('nosniff'); + expect(new Uint8Array(await raw.arrayBuffer())).toEqual(bytes); + const base64 = await a.request(`/api/assets/${id}?encoding=base64`); + expect(base64.status).toBe(200); + expect(await base64.json()).toEqual({id, mime, size: bytes.length, data: Buffer.from(bytes).toString('base64')}); + for (const response of [raw, base64]) { + expect(response.headers.get('ETag')).toBe(`"${id}"`); + expect(response.headers.get('Cache-Control')).toBe('private, max-age=31536000, immutable'); + } + for (const suffix of ['', '?encoding=base64']) { + const cached = await a.request(`/api/assets/${id}${suffix}`, {headers: {'If-None-Match': `"${id}"`}}); + expect(cached.status).toBe(304); + expect(await cached.text()).toBe(''); + expect(cached.headers.get('ETag')).toBe(`"${id}"`); + expect(cached.headers.get('Cache-Control')).toBe('private, max-age=31536000, immutable'); + } + } + }, + ); + + it.each([false, true])('rejects over-cap audio with 413 (base64-JSON: %s)', async (json) => { + const a = app(); + const page = await store.upsertPage({name: `audio-cap-${seq}`, data: snapshot()}); + const bytes = new Uint8Array(10 * 1024 * 1024 + 1); + const res = json + ? await a.request(`/api/assets?pageId=${page.id}`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-OpenBook-Client': '1'}, + body: JSON.stringify({data: Buffer.from(bytes).toString('base64'), mime: 'audio/webm'}), + }) + : await upload(a, page.id, bytes, 'audio/webm'); + expect(res.status).toBe(413); + expect(await store.assetStorageBytes()).toBe(0); + }); + it('the base64 variant is byte-exact with the raw binary', async () => { const a = app(); const page = await store.upsertPage({name: `p-${seq}`, data: snapshot()}); @@ -362,6 +421,29 @@ describe('asset routes — claimed instance read-gate (no leak)', () => { assetId = ((await res.json()) as {id: string}).id; }); + it('applies the same read and write gates to audio, including conditional and base64 GETs', async () => { + const a = app(); + const uploadAudio = async (sub: string) => a.request(`/api/assets?pageId=${restr}`, { + method: 'POST', + headers: {'Content-Type': 'audio/webm', [IDENTITY_HEADER]: await idFor(sub)}, + body: new Uint8Array([0, 255, 42]), + }); + expect((await uploadAudio('granted')).status).toBe(403); + expect((await uploadAudio('stranger')).status).toBe(404); + const uploaded = await uploadAudio('owner'); + expect(uploaded.status).toBe(201); + const {id} = await uploaded.json() as {id: string}; + for (const suffix of ['', '?encoding=base64']) { + expect((await req(a, `/api/assets/${id}${suffix}`, await idFor('granted'))).status).toBe(200); + const denied = await a.request(`/api/assets/${id}${suffix}`, { + headers: {'If-None-Match': `"${id}"`, 'X-OpenBook-Client': '1'}, + }); + expect(denied.status).toBe(404); + expect(denied.headers.get('ETag')).toBeNull(); + expect(denied.headers.get('Cache-Control')).toBe('no-store'); + } + }); + it('serves the bytes to a reader of the referencing page (owner + ACL grantee)', async () => { const a = app(); expect((await req(a, `/api/assets/${assetId}`, await idFor('owner'))).status).toBe(200); diff --git a/packages/server/src/backups.test.ts b/packages/server/src/backups.test.ts index a41ef73b..fcd021ce 100644 --- a/packages/server/src/backups.test.ts +++ b/packages/server/src/backups.test.ts @@ -414,6 +414,33 @@ describe('BackupScheduler', () => { expect(errorLog).toHaveBeenCalledTimes(1); }); + it('preserves audio MIME types and bytes through backup restore', async () => { + const assets = []; + for (const mime of ['audio/webm', 'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/wav']) { + const bytes = new TextEncoder().encode(mime); + const {id} = await store.putAsset(bytes, mime); + assets.push({id, mime, bytes}); + } + await store.upsertPage({ + name: `audio-backup-${seq}`, + data: { + ...snapshot(), + blockdoc: {v: 1, update: '', blocks: assets.map(({id}) => ({id, type: 'audio', props: {assetId: id}}))}, + }, + }); + const bundle = await store.exportAll(); + const restoreDir = join(tmpdir(), `ob-audio-restore-${process.pid}-${seq}`); + const restored = new PageStore(await PgliteDb.create(restoreDir)); + try { + await restored.migrate(); + await restored.importBundle({...bundle, mode: 'copy', installForeignPageAccess: true}); + for (const {id, mime, bytes} of assets) expect(await restored.getAsset(id)).toMatchObject({mime, bytes}); + } finally { + await restored.close(); + await rm(restoreDir, {recursive: true, force: true}); + } + }); + it('keeps backup reachability aligned with GC for future asset URL shapes', async () => { const bytes = Uint8Array.from([7, 8, 9]); const {id} = await store.putAsset(bytes, 'image/png'); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 0cc05558..42c1e9b5 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -54,7 +54,7 @@ import type { VerifiedVia, } from '@book.dev/sdk'; import {AGENT_EDITS_POLICIES, authorize, BACKUP_VERSION, dateStart, DEFAULT_ACCOUNT_URL, DEFAULT_BACKUP_CONFIG, DEFAULT_INSTANCE_CONFIG, emptyPageSnapshot, extractMentionIds, extractPropertyReferenceIds, FORM_SUBMISSION_PROPERTY_ID, isEmailAuthoritative, latestSnapshotAuthor, PAGE_VISIBILITIES, parseDay, projectExports, propertiesReferencePage, remapBundle, resolveAutoExpiry, stampSnapshotAuthors, stampSnapshotAuthorsPerBlock, stampSnapshotMtimes, verifiedSubject, type Decision, type EffectiveVisibility, type PageGraph, type PageGraphEdge, type PluginPackage, type StoredPlugin} from '@book.dev/sdk'; -import {LedgerError, LEDGER_AUDIT_ACTIONS, ASSET_IMAGE_MIMES, DEFAULT_MAX_ASSET_BYTES, canonicalLedgerJson, ledgerAuditEventHash, ledgerRestorePayloadContent, verifyLedgerAuditChain} from '@book.dev/sdk'; +import {LedgerError, LEDGER_AUDIT_ACTIONS, ASSET_MIMES, DEFAULT_MAX_ASSET_BYTES, canonicalLedgerJson, ledgerAuditEventHash, ledgerRestorePayloadContent, verifyLedgerAuditChain} from '@book.dev/sdk'; import {compareSemver, isSemver} from '@book.dev/sdk'; import {authoredSubject} from './agentWriteGate'; import {AbleOidcError} from './ableOidcError'; @@ -677,7 +677,7 @@ function safeBackupMime(raw: unknown, assetId: string): string { throw new BackupFormatError(`invalid backup: asset ${assetId} carries a control character in its mime`); } const base = value.split(';', 1)[0].trim().toLowerCase(); - return ASSET_IMAGE_MIMES.has(base) ? base : 'application/octet-stream'; + return ASSET_MIMES.has(base) ? base : 'application/octet-stream'; } function partialRestoreDiagnostic(version: 1 | 2): BackupRestoreDiagnostic { @@ -2149,7 +2149,7 @@ export class PageStore { // The SAME mime discipline as the upload door's `safeAssetMime` (app.ts), // against the same sdk allowlist: control characters refuse the bundle // (header-injection shape — nothing legitimate produces one); anything - // not an allowlisted image stores as octet-stream, which nosniff + + // not an allowlisted image or audio stores as octet-stream, which nosniff + // attachment disposition can never execute. Without this, a bundle could // plant `text/html` bytes an image block then serves from the app origin. const rawMime = typeof asset.mime === 'string' ? asset.mime : ''; @@ -2158,7 +2158,7 @@ export class PageStore { throw new Error(`invalid ledger backup: asset ${asset.id} carries a control character in its mime`); } const base = rawMime.split(';', 1)[0].trim().toLowerCase(); - const mime = ASSET_IMAGE_MIMES.has(base) ? base : 'application/octet-stream'; + const mime = ASSET_MIMES.has(base) ? base : 'application/octet-stream'; // The budget-guarded insert `putAsset` uses, on THIS transaction: the row // lands only if the content already exists (dedup) or the running total // plus this asset stays within budget. See putAsset for the $5/$6 note. @@ -5391,7 +5391,7 @@ export class PageStore { * gates the upload and refs the asset to a page. * * `mime` MUST already be a sanitized, safe-to-serve type (the route runs - * `safeAssetMime` before calling in — an allowlisted image or + * `safeAssetMime` before calling in — an allowlisted image or audio or * `application/octet-stream`). Because only sanitized mimes are ever stored, the * first-seen-mime dedup above can never be poisoned into serving an executable * type (the stored-XSS defense; see the upload route). From 69f01e0074451e11e58c370184649c24c1a6dbd2 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sat, 3 Oct 2026 21:47:24 +0800 Subject: [PATCH 03/32] feat(server,sdk): add asset transcription service and backend (MEET-2) --- _brief.md | 29 ++++ packages/sdk/src/ai.ts | 19 +++ packages/sdk/src/client.ts | 6 + packages/sdk/src/index.ts | 2 + packages/sdk/src/routes.ts | 1 + packages/server/src/ai/providers.ts | 50 +++++- packages/server/src/ai/routes.ts | 54 ++++++- packages/server/src/ai/service.ts | 29 +++- packages/server/src/ai/usage.ts | 6 +- packages/server/src/localClient.ts | 5 + packages/server/src/transcription.test.ts | 187 ++++++++++++++++++++++ 11 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 _brief.md create mode 100644 packages/server/src/transcription.test.ts diff --git a/_brief.md b/_brief.md new file mode 100644 index 00000000..eac54b32 --- /dev/null +++ b/_brief.md @@ -0,0 +1,29 @@ +# MEET-2 — Transcription service + OpenAI-compatible backend + +You are a Worker agent on the OpenBook team. Work ONLY in this worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-2`, branch `feat/meet-2-transcribe`). Do NOT push. Do NOT touch main. Conventional commits (`feat(server,sdk): … (MEET-2)`), committed incrementally. + +## Task +Add a transcription capability to the AI layer. No engine has audio today. Key anchors (recon-time line hints — trust the code): +- Engines: `packages/server/src/ai/providers.ts` — `MockEngine` :108, `OpenAiCompatEngine` :196, `AnthropicEngine` :620, factory `createEngine` :821. +- Config: `AiConfig` `packages/sdk/src/ai.ts:66`, persisted in DB `settings` key `'ai'` (`ai/service.ts:115-123`); API keys are write-only over the wire (`resolveKey` service.ts:31-37 — blank keeps, null clears). Preserve those semantics for any new key field. +- Routes: `packages/server/src/ai/routes.ts` — `aiComplete` :137 is the request-scoped shape to mirror; paid-provider gate `requirePaidInferenceAccess` :412; usage logging `ai/usage.ts`. +- Access: default-deny via `access.ts` (`requireAccess`); unreadable → 404. + +## Design (contract for MEET-3 local whisper and MEET-5 UI — keep the interface clean) +1. Extend `AiConfig` with a `transcription` section: `{ provider: 'off' | 'local' | 'openai-compat', baseUrl?, model?, apiKey? }`. **Resolution order: explicit cloud config > local (arrives in MEET-3; stub the enum/dispatch now) > clear actionable error pointing at Settings → AI.** Local is the product default — cloud only when explicitly configured. +2. New `transcribe` capability speaking the OpenAI-compatible `POST /v1/audio/transcriptions` multipart shape (works for OpenAI cloud and local servers like faster-whisper/speaches — ONE code path). Request `verbose_json`/segments where available. +3. Server route (under ai/routes.ts): POST taking `{ assetId, pageId }` (bytes already in the asset store — fetch via the store with the caller's read access enforced), returning `{ text, segments?: [{start, end, text}], durationMs }`. Request-scoped like aiComplete. Do NOT build a job queue. +4. Cloud transcription goes through `requirePaidInferenceAccess`; `local` will be ungated. Log usage rows. +5. sdk: `transcribeAsset(assetId, pageId)` on the HTTP client (pattern near `client.ts:1571`). `LocalDataClient` (`server/localClient.ts:888-932`) keeps rejecting AI calls — extend its rejection list to the new method. + +## Acceptance (each maps to a test) +- MockEngine grows a deterministic `transcribe` for tests. +- Unit tests: config round-trip incl. write-only key semantics; route happy path via mock; paid-gate enforced for cloud provider; 404 on missing/unreadable asset; `off`/unconfigured → actionable 4xx error. +- NOTE: audio MIME allowlist work is MEET-1 (parallel branch) — do not depend on it; transcribe takes the asset bytes regardless of stored MIME. + +## Definition of done +- `pnpm verify` green FOREGROUND in this worktree, output in report. (Provisioned; artifact-failure symptom → rebuild viewer/server/mcp bundles.) +- All committed, not pushed. Write `_report.md` (outcome first, head sha, criterion→test map, interface summary for MEET-3/MEET-5 consumers, deviations, open questions) and reply with it. Terse. + +## Rules +Never poll external state. Stuck after a real attempt → commit + report where wedged. Never delete/weaken existing tests (reviewers check the commit RANGE). diff --git a/packages/sdk/src/ai.ts b/packages/sdk/src/ai.ts index ff0fcf70..5212ad79 100644 --- a/packages/sdk/src/ai.ts +++ b/packages/sdk/src/ai.ts @@ -63,7 +63,26 @@ export interface AiProviderSettings { autoStart?: boolean; } +/** Audio configuration is independent of the chat engine. Omitted means local. + * Keys follow AiProviderSettings.apiKey preserve/set/clear semantics. */ +export interface AiTranscriptionConfig { + provider: 'off' | 'local' | 'openai-compat'; + baseUrl?: string; + model?: string; + apiKey?: string | null; + apiKeySet?: boolean; +} + +export interface AiTranscriptionResult { + text: string; + /** Segment offsets are seconds from the start of the recording. */ + segments?: Array<{start: number; end: number; text: string}>; + /** Audio duration in milliseconds (0 if the backend does not report it). */ + durationMs: number; +} + export interface AiConfig { + transcription?: AiTranscriptionConfig; /** The default provider — used unless an agent run overrides it. */ provider: AiProvider; /** Per-provider settings, so every provider can be configured at once. */ diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c1657696..013fb178 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -5,6 +5,7 @@ import type { AgentChatMessage, AgentChatOptions, AiConfig, + AiTranscriptionResult, AiPricingResponse, AiPricingTable, AiUsageResponse, @@ -208,6 +209,7 @@ export interface DataClient { aiSearch(query: string, limit?: number): Promise; aiTasks(goal: string, context?: string): Promise; aiDownloadModel(url?: string): Promise; + transcribeAsset(assetId: string, pageId: string): Promise; aiComplete(text: string, onToken: (token: string) => void, opts?: {instruction?: string; signal?: AbortSignal}): Promise; aiGenerate(prompt: string, onToken: (token: string) => void, opts?: {system?: string; maxTokens?: number; signal?: AbortSignal}): Promise; /** @@ -2146,6 +2148,10 @@ export class HttpDataClient implements DataClient { return this.request('POST', API.aiTasks, {goal, context}); } + async transcribeAsset(assetId: string, pageId: string): Promise { + return this.request('POST', API.aiTranscribe, {assetId, pageId}); + } + async aiDownloadModel(url?: string): Promise { return this.request('POST', API.aiModelDownload, {url}); } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6ca60969..53fe6687 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -690,6 +690,8 @@ export type { InterviewStep, AiProvider, AiConfig, + AiTranscriptionConfig, + AiTranscriptionResult, AiProviderSettings, AiEffort, AiSkill, diff --git a/packages/sdk/src/routes.ts b/packages/sdk/src/routes.ts index 6474c800..33df46d8 100644 --- a/packages/sdk/src/routes.ts +++ b/packages/sdk/src/routes.ts @@ -137,6 +137,7 @@ export const API = { aiTasks: '/api/ai/tasks', /** Continue/complete document text: `POST` `{text, instruction?}` → SSE. */ aiComplete: '/api/ai/complete', + aiTranscribe: '/api/ai/transcribe', /** Download a model file for the in-process engine: `POST` `{url?}`. */ aiModelDownload: '/api/ai/models/download', /** The agent harness: `POST` `{messages, effort?, thinking?, skills?}` → SSE tool/reasoning/proposal/final events. */ diff --git a/packages/server/src/ai/providers.ts b/packages/server/src/ai/providers.ts index 74727e3b..5155fa52 100644 --- a/packages/server/src/ai/providers.ts +++ b/packages/server/src/ai/providers.ts @@ -1,7 +1,7 @@ import {spawn, type ChildProcess} from 'node:child_process'; import {existsSync} from 'node:fs'; import path from 'node:path'; -import {providerSettings, type AiConfig, type AiProvider} from '@book.dev/sdk'; +import {providerSettings, type AiConfig, type AiProvider, type AiTranscriptionResult} from '@book.dev/sdk'; /** * Inference engines behind one interface. Generation streams tokens; @@ -84,7 +84,19 @@ export interface GenerateOptions { signal?: AbortSignal; } +export interface TranscribeOptions { + filename?: string; + mime?: string; + signal?: AbortSignal; +} + +/** MEET-3 implements this capability; no chat/embedding engine is required. */ +export interface TranscriptionEngine { + transcribe(bytes: Uint8Array, opts?: TranscribeOptions): Promise; +} + export interface AiEngine { + transcribe?: TranscriptionEngine['transcribe']; readonly kind: string; /** Throws (with a user-readable message) when the engine can't run. */ ensureReady(): Promise; @@ -108,6 +120,12 @@ export interface AiEngine { export class MockEngine implements AiEngine { readonly kind = 'mock'; + async transcribe(bytes: Uint8Array, opts: TranscribeOptions = {}): Promise { + opts.signal?.throwIfAborted(); + const text = `Mock transcription (${bytes.byteLength} bytes).`; + return {text, segments: [{start: 0, end: 1, text}], durationMs: 1000}; + } + async ensureReady(): Promise { // always ready } @@ -199,10 +217,40 @@ export class OpenAiCompatEngine implements AiEngine { private readonly baseUrl: string, private readonly model: string, kind = 'openai', + private readonly apiKey?: string | null, ) { this.kind = kind; } + async transcribe(bytes: Uint8Array, opts: TranscribeOptions = {}): Promise { + const form = new FormData(); + const extensions: Record = {'audio/webm': 'webm', 'video/webm': 'webm', 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a', 'audio/ogg': 'ogg', 'audio/flac': 'flac'}; + const filename = opts.filename || `audio.${extensions[opts.mime?.split(';')[0] ?? ''] ?? 'bin'}`; + form.append('file', new Blob([new Uint8Array(bytes)], {type: opts.mime || 'application/octet-stream'}), filename); + form.append('model', this.model || 'whisper-1'); + form.append('response_format', 'verbose_json'); + form.append('timestamp_granularities[]', 'segment'); + const base = this.baseUrl.replace(/\/+$/, '').replace(/\/v1$/, ''); + const res = await fetch(`${base}/v1/audio/transcriptions`, { + method: 'POST', + headers: this.apiKey ? {Authorization: `Bearer ${this.apiKey}`} : {}, + body: form, + signal: opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(300_000)]) : AbortSignal.timeout(300_000), + }); + // Do not echo upstream bodies: they can contain credentials or recording text. + if (!res.ok) throw new Error(`Transcription provider returned HTTP ${res.status}`); + const data = await res.json() as {text?: unknown; duration?: unknown; segments?: unknown}; + if (typeof data.text !== 'string') throw new Error('Invalid transcription provider response'); + const segments: AiTranscriptionResult['segments'] = Array.isArray(data.segments) + ? data.segments.filter((s): s is {start: number; end: number; text: string} => + s && Number.isFinite(s.start) && s.start >= 0 && Number.isFinite(s.end) && s.end >= s.start && typeof s.text === 'string') + .map(({start, end, text}) => ({start, end, text})) + : undefined; + const duration = typeof data.duration === 'number' && Number.isFinite(data.duration) && data.duration >= 0 + ? data.duration : segments?.reduce((end, s) => Math.max(end, s.end), 0) ?? 0; + return {text: data.text, ...(segments ? {segments} : {}), durationMs: Math.round(duration * 1000)}; + } + async ensureReady(): Promise { const res = await fetch(`${this.baseUrl}/v1/models`, {signal: AbortSignal.timeout(3000)}).catch(() => null); if (!res?.ok) { diff --git a/packages/server/src/ai/routes.ts b/packages/server/src/ai/routes.ts index 2b09d54a..3cb003ef 100644 --- a/packages/server/src/ai/routes.ts +++ b/packages/server/src/ai/routes.ts @@ -6,7 +6,7 @@ import type {PageStore} from '../store'; import type {AppEnv} from '../appEnv'; import {isLocalInstanceOwner, requireAuthenticatedRead, requireCreate, requireInstanceAdmin, requireInstanceOwner} from '../access'; import {AgentRunner, type AgentMessage} from './agent'; -import type {AiService} from './service'; +import {TranscriptionConfigError, type AiService} from './service'; import {McpConfigError, type ExternalAgentTool, type McpClientManager} from './mcpClients'; import type {TokenUsage} from './providers'; import type {AiUsageLog, UsageKind} from './usage'; @@ -61,6 +61,22 @@ export function mountAiRoutes(app: Hono, ai: AiService, store: PageStore if (!['off', 'mock', 'llama', 'mlx', 'openai', 'claude'].includes(body.provider)) { return c.json({error: `Unknown provider: ${String(body.provider)}`}, 400); } + if (body.transcription !== undefined) { + const audio = body.transcription; + if (!audio || !['off', 'local', 'openai-compat'].includes(audio.provider) + || [audio.baseUrl, audio.model].some((v) => v !== undefined && typeof v !== 'string') + || (audio.apiKey !== undefined && audio.apiKey !== null && typeof audio.apiKey !== 'string')) { + return c.json({error: 'Invalid transcription configuration'}, 400); + } + if (audio.baseUrl) { + try { + const url = new URL(audio.baseUrl); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error(); + } catch { + return c.json({error: 'Transcription baseUrl must be an HTTP(S) URL without embedded credentials'}, 400); + } + } + } // Redact the echoed config too: a blank-on-save PRESERVES the stored key // (see `AiService.setConfig`), so returning the saved config raw would hand a // previously-stored key back to the writer that just blanked the field. The key @@ -160,6 +176,36 @@ export function mountAiRoutes(app: Hono, ai: AiService, store: PageStore }); }); + app.post(API.aiTranscribe, async (c) => { + const body = await c.req.json().catch(() => null) as {assetId?: unknown; pageId?: unknown} | null; + if (typeof body?.assetId !== 'string' || typeof body.pageId !== 'string' || !body.assetId || !body.pageId) { + return c.json({error: 'assetId and pageId are required'}, 400); + } + const {assetId, pageId} = body; + if (!/^[0-9a-f]{64}$/.test(assetId) || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(pageId)) { + return c.json({error: 'asset not found'}, 404); + } + const principal = c.get('principal'); + if (!await store.getPageFor(principal, pageId) + || !(await store.pagesReferencingAsset(assetId)).includes(pageId)) { + return c.json({error: 'asset not found'}, 404); + } + const asset = await store.getAssetFor(principal, assetId); + if (!asset) return c.json({error: 'asset not found'}, 404); + try { + const {engine, provider, model} = await ai.transcriptionBackend(); + await requirePaidInferenceAccess(c, store, provider === 'openai-compat' ? 'openai' : 'off'); + const result = await engine.transcribe(asset.bytes, {mime: asset.mime, signal: c.req.raw.signal}); + // Audio backends do not report token counts. Keep unknown cloud cost null. + await aiUsage?.log({provider, model, kind: 'transcribe', principal, usage: {inputTokens: 0, outputTokens: 0}}); + return c.json(result); + } catch (err) { + if (err instanceof HTTPException) throw err; + if (err instanceof TranscriptionConfigError) return c.json({error: err.message}, 400); + return c.json({error: 'Transcription failed. Check the provider in Settings → AI and retry.'}, 502); + } + }); + app.post(API.aiModelDownload, async (c) => { // Fetches a caller-supplied URL onto the server's disk (SSRF + disk-fill // surface) — only the trusted instance owner may supply it. @@ -378,6 +424,12 @@ function redactAiConfig(config: AiConfig): AiConfig { delete redacted.apiKey; if (hasKey(config.apiKey)) redacted.apiKeySet = true; else delete redacted.apiKeySet; + if (config.transcription) { + redacted.transcription = {...config.transcription}; + delete redacted.transcription.apiKey; + if (hasKey(config.transcription.apiKey)) redacted.transcription.apiKeySet = true; + else delete redacted.transcription.apiKeySet; + } if (config.providers) { redacted.providers = Object.fromEntries( Object.entries(config.providers).map(([p, settings]) => { diff --git a/packages/server/src/ai/service.ts b/packages/server/src/ai/service.ts index 73351533..f03187b1 100644 --- a/packages/server/src/ai/service.ts +++ b/packages/server/src/ai/service.ts @@ -3,7 +3,7 @@ import {rename, unlink} from 'node:fs/promises'; import path from 'node:path'; import {providerSettings, type AiConfig, type AiProvider, type AiProviderSettings, type AiSearchResponse, type AiStatus, type AiTasksResponse} from '@book.dev/sdk'; import type {Db} from '../db'; -import {createEngine, type AiEngine, type GenerateOptions} from './providers'; +import {createEngine, MockEngine, OpenAiCompatEngine, type TranscriptionEngine, type AiEngine, type GenerateOptions} from './providers'; import {assembleSearchResults, bm25Scores, buildIndex, cosine, pageRowsToDocs, parseTaskList, type Bm25Index} from './search'; import {SkillStore} from './skills'; @@ -73,6 +73,13 @@ function mergeStoredKeys(prev: AiConfig, next: AiConfig): AiConfig { } merged.providers = out as AiConfig['providers']; } + if (next.transcription || prev.transcription) { + merged.transcription = {...(next.transcription ?? prev.transcription!)}; + delete merged.transcription.apiKeySet; + const key = resolveKey(prev.transcription?.apiKey, next.transcription?.apiKey); + if (key === undefined) delete merged.transcription.apiKey; + else merged.transcription.apiKey = key; + } return merged; } @@ -88,6 +95,8 @@ interface DownloadState { error?: string; } +export class TranscriptionConfigError extends Error {} + export class AiService { private config: AiConfig = DEFAULT_CONFIG; private engine: AiEngine | null = null; @@ -103,6 +112,8 @@ export class AiService { constructor( private readonly db: Db, private readonly modelsDir: string, + /** MEET-3: lazily resolve the managed local audio backend. */ + private readonly localTranscription?: () => Promise, ) { this.skills = new SkillStore(db); } @@ -140,6 +151,22 @@ export class AiService { return this.config; } + /** Resolve once per request so config changes cannot bypass the paid gate. */ + async transcriptionBackend(): Promise<{engine: TranscriptionEngine; provider: 'local' | 'mock' | 'openai-compat'; model: string}> { + const config = await this.getConfig(); + const audio = config.transcription; + if (audio?.provider === 'off') { + throw new TranscriptionConfigError('Transcription is off. Enable it in Settings → AI.'); + } + if (audio?.provider === 'openai-compat') { + return {engine: new OpenAiCompatEngine(audio.baseUrl?.trim() || 'https://api.openai.com', audio.model?.trim() || 'whisper-1', 'openai', audio.apiKey), provider: 'openai-compat', model: audio.model?.trim() || 'whisper-1'}; + } + const local = await this.localTranscription?.(); + if (local) return {engine: local, provider: 'local', model: audio?.model ?? 'local'}; + if (config.provider === 'mock') return {engine: new MockEngine(), provider: 'mock', model: 'mock'}; + throw new TranscriptionConfigError('Local transcription is unavailable. Configure transcription in Settings → AI.'); + } + async status(): Promise { await this.loadConfig(); let ready = false; diff --git a/packages/server/src/ai/usage.ts b/packages/server/src/ai/usage.ts index 80c5f1a4..289dbb35 100644 --- a/packages/server/src/ai/usage.ts +++ b/packages/server/src/ai/usage.ts @@ -68,11 +68,11 @@ const PROP = { } as const; /** The kinds of model call we attribute. */ -export type UsageKind = 'agent' | 'complete' | 'generate'; +export type UsageKind = 'agent' | 'complete' | 'generate' | 'transcribe'; /** One model call to attribute: what ran, how many tokens, and for whom. */ export interface UsageEvent { - provider: AiProvider; + provider: AiProvider | 'local' | 'openai-compat'; model: string; kind: UsageKind; usage: TokenUsage; @@ -364,7 +364,7 @@ export class AiUsageLog { // A claude call with no configured model runs on the engine's default — price // (and log) against that so cost isn't spuriously null. const model = event.model || (event.provider === 'claude' ? AnthropicEngine.DEFAULT_MODEL : ''); - const cost = this.computeCost(event.provider, model, event.usage, effective); + const cost = event.provider === 'openai-compat' ? null : event.provider === 'local' ? 0 : this.computeCost(event.provider, model, event.usage, effective); const properties: Record = { [PROP.time]: new Date().toISOString(), [PROP.user]: formatUser(event.principal), diff --git a/packages/server/src/localClient.ts b/packages/server/src/localClient.ts index d8ca4560..69001f19 100644 --- a/packages/server/src/localClient.ts +++ b/packages/server/src/localClient.ts @@ -5,6 +5,7 @@ import type { AgentTokenList, CreatedAgentToken, AiConfig, + AiTranscriptionResult, AiPricingResponse, AiPricingTable, AiUsageResponse, @@ -920,6 +921,10 @@ export class LocalDataClient implements DataClient { return Promise.reject(this.aiUnavailable()); } + transcribeAsset(): Promise { + return Promise.reject(this.aiUnavailable()); + } + aiComplete(): Promise { return Promise.reject(this.aiUnavailable()); } diff --git a/packages/server/src/transcription.test.ts b/packages/server/src/transcription.test.ts new file mode 100644 index 00000000..6d6fce60 --- /dev/null +++ b/packages/server/src/transcription.test.ts @@ -0,0 +1,187 @@ +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {API, FORWARDED_HEADER, LOCAL_OWNER_HEADER, HttpDataClient} from '@book.dev/sdk'; +import {PgliteDb} from './db'; +import {PageStore} from './store'; +import {PageHub} from './hub'; +import {createApp} from './app'; +import {AiService} from './ai/service'; +import {MockEngine, OpenAiCompatEngine} from './ai/providers'; +import {AiUsageLog} from './ai/usage'; +import {LocalDataClient} from './localClient'; + +let db: PgliteDb; +let store: PageStore; +let ai: AiService; +let dir: string; +let pageId: string; +let assetId: string; +const secret = 'transcription-owner'; +const headers = {'content-type': 'application/json', 'X-OpenBook-Client': '1'}; +const snapshot = () => ({editorjs: {blocks: []}, values: [], names: []}); + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'meet2-')); + db = await PgliteDb.create(dir); + store = new PageStore(db); + await store.migrate(); + ai = new AiService(db, join(dir, 'models')); + pageId = (await store.upsertPage({name: 'Recording', data: snapshot()})).id; + assetId = (await store.putAsset(new Uint8Array([1, 2, 3]), 'application/octet-stream')).id; + await store.refAsset(assetId, pageId); +}); +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + await store.close(); + rmSync(dir, {recursive: true, force: true}); +}); +const appWith = (service = ai, usage?: AiUsageLog) => createApp(store, service, new PageHub(), {localOwnerSecret: secret, aiUsage: usage}); +const post = (app = appWith(), body: unknown = {assetId, pageId}, guest = false) => app.request(API.aiTranscribe, { + method: 'POST', headers: {...headers, ...(guest ? {[FORWARDED_HEADER]: '1'} : {[LOCAL_OWNER_HEADER]: secret})}, body: JSON.stringify(body), +}); + +describe('transcription contract', () => { + it('round-trips and redacts keys, preserves blank/omitted keys, replaces and clears explicitly', async () => { + const app = appWith(); + const save = async (transcription?: unknown) => { + const res = await app.request(API.aiConfig, {method: 'PUT', headers: {...headers, [LOCAL_OWNER_HEADER]: secret}, body: JSON.stringify({provider: 'off', transcription})}); + expect(res.status).toBe(200); + const value = await res.json(); + expect(JSON.stringify(value)).not.toContain('secret-key'); + expect(value.transcription.apiKey).toBeUndefined(); + return value; + }; + expect((await save({provider: 'openai-compat', baseUrl: 'https://example.test/v1', model: 'whisper', apiKey: ' secret-key ', apiKeySet: true})).transcription.apiKeySet).toBe(true); + expect((await new AiService(db, dir).getConfig()).transcription).toEqual({provider: 'openai-compat', baseUrl: 'https://example.test/v1', model: 'whisper', apiKey: 'secret-key'}); + for (const apiKey of ['', ' ', undefined]) { + await save({provider: 'openai-compat', apiKey}); + expect((await ai.getConfig()).transcription?.apiKey).toBe('secret-key'); + } + await save(); + expect((await ai.getConfig()).transcription?.apiKey).toBe('secret-key'); + await save({provider: 'local', apiKey: 'replacement-secret-key'}); + expect((await ai.getConfig()).transcription?.apiKey).toBe('replacement-secret-key'); + const status = await app.request(API.aiStatus, {headers: {...headers, [LOCAL_OWNER_HEADER]: secret}}); + expect((await status.json()).config.transcription).toEqual({provider: 'local', apiKeySet: true}); + expect((await save({provider: 'local', apiKey: null, apiKeySet: true})).transcription.apiKeySet).toBeUndefined(); + expect((await new AiService(db, dir).getConfig()).transcription?.apiKey).toBeUndefined(); + }); + + it('returns the deterministic mock result and logs one attributed usage row', async () => { + await ai.setConfig({provider: 'mock'}); + const usage = new AiUsageLog(store); + const res = await post(appWith(ai, usage)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(await new MockEngine().transcribe(new Uint8Array([1, 2, 3]))); + const report = await usage.report(); + expect(report.rows).toHaveLength(1); + expect(report.rows?.[0]).toMatchObject({provider: 'mock', kind: 'transcribe', inputTokens: 0, outputTokens: 0, cost: 0}); + }); + + it('denies cloud to claimed-instance guests before sending bytes, even when chat is mock', async () => { + await store.updateInstanceConfig({ownerSubject: 'test#owner', guestAccess: 'write'}); + await store.setPageVisibility(pageId, 'public'); + await ai.setConfig({provider: 'mock', transcription: {provider: 'openai-compat'}}); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + expect((await post(appWith(), undefined, true)).status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('allows authenticated cloud transcription, attributes unknown cost, and sanitizes failures', async () => { + await ai.setConfig({provider: 'off', transcription: {provider: 'openai-compat', model: 'whisper-1', apiKey: 'secret'}}); + const usage = new AiUsageLog(store); + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({text: 'Cloud', duration: 2}))) + .mockRejectedValueOnce(new Error('secret')); + vi.stubGlobal('fetch', fetchMock); + const app = appWith(ai, usage); + const res = await post(app); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({text: 'Cloud', durationMs: 2000}); + expect((await usage.report()).rows?.[0]).toMatchObject({provider: 'openai-compat', model: 'whisper-1', kind: 'transcribe', cost: null}); + const failed = await post(app); + expect(failed.status).toBe(502); + expect(await failed.text()).not.toContain('secret'); + expect((await usage.report()).rows).toHaveLength(1); + }); + + it('defaults to local, leaves it ungated, and gives explicit cloud configuration precedence', async () => { + const local = vi.fn(async () => new MockEngine()); + const service = new AiService(db, dir, local); + await store.updateInstanceConfig({ownerSubject: 'test#owner', guestAccess: 'write'}); + await store.setPageVisibility(pageId, 'public'); + expect((await post(appWith(service), undefined, true)).status).toBe(200); + expect(local).toHaveBeenCalledOnce(); + await service.setConfig({provider: 'off', transcription: {provider: 'openai-compat'}}); + expect((await service.transcriptionBackend()).provider).toBe('openai-compat'); + expect(local).toHaveBeenCalledOnce(); + }); + + it('returns identical 404s for missing, unreadable, unreferenced, and unrelated assets/pages', async () => { + await ai.setConfig({provider: 'mock'}); + await store.updateInstanceConfig({ownerSubject: 'test#owner', guestAccess: 'read'}); + await store.setPageVisibility(pageId, 'restricted'); + const app = appWith(); + const unreadable = await post(app, undefined, true); + expect(unreadable.status).toBe(404); + expect(await unreadable.json()).toEqual({error: 'asset not found'}); + const other = (await store.upsertPage({name: 'Other', data: snapshot()})).id; + for (const body of [{assetId: 'missing', pageId}, {assetId: '0'.repeat(64), pageId}, {assetId, pageId: 'missing'}, {assetId, pageId: '00000000-0000-0000-0000-000000000000'}, {assetId, pageId: other}]) { + expect((await post(app, body)).status).toBe(404); + } + await store.unrefAsset(assetId, pageId); + expect((await post(app)).status).toBe(404); + }); + + it('gives actionable 400s for off/unconfigured/local unavailable and rejects malformed bodies', async () => { + for (const provider of [undefined, 'off', 'local'] as const) { + await ai.setConfig({provider: 'off', ...(provider ? {transcription: {provider}} : {})}); + const res = await post(); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('Settings → AI'); + } + for (const body of [null, {}, {assetId: 42, pageId}, {assetId, pageId: ''}]) expect((await post(appWith(), body)).status).toBe(400); + }); + + it('uses multipart verbose JSON, auth, normalized URLs, exact bytes, and audio timing', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({text: 'Hello', duration: 1.25, segments: [{start: 0, end: 1.25, text: 'Hello', id: 0}]}))); + vi.stubGlobal('fetch', fetchMock); + const engine = new OpenAiCompatEngine('https://audio.test/v1/', 'whisper', 'openai', 'private-key'); + const result = await engine.transcribe(new Uint8Array([0, 255, 3]), {mime: 'audio/webm'}); + expect(result).toEqual({text: 'Hello', durationMs: 1250, segments: [{start: 0, end: 1.25, text: 'Hello'}]}); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://audio.test/v1/audio/transcriptions'); + expect(init.headers).toEqual({Authorization: 'Bearer private-key'}); + const form = init.body as FormData; + expect(form.get('model')).toBe('whisper'); + expect(form.get('response_format')).toBe('verbose_json'); + expect(form.get('timestamp_granularities[]')).toBe('segment'); + expect(new Uint8Array(await (form.get('file') as Blob).arrayBuffer())).toEqual(new Uint8Array([0, 255, 3])); + }); + + it('accepts text-only JSON and derives duration from valid segments; upstream failures are safe', async () => { + const engine = new OpenAiCompatEngine('https://audio.test', 'whisper'); + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({text: 'Hi'}))) + .mockResolvedValueOnce(new Response(JSON.stringify({text: 'Hi', segments: [{start: 0, end: 2, text: 'Hi'}, {start: -1, end: 3, text: 'Bad'}]}))) + .mockResolvedValueOnce(new Response('secret', {status: 401})) + .mockResolvedValueOnce(new Response(JSON.stringify({text: 42}))); + vi.stubGlobal('fetch', fetchMock); + expect(await engine.transcribe(new Uint8Array())).toEqual({text: 'Hi', durationMs: 0}); + expect((await engine.transcribe(new Uint8Array())).durationMs).toBe(2000); + await expect(engine.transcribe(new Uint8Array())).rejects.toThrow('HTTP 401'); + await expect(engine.transcribe(new Uint8Array())).rejects.toThrow('Invalid transcription'); + }); + + it('HTTP client posts the contract and LocalDataClient rejects transcription', async () => { + const result = {text: 'Hello', durationMs: 1000}; + const fetchMock = vi.fn(async () => new Response(JSON.stringify(result), {headers: {'content-type': 'application/json'}})); + vi.stubGlobal('fetch', fetchMock); + expect(await new HttpDataClient('http://test').transcribeAsset(assetId, pageId)).toEqual(result); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`http://test${API.aiTranscribe}`); + expect(JSON.parse(init.body as string)).toEqual({assetId, pageId}); + await expect(LocalDataClient.prototype.transcribeAsset.call({aiUnavailable: () => new Error('AI unavailable')} as never)).rejects.toThrow('AI unavailable'); + }); +}); From a535c2f3d6f2daf422d8575e5756a23af30869a8 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sat, 3 Oct 2026 22:15:42 +0800 Subject: [PATCH 04/32] test(server): keep OIDC gate fixture PAT unexpired (MEET-1) --- packages/server/src/ableOidc.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/src/ableOidc.test.ts b/packages/server/src/ableOidc.test.ts index 13ee3512..8025b33f 100644 --- a/packages/server/src/ableOidc.test.ts +++ b/packages/server/src/ableOidc.test.ts @@ -568,7 +568,8 @@ describe('able OIDC relying party', () => { issuer: 'local', scope: 'read', createdBy: 'test', - expiresAt: new Date(NOW + 60_000), + // PAT authentication uses wall-clock time, independently of the OIDC clock. + expiresAt: new Date(Date.now() + 60_000), }); const app = createApp(store, undefined, new PageHub(), { ableOidc: {...idp.options, discoveryTtlMs: 0}, From 5c0497889afe14d6e713ad12e02d6a8c47448546 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sat, 3 Oct 2026 22:21:41 +0800 Subject: [PATCH 05/32] test(server): keep OIDC access-gate PAT fixture valid (MEET-2) --- packages/server/src/ableOidc.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/src/ableOidc.test.ts b/packages/server/src/ableOidc.test.ts index 13ee3512..a5328dc8 100644 --- a/packages/server/src/ableOidc.test.ts +++ b/packages/server/src/ableOidc.test.ts @@ -568,7 +568,8 @@ describe('able OIDC relying party', () => { issuer: 'local', scope: 'read', createdBy: 'test', - expiresAt: new Date(NOW + 60_000), + // PAT validation uses the database clock, not the injected OIDC clock. + expiresAt: new Date(Date.now() + 60_000), }); const app = createApp(store, undefined, new PageHub(), { ableOidc: {...idp.options, discoveryTtlMs: 0}, From fb162867fd1d4f9319da4de74984153531a523a8 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sat, 3 Oct 2026 23:16:10 +0800 Subject: [PATCH 06/32] docs: report MEET-2 implementation and verification blocker --- _report.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 _report.md diff --git a/_report.md b/_report.md new file mode 100644 index 00000000..8f38dca2 --- /dev/null +++ b/_report.md @@ -0,0 +1,69 @@ +MEET-2 implemented and committed. Full verification is BLOCKED by host disk exhaustion (`No space left on device`); it is NOT green. No push. The implementation provides request-scoped asset transcription, independent audio configuration, OpenAI-compatible multipart backend, SDK client, access/paid gates, write-only credentials, and usage attribution. + +Implementation commit: `69f01e0074451e11e58c370184649c24c1a6dbd2` (`feat(server,sdk): add asset transcription service and backend (MEET-2)`). Verification head: `5c0497889afe14d6e713ad12e02d6a8c47448546` (includes the OIDC fixture correction). The report is committed separately; final branch HEAD is reported in the handoff. + +Verification (all commands foreground): + +- MEET-2 focused suite: **10/10 passed**; server typecheck and changed-file lint passed. +- First `pnpm verify`: builds, generated-file check, all typechecks/lint, SDK **546**, UI **2319**, desktop **7**, and MCP contract tests passed. Server suite reported the expired OIDC fixture and mirror timeout; stopped that already-failed run after diagnosis. Server suite did not finish; end-to-end tests were not reached. +- Corrected OIDC fixture: **22/22 passed**. Mirror tests unchanged outside sandbox: **3/3 passed**. +- Second `pnpm verify` (outside sandbox): builds, generated-file check, all typechecks/lint passed; SDK money property test hit its five-second timeout under load (**545 passed, 1 timed out**). Unchanged SDK with `VITEST_MAX_WORKERS=1`: **546/546 passed**. +- Third `VITEST_MAX_WORKERS=1 pnpm verify` (outside sandbox): blocked rebuilding viewer by **ENOSPC**. `df -h .` reported **100% capacity, 133 MiB available** immediately afterward. No tests/assertions disabled, no timeouts increased. + +Relevant foreground output: + +```text +transcription.test.ts: Test Files 1 passed (1); Tests 10 passed (10) +ableOidc.test.ts: Test Files 1 passed (1); Tests 22 passed (22) +mirror.integration.test.ts (outside sandbox): Tests 3 passed (3) +SDK (VITEST_MAX_WORKERS=1): Test Files 32 passed (32); Tests 546 passed (546) + +Second full attempt: +FAIL src/money.test.ts > round-trips 1e6 seeded random amounts with zero drift +Error: Test timed out in 5000ms. + +Third full attempt: +Failed to write file in packages/ui/src/export/vendor/openbook-viewer.js +Caused by: No space left on device (os error 28) +ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @book.dev/ui build +Exit status 1 +``` + +Resume after freeing host disk capacity: rebuild viewer/server/MCP via the normal `build:libs` stage and rerun foreground `VITEST_MAX_WORKERS=1 pnpm verify` outside the sandbox. The last failed build may have left an incomplete ignored viewer artifact. Do not treat partial checks as full verification. Logs remain at `/tmp/meet2-verify.log`, `/tmp/meet2-verify-final.log`, and `/tmp/meet2-verify-serial.log`. + +Criterion → test map (`packages/server/src/transcription.test.ts`): + +| Criterion | Test | +| --- | --- | +| Config persistence, write-only keys, blank/omitted preserve, replacement, null clear | round-trips and redacts keys… | +| Deterministic mock; route success; usage row | returns the deterministic mock result… | +| Cloud paid gate independent of chat provider | denies cloud to claimed-instance guests… | +| Authenticated cloud, unknown cost, sanitized errors | allows authenticated cloud transcription… | +| Local default, ungated local, cloud precedence | defaults to local… | +| Missing/unreadable/unreferenced/unrelated assets, invalid/missing pages → 404 | returns identical 404s… | +| Off/unconfigured/local unavailable → actionable 400; malformed body | gives actionable 400s… | +| Multipart/auth/URL normalization/exact bytes/timing | uses multipart verbose JSON… | +| Text-only JSON, validated segments, duration fallback, provider failure | accepts text-only JSON… | +| HTTP SDK and LocalDataClient rejection | HTTP client posts the contract… | + +MEET-5 contract: + +- `DataClient.transcribeAsset(assetId: string, pageId: string): Promise`; HTTP `POST /api/ai/transcribe` with JSON `{assetId, pageId}`. Assets must already exist and reference that page; the caller must read both. Stored MIME is passed through, with no MIME allowlist dependency. +- Result `{text: string, segments?: Array<{start: number, end: number, text: string}>, durationMs: number}`. Segment offsets use **seconds**; duration uses **milliseconds**. Duration comes from the backend, otherwise maximum segment end, otherwise `0` (unknown). No queue, streaming, persistence, or page mutation. +- Errors: `400` missing arguments or disabled/unavailable local configuration (points to Settings → AI); `404` missing/unreadable/unrelated asset/page; `403` claimed-instance guest using cloud; `502` upstream failure with sanitized message. Existing application authentication/request gates still apply. +- `AiConfig.transcription?: {provider: 'off' | 'local' | 'openai-compat', baseUrl?: string, model?: string, apiKey?: string | null, apiKeySet?: boolean}`. Save through existing `aiSetConfig`, read through `aiStatus`. Missing section means local; `off` explicitly disables. Omitted section on save preserves prior audio settings. Present section replaces settings while preserving an omitted/blank key. Nonempty key replaces (trimmed); `null` clears. Responses remove keys and expose only `apiKeySet`; that flag is never persisted. +- Explicit `openai-compat` opts into the paid gate, even for a user-managed local URL. Defaults: `https://api.openai.com`, `whisper-1`. No chat key/config inheritance. Optional bearer key; base URL can include `/v1` and/or trailing slash. One multipart path requests `verbose_json` plus segment timestamps. JSON responses without segments are accepted. No automatic retry/downgrade on a provider rejecting verbose JSON. + +MEET-3 contract: + +- Implement `TranscriptionEngine` from `packages/server/src/ai/providers.ts`: `transcribe(bytes: Uint8Array, opts?: TranscribeOptions): Promise`. Options: `{filename?: string, mime?: string, signal?: AbortSignal}`. Respect cancellation; return the units above. +- Inject the optional third `AiService` constructor argument: `() => Promise`. Resolve/start the managed local backend lazily. Return `null` when unavailable. MEET-3 owns backend process/model lifecycle; MEET-2 does not dispose the injected engine per request. +- Reuse `new OpenAiCompatEngine(baseUrl, model)` for a local OpenAI-compatible server. The local resolver result is classified `local` and is ungated; omitted transcription config selects this resolver. `transcriptionBackend()` captures one backend/provider/model per request before the paid gate. Explicit `off` disables; explicit cloud wins; local resolver comes next; chat `mock` supplies deterministic fallback for tests/demos; otherwise actionable `400`. +- `AiEngine.transcribe` is optional, so chat-only engines need no changes. The endpoint never calls chat readiness/model probes. +- Successful requests log one `kind: 'transcribe'` row under `openai-compat`, `local`, or `mock` with server-resolved principal/model. Audio token counts are `0` (unreported); cloud cost is `null`, local/mock cost `0`. No audio-minute pricing/schema migration. The local model label is configured `transcription.model` or `local`. + +Verification follow-up: the first sandboxed run found an existing OIDC PAT fixture expired on September 8, 2026. Commit `5c049788` makes its expiry relative to the real database clock, preserving every assertion; all 22 OIDC tests passed afterward. Existing mirror integration tests also hit filesystem-watcher `EMFILE` errors and a timeout in the sandbox. All 3 passed unchanged outside the sandbox. Stopped the already-failed sandboxed run and restarted full foreground verification outside the sandbox. No tests or assertions disabled, no timeout increased. + +Deviations/limitations: Local implementation remains the planned MEET-3 hook; no MEET-1 MIME changes. Cloud errors do not trigger a silent local fallback. Tests use mocked upstream fetch, not a live paid service. Backend timeout is five minutes. No existing tests deleted or weakened. + +Open blocker: host disk capacity; full verification and end-to-end checks remain outstanding. No interface questions. MEET-3 should wire its resolver at AiService construction; MEET-5 should render `apiKeySet` and surface the actionable errors. From f089e02937e928cabf1c90bb8ad1c62c53ee66aa Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 00:39:01 +0800 Subject: [PATCH 07/32] docs: report audio asset verification (MEET-1) --- _report.md | 2211 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2211 insertions(+) create mode 100644 _report.md diff --git a/_report.md b/_report.md new file mode 100644 index 00000000..89187b72 --- /dev/null +++ b/_report.md @@ -0,0 +1,2211 @@ +PASS — MEET-1 audio asset support implemented. `VITEST_MAX_WORKERS=1 pnpm verify` passed in the foreground outside the sandbox (exit 0); full output below. No push. + +Verified implementation HEAD: `a535c2f3d6f2daf422d8575e5756a23af30869a8`. Report commit is the final branch HEAD (reported in the handoff). + +| Criterion | Tests | Commit | +| --- | --- | --- | +| 1. Five audio MIME types stored/served | `assets.test.ts`: “round-trips %s through binary and base64-JSON uploads…” (all five); `backups.test.ts`: “preserves audio MIME types and bytes through backup restore” | `451ea1d5` | +| 2. Binary/base64 uploads, routes, ETag/304 and private caching | `assets.test.ts`: five audio round-trip cases, both upload and GET formats | `451ea1d5` | +| 3. 10 MiB cap and chunk-size comment | `assets.test.ts`: “rejects over-cap audio with 413…” (binary + JSON); comment beside `ASSET_MAX_BYTES` | `451ea1d5` | +| 4. GC retains audio ID in block props | `assetGc.test.ts`: “keeps audio referenced only by block props until the reference is removed” (zero ref-table edges; removal permits GC) | `451ea1d5` | +| 5. Attachment/nosniff and unchanged access gates | Audio round-trip header assertions; “applies the same read and write gates to audio…”; existing image/HTML security suites | `451ea1d5` | + +Deltas/deviations: +- Added shared `ASSET_MIMES` union alongside the audio allowlist; both restore paths use it to preserve audio MIME types. +- Corrected an initial new restore assertion to match `getAsset`’s actual return shape (MIME/bytes, no ID). +- First full verify exposed an existing OIDC fixture PAT expired since September 8, 2026 (401 vs expected 302). `a535c2f3` bases its expiry on the PAT wall clock; all assertions retained. Isolated regression passed after correction. +- Sandboxed mirror integration failed with filesystem-watcher `EMFILE`; all three tests passed unchanged outside the sandbox. Stopped the known-failing initial verify (exit 130) and ran the full foreground command outside the sandbox. +- A full unsandboxed attempt hit the unchanged SDK million-case money test’s 5s timeout under host load; all 546 SDK tests passed with `VITEST_MAX_WORKERS=1`. Final verification uses that setting, preserving all assertions and timeouts. +- Another attempt exited during UI stylelint checks while host storage fell to 116 MiB free. Stylelint passed unchanged in isolation; free space recovered before the final run. +- Git emitted a sandbox warning about `packed-refs.lock`; commits succeeded and were verified. + +Validation: SDK 546 passed; UI 2,319 passed; desktop 7 passed; server 1,336 passed, 6 existing skips across 97 files; MCP checks and server/MCP end-to-end suites passed. + +Open questions: none. + +
+Full foreground pnpm verify output (exit 0) + +```text + +> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e + + +> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> node --test eslint-rules/*.test.mjs + +✔ allows spacing-scale utilities and non-spacing arbitrary values (56.527041ms) +✔ flags arbitrary padding, margin, and gap values in className (29.122834ms) +✔ checks nested, template, and conventionally named hoisted class strings (10.808791ms) +✔ allows paint-only hover changes and non-hover geometry (49.224459ms) +✔ flags every guarded hover-geometry utility family (30.007542ms) +✔ flags display swaps in either class ordering and nested class strings (6.503291ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 6 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 678.04025 + +> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build + + +> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk +> tsc -p tsconfig.build.json + + +> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> vite build --config vite.viewer.config.js + +vite v8.0.14 building client environment for production... + +transforming...✓ 2076 modules transformed. +rendering chunks... +computing gzip size... +src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB + +✓ built in 2.15s +vite v8.0.14 building client environment for production... + +transforming...✓ 356 modules transformed. +rendering chunks... +computing gzip size... +dist/style.css 190.24 kB │ gzip: 31.11 kB +dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB +dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB +dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB +dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB +dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB +dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB +dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB +dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB +dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB +dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB +dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB +dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB +dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB +dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB +dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB +dist/index.js 2,962.77 kB │ gzip: 813.80 kB + +[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. + +[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. + +[PLUGIN_TIMINGS] Your build spent significant time in plugins. Here is a breakdown: + - vite:asset (45%) + - vite:worker (19%) + - vite:css (13%) + - vite:css-post (7%) +See https://rolldown.rs/options/checks#plugintimings for more details. + +✓ built in 3.87s + +> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/bin.js 2.40 KB +ESM dist/index.js 136.00 B +ESM dist/chunk-N6662UMY.js 98.60 KB +ESM dist/bin.js.map 6.65 KB +ESM dist/index.js.map 71.00 B +ESM dist/chunk-N6662UMY.js.map 181.29 KB +ESM ⚡️ Build success in 90ms +DTS Build start +DTS ⚡️ Build success in 5150ms +DTS dist/index.d.ts 1.84 KB + +> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/bin.js 215.00 B +ESM dist/browser.js 30.24 KB +ESM dist/index.js 552.00 B +ESM dist/chunk-Z5QDSVHR.js 415.58 KB +ESM dist/chunk-V6MBCKVR.js 422.16 KB +ESM dist/bin.js.map 556.00 B +ESM dist/browser.js.map 61.42 KB +ESM dist/index.js.map 71.00 B +ESM dist/chunk-V6MBCKVR.js.map 787.04 KB +ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB +ESM ⚡️ Build success in 236ms +DTS Build start +DTS ⚡️ Build success in 12816ms +DTS dist/index.d.ts 68.49 KB +DTS dist/browser.d.ts 13.43 KB +DTS dist/hub-CUJHMMTq.d.ts 136.66 KB + +> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/ui run check:gen + + +> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r --if-present run typecheck + +Scope: 6 of 7 workspace projects +packages/sdk typecheck$ tsc -p tsconfig.json --noEmit +packages/sdk typecheck: Done +packages/ui typecheck$ tsc --noEmit +packages/ui typecheck: Done +packages/app typecheck$ tsc --noEmit +packages/app typecheck: Done +packages/mcp typecheck$ tsc --noEmit +packages/server typecheck$ tsc --noEmit +packages/mcp typecheck: Done +packages/server typecheck: Done +packages/web typecheck$ tsc --noEmit +packages/web typecheck: Done + +> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r run lint + +Scope: 6 of 7 workspace projects +packages/sdk lint$ eslint . +packages/sdk lint: Done +packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint +packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > stylelint src/index.css +packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > node scripts/assert-spacing-stylelint.mjs +packages/ui lint: SPC-2 stylelint controls passed (2 positive, 4 negative) +packages/ui lint: Done +packages/app lint$ eslint . +packages/app lint: Done +packages/mcp lint$ eslint . +packages/server lint$ eslint . +packages/mcp lint: Done +packages/server lint: Done +packages/web lint$ eslint . +packages/web lint: Done + +> open-book@0.0.0-workspace test /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r --if-present run test + +Scope: 6 of 7 workspace projects +packages/sdk test$ vitest run +packages/sdk test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk +packages/sdk test: Test Files 32 passed (32) +packages/sdk test: Tests 546 passed (546) +packages/sdk test: Start at 23:21:30 +packages/sdk test: Duration 22.72s (transform 2.10s, setup 0ms, import 4.26s, tests 6.54s, environment 8ms) +packages/sdk test: Done +packages/ui test$ vitest run +packages/ui test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui test: Test Files 244 passed (244) +packages/ui test: Tests 2319 passed (2319) +packages/ui test: Start at 23:21:54 +packages/ui test: Duration 719.48s (transform 20.81s, setup 0ms, import 251.50s, tests 212.69s, environment 158.24s) +packages/ui test: Done +packages/app test$ vitest run +packages/app test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/app +packages/app test: Test Files 2 passed (2) +packages/app test: Tests 7 passed (7) +packages/app test: Start at 23:33:54 +packages/app test: Duration 10.63s (transform 6.69s, setup 0ms, import 8.76s, tests 153ms, environment 1.04s) +packages/app test: Done +packages/server test$ vitest run +packages/mcp test$ node --import tsx scripts/listed.test.mts && tsx scripts/pages.test.mts && tsx scripts/suggestions.test.mts && tsx scripts/databases.test.mts && tsx scripts/assets.test.mts && tsx scripts/blocks.test.mts && tsx scripts/tables.test.mts && tsx scripts/forms.test.mts && tsx scripts/blockTypes.test.mts && tsx scripts/readme.test.mts && tsx scripts/endpoint.test.mts && tsx scripts/coverage.test.mts +packages/server test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server +packages/mcp test: ✅ ALL 3 MCP LISTED CONTRACT CHECKS PASSED +packages/mcp test: ✓ set_page_appearance applies in direct mode +packages/mcp test: ✓ valid gradient id resolves to curated css +packages/mcp test: ✓ read_page reflects persisted appearance +packages/mcp test: ✓ move_page reparents a page +packages/mcp test: ✓ list_pages exposes the new parent and order +packages/mcp test: ✓ get/set page properties round-trip +packages/mcp test: ✓ unknown page returns a typed safe error +packages/mcp test: ✓ moving into an own subtree is refused +packages/mcp test: ✓ invalid appearance enum is refused without path leakage +packages/mcp test: ✓ arbitrary gradient css is refused +packages/mcp test: ✓ http image cover is refused +packages/mcp test: ✓ javascript image cover is refused +packages/mcp test: ✓ https image cover is accepted +packages/mcp test: ✓ OpenBook asset image cover is accepted +packages/mcp test: ✓ computed/unknown property writes are typed refusals +packages/mcp test: ✓ read-only instance refuses writes with a typed error +packages/mcp test: ✓ read-only refusal leaves properties unchanged +packages/mcp test: ✅ ALL 17 API-10 PAGE TOOL CHECKS PASSED +packages/mcp test: OpenBook server up at http://127.0.0.1:4406 +packages/mcp test: Resolved suggest (instance suggest, page inherit) — writes create suggestions +packages/mcp test: ✓ upload_asset refuses suggest/read-only policy (bytes never become a suggestion) +packages/mcp test: ✓ update_block returns a "Suggested for review" result +packages/mcp test: ✓ update_block recorded exactly one suggestion +packages/mcp test: ✓ suggestion kind maps update_block → replace-text +packages/mcp test: ✓ suggestion is authored by the MCP client (authorKind ai) +packages/mcp test: ✓ suggestion targets the block +packages/mcp test: ✓ suggestion payload mirrors the agent (applyKind + before merge base) +packages/mcp test: ✓ the page text was NOT mutated (still original) +packages/mcp test: ✓ set_db_cell returns a "Suggested for review" result +packages/mcp test: ✓ set_db_cell recorded a set-cell suggestion on the host page +packages/mcp test: ✓ set-cell suggestion targets the db/row/property +packages/mcp test: ✓ the database cell was NOT mutated +packages/mcp test: ✓ all six API-9 database writes suggest under suggest policy +packages/mcp test: ✓ create_database creates its host page immediately under suggest policy +packages/mcp test: ✓ whitespace property name is refused under suggest policy +packages/mcp test: ✓ describe_database remains a read under suggest policy +packages/mcp test: ✓ suggested update/delete row did not mutate +packages/mcp test: ✓ API-10 page writes suggest and get_page_properties remains a read +packages/mcp test: ✓ suggested API-10 writes do not mutate +packages/mcp test: ✓ create_page applies immediately (page exists) +packages/mcp test: ✓ create_page recorded no suggestion +packages/mcp test: Resolved direct (instance policy = direct) — writes mutate directly +packages/mcp test: ✓ all six API-9 database writes apply under direct policy +packages/mcp test: ✓ whitespace property name is refused under direct policy +packages/mcp test: ✓ direct delete_row moved the row to trash +packages/mcp test: ✓ all four API-10 page tools succeed under direct policy +packages/mcp test: ✓ upload_asset applies under direct policy +packages/mcp test: ✓ update_block confirms a direct write +packages/mcp test: ✓ the page text WAS mutated under instance=direct +packages/mcp test: ✓ direct write recorded NO new suggestion +packages/mcp test: Page override (suggest) beats instance (direct) +packages/mcp test: ✓ page suggest override → a suggestion despite instance direct +packages/mcp test: ✓ page suggest override did NOT mutate +packages/mcp test: ✓ page suggest override recorded a new suggestion +packages/mcp test: Page override (direct) beats instance (suggest) +packages/mcp test: ✓ page direct override → a direct write despite instance suggest +packages/mcp test: ✓ page direct override mutated the page +packages/mcp test: ✓ page direct override recorded NO new suggestion +packages/mcp test: Retired OPENBOOK_MCP_ALLOW_DIRECT_EDITS — never enables direct, logs a deprecation +packages/mcp test: ✓ env var set + policy suggest → still a suggestion +packages/mcp test: ✓ env var did NOT force a direct write +packages/mcp test: ✓ env var write recorded a suggestion (not a mutation) +packages/mcp test: ✓ the connector logged a deprecation for the retired env var +packages/mcp test: Fail-safe: older server (agent-edits route absent) → suggest even under instance=direct +packages/mcp test: ✓ policy fetch failing (404) → a suggestion, not a direct write +packages/mcp test: ✓ fail-safe did NOT mutate despite instance=direct +packages/mcp test: ✓ fail-safe recorded a suggestion +packages/mcp test: ✓ all six API-9 database writes return typed refusals on a read-only instance +packages/mcp test: ✓ API-10 writes refuse read-only while get_page_properties remains readable +packages/mcp test: ✅ ALL 44 CHECKS PASSED — MCP writes honor the resolved agent-edits policy per write (env grant retired). +packages/mcp test: ✓ create_database returns page and database ids +packages/mcp test: ✓ update_database returns the new name +packages/mcp test: ✓ create_property returns text/number/select schemas +packages/mcp test: ✓ update_property returns rename and replacement options +packages/mcp test: ✓ update_row returns merged typed values +packages/mcp test: ✓ describe_database returns schema and counts +packages/mcp test: ✓ unknown database id is a typed error +packages/mcp test: ✓ unknown property id is a typed error +packages/mcp test: ✓ wrong property value is typed and leaks no paths +packages/mcp test: ✓ delete_row soft-deletes to trash +packages/mcp test: ✓ describeDatabaseTool is shared by agent and MCP +packages/mcp test: ✓ createDatabaseTool is shared by agent and MCP +packages/mcp test: ✓ updateDatabaseTool is shared by agent and MCP +packages/mcp test: ✓ createPropertyTool is shared by agent and MCP +packages/mcp test: ✓ updatePropertyTool is shared by agent and MCP +packages/mcp test: ✓ updateRowTool is shared by agent and MCP +packages/mcp test: ✓ deleteRowTool is shared by agent and MCP +packages/mcp test: ✅ ALL 17 CHECKS PASSED — API-9 database round-trip and negatives. +packages/mcp test: ✓ oversize base64 is refused before decode or page lookup +packages/mcp test: ✓ exactly 10 MiB passes the padding-aware pre-check +packages/mcp test: ✓ 10 MiB plus one byte is refused by the padding-aware pre-check +packages/mcp test: ✓ in-app agent refuses upload_asset without direct edit access +packages/mcp test: ✓ in-app agent refuses upload_asset after external-tool taint +packages/mcp test: ✓ PNG upload returns typed metadata without payload +packages/mcp test: ✓ image block round-trips its uploaded assetId +packages/mcp test: ✓ image alt and width update round-trip +packages/mcp test: ✓ htmlArtifact round-trips its inert assetId +packages/mcp test: ✓ disallowed MIME returns a typed error +packages/mcp test: ✓ malformed base64 returns a typed error +packages/mcp test: ✓ missing page returns a typed error +packages/mcp test: ✓ suggest/read-only policy refuses immediate upload +packages/mcp test: 13 asset checks passed. +packages/mcp test: OpenBook server up at http://127.0.0.1:4411 +packages/mcp test: API-1: tool catalogue exposes nested children + the new write tools +packages/mcp test: API-8: rich text inputs materialize as editor runs +packages/mcp test: ✓ update_block parses mini-markdown into bold and link runs +packages/mcp test: ✓ explicit runs round-trip exactly +packages/mcp test: ✓ plain true keeps marker bytes literal +packages/mcp test: ✓ unmarked strings preserve existing plain behavior +packages/mcp test: ✓ the catalogue includes delete_block and update_block_props +packages/mcp test: ✓ the catalogue includes move_block and insert_blocks +packages/mcp test: ✓ append_blocks advertises a recursive `children` array in its JSON Schema +packages/mcp test: ✓ append_blocks documents the table and columns shapes +packages/mcp test: API-1: a nested columns/group payload lands as a real tree +packages/mcp test: ✓ append_blocks confirms a direct write and counts the nested blocks +packages/mcp test: ✓ the tree contains columns → column → group → paragraph at increasing depth +packages/mcp test: ✓ a nested kit input kept its props +packages/mcp test: ✓ nested block ids are unique and addressable +packages/mcp test: ✓ read_page projects the nested text +packages/mcp test: API-7: move_block and insert_blocks apply directly at precise positions +packages/mcp test: ✓ move_block reparents directly using afterId +packages/mcp test: ✓ insert_blocks accepts a nested payload at index +packages/mcp test: ✓ direct move + insert produced B, nested insert, C inside the group +packages/mcp test: API-1: a table → row → cell payload lands as a 3×3 table +packages/mcp test: ✓ the table has 3 rows and 9 cells nested under it +packages/mcp test: ✓ every cell kept its text in payload order +packages/mcp test: ✓ the header row kept its props +packages/mcp test: ✓ move_block allows moving a whole table block +packages/mcp test: ✓ move_block refuses moving table internals in favour of table_* tools +packages/mcp test: API-1: structural caps are refused with an actionable message +packages/mcp test: ✓ a 9-level payload is refused (max 8) with an explicit depth error +packages/mcp test: ✓ an 8-level payload (exactly at the cap) is accepted +packages/mcp test: ✓ a 401-node payload is refused (max 400) counting nested children +packages/mcp test: API-1 (should-fix 1.1): the container parent/child contract is enforced (no silent drop) +packages/mcp test: ✓ children on a non-container leaf are refused, naming the offending type +packages/mcp test: ✓ a top-level row (no table parent) is refused — this is the wall-of-placeholders bug +packages/mcp test: ✓ a cell directly under a table (skipping row) is refused +packages/mcp test: ✓ NONE of the rejected structural payloads mutated the page +packages/mcp test: API-1 (should-fix 7.1/7.2): create_artifact_page accepts a NESTED payload and rejects a nested typo / bad structure +packages/mcp test: ✓ create_artifact_page confirms creation of a nested layout +packages/mcp test: ✓ create_artifact_page returned a new page id +packages/mcp test: ✓ the artifact page materialized columns → column → group → paragraph +packages/mcp test: ✓ create_artifact_page rejects a NESTED typo type, naming it +packages/mcp test: ✓ create_artifact_page rejects a top-level row (same structural guard as append_blocks) +packages/mcp test: API-4 (direct): update_block_props merges shallowly and null removes a key +packages/mcp test: ✓ update_block_props confirms a direct write on an image block +packages/mcp test: ✓ the passed props were merged and the untouched ones survived +packages/mcp test: ✓ a numeric image width is refused as mistyped +packages/mcp test: ✓ update_block_props reaches a NESTED block (inside a group) +packages/mcp test: ✓ an explicit null REMOVED the key (and did not store a null) +packages/mcp test: ✓ the block text is untouched by a props update +packages/mcp test: ✓ update_block_props on an unknown id is a clean error naming inspect_page_structure +packages/mcp test: ✓ update_block_props with no props is refused +packages/mcp test: ✓ update_block_props refuses the table `ord` key, pointing at the table tools +packages/mcp test: ✓ update_block_props refuses the cell `col` key +packages/mcp test: ✓ update_block_props refuses a `col:` column-registry key +packages/mcp test: ✓ update_block_props refuses a `colbg:` column-tint key +packages/mcp test: ✓ a refused table-key write left the block untouched +packages/mcp test: API-4 (direct): delete_block removes nested blocks, table rows, and whole containers +packages/mcp test: ✓ delete_block removes a nested table ROW directly +packages/mcp test: ✓ the row and its 3 cells are gone (subtree removed) +packages/mcp test: ✓ the sibling rows survived +packages/mcp test: ✓ delete_block removes a block nested inside a group +packages/mcp test: ✓ delete_block removes a whole container +packages/mcp test: ✓ only the image block remains +packages/mcp test: ✓ delete_block on an unknown id is a clean error (nothing deleted) +packages/mcp test: ✓ delete_block on an unknown page reports "Page not found" +packages/mcp test: API-4 (should-fix 4.1): deleting a table's last row/cell removes the table (keyed + legacy) +packages/mcp test: ✓ deleting the last row of a KEYED table succeeds +packages/mcp test: ✓ the keyed table is removed WHOLE, the sibling paragraph survives +packages/mcp test: ✓ deleting the last row of a LEGACY table succeeds +packages/mcp test: ✓ the legacy table is gone and the doc self-heals to one paragraph (never zero-root) +packages/mcp test: ✓ deleting the only cell of the only row succeeds +packages/mcp test: ✓ the 1×1 table is removed whole, the sibling paragraph survives +packages/mcp test: API-4 (should-fix 4.2): delete prunes empty containers and never leaves a zero-root doc +packages/mcp test: ✓ deleting the only block in a column succeeds +packages/mcp test: ✓ the emptied column is pruned and the single-column layout is unwrapped +packages/mcp test: ✓ deleting a page's only block succeeds +packages/mcp test: ✓ the page self-heals to exactly one paragraph +packages/mcp test: Policy gate: the new tools + nested payloads go through review under suggest +packages/mcp test: ✓ a nested append under suggest is queued, not applied +packages/mcp test: ✓ delete_block under suggest is queued, not applied +packages/mcp test: ✓ update_block_props under suggest is queued, not applied +packages/mcp test: ✓ move_block under suggest is queued, not applied +packages/mcp test: ✓ insert_blocks under suggest keeps a nested payload +packages/mcp test: ✓ five suggestions were recorded +packages/mcp test: ✓ move_block uses the move review kind and insert_blocks reuses insert +packages/mcp test: ✓ insert_blocks suggestion preserves recursive children +packages/mcp test: ✓ the queued nested payload kept its children (3 rows × 3 cells) +packages/mcp test: ✓ delete_block maps to the `delete` suggestion kind and targets the block +packages/mcp test: ✓ update_block_props maps to `replace-text` with applyKind set_block_props (the bridge's patchBlock) +packages/mcp test: ✓ both new suggestions are authored by the MCP client +packages/mcp test: ✓ the diff card shows a before/after for each +packages/mcp test: ✓ NOTHING was mutated under suggest (block still there, props unchanged) +packages/mcp test: ✓ a cap violation errors under suggest too, queueing nothing +packages/mcp test: ✓ insert_blocks enforces the same depth cap before queueing +packages/mcp test: ✅ ALL 80 CHECKS PASSED — nested children over MCP + delete_block / update_block_props. +packages/mcp test: OpenBook server up at http://127.0.0.1:4412 +packages/mcp test: Catalogue: the twelve table tools are exposed with descriptions +packages/mcp test: ✓ every table tool is registered +packages/mcp test: ✓ each documents that coordinates are RENDER order +packages/mcp test: ✓ table_insert_row documents the header-row refusal +packages/mcp test: ✓ the delete tools document that the last row/column removes the table +packages/mcp test: A table built by append_blocks is immediately inspectable + operable +packages/mcp test: ✓ inspect_table with no tableId lists the page's tables +packages/mcp test: ✓ the table reads back 3×3 with a header row +packages/mcp test: ✓ an append_blocks table has NO order keys yet (reported as unmigrated) +packages/mcp test: ✓ cells are reported in payload order with addressable ids +packages/mcp test: The header-row invariant (the editor hides insert-above on row 0) +packages/mcp test: ✓ inserting a row ABOVE the header row is refused with an explanation +packages/mcp test: ✓ the refusal changed nothing +packages/mcp test: table_insert_row: the first op migrates col:/ord without reshuffling +packages/mcp test: ✓ the insert reports a direct apply and the new size +packages/mcp test: ✓ order keys are now assigned (migrated) with 3 columns +packages/mcp test: ✓ the blank row landed at render position 1 and nothing else moved +packages/mcp test: ✓ the stored projection really carries col:/ord props +packages/mcp test: table_set_cell by index and by cell id +packages/mcp test: ✓ a cell id alone identifies the table AND the coordinates +packages/mcp test: ✓ both addressing styles wrote the cells they meant +packages/mcp test: Columns: insert, move, tint, delete +packages/mcp test: ✓ a column was inserted at render position 1 with a cell in every row +packages/mcp test: ✓ table_move_column reports success +packages/mcp test: ✓ the moved column is last and the others closed up +packages/mcp test: ✓ a column tint is stored as colbg: on the table +packages/mcp test: ✓ a column width is stored as colw: on the table +packages/mcp test: ✓ deleting a column by id removes its cells everywhere +packages/mcp test: ✓ the deleted column left no orphan colbg entry +packages/mcp test: ✓ the deleted column left no orphan colw entry +packages/mcp test: Rows: duplicate, move (by id), tint, delete +packages/mcp test: ✓ duplicate_row copies the row directly below with FRESH ids +packages/mcp test: ✓ the duplicated cells are distinct blocks +packages/mcp test: ✓ table_move_row addressed by row ID moved it to render position 1 +packages/mcp test: ✓ the header row stayed put +packages/mcp test: ✓ a row tint is the row block's bg prop +packages/mcp test: ✓ table_delete_row by id succeeds +packages/mcp test: ✓ the row is gone and the rest kept their render order +packages/mcp test: Bounds + unknown-id errors are clean and actionable +packages/mcp test: ✓ an out-of-range index names the table dimensions +packages/mcp test: ✓ an unknown cell id points at inspect_table +packages/mcp test: ✓ an unknown column id errors cleanly +packages/mcp test: ✓ an unknown tableId errors cleanly +packages/mcp test: ✓ omitting every way of naming the table is refused +packages/mcp test: ✓ a move target past the axis (counted with the row removed) is refused +packages/mcp test: ✓ every refusal left the table exactly as it was +packages/mcp test: A table cell also answers to the generic block tools (no regression) +packages/mcp test: ✓ update_block can still write a cell by its block id +packages/mcp test: The last row / column removes the WHOLE table (editor parity) +packages/mcp test: ✓ deleting the last row says the whole table block was removed +packages/mcp test: ✓ the table really is gone from the page +packages/mcp test: Policy gate: every table op queues a reviewable table-op suggestion +packages/mcp test: ✓ table_insert_row under suggest is queued, not applied +packages/mcp test: ✓ all five ops were queued +packages/mcp test: ✓ every one reviews as the `table-op` suggestion kind anchored on the TABLE block +packages/mcp test: ✓ each payload carries the bridge applyKind = the tool name +packages/mcp test: ✓ payloads carry the page, the table, and RESOLVED sorted coordinates +packages/mcp test: ✓ a node-targeting op also carries the STABLE id it resolved to (survives a reorder in review) +packages/mcp test: ✓ a cell op carries the cell id and a before→after for the diff card +packages/mcp test: ✓ authorship is the MCP client +packages/mcp test: ✓ NOTHING was mutated under suggest — same grid, still unmigrated +packages/mcp test: ✓ the header guard bites under suggest too, queueing nothing +packages/mcp test: ✓ bounds are validated BEFORE the policy branch +packages/mcp test: An accepted suggestion applies IDENTICALLY to a direct write +packages/mcp test: ✓ replaying the queued payload yields the same grid as the direct write +packages/mcp test: ✓ …and it really moved something +packages/mcp test: ✅ ALL 54 CHECKS PASSED — table structure tools over MCP (API-3). +packages/mcp test: FORM-7 read tools + capability redaction +packages/mcp test: ✓ list_forms recursively finds the nested form +packages/mcp test: ✓ list_forms returns summary fields and the database binding +packages/mcp test: ✓ list_forms never returns either submission key +packages/mcp test: ✓ get_form_schema returns the fields plus enabled/databaseId +packages/mcp test: ✓ get_form_schema recursively redacts the capability +packages/mcp test: ✓ inspect_page_structure still shows the nested form +packages/mcp test: ✓ inspect_page_structure redacts both key copies +packages/mcp test: FORM-7 update_block_props capability guard +packages/mcp test: ✓ update_block_props refuses a top-level submissionKey write +packages/mcp test: ✓ update_block_props refuses a nested schema.submissionKey smuggle +packages/mcp test: ✓ both refused probes leave both stored key copies unchanged +packages/mcp test: ✓ update_block_props still applies harmless form props directly +packages/mcp test: ✓ the merged-props success echo contains no submission key bytes +packages/mcp test: FORM-7 list_form_submissions filtering + cursor +packages/mcp test: ✓ list_form_submissions returns one marked row and a cursor +packages/mcp test: ✓ pagination returns exactly the two rows marked for this form +packages/mcp test: ✓ the last submissions page omits nextCursor +packages/mcp test: ✓ a cursor from outside the filtered form is rejected +packages/mcp test: ✓ a database hosted by another page is rejected +packages/mcp test: ✓ a schema-only database binding is not authoritative +packages/mcp test: FORM-7 strict field-op schemas + suggest mode +packages/mcp test: ✓ zod rejects an unknown field kind before the handler +packages/mcp test: ✓ zod rejects an empty update patch +packages/mcp test: ✓ zod rejects an unknown operation discriminator +packages/mcp test: ✓ zod rejects a non-http(s) confirmation redirect +packages/mcp test: ✓ update_form_field queues a suggestion on a suggest page +packages/mcp test: ✓ suggest-mode update_form_field leaves the block unchanged +packages/mcp test: ✓ the field suggestion targets the form block through set_block_props +packages/mcp test: ✓ the suggestion preserves the submission capability without exposing it in the tool result +packages/mcp test: ✓ set_form_settings also queues through the suggest policy +packages/mcp test: ✓ suggest-mode set_form_settings leaves settings unchanged +packages/mcp test: FORM-7 direct field operations + settings +packages/mcp test: ✓ add applies directly on a direct page with a 43-character key +packages/mcp test: ✓ update applies directly +packages/mcp test: ✓ reorder applies directly +packages/mcp test: ✓ remove applies directly +packages/mcp test: ✓ all direct operations landed in final order with the patch +packages/mcp test: ✓ direct field edits preserve both 43-character key copies +packages/mcp test: ✓ set_form_settings applies directly +packages/mcp test: ✓ settings synchronize outer gate props and nested schema +packages/mcp test: ✓ settings carry label/confirmation and accept maxSubmissions=0 +packages/mcp test: ✓ set_form_settings cannot rotate or corrupt the key +packages/mcp test: ✓ nullable settings clear optional values directly +packages/mcp test: ✓ cleared settings are removed rather than stored as null +packages/mcp test: ✅ ALL 40 CHECKS PASSED — FORM-7 tools, policy handling, pagination, validation, and key redaction verified. +packages/mcp test: OpenBook server up at http://127.0.0.1:4414 +packages/mcp test: API-2: the tool catalogue exposes list_block_types +packages/mcp test: ✓ list_block_types is registered +packages/mcp test: ✓ create_artifact_page advertises catalogue types in its schema (generated, not hand-written) +packages/mcp test: API-2: EVERY catalogued type is creatable via create_artifact_page (coverage) +packages/mcp test: ✓ one artifact page holding every catalogued type is accepted +packages/mcp test: ✓ stored page contains a "paragraph" block +packages/mcp test: ✓ stored page contains a "heading" block +packages/mcp test: ✓ stored page contains a "list" block +packages/mcp test: ✓ stored page contains a "todo" block +packages/mcp test: ✓ stored page contains a "quote" block +packages/mcp test: ✓ stored page contains a "callout" block +packages/mcp test: ✓ stored page contains a "code" block +packages/mcp test: ✓ stored page contains a "notes" block +packages/mcp test: ✓ stored page contains a "divider" block +packages/mcp test: ✓ stored page contains a "image" block +packages/mcp test: ✓ stored page contains a "htmlArtifact" block +packages/mcp test: ✓ stored page contains a "columns" block +packages/mcp test: ✓ stored page contains a "column" block +packages/mcp test: ✓ stored page contains a "table" block +packages/mcp test: ✓ stored page contains a "row" block +packages/mcp test: ✓ stored page contains a "cell" block +packages/mcp test: ✓ stored page contains a "group" block +packages/mcp test: ✓ stored page contains a "tabs" block +packages/mcp test: ✓ stored page contains a "tab" block +packages/mcp test: ✓ stored page contains a "accordion" block +packages/mcp test: ✓ stored page contains a "accordionsection" block +packages/mcp test: ✓ stored page contains a "slider" block +packages/mcp test: ✓ stored page contains a "number" block +packages/mcp test: ✓ stored page contains a "textfield" block +packages/mcp test: ✓ stored page contains a "longtext" block +packages/mcp test: ✓ stored page contains a "richtext" block +packages/mcp test: ✓ stored page contains a "toggle" block +packages/mcp test: ✓ stored page contains a "radio" block +packages/mcp test: ✓ stored page contains a "dropdown" block +packages/mcp test: ✓ stored page contains a "checklist" block +packages/mcp test: ✓ stored page contains a "choicecards" block +packages/mcp test: ✓ stored page contains a "searchselect" block +packages/mcp test: ✓ stored page contains a "tagfield" block +packages/mcp test: ✓ stored page contains a "location" block +packages/mcp test: ✓ stored page contains a "actionbutton" block +packages/mcp test: ✓ stored page contains a "kitchart" block +packages/mcp test: ✓ stored page contains a "statuslight" block +packages/mcp test: ✓ stored page contains a "progressbar" block +packages/mcp test: ✓ stored page contains a "formula" block +packages/mcp test: ✓ stored page contains a "linkcard" block +packages/mcp test: ✓ stored page contains a "tooltipcard" block +packages/mcp test: ✓ stored page contains a "dbview" block +packages/mcp test: ✓ stored page contains a "dbform" block +packages/mcp test: ✓ stored page contains a "form" block +packages/mcp test: API-2: unknown types are refused with a pointer at list_block_types +packages/mcp test: ✓ a typo'd type is refused naming the type +packages/mcp test: ✓ the refusal points at list_block_types +packages/mcp test: API-2: the table cell-count rule holds on both write paths +packages/mcp test: ✓ create_artifact_page refuses a ragged table +packages/mcp test: ✓ append_blocks refuses a ragged table +packages/mcp test: API-2: update_block_props validates declared prop VALUE types +packages/mcp test: ✓ a declared prop with the wrong value type is refused, naming prop and expectation +packages/mcp test: ✓ an invalid structured option is refused with a typed error naming opts +packages/mcp test: API-2: plugin block types — rejected while uninstalled, accepted once installed +packages/mcp test: ✓ an uninstalled plugin's type is refused as not installed +packages/mcp test: ✓ list_block_types lists every catalogued core + kit type +packages/mcp test: ✓ with no plugins installed, pluginBlocks is empty +packages/mcp test: ✓ list_block_types filters its payload to requested types +packages/mcp test: ✓ after installing the bundled ledger plugin, its declared blocks are listed +packages/mcp test: ✓ plugin entries carry a description +packages/mcp test: ✓ the installed plugin's namespaced type is now accepted +packages/mcp test: All 60 checks passed. +packages/mcp test: ✅ README covers all 50 registered MCP tools and agent reference sections +packages/mcp test: OpenBook sidecar up (socket + TCP) at http://127.0.0.1:4409 +packages/mcp test: ✓ the server advertises a stable instanceId +packages/mcp test: ✓ the foreign server has a DIFFERENT instanceId +packages/mcp test: Happy path: connector over the unified endpoint lists the SAME pages +packages/mcp test: ✓ connector lists the page the server API shows +packages/mcp test: ✓ list_pages inherits the client list filter (no MCP-side reimplementation) +packages/mcp test: ✓ search_notes inherits the client search filter +packages/mcp test: ✓ an unlisted page remains directly readable +packages/mcp test: Refusal: foreign responder on the target endpoint +packages/mcp test: ✓ connector exits non-zero on an identity mismatch +packages/mcp test: ✓ the error names the mismatch (never silently adopts the foreign server) +packages/mcp test: Refusal: nothing listening on the target port +packages/mcp test: ✓ connector exits non-zero when the endpoint is unreachable +packages/mcp test: ✓ the error is a clear reachability message +packages/mcp test: ✅ ALL 10 CHECKS PASSED — unified endpoint + instance verification. +packages/mcp test: ✓ paragraph +packages/mcp test: ✓ heading +packages/mcp test: ✓ list +packages/mcp test: ✓ todo +packages/mcp test: ✓ quote +packages/mcp test: ✓ callout +packages/mcp test: ✓ code +packages/mcp test: ✓ notes +packages/mcp test: ✓ divider +packages/mcp test: ✓ image +packages/mcp test: ✓ htmlArtifact +packages/mcp test: ✓ columns +packages/mcp test: ✓ column +packages/mcp test: ✓ table +packages/mcp test: ✓ row +packages/mcp test: ✓ cell +packages/mcp test: ✓ group +packages/mcp test: ✓ tabs +packages/mcp test: ✓ tab +packages/mcp test: ✓ accordion +packages/mcp test: ✓ accordionsection +packages/mcp test: ✓ slider +packages/mcp test: ✓ number +packages/mcp test: ✓ textfield +packages/mcp test: ✓ longtext +packages/mcp test: ✓ richtext +packages/mcp test: ✓ toggle +packages/mcp test: ✓ radio +packages/mcp test: ✓ dropdown +packages/mcp test: ✓ checklist +packages/mcp test: ✓ choicecards +packages/mcp test: ✓ searchselect +packages/mcp test: ✓ tagfield +packages/mcp test: ✓ location +packages/mcp test: ✓ actionbutton +packages/mcp test: ✓ kitchart +packages/mcp test: ✓ statuslight +packages/mcp test: ✓ progressbar +packages/mcp test: ✓ formula +packages/mcp test: ✓ linkcard +packages/mcp test: ✓ tooltipcard +packages/mcp test: ✓ dbview +packages/mcp test: ✓ dbform +packages/mcp test: ✓ form +packages/mcp test: ✓ openbook.ledger/journal-entry +packages/mcp test: ✓ openbook.ledger/trial-balance +packages/mcp test: ✓ openbook.ledger/account-register +packages/mcp test: ✓ openbook.ledger/bank-import +packages/mcp test: ✓ openbook.ledger/reconcile +packages/mcp test: ✓ openbook.ledger/balance-sheet +packages/mcp test: ✓ openbook.ledger/income-statement +packages/mcp test: ✓ openbook.ledger/period-close +packages/mcp test: ✓ openbook.ledger/beancount-export +packages/mcp test: All 44 catalogue types + 9 plugin blocks have MCP API coverage; exemptions: none. +packages/mcp test: Done +packages/server test: Test Files 97 passed (97) +packages/server test: Tests 1336 passed | 6 skipped (1342) +packages/server test: Start at 23:34:06 +packages/server test: Duration 3819.18s (transform 6.63s, setup 0ms, import 67.16s, tests 3724.62s, environment 19ms) +packages/server test: Done + +> open-book@0.0.0-workspace test:e2e /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/server run test:e2e && pnpm --filter @book.dev/mcp run test:e2e + + +> @book.dev/server@3.17.0 test:e2e /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server +> tsx scripts/e2e.mts + + +=== 1. EMBEDDED MODE (embedded PGlite) === + server up at http://127.0.0.1:4401 + ✓ health endpoint returns ok + +[embedded] API responses are non-cacheable + ✓ /api/pages sends Cache-Control: no-store + ✓ /api/trash sends Cache-Control: no-store + +[embedded] CRUD via HttpDataClient + ✓ create returns a uuid + ✓ create round-trips name + ✓ create round-trips values + ✓ create round-trips names + ✓ create sets timestamps + ✓ get returns the page + ✓ get round-trips data + ✓ get of unknown id -> null + ✓ list includes the page + ✓ list items carry no data payload + ✓ update keeps id + ✓ update replaces values + ✓ update bumps updatedAt + ✓ duplicate name allowed (distinct page) + ✓ both same-named pages are listed + ✓ rename changes the name + ✓ rename preserves data + ✓ delete returns true + ✓ get after delete -> null + ✓ second delete returns false + +[embedded] Page properties: owner, verification, backlinks + ✓ backlinks include the linking page + ✓ backlinks exclude the target itself + ✓ a never-linked page has no backlinks + ✓ owner persists + ✓ verification persists + ✓ a second property merges (owner preserved) + ✓ a content save preserves properties + ✓ a relation property counts as a backlink + ✓ the mention link is still a backlink too + ✓ backlink clears when the linker is trashed + +[embedded] Database via HttpDataClient + ✓ create database returns id + ✓ database is linked to its host page + ✓ database round-trips schema + ✓ host page reports hostedDatabaseId + ✓ host page keeps its own content + ✓ database is reachable via its host page + ✓ host page appears in the page list + ✓ row create returns a page id + ✓ row carries its database membership + ✓ rows are excluded from the page list + ✓ listRows returns both rows + ✓ row round-trips manual property + ✓ row projects exported cell value + ✓ rows list in creation order + ✓ reorderRows sets the manual order + ✓ new row appends at the bottom + ✓ sub-item carries its parentId + ✓ top-level rows have a null parentId + ✓ exports refresh after a content save + ✓ updateRow changes the title + ✓ updateRow changes a property + ✓ updateRow leaves exports intact + ✓ applyView sorts by title ascending + ✓ delete host page (soft) + ✓ host page hidden after delete + ✓ database survives a soft delete + ✓ host page restores + ✓ rows survive the round-trip + ✓ purge host page + ✓ database removed by cascade after purge + ✓ row page removed by cascade after purge + +[embedded] Nested pages via HttpDataClient + ✓ child records its parent + ✓ grandchild records its parent + ✓ top-level page has no parent + ✓ nested pages appear in the list with parentId + ✓ content save preserves the parent + ✓ delete parent (soft) + ✓ child hidden with the parent + ✓ grandchild hidden with the parent + ✓ parent is a trash root + ✓ nested child is not a separate trash root + ✓ restore parent + ✓ child restored with the parent + ✓ grandchild restored with the parent + ✓ parent left the trash + +[embedded] Page ordering & movePage + ✓ new children list in creation order (appended) + ✓ movePage returns the moved page + ✓ movePage reorders siblings + ✓ movePage re-parents the page + ✓ the re-nested page leaves its old group + ✓ the re-nested page joins its new parent + ✓ movePage rejects a cycle (409) + +[embedded] Whole-space backup: export / import + ✓ export includes live pages with data + ✓ export includes nested pages + ✓ copy import creates new pages + ✓ copy import suffixes clashing names + ✓ copy import: parent copied under a fresh id + ✓ copy import: nesting preserved + ✓ overwrite replaces by id (no new page) + ✓ overwrite applies the new content + +[embedded] Trash: restore, purge, empty + ✓ soft delete returns true + ✓ soft-deleted page is hidden + ✓ soft-deleted page is in the trash + ✓ a trashed name is freed for reuse + ✓ restore brings the page back + ✓ restore keeps the original name despite a live twin + ✓ restored page is visible again + ✓ purge a single trashed page + ✓ purging a missing page returns false + ✓ a purged page cannot be restored + ✓ delete a row (soft) + ✓ row leaves the database view + ✓ row appears in the trash + ✓ restore the row + ✓ row returns to the database view + ✓ emptyTrash purges remaining trash + ✓ trash is empty afterwards + +[embedded] optional local AI (mock engine) + ✓ ai defaults to off + ✓ lexical search works with AI off + ✓ lexical search ranks the budget note first + ✓ search returns a snippet + ✓ config accepts the mock provider + ✓ mock engine reports ready + embeddings + ✓ search upgrades to hybrid with an embedding engine + ✓ task breakdown returns parsed tasks + ✓ tasks are clean strings (no list markers) + ✓ completion streams tokens over SSE + ✓ completion stream closes with done + ✓ reindex counts pages + ✓ unknown provider rejected (400) + ✓ agent calls search_notes first + ✓ agent streams the tool result + ✓ agent ends with a grounded final answer + ✓ agent events arrive in order (tool → result → final) + ✓ agent rejects an empty conversation (400) + +[embedded] extensions (plugins API) + ✓ install returns the stored plugin + ✓ installed plugin is listed + ✓ signature survives the round-trip + ✓ stored package verifies against the trusted key + ✓ malformed id rejected (400) + ✓ missing entry file rejected (400) + ✓ disable persists + ✓ remove returns true + ✓ removed plugin is gone + +=== 2. PERSISTENCE ACROSS RESTART === + ✓ seeded a page before restart + server stopped + server restarted at http://127.0.0.1:4401 + ✓ page survived restart + ✓ data survived restart + ✓ manual order survived restart + +=== 3. HEADLESS MODE (Postgres wire protocol) === + postgres-compatible socket up at 127.0.0.1:5599 + headless server up at http://127.0.0.1:4402 + +[headless] CRUD via HttpDataClient + ✓ create returns a uuid + ✓ create round-trips name + ✓ create round-trips values + ✓ create round-trips names + ✓ create sets timestamps + ✓ get returns the page + ✓ get round-trips data + ✓ get of unknown id -> null + ✓ list includes the page + ✓ list items carry no data payload + ✓ update keeps id + ✓ update replaces values + ✓ update bumps updatedAt + ✓ duplicate name allowed (distinct page) + ✓ both same-named pages are listed + ✓ rename changes the name + ✓ rename preserves data + ✓ delete returns true + ✓ get after delete -> null + ✓ second delete returns false + +[headless] Page properties: owner, verification, backlinks + ✓ backlinks include the linking page + ✓ backlinks exclude the target itself + ✓ a never-linked page has no backlinks + ✓ owner persists + ✓ verification persists + ✓ a second property merges (owner preserved) + ✓ a content save preserves properties + ✓ a relation property counts as a backlink + ✓ the mention link is still a backlink too + ✓ backlink clears when the linker is trashed + +[headless] Database via HttpDataClient + ✓ create database returns id + ✓ database is linked to its host page + ✓ database round-trips schema + ✓ host page reports hostedDatabaseId + ✓ host page keeps its own content + ✓ database is reachable via its host page + ✓ host page appears in the page list + ✓ row create returns a page id + ✓ row carries its database membership + ✓ rows are excluded from the page list + ✓ listRows returns both rows + ✓ row round-trips manual property + ✓ row projects exported cell value + ✓ rows list in creation order + ✓ reorderRows sets the manual order + ✓ new row appends at the bottom + ✓ sub-item carries its parentId + ✓ top-level rows have a null parentId + ✓ exports refresh after a content save + ✓ updateRow changes the title + ✓ updateRow changes a property + ✓ updateRow leaves exports intact + ✓ applyView sorts by title ascending + ✓ delete host page (soft) + ✓ host page hidden after delete + ✓ database survives a soft delete + ✓ host page restores + ✓ rows survive the round-trip + ✓ purge host page + ✓ database removed by cascade after purge + ✓ row page removed by cascade after purge + +[headless] Nested pages via HttpDataClient + ✓ child records its parent + ✓ grandchild records its parent + ✓ top-level page has no parent + ✓ nested pages appear in the list with parentId + ✓ content save preserves the parent + ✓ delete parent (soft) + ✓ child hidden with the parent + ✓ grandchild hidden with the parent + ✓ parent is a trash root + ✓ nested child is not a separate trash root + ✓ restore parent + ✓ child restored with the parent + ✓ grandchild restored with the parent + ✓ parent left the trash + +[headless] Page ordering & movePage + ✓ new children list in creation order (appended) + ✓ movePage returns the moved page + ✓ movePage reorders siblings + ✓ movePage re-parents the page + ✓ the re-nested page leaves its old group + ✓ the re-nested page joins its new parent + ✓ movePage rejects a cycle (409) + +[headless] Trash: restore, purge, empty + ✓ soft delete returns true + ✓ soft-deleted page is hidden + ✓ soft-deleted page is in the trash + ✓ a trashed name is freed for reuse + ✓ restore brings the page back + ✓ restore keeps the original name despite a live twin + ✓ restored page is visible again + ✓ purge a single trashed page + ✓ purging a missing page returns false + ✓ a purged page cannot be restored + ✓ delete a row (soft) + ✓ row leaves the database view + ✓ row appears in the trash + ✓ restore the row + ✓ row returns to the database view + ✓ emptyTrash purges remaining trash + ✓ trash is empty afterwards + +=== 4. TRASH CLEANUP JOB === + ✓ janitor: page created + ✓ janitor: soft delete +OpenBook trash cleanup: purged 1 expired page(s) + ✓ janitor: cleanup job purged the expired page + ✓ janitor: purged page is gone for good + +=== 5. ACCESS-TOKEN AUTH === + ✓ tokenless request rejected (401) + ✓ tokenless write rejected (401) + ✓ token-bearing client can write + ✓ token-bearing client can read + ✓ raw ?token= authenticates + ✓ raw missing token is 401 + ✓ health stays open without a token + +=== 6. LEDGER EXPORT + VERIFY === + ✓ ledger: entry posted with entry number 1 + ✓ ledger: canonical CSV export is byte-stable + ✓ ledger: CSV carries the posting rows + ✓ ledger: verify route answers 200 + ✓ ledger: independent verifier reports a CLEAN book + +✅ ALL 256 CHECKS PASSED — embedded, persistence, headless, trash-cleanup, access-token, and ledger flows verified. + +> @book.dev/mcp@3.17.0 test:e2e /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp +> tsx scripts/e2e.mts + + +OpenBook server up at http://127.0.0.1:4402 + +MCP handshake + tool catalogue + ✓ handshake reports the openbook server + ✓ the registered tool set is non-trivial and duplicate-free + ✓ all 50 registered tools are listed (derived from src/server.ts) + ✓ tools carry descriptions + ✓ handshake advertises upload_asset + +Read tools + ✓ list_pages includes seeded pages + ✓ read_page returns title and body + ✓ read_page flags a missing page as an error + ✓ search_notes ranks the planning note first + +Write tools + ✓ create_page returns the new id + ✓ set_page_appearance is available through the handshake + ✓ move_page is available through the handshake + ✓ set_page_properties is available through the handshake + ✓ get_page_properties is available through the handshake + ✓ append_to_page queues a suggestion + ✓ append_to_page did not mutate the page + ✓ append_to_page recorded an insert suggestion + ✓ append_to_page proposes on a block-editor page + ✓ append_to_page did not mutate the block page + ✓ create_page allows a duplicate title (new page) + +Artifact tool + ✓ create_artifact_page returns the new id + ✓ the artifact is stamped for the block editor + ✓ all five blocks landed in order + ✓ read_page sees the artifact heading + ✓ unknown block types are rejected + +Database tools + ✓ describe_database is callable in the handshake + ✓ create_database is callable in the handshake + ✓ update_database is callable in the handshake + ✓ create_property is callable in the handshake + ✓ update_property is callable in the handshake + ✓ update_row is callable in the handshake + ✓ delete_row is callable in the handshake + ✓ list_database_rows lists the seeded row + ✓ create_database_row confirms + ✓ the new row shows up + ✓ list_database_rows flags a page without a database + +Inspection tools (T11) + ✓ inspect_page_structure shows the block tree + ✓ inspect_page_structure exposes block ids + ✓ get_kit_values reads the published input + ✓ get_kit_values reports a page with no kit values + ✓ list_db_views lists the database views + ✓ get_db_row reads the row by id + +Write tools (T11) — default = reviewable suggestions + ✓ set_kit_value queues a suggestion + ✓ set_kit_value did not mutate the value (still 3) + ✓ set_kit_value rejects an unknown input + ✓ update_block queues a suggestion + ✓ update_block did not mutate the heading + ✓ update_block rejects an unknown block id + ✓ append_blocks queues a suggestion + ✓ append_blocks did not mutate the page + ✓ append_blocks refuses legacy editor pages + ✓ move_block queues a suggestion + ✓ insert_blocks queues a nested suggestion + ✓ the artifact page collected the queued edit suggestions + ✓ set_db_cell queues a suggestion + ✓ set_db_cell did not mutate the cell + ✓ set_db_cell recorded a set-cell suggestion + ✓ set_db_cell rejects an unknown property + +Form tools (FORM-7) — redacted reads, suggest then direct + ✓ list_forms finds the seeded form with summary metadata + ✓ list_forms does not expose the submission key + ✓ get_form_schema returns the field and binding + ✓ get_form_schema redacts both key copies + ✓ inspect_page_structure redacts form capabilities + ✓ list_form_submissions returns only the sys_form_submission-marked row + ✓ update_form_field queues a suggestion under the default policy + ✓ suggest-mode update_form_field leaves the form unchanged + ✓ the form edit is a set_block_props suggestion targeting the form block + ✓ update_form_field applies directly under a page direct override + ✓ set_form_settings applies directly + ✓ direct field and settings edits are readable and still redacted + +✅ ALL 70 CHECKS PASSED — MCP handshake, catalogue, and every tool verified. + +``` + +
+ +
+Initial sandboxed pnpm verify (stopped after confirmed failures; exit 130) + +```text + +> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e + + +> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> node --test eslint-rules/*.test.mjs + +✔ allows spacing-scale utilities and non-spacing arbitrary values (16.106917ms) +✔ flags arbitrary padding, margin, and gap values in className (5.420291ms) +✔ checks nested, template, and conventionally named hoisted class strings (2.09ms) +✔ allows paint-only hover changes and non-hover geometry (13.772042ms) +✔ flags every guarded hover-geometry utility family (14.331958ms) +✔ flags display swaps in either class ordering and nested class strings (3.465834ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 6 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 138.82 + +> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build + + +> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk +> tsc -p tsconfig.build.json + + +> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> vite build --config vite.viewer.config.js + +vite v8.0.14 building client environment for production... + +transforming...✓ 2076 modules transformed. +rendering chunks... +computing gzip size... +src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB + +✓ built in 512ms +vite v8.0.14 building client environment for production... + +transforming...✓ 356 modules transformed. +rendering chunks... +computing gzip size... +dist/style.css 190.24 kB │ gzip: 31.11 kB +dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB +dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB +dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB +dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB +dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB +dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB +dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB +dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB +dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB +dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB +dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB +dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB +dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB +dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB +dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB +dist/index.js 2,962.77 kB │ gzip: 813.80 kB + +[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. + +[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. + +✓ built in 444ms + +> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/chunk-N6662UMY.js 98.60 KB +ESM dist/index.js 136.00 B +ESM dist/bin.js 2.40 KB +ESM dist/chunk-N6662UMY.js.map 181.29 KB +ESM dist/bin.js.map 6.65 KB +ESM dist/index.js.map 71.00 B +ESM ⚡️ Build success in 11ms +DTS Build start +DTS ⚡️ Build success in 673ms +DTS dist/index.d.ts 1.84 KB + +> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/index.js 552.00 B +ESM dist/browser.js 30.24 KB +ESM dist/bin.js 215.00 B +ESM dist/chunk-Z5QDSVHR.js 415.58 KB +ESM dist/chunk-V6MBCKVR.js 422.16 KB +ESM dist/index.js.map 71.00 B +ESM dist/bin.js.map 556.00 B +ESM dist/browser.js.map 61.42 KB +ESM dist/chunk-V6MBCKVR.js.map 787.04 KB +ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB +ESM ⚡️ Build success in 42ms +DTS Build start +DTS ⚡️ Build success in 2136ms +DTS dist/index.d.ts 68.49 KB +DTS dist/browser.d.ts 13.43 KB +DTS dist/hub-CUJHMMTq.d.ts 136.66 KB + +> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/ui run check:gen + + +> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r --if-present run typecheck + +Scope: 6 of 7 workspace projects +packages/sdk typecheck$ tsc -p tsconfig.json --noEmit +packages/sdk typecheck: Done +packages/ui typecheck$ tsc --noEmit +packages/ui typecheck: Done +packages/app typecheck$ tsc --noEmit +packages/app typecheck: Done +packages/mcp typecheck$ tsc --noEmit +packages/server typecheck$ tsc --noEmit +packages/mcp typecheck: Done +packages/server typecheck: Done +packages/web typecheck$ tsc --noEmit +packages/web typecheck: Done + +> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r run lint + +Scope: 6 of 7 workspace projects +packages/sdk lint$ eslint . +packages/sdk lint: Done +packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint +packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > stylelint src/index.css +packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > node scripts/assert-spacing-stylelint.mjs +packages/ui lint: SPC-2 stylelint controls passed (2 positive, 4 negative) +packages/ui lint: Done +packages/app lint$ eslint . +packages/app lint: Done +packages/mcp lint$ eslint . +packages/server lint$ eslint . +packages/mcp lint: Done +packages/server lint: Done +packages/web lint$ eslint . +packages/web lint: Done + +> open-book@0.0.0-workspace test /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r --if-present run test + +Scope: 6 of 7 workspace projects +packages/sdk test$ vitest run +packages/sdk test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk +packages/sdk test: Test Files 32 passed (32) +packages/sdk test: Tests 546 passed (546) +packages/sdk test: Start at 21:47:29 +packages/sdk test: Duration 2.01s (transform 2.52s, setup 0ms, import 3.86s, tests 3.20s, environment 3ms) +packages/sdk test: Done +packages/ui test$ vitest run +packages/ui test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui test: Test Files 244 passed (244) +packages/ui test: Tests 2319 passed (2319) +packages/ui test: Start at 21:47:31 +packages/ui test: Duration 44.12s (transform 20.81s, setup 0ms, import 150.57s, tests 100.48s, environment 95.13s) +packages/ui test: Done +packages/app test$ vitest run +packages/app test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/app +packages/app test: Test Files 2 passed (2) +packages/app test: Tests 7 passed (7) +packages/app test: Start at 21:48:16 +packages/app test: Duration 3.30s (transform 2.15s, setup 0ms, import 2.97s, tests 66ms, environment 380ms) +packages/app test: Done +packages/mcp test$ node --import tsx scripts/listed.test.mts && tsx scripts/pages.test.mts && tsx scripts/suggestions.test.mts && tsx scripts/databases.test.mts && tsx scripts/assets.test.mts && tsx scripts/blocks.test.mts && tsx scripts/tables.test.mts && tsx scripts/forms.test.mts && tsx scripts/blockTypes.test.mts && tsx scripts/readme.test.mts && tsx scripts/endpoint.test.mts && tsx scripts/coverage.test.mts +packages/server test$ vitest run +packages/server test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server +packages/mcp test: ✅ ALL 3 MCP LISTED CONTRACT CHECKS PASSED +packages/mcp test: ✓ set_page_appearance applies in direct mode +packages/mcp test: ✓ valid gradient id resolves to curated css +packages/mcp test: ✓ read_page reflects persisted appearance +packages/mcp test: ✓ move_page reparents a page +packages/mcp test: ✓ list_pages exposes the new parent and order +packages/mcp test: ✓ get/set page properties round-trip +packages/mcp test: ✓ unknown page returns a typed safe error +packages/mcp test: ✓ moving into an own subtree is refused +packages/mcp test: ✓ invalid appearance enum is refused without path leakage +packages/mcp test: ✓ arbitrary gradient css is refused +packages/mcp test: ✓ http image cover is refused +packages/mcp test: ✓ javascript image cover is refused +packages/mcp test: ✓ https image cover is accepted +packages/mcp test: ✓ OpenBook asset image cover is accepted +packages/mcp test: ✓ computed/unknown property writes are typed refusals +packages/mcp test: ✓ read-only instance refuses writes with a typed error +packages/mcp test: ✓ read-only refusal leaves properties unchanged +packages/mcp test: ✅ ALL 17 API-10 PAGE TOOL CHECKS PASSED +packages/mcp test: OpenBook server up at http://127.0.0.1:4406 +packages/mcp test: Resolved suggest (instance suggest, page inherit) — writes create suggestions +packages/mcp test: ✓ upload_asset refuses suggest/read-only policy (bytes never become a suggestion) +packages/mcp test: ✓ update_block returns a "Suggested for review" result +packages/mcp test: ✓ update_block recorded exactly one suggestion +packages/mcp test: ✓ suggestion kind maps update_block → replace-text +packages/mcp test: ✓ suggestion is authored by the MCP client (authorKind ai) +packages/mcp test: ✓ suggestion targets the block +packages/mcp test: ✓ suggestion payload mirrors the agent (applyKind + before merge base) +packages/mcp test: ✓ the page text was NOT mutated (still original) +packages/mcp test: ✓ set_db_cell returns a "Suggested for review" result +packages/mcp test: ✓ set_db_cell recorded a set-cell suggestion on the host page +packages/mcp test: ✓ set-cell suggestion targets the db/row/property +packages/mcp test: ✓ the database cell was NOT mutated +packages/mcp test: ✓ all six API-9 database writes suggest under suggest policy +packages/mcp test: ✓ create_database creates its host page immediately under suggest policy +packages/mcp test: ✓ whitespace property name is refused under suggest policy +packages/mcp test: ✓ describe_database remains a read under suggest policy +packages/mcp test: ✓ suggested update/delete row did not mutate +packages/mcp test: ✓ API-10 page writes suggest and get_page_properties remains a read +packages/mcp test: ✓ suggested API-10 writes do not mutate +packages/mcp test: ✓ create_page applies immediately (page exists) +packages/mcp test: ✓ create_page recorded no suggestion +packages/mcp test: Resolved direct (instance policy = direct) — writes mutate directly +packages/mcp test: ✓ all six API-9 database writes apply under direct policy +packages/mcp test: ✓ whitespace property name is refused under direct policy +packages/mcp test: ✓ direct delete_row moved the row to trash +packages/mcp test: ✓ all four API-10 page tools succeed under direct policy +packages/mcp test: ✓ upload_asset applies under direct policy +packages/mcp test: ✓ update_block confirms a direct write +packages/mcp test: ✓ the page text WAS mutated under instance=direct +packages/mcp test: ✓ direct write recorded NO new suggestion +packages/mcp test: Page override (suggest) beats instance (direct) +packages/mcp test: ✓ page suggest override → a suggestion despite instance direct +packages/mcp test: ✓ page suggest override did NOT mutate +packages/mcp test: ✓ page suggest override recorded a new suggestion +packages/mcp test: Page override (direct) beats instance (suggest) +packages/mcp test: ✓ page direct override → a direct write despite instance suggest +packages/mcp test: ✓ page direct override mutated the page +packages/mcp test: ✓ page direct override recorded NO new suggestion +packages/mcp test: Retired OPENBOOK_MCP_ALLOW_DIRECT_EDITS — never enables direct, logs a deprecation +packages/mcp test: ✓ env var set + policy suggest → still a suggestion +packages/mcp test: ✓ env var did NOT force a direct write +packages/mcp test: ✓ env var write recorded a suggestion (not a mutation) +packages/mcp test: ✓ the connector logged a deprecation for the retired env var +packages/mcp test: Fail-safe: older server (agent-edits route absent) → suggest even under instance=direct +packages/mcp test: ✓ policy fetch failing (404) → a suggestion, not a direct write +packages/mcp test: ✓ fail-safe did NOT mutate despite instance=direct +packages/mcp test: ✓ fail-safe recorded a suggestion +packages/mcp test: ✓ all six API-9 database writes return typed refusals on a read-only instance +packages/mcp test: ✓ API-10 writes refuse read-only while get_page_properties remains readable +packages/mcp test: ✅ ALL 44 CHECKS PASSED — MCP writes honor the resolved agent-edits policy per write (env grant retired). +packages/mcp test: ✓ create_database returns page and database ids +packages/mcp test: ✓ update_database returns the new name +packages/mcp test: ✓ create_property returns text/number/select schemas +packages/mcp test: ✓ update_property returns rename and replacement options +packages/mcp test: ✓ update_row returns merged typed values +packages/mcp test: ✓ describe_database returns schema and counts +packages/mcp test: ✓ unknown database id is a typed error +packages/mcp test: ✓ unknown property id is a typed error +packages/mcp test: ✓ wrong property value is typed and leaks no paths +packages/mcp test: ✓ delete_row soft-deletes to trash +packages/mcp test: ✓ describeDatabaseTool is shared by agent and MCP +packages/mcp test: ✓ createDatabaseTool is shared by agent and MCP +packages/mcp test: ✓ updateDatabaseTool is shared by agent and MCP +packages/mcp test: ✓ createPropertyTool is shared by agent and MCP +packages/mcp test: ✓ updatePropertyTool is shared by agent and MCP +packages/mcp test: ✓ updateRowTool is shared by agent and MCP +packages/mcp test: ✓ deleteRowTool is shared by agent and MCP +packages/mcp test: ✅ ALL 17 CHECKS PASSED — API-9 database round-trip and negatives. +packages/mcp test: ✓ oversize base64 is refused before decode or page lookup +packages/mcp test: ✓ exactly 10 MiB passes the padding-aware pre-check +packages/mcp test: ✓ 10 MiB plus one byte is refused by the padding-aware pre-check +packages/mcp test: ✓ in-app agent refuses upload_asset without direct edit access +packages/mcp test: ✓ in-app agent refuses upload_asset after external-tool taint +packages/mcp test: ✓ PNG upload returns typed metadata without payload +packages/mcp test: ✓ image block round-trips its uploaded assetId +packages/mcp test: ✓ image alt and width update round-trip +packages/mcp test: ✓ htmlArtifact round-trips its inert assetId +packages/mcp test: ✓ disallowed MIME returns a typed error +packages/mcp test: ✓ malformed base64 returns a typed error +packages/mcp test: ✓ missing page returns a typed error +packages/mcp test: ✓ suggest/read-only policy refuses immediate upload +packages/mcp test: 13 asset checks passed. +packages/mcp test: OpenBook server up at http://127.0.0.1:4411 +packages/mcp test: API-1: tool catalogue exposes nested children + the new write tools +packages/mcp test: API-8: rich text inputs materialize as editor runs +packages/mcp test: ✓ update_block parses mini-markdown into bold and link runs +packages/mcp test: ✓ explicit runs round-trip exactly +packages/mcp test: ✓ plain true keeps marker bytes literal +packages/mcp test: ✓ unmarked strings preserve existing plain behavior +packages/mcp test: ✓ the catalogue includes delete_block and update_block_props +packages/mcp test: ✓ the catalogue includes move_block and insert_blocks +packages/mcp test: ✓ append_blocks advertises a recursive `children` array in its JSON Schema +packages/mcp test: ✓ append_blocks documents the table and columns shapes +packages/mcp test: API-1: a nested columns/group payload lands as a real tree +packages/mcp test: ✓ append_blocks confirms a direct write and counts the nested blocks +packages/mcp test: ✓ the tree contains columns → column → group → paragraph at increasing depth +packages/mcp test: ✓ a nested kit input kept its props +packages/mcp test: ✓ nested block ids are unique and addressable +packages/mcp test: ✓ read_page projects the nested text +packages/mcp test: API-7: move_block and insert_blocks apply directly at precise positions +packages/mcp test: ✓ move_block reparents directly using afterId +packages/mcp test: ✓ insert_blocks accepts a nested payload at index +packages/mcp test: ✓ direct move + insert produced B, nested insert, C inside the group +packages/mcp test: API-1: a table → row → cell payload lands as a 3×3 table +packages/mcp test: ✓ the table has 3 rows and 9 cells nested under it +packages/mcp test: ✓ every cell kept its text in payload order +packages/mcp test: ✓ the header row kept its props +packages/mcp test: ✓ move_block allows moving a whole table block +packages/mcp test: ✓ move_block refuses moving table internals in favour of table_* tools +packages/mcp test: API-1: structural caps are refused with an actionable message +packages/mcp test: ✓ a 9-level payload is refused (max 8) with an explicit depth error +packages/mcp test: ✓ an 8-level payload (exactly at the cap) is accepted +packages/mcp test: ✓ a 401-node payload is refused (max 400) counting nested children +packages/mcp test: API-1 (should-fix 1.1): the container parent/child contract is enforced (no silent drop) +packages/mcp test: ✓ children on a non-container leaf are refused, naming the offending type +packages/mcp test: ✓ a top-level row (no table parent) is refused — this is the wall-of-placeholders bug +packages/mcp test: ✓ a cell directly under a table (skipping row) is refused +packages/mcp test: ✓ NONE of the rejected structural payloads mutated the page +packages/mcp test: API-1 (should-fix 7.1/7.2): create_artifact_page accepts a NESTED payload and rejects a nested typo / bad structure +packages/mcp test: ✓ create_artifact_page confirms creation of a nested layout +packages/mcp test: ✓ create_artifact_page returned a new page id +packages/mcp test: ✓ the artifact page materialized columns → column → group → paragraph +packages/mcp test: ✓ create_artifact_page rejects a NESTED typo type, naming it +packages/mcp test: ✓ create_artifact_page rejects a top-level row (same structural guard as append_blocks) +packages/mcp test: API-4 (direct): update_block_props merges shallowly and null removes a key +packages/mcp test: ✓ update_block_props confirms a direct write on an image block +packages/mcp test: ✓ the passed props were merged and the untouched ones survived +packages/mcp test: ✓ a numeric image width is refused as mistyped +packages/mcp test: ✓ update_block_props reaches a NESTED block (inside a group) +packages/mcp test: ✓ an explicit null REMOVED the key (and did not store a null) +packages/mcp test: ✓ the block text is untouched by a props update +packages/mcp test: ✓ update_block_props on an unknown id is a clean error naming inspect_page_structure +packages/mcp test: ✓ update_block_props with no props is refused +packages/mcp test: ✓ update_block_props refuses the table `ord` key, pointing at the table tools +packages/mcp test: ✓ update_block_props refuses the cell `col` key +packages/mcp test: ✓ update_block_props refuses a `col:` column-registry key +packages/mcp test: ✓ update_block_props refuses a `colbg:` column-tint key +packages/mcp test: ✓ a refused table-key write left the block untouched +packages/mcp test: API-4 (direct): delete_block removes nested blocks, table rows, and whole containers +packages/mcp test: ✓ delete_block removes a nested table ROW directly +packages/mcp test: ✓ the row and its 3 cells are gone (subtree removed) +packages/mcp test: ✓ the sibling rows survived +packages/mcp test: ✓ delete_block removes a block nested inside a group +packages/mcp test: ✓ delete_block removes a whole container +packages/mcp test: ✓ only the image block remains +packages/mcp test: ✓ delete_block on an unknown id is a clean error (nothing deleted) +packages/mcp test: ✓ delete_block on an unknown page reports "Page not found" +packages/mcp test: API-4 (should-fix 4.1): deleting a table's last row/cell removes the table (keyed + legacy) +packages/mcp test: ✓ deleting the last row of a KEYED table succeeds +packages/mcp test: ✓ the keyed table is removed WHOLE, the sibling paragraph survives +packages/mcp test: ✓ deleting the last row of a LEGACY table succeeds +packages/mcp test: ✓ the legacy table is gone and the doc self-heals to one paragraph (never zero-root) +packages/mcp test: ✓ deleting the only cell of the only row succeeds +packages/mcp test: ✓ the 1×1 table is removed whole, the sibling paragraph survives +packages/mcp test: API-4 (should-fix 4.2): delete prunes empty containers and never leaves a zero-root doc +packages/mcp test: ✓ deleting the only block in a column succeeds +packages/mcp test: ✓ the emptied column is pruned and the single-column layout is unwrapped +packages/mcp test: ✓ deleting a page's only block succeeds +packages/mcp test: ✓ the page self-heals to exactly one paragraph +packages/mcp test: Policy gate: the new tools + nested payloads go through review under suggest +packages/mcp test: ✓ a nested append under suggest is queued, not applied +packages/mcp test: ✓ delete_block under suggest is queued, not applied +packages/mcp test: ✓ update_block_props under suggest is queued, not applied +packages/mcp test: ✓ move_block under suggest is queued, not applied +packages/mcp test: ✓ insert_blocks under suggest keeps a nested payload +packages/mcp test: ✓ five suggestions were recorded +packages/mcp test: ✓ move_block uses the move review kind and insert_blocks reuses insert +packages/mcp test: ✓ insert_blocks suggestion preserves recursive children +packages/mcp test: ✓ the queued nested payload kept its children (3 rows × 3 cells) +packages/mcp test: ✓ delete_block maps to the `delete` suggestion kind and targets the block +packages/mcp test: ✓ update_block_props maps to `replace-text` with applyKind set_block_props (the bridge's patchBlock) +packages/mcp test: ✓ both new suggestions are authored by the MCP client +packages/mcp test: ✓ the diff card shows a before/after for each +packages/mcp test: ✓ NOTHING was mutated under suggest (block still there, props unchanged) +packages/mcp test: ✓ a cap violation errors under suggest too, queueing nothing +packages/mcp test: ✓ insert_blocks enforces the same depth cap before queueing +packages/mcp test: ✅ ALL 80 CHECKS PASSED — nested children over MCP + delete_block / update_block_props. +packages/mcp test: OpenBook server up at http://127.0.0.1:4412 +packages/mcp test: Catalogue: the twelve table tools are exposed with descriptions +packages/mcp test: ✓ every table tool is registered +packages/mcp test: ✓ each documents that coordinates are RENDER order +packages/mcp test: ✓ table_insert_row documents the header-row refusal +packages/mcp test: ✓ the delete tools document that the last row/column removes the table +packages/mcp test: A table built by append_blocks is immediately inspectable + operable +packages/mcp test: ✓ inspect_table with no tableId lists the page's tables +packages/mcp test: ✓ the table reads back 3×3 with a header row +packages/mcp test: ✓ an append_blocks table has NO order keys yet (reported as unmigrated) +packages/mcp test: ✓ cells are reported in payload order with addressable ids +packages/mcp test: The header-row invariant (the editor hides insert-above on row 0) +packages/mcp test: ✓ inserting a row ABOVE the header row is refused with an explanation +packages/mcp test: ✓ the refusal changed nothing +packages/mcp test: table_insert_row: the first op migrates col:/ord without reshuffling +packages/mcp test: ✓ the insert reports a direct apply and the new size +packages/mcp test: ✓ order keys are now assigned (migrated) with 3 columns +packages/mcp test: ✓ the blank row landed at render position 1 and nothing else moved +packages/mcp test: ✓ the stored projection really carries col:/ord props +packages/mcp test: table_set_cell by index and by cell id +packages/mcp test: ✓ a cell id alone identifies the table AND the coordinates +packages/mcp test: ✓ both addressing styles wrote the cells they meant +packages/mcp test: Columns: insert, move, tint, delete +packages/mcp test: ✓ a column was inserted at render position 1 with a cell in every row +packages/mcp test: ✓ table_move_column reports success +packages/mcp test: ✓ the moved column is last and the others closed up +packages/mcp test: ✓ a column tint is stored as colbg: on the table +packages/mcp test: ✓ a column width is stored as colw: on the table +packages/mcp test: ✓ deleting a column by id removes its cells everywhere +packages/mcp test: ✓ the deleted column left no orphan colbg entry +packages/mcp test: ✓ the deleted column left no orphan colw entry +packages/mcp test: Rows: duplicate, move (by id), tint, delete +packages/mcp test: ✓ duplicate_row copies the row directly below with FRESH ids +packages/mcp test: ✓ the duplicated cells are distinct blocks +packages/mcp test: ✓ table_move_row addressed by row ID moved it to render position 1 +packages/mcp test: ✓ the header row stayed put +packages/mcp test: ✓ a row tint is the row block's bg prop +packages/mcp test: ✓ table_delete_row by id succeeds +packages/mcp test: ✓ the row is gone and the rest kept their render order +packages/mcp test: Bounds + unknown-id errors are clean and actionable +packages/mcp test: ✓ an out-of-range index names the table dimensions +packages/mcp test: ✓ an unknown cell id points at inspect_table +packages/mcp test: ✓ an unknown column id errors cleanly +packages/mcp test: ✓ an unknown tableId errors cleanly +packages/mcp test: ✓ omitting every way of naming the table is refused +packages/mcp test: ✓ a move target past the axis (counted with the row removed) is refused +packages/mcp test: ✓ every refusal left the table exactly as it was +packages/mcp test: A table cell also answers to the generic block tools (no regression) +packages/mcp test: ✓ update_block can still write a cell by its block id +packages/mcp test: The last row / column removes the WHOLE table (editor parity) +packages/mcp test: ✓ deleting the last row says the whole table block was removed +packages/mcp test: ✓ the table really is gone from the page +packages/mcp test: Policy gate: every table op queues a reviewable table-op suggestion +packages/mcp test: ✓ table_insert_row under suggest is queued, not applied +packages/mcp test: ✓ all five ops were queued +packages/mcp test: ✓ every one reviews as the `table-op` suggestion kind anchored on the TABLE block +packages/mcp test: ✓ each payload carries the bridge applyKind = the tool name +packages/mcp test: ✓ payloads carry the page, the table, and RESOLVED sorted coordinates +packages/mcp test: ✓ a node-targeting op also carries the STABLE id it resolved to (survives a reorder in review) +packages/mcp test: ✓ a cell op carries the cell id and a before→after for the diff card +packages/mcp test: ✓ authorship is the MCP client +packages/mcp test: ✓ NOTHING was mutated under suggest — same grid, still unmigrated +packages/mcp test: ✓ the header guard bites under suggest too, queueing nothing +packages/mcp test: ✓ bounds are validated BEFORE the policy branch +packages/mcp test: An accepted suggestion applies IDENTICALLY to a direct write +packages/mcp test: ✓ replaying the queued payload yields the same grid as the direct write +packages/mcp test: ✓ …and it really moved something +packages/mcp test: ✅ ALL 54 CHECKS PASSED — table structure tools over MCP (API-3). +packages/mcp test: FORM-7 read tools + capability redaction +packages/mcp test: ✓ list_forms recursively finds the nested form +packages/mcp test: ✓ list_forms returns summary fields and the database binding +packages/mcp test: ✓ list_forms never returns either submission key +packages/mcp test: ✓ get_form_schema returns the fields plus enabled/databaseId +packages/mcp test: ✓ get_form_schema recursively redacts the capability +packages/mcp test: ✓ inspect_page_structure still shows the nested form +packages/mcp test: ✓ inspect_page_structure redacts both key copies +packages/mcp test: FORM-7 update_block_props capability guard +packages/mcp test: ✓ update_block_props refuses a top-level submissionKey write +packages/mcp test: ✓ update_block_props refuses a nested schema.submissionKey smuggle +packages/mcp test: ✓ both refused probes leave both stored key copies unchanged +packages/mcp test: ✓ update_block_props still applies harmless form props directly +packages/mcp test: ✓ the merged-props success echo contains no submission key bytes +packages/mcp test: FORM-7 list_form_submissions filtering + cursor +packages/mcp test: ✓ list_form_submissions returns one marked row and a cursor +packages/mcp test: ✓ pagination returns exactly the two rows marked for this form +packages/mcp test: ✓ the last submissions page omits nextCursor +packages/mcp test: ✓ a cursor from outside the filtered form is rejected +packages/mcp test: ✓ a database hosted by another page is rejected +packages/mcp test: ✓ a schema-only database binding is not authoritative +packages/mcp test: FORM-7 strict field-op schemas + suggest mode +packages/mcp test: ✓ zod rejects an unknown field kind before the handler +packages/mcp test: ✓ zod rejects an empty update patch +packages/mcp test: ✓ zod rejects an unknown operation discriminator +packages/mcp test: ✓ zod rejects a non-http(s) confirmation redirect +packages/mcp test: ✓ update_form_field queues a suggestion on a suggest page +packages/mcp test: ✓ suggest-mode update_form_field leaves the block unchanged +packages/mcp test: ✓ the field suggestion targets the form block through set_block_props +packages/mcp test: ✓ the suggestion preserves the submission capability without exposing it in the tool result +packages/mcp test: ✓ set_form_settings also queues through the suggest policy +packages/mcp test: ✓ suggest-mode set_form_settings leaves settings unchanged +packages/mcp test: FORM-7 direct field operations + settings +packages/mcp test: ✓ add applies directly on a direct page with a 43-character key +packages/mcp test: ✓ update applies directly +packages/mcp test: ✓ reorder applies directly +packages/mcp test: ✓ remove applies directly +packages/mcp test: ✓ all direct operations landed in final order with the patch +packages/mcp test: ✓ direct field edits preserve both 43-character key copies +packages/mcp test: ✓ set_form_settings applies directly +packages/mcp test: ✓ settings synchronize outer gate props and nested schema +packages/mcp test: ✓ settings carry label/confirmation and accept maxSubmissions=0 +packages/mcp test: ✓ set_form_settings cannot rotate or corrupt the key +packages/mcp test: ✓ nullable settings clear optional values directly +packages/mcp test: ✓ cleared settings are removed rather than stored as null +packages/mcp test: ✅ ALL 40 CHECKS PASSED — FORM-7 tools, policy handling, pagination, validation, and key redaction verified. +packages/mcp test: OpenBook server up at http://127.0.0.1:4414 +packages/mcp test: API-2: the tool catalogue exposes list_block_types +packages/mcp test: ✓ list_block_types is registered +packages/mcp test: ✓ create_artifact_page advertises catalogue types in its schema (generated, not hand-written) +packages/mcp test: API-2: EVERY catalogued type is creatable via create_artifact_page (coverage) +packages/mcp test: ✓ one artifact page holding every catalogued type is accepted +packages/mcp test: ✓ stored page contains a "paragraph" block +packages/mcp test: ✓ stored page contains a "heading" block +packages/mcp test: ✓ stored page contains a "list" block +packages/mcp test: ✓ stored page contains a "todo" block +packages/mcp test: ✓ stored page contains a "quote" block +packages/mcp test: ✓ stored page contains a "callout" block +packages/mcp test: ✓ stored page contains a "code" block +packages/mcp test: ✓ stored page contains a "notes" block +packages/mcp test: ✓ stored page contains a "divider" block +packages/mcp test: ✓ stored page contains a "image" block +packages/mcp test: ✓ stored page contains a "htmlArtifact" block +packages/mcp test: ✓ stored page contains a "columns" block +packages/mcp test: ✓ stored page contains a "column" block +packages/mcp test: ✓ stored page contains a "table" block +packages/mcp test: ✓ stored page contains a "row" block +packages/mcp test: ✓ stored page contains a "cell" block +packages/mcp test: ✓ stored page contains a "group" block +packages/mcp test: ✓ stored page contains a "tabs" block +packages/mcp test: ✓ stored page contains a "tab" block +packages/mcp test: ✓ stored page contains a "accordion" block +packages/mcp test: ✓ stored page contains a "accordionsection" block +packages/mcp test: ✓ stored page contains a "slider" block +packages/mcp test: ✓ stored page contains a "number" block +packages/mcp test: ✓ stored page contains a "textfield" block +packages/mcp test: ✓ stored page contains a "longtext" block +packages/mcp test: ✓ stored page contains a "richtext" block +packages/mcp test: ✓ stored page contains a "toggle" block +packages/mcp test: ✓ stored page contains a "radio" block +packages/mcp test: ✓ stored page contains a "dropdown" block +packages/mcp test: ✓ stored page contains a "checklist" block +packages/mcp test: ✓ stored page contains a "choicecards" block +packages/mcp test: ✓ stored page contains a "searchselect" block +packages/mcp test: ✓ stored page contains a "tagfield" block +packages/mcp test: ✓ stored page contains a "location" block +packages/mcp test: ✓ stored page contains a "actionbutton" block +packages/mcp test: ✓ stored page contains a "kitchart" block +packages/mcp test: ✓ stored page contains a "statuslight" block +packages/mcp test: ✓ stored page contains a "progressbar" block +packages/mcp test: ✓ stored page contains a "formula" block +packages/mcp test: ✓ stored page contains a "linkcard" block +packages/mcp test: ✓ stored page contains a "tooltipcard" block +packages/mcp test: ✓ stored page contains a "dbview" block +packages/mcp test: ✓ stored page contains a "dbform" block +packages/mcp test: ✓ stored page contains a "form" block +packages/mcp test: API-2: unknown types are refused with a pointer at list_block_types +packages/mcp test: ✓ a typo'd type is refused naming the type +packages/mcp test: ✓ the refusal points at list_block_types +packages/mcp test: API-2: the table cell-count rule holds on both write paths +packages/mcp test: ✓ create_artifact_page refuses a ragged table +packages/mcp test: ✓ append_blocks refuses a ragged table +packages/mcp test: API-2: update_block_props validates declared prop VALUE types +packages/mcp test: ✓ a declared prop with the wrong value type is refused, naming prop and expectation +packages/mcp test: ✓ an invalid structured option is refused with a typed error naming opts +packages/mcp test: API-2: plugin block types — rejected while uninstalled, accepted once installed +packages/mcp test: ✓ an uninstalled plugin's type is refused as not installed +packages/mcp test: ✓ list_block_types lists every catalogued core + kit type +packages/mcp test: ✓ with no plugins installed, pluginBlocks is empty +packages/mcp test: ✓ list_block_types filters its payload to requested types +packages/mcp test: ✓ after installing the bundled ledger plugin, its declared blocks are listed +packages/mcp test: ✓ plugin entries carry a description +packages/mcp test: ✓ the installed plugin's namespaced type is now accepted +packages/mcp test: All 60 checks passed. +packages/mcp test: ✅ README covers all 50 registered MCP tools and agent reference sections +packages/mcp test: OpenBook sidecar up (socket + TCP) at http://127.0.0.1:4409 +packages/mcp test: ✓ the server advertises a stable instanceId +packages/mcp test: ✓ the foreign server has a DIFFERENT instanceId +packages/mcp test: Happy path: connector over the unified endpoint lists the SAME pages +packages/mcp test: ✓ connector lists the page the server API shows +packages/mcp test: ✓ list_pages inherits the client list filter (no MCP-side reimplementation) +packages/mcp test: ✓ search_notes inherits the client search filter +packages/mcp test: ✓ an unlisted page remains directly readable +packages/mcp test: Refusal: foreign responder on the target endpoint +packages/mcp test: ✓ connector exits non-zero on an identity mismatch +packages/mcp test: ✓ the error names the mismatch (never silently adopts the foreign server) +packages/mcp test: Refusal: nothing listening on the target port +packages/mcp test: ✓ connector exits non-zero when the endpoint is unreachable +packages/mcp test: ✓ the error is a clear reachability message +packages/mcp test: ✅ ALL 10 CHECKS PASSED — unified endpoint + instance verification. +packages/mcp test: ✓ paragraph +packages/mcp test: ✓ heading +packages/mcp test: ✓ list +packages/mcp test: ✓ todo +packages/mcp test: ✓ quote +packages/mcp test: ✓ callout +packages/mcp test: ✓ code +packages/mcp test: ✓ notes +packages/mcp test: ✓ divider +packages/mcp test: ✓ image +packages/mcp test: ✓ htmlArtifact +packages/mcp test: ✓ columns +packages/mcp test: ✓ column +packages/mcp test: ✓ table +packages/mcp test: ✓ row +packages/mcp test: ✓ cell +packages/mcp test: ✓ group +packages/mcp test: ✓ tabs +packages/mcp test: ✓ tab +packages/mcp test: ✓ accordion +packages/mcp test: ✓ accordionsection +packages/mcp test: ✓ slider +packages/mcp test: ✓ number +packages/mcp test: ✓ textfield +packages/mcp test: ✓ longtext +packages/mcp test: ✓ richtext +packages/mcp test: ✓ toggle +packages/mcp test: ✓ radio +packages/mcp test: ✓ dropdown +packages/mcp test: ✓ checklist +packages/mcp test: ✓ choicecards +packages/mcp test: ✓ searchselect +packages/mcp test: ✓ tagfield +packages/mcp test: ✓ location +packages/mcp test: ✓ actionbutton +packages/mcp test: ✓ kitchart +packages/mcp test: ✓ statuslight +packages/mcp test: ✓ progressbar +packages/mcp test: ✓ formula +packages/mcp test: ✓ linkcard +packages/mcp test: ✓ tooltipcard +packages/mcp test: ✓ dbview +packages/mcp test: ✓ dbform +packages/mcp test: ✓ form +packages/mcp test: ✓ openbook.ledger/journal-entry +packages/mcp test: ✓ openbook.ledger/trial-balance +packages/mcp test: ✓ openbook.ledger/account-register +packages/mcp test: ✓ openbook.ledger/bank-import +packages/mcp test: ✓ openbook.ledger/reconcile +packages/mcp test: ✓ openbook.ledger/balance-sheet +packages/mcp test: ✓ openbook.ledger/income-statement +packages/mcp test: ✓ openbook.ledger/period-close +packages/mcp test: ✓ openbook.ledger/beancount-export +packages/mcp test: All 44 catalogue types + 9 plugin blocks have MCP API coverage; exemptions: none. +packages/mcp test: Done +packages/server test: ❯ src/ableOidc.test.ts (22 tests | 1 failed) 82811ms +packages/server test: × uses last-good discovery while offline and exempts both routes from access/guest/PAT gates 3946ms +packages/server test: ❯ src/mirror.integration.test.ts (3 tests | 1 failed) 18588ms +packages/server test: × mirrors live edits, syncs multiple clients, re-imports external edits, and resolves conflicts DB-wins 11316ms +packages/server test: Failed +/Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server: + ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @book.dev/server@3.17.0 test: `vitest run` +Exit status 130 + ELIFECYCLE  Test failed. See above for more details. + ELIFECYCLE  Command failed with exit code 130. + +``` + +
+ +
+Unsandboxed pnpm verify (SDK property-test timeout; exit 1) + +```text + +> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e + + +> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> node --test eslint-rules/*.test.mjs + +✔ allows spacing-scale utilities and non-spacing arbitrary values (260.361375ms) +✔ flags arbitrary padding, margin, and gap values in className (104.49275ms) +✔ checks nested, template, and conventionally named hoisted class strings (8.074959ms) +✔ allows paint-only hover changes and non-hover geometry (281.659458ms) +✔ flags every guarded hover-geometry utility family (241.359375ms) +✔ flags display swaps in either class ordering and nested class strings (33.757792ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 6 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2950.026541 + +> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build + + +> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk +> tsc -p tsconfig.build.json + + +> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> vite build --config vite.viewer.config.js + +vite v8.0.14 building client environment for production... + +transforming...✓ 2076 modules transformed. +rendering chunks... +computing gzip size... +src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB + +✓ built in 5.76s +vite v8.0.14 building client environment for production... + +transforming...[PLUGIN_TIMINGS] Your build spent significant time in plugin `vite:worker`. See https://rolldown.rs/options/checks#plugintimings for more details. + +✓ 356 modules transformed. +rendering chunks... +computing gzip size... +dist/style.css 190.24 kB │ gzip: 31.11 kB +dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB +dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB +dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB +dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB +dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB +dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB +dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB +dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB +dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB +dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB +dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB +dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB +dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB +dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB +dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB +dist/index.js 2,962.77 kB │ gzip: 813.80 kB + +[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. + +[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. + +✓ built in 6.65s + +> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/bin.js 2.40 KB +ESM dist/index.js 136.00 B +ESM dist/chunk-N6662UMY.js 98.60 KB +ESM dist/index.js.map 71.00 B +ESM dist/bin.js.map 6.65 KB +ESM dist/chunk-N6662UMY.js.map 181.29 KB +ESM ⚡️ Build success in 103ms +DTS Build start +DTS ⚡️ Build success in 6416ms +DTS dist/index.d.ts 1.84 KB + +> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/index.js 552.00 B +ESM dist/bin.js 215.00 B +ESM dist/chunk-Z5QDSVHR.js 415.58 KB +ESM dist/chunk-V6MBCKVR.js 422.16 KB +ESM dist/browser.js 30.24 KB +ESM dist/bin.js.map 556.00 B +ESM dist/index.js.map 71.00 B +ESM dist/browser.js.map 61.42 KB +ESM dist/chunk-V6MBCKVR.js.map 787.04 KB +ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB +ESM ⚡️ Build success in 432ms +DTS Build start +DTS ⚡️ Build success in 25736ms +DTS dist/index.d.ts 68.49 KB +DTS dist/browser.d.ts 13.43 KB +DTS dist/hub-CUJHMMTq.d.ts 136.66 KB + +> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/ui run check:gen + + +> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r --if-present run typecheck + +Scope: 6 of 7 workspace projects +packages/sdk typecheck$ tsc -p tsconfig.json --noEmit +packages/sdk typecheck: Done +packages/ui typecheck$ tsc --noEmit +packages/ui typecheck: Done +packages/app typecheck$ tsc --noEmit +packages/app typecheck: Done +packages/mcp typecheck$ tsc --noEmit +packages/server typecheck$ tsc --noEmit +packages/mcp typecheck: Done +packages/server typecheck: Done +packages/web typecheck$ tsc --noEmit +packages/web typecheck: Done + +> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r run lint + +Scope: 6 of 7 workspace projects +packages/sdk lint$ eslint . +packages/sdk lint: Done +packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint +packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > stylelint src/index.css +packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > node scripts/assert-spacing-stylelint.mjs +packages/ui lint: SPC-2 stylelint controls passed (2 positive, 4 negative) +packages/ui lint: Done +packages/app lint$ eslint . +packages/app lint: Done +packages/mcp lint$ eslint . +packages/server lint$ eslint . +packages/mcp lint: Done +packages/server lint: Done +packages/web lint$ eslint . +packages/web lint: Done + +> open-book@0.0.0-workspace test /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r --if-present run test + +Scope: 6 of 7 workspace projects +packages/sdk test$ vitest run +packages/sdk test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk +packages/sdk test: ❯ src/money.test.ts (27 tests | 1 failed) 7156ms +packages/sdk test: × round-trips 1e6 seeded random amounts with zero drift 6046ms +packages/sdk test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ +packages/sdk test: FAIL src/money.test.ts > parse ↔ format round-trip (property) > round-trips 1e6 seeded random amounts with zero drift +packages/sdk test: Error: Test timed out in 5000ms. +packages/sdk test: If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". +packages/sdk test: ❯ src/money.test.ts:38:3 +packages/sdk test: 36| +packages/sdk test: 37| describe('parse ↔ format round-trip (property)', () => { +packages/sdk test: 38| it('round-trips 1e6 seeded random amounts with zero drift', () => { +packages/sdk test: | ^ +packages/sdk test: 39| const rand = mulberry32(0x1ed6e12); +packages/sdk test: 40| // Deterministic edges first: zero, ±unit, boundary-of-grouping, n… +packages/sdk test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ +packages/sdk test: Test Files 1 failed | 31 passed (32) +packages/sdk test: Tests 1 failed | 545 passed (546) +packages/sdk test: Start at 23:07:57 +packages/sdk test: Duration 8.62s (transform 10.81s, setup 0ms, import 16.65s, tests 12.01s, environment 42ms) +packages/sdk test: Failed +/Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk: + ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @book.dev/sdk@3.17.0 test: `vitest run` +Exit status 1 + ELIFECYCLE  Test failed. See above for more details. + ELIFECYCLE  Command failed with exit code 1. + +``` + +
+ +
+One-worker unsandboxed pnpm verify (UI lint-stage process exit during low disk space; exit 1) + +```text + +> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e + + +> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> node --test eslint-rules/*.test.mjs + +✔ allows spacing-scale utilities and non-spacing arbitrary values (342.133917ms) +✔ flags arbitrary padding, margin, and gap values in className (169.646708ms) +✔ checks nested, template, and conventionally named hoisted class strings (52.958584ms) +✔ allows paint-only hover changes and non-hover geometry (244.081333ms) +✔ flags every guarded hover-geometry utility family (190.840042ms) +✔ flags display swaps in either class ordering and nested class strings (77.12425ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 6 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2477.402459 + +> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build + + +> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk +> tsc -p tsconfig.build.json + + +> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> vite build --config vite.viewer.config.js + +vite v8.0.14 building client environment for production... + +transforming...✓ 2076 modules transformed. +rendering chunks... +computing gzip size... +src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB + +✓ built in 2.88s +vite v8.0.14 building client environment for production... + +transforming...✓ 356 modules transformed. +rendering chunks... +computing gzip size... +dist/style.css 190.24 kB │ gzip: 31.11 kB +dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB +dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB +dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB +dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB +dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB +dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB +dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB +dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB +dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB +dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB +dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB +dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB +dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB +dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB +dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB +dist/index.js 2,962.77 kB │ gzip: 813.80 kB + +[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. + +[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. + +✓ built in 2.91s + +> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/bin.js 2.40 KB +ESM dist/index.js 136.00 B +ESM dist/chunk-N6662UMY.js 98.60 KB +ESM dist/index.js.map 71.00 B +ESM dist/bin.js.map 6.65 KB +ESM dist/chunk-N6662UMY.js.map 181.29 KB +ESM ⚡️ Build success in 65ms +DTS Build start +DTS ⚡️ Build success in 3852ms +DTS dist/index.d.ts 1.84 KB + +> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server +> tsup + +CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} +CLI Using tsconfig: tsconfig.json +CLI tsup v8.5.1 +CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts +CLI Target: node18 +CLI Cleaning output folder +ESM Build start +ESM dist/index.js 552.00 B +ESM dist/bin.js 215.00 B +ESM dist/browser.js 30.24 KB +ESM dist/chunk-Z5QDSVHR.js 415.58 KB +ESM dist/chunk-V6MBCKVR.js 422.16 KB +ESM dist/bin.js.map 556.00 B +ESM dist/index.js.map 71.00 B +ESM dist/browser.js.map 61.42 KB +ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB +ESM dist/chunk-V6MBCKVR.js.map 787.04 KB +ESM ⚡️ Build success in 260ms +DTS Build start +DTS ⚡️ Build success in 10979ms +DTS dist/index.d.ts 68.49 KB +DTS dist/browser.d.ts 13.43 KB +DTS dist/hub-CUJHMMTq.d.ts 136.66 KB + +> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm --filter @book.dev/ui run check:gen + + +> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) + + +> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +> node --import tsx scripts/bundlePlugins.ts + +[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). +wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) +wrote src/export/ledgerFolds.gen (2 fold module(s)) + +> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r --if-present run typecheck + +Scope: 6 of 7 workspace projects +packages/sdk typecheck$ tsc -p tsconfig.json --noEmit +packages/sdk typecheck: Done +packages/ui typecheck$ tsc --noEmit +packages/ui typecheck: Done +packages/app typecheck$ tsc --noEmit +packages/app typecheck: Done +packages/server typecheck$ tsc --noEmit +packages/mcp typecheck$ tsc --noEmit +packages/mcp typecheck: Done +packages/server typecheck: Done +packages/web typecheck$ tsc --noEmit +packages/web typecheck: Done + +> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 +> pnpm -r run lint + +Scope: 6 of 7 workspace projects +packages/sdk lint$ eslint . +packages/sdk lint: Done +packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint +packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > stylelint src/index.css +packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui +packages/ui lint: > node scripts/assert-spacing-stylelint.mjs + +Node.js v24.14.1 + +Node.js v24.14.1 + +``` + +
From 0824d45017fe9f3fd737081e0769567b49670de1 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 00:44:36 +0800 Subject: [PATCH 08/32] chore: drop worker docs (MEET-1) --- _brief.md | 23 - _report.md | 2211 ---------------------------------------------------- 2 files changed, 2234 deletions(-) delete mode 100644 _brief.md delete mode 100644 _report.md diff --git a/_brief.md b/_brief.md deleted file mode 100644 index a03cd284..00000000 --- a/_brief.md +++ /dev/null @@ -1,23 +0,0 @@ -# MEET-1 — Audio asset support in the asset store - -You are a Worker agent on the OpenBook team. Work ONLY in this worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-1`, branch `feat/meet-1-audio-assets`). Do NOT push. Do NOT touch main. Commit incrementally with conventional commits (`feat(server,sdk): … (MEET-1)`). - -## Task -Extend the asset store to accept and serve AUDIO. Today the allowlist is images-only: `ASSET_IMAGE_MIMES` at `packages/sdk/src/importAssets.ts:55`; `safeAssetMime` at `packages/server/src/app.ts:332` rewrites everything else to `application/octet-stream`. Upload route `POST /api/assets?pageId=` at `app.ts:1581-1633`; serve route `GET /api/assets/:id` at `app.ts:1660-1690` (ETag/304, `private, immutable`, nosniff, `Content-Disposition: attachment`, `?encoding=base64` variant). Line numbers are recon-time hints — trust the code. - -## Acceptance (each maps to a test) -1. New audio allowlist in sdk (e.g. `ASSET_AUDIO_MIMES`): `audio/webm`, `audio/ogg`, `audio/mpeg`, `audio/mp4`, `audio/wav`. Audio uploads are stored AND served back with their real MIME, not octet-stream. -2. Route shapes unchanged; ETag / attachment / nosniff behavior preserved for all types. Unit tests cover an audio round-trip including the base64-JSON upload variant (desktop IPC path — see `packages/sdk/src/client.ts:1571`). -3. The 10 MiB per-asset cap (`app.ts:1485`) stays; add a code comment noting meeting-recording chunks are sized under it. -4. GC safety: `gcUnreferencedAssets` (`packages/server/src/store.ts:5610`; NOTE: store.ts contains a NUL byte — use `grep -a`) keeps an asset whose id appears in page data. Add a test proving an audio assetId stored in block props survives GC. -5. Security: audio must never be served inline-executable; keep `Content-Disposition: attachment` and nosniff. No change to access gating (default-deny, `access.ts`). - -## Definition of done -- `pnpm verify` green, run FOREGROUND in this worktree, full output in your report. (Worktree is pre-provisioned; if you see "N test FILES failed, 0 tests failed" it's build artifacts — run `pnpm --filter @book.dev/ui run build:viewer` and `pnpm --filter @book.dev/server build && pnpm --filter @book.dev/mcp build`.) -- All work committed. Do not push. -- Write `_report.md` in the worktree root AND reply with it: outcome first (PASS/FAIL), head sha, criterion→test/commit map, deltas/deviations only, open questions. Terse. - -## Rules -- Never poll/babysit anything external; deliver, report, end. -- If genuinely stuck after a real attempt, commit what you have and report where you're wedged — do not thrash. -- Don't delete or weaken ANY existing test. Reviews will check the full commit range. diff --git a/_report.md b/_report.md deleted file mode 100644 index 89187b72..00000000 --- a/_report.md +++ /dev/null @@ -1,2211 +0,0 @@ -PASS — MEET-1 audio asset support implemented. `VITEST_MAX_WORKERS=1 pnpm verify` passed in the foreground outside the sandbox (exit 0); full output below. No push. - -Verified implementation HEAD: `a535c2f3d6f2daf422d8575e5756a23af30869a8`. Report commit is the final branch HEAD (reported in the handoff). - -| Criterion | Tests | Commit | -| --- | --- | --- | -| 1. Five audio MIME types stored/served | `assets.test.ts`: “round-trips %s through binary and base64-JSON uploads…” (all five); `backups.test.ts`: “preserves audio MIME types and bytes through backup restore” | `451ea1d5` | -| 2. Binary/base64 uploads, routes, ETag/304 and private caching | `assets.test.ts`: five audio round-trip cases, both upload and GET formats | `451ea1d5` | -| 3. 10 MiB cap and chunk-size comment | `assets.test.ts`: “rejects over-cap audio with 413…” (binary + JSON); comment beside `ASSET_MAX_BYTES` | `451ea1d5` | -| 4. GC retains audio ID in block props | `assetGc.test.ts`: “keeps audio referenced only by block props until the reference is removed” (zero ref-table edges; removal permits GC) | `451ea1d5` | -| 5. Attachment/nosniff and unchanged access gates | Audio round-trip header assertions; “applies the same read and write gates to audio…”; existing image/HTML security suites | `451ea1d5` | - -Deltas/deviations: -- Added shared `ASSET_MIMES` union alongside the audio allowlist; both restore paths use it to preserve audio MIME types. -- Corrected an initial new restore assertion to match `getAsset`’s actual return shape (MIME/bytes, no ID). -- First full verify exposed an existing OIDC fixture PAT expired since September 8, 2026 (401 vs expected 302). `a535c2f3` bases its expiry on the PAT wall clock; all assertions retained. Isolated regression passed after correction. -- Sandboxed mirror integration failed with filesystem-watcher `EMFILE`; all three tests passed unchanged outside the sandbox. Stopped the known-failing initial verify (exit 130) and ran the full foreground command outside the sandbox. -- A full unsandboxed attempt hit the unchanged SDK million-case money test’s 5s timeout under host load; all 546 SDK tests passed with `VITEST_MAX_WORKERS=1`. Final verification uses that setting, preserving all assertions and timeouts. -- Another attempt exited during UI stylelint checks while host storage fell to 116 MiB free. Stylelint passed unchanged in isolation; free space recovered before the final run. -- Git emitted a sandbox warning about `packed-refs.lock`; commits succeeded and were verified. - -Validation: SDK 546 passed; UI 2,319 passed; desktop 7 passed; server 1,336 passed, 6 existing skips across 97 files; MCP checks and server/MCP end-to-end suites passed. - -Open questions: none. - -
-Full foreground pnpm verify output (exit 0) - -```text - -> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e - - -> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> node --test eslint-rules/*.test.mjs - -✔ allows spacing-scale utilities and non-spacing arbitrary values (56.527041ms) -✔ flags arbitrary padding, margin, and gap values in className (29.122834ms) -✔ checks nested, template, and conventionally named hoisted class strings (10.808791ms) -✔ allows paint-only hover changes and non-hover geometry (49.224459ms) -✔ flags every guarded hover-geometry utility family (30.007542ms) -✔ flags display swaps in either class ordering and nested class strings (6.503291ms) -ℹ tests 6 -ℹ suites 0 -ℹ pass 6 -ℹ fail 0 -ℹ cancelled 0 -ℹ skipped 0 -ℹ todo 0 -ℹ duration_ms 678.04025 - -> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build - - -> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk -> tsc -p tsconfig.build.json - - -> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> vite build --config vite.viewer.config.js - -vite v8.0.14 building client environment for production... - -transforming...✓ 2076 modules transformed. -rendering chunks... -computing gzip size... -src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB - -✓ built in 2.15s -vite v8.0.14 building client environment for production... - -transforming...✓ 356 modules transformed. -rendering chunks... -computing gzip size... -dist/style.css 190.24 kB │ gzip: 31.11 kB -dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB -dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB -dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB -dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB -dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB -dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB -dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB -dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB -dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB -dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB -dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB -dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB -dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB -dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB -dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB -dist/index.js 2,962.77 kB │ gzip: 813.80 kB - -[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. - -[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. - -[PLUGIN_TIMINGS] Your build spent significant time in plugins. Here is a breakdown: - - vite:asset (45%) - - vite:worker (19%) - - vite:css (13%) - - vite:css-post (7%) -See https://rolldown.rs/options/checks#plugintimings for more details. - -✓ built in 3.87s - -> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/bin.js 2.40 KB -ESM dist/index.js 136.00 B -ESM dist/chunk-N6662UMY.js 98.60 KB -ESM dist/bin.js.map 6.65 KB -ESM dist/index.js.map 71.00 B -ESM dist/chunk-N6662UMY.js.map 181.29 KB -ESM ⚡️ Build success in 90ms -DTS Build start -DTS ⚡️ Build success in 5150ms -DTS dist/index.d.ts 1.84 KB - -> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/bin.js 215.00 B -ESM dist/browser.js 30.24 KB -ESM dist/index.js 552.00 B -ESM dist/chunk-Z5QDSVHR.js 415.58 KB -ESM dist/chunk-V6MBCKVR.js 422.16 KB -ESM dist/bin.js.map 556.00 B -ESM dist/browser.js.map 61.42 KB -ESM dist/index.js.map 71.00 B -ESM dist/chunk-V6MBCKVR.js.map 787.04 KB -ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB -ESM ⚡️ Build success in 236ms -DTS Build start -DTS ⚡️ Build success in 12816ms -DTS dist/index.d.ts 68.49 KB -DTS dist/browser.d.ts 13.43 KB -DTS dist/hub-CUJHMMTq.d.ts 136.66 KB - -> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/ui run check:gen - - -> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r --if-present run typecheck - -Scope: 6 of 7 workspace projects -packages/sdk typecheck$ tsc -p tsconfig.json --noEmit -packages/sdk typecheck: Done -packages/ui typecheck$ tsc --noEmit -packages/ui typecheck: Done -packages/app typecheck$ tsc --noEmit -packages/app typecheck: Done -packages/mcp typecheck$ tsc --noEmit -packages/server typecheck$ tsc --noEmit -packages/mcp typecheck: Done -packages/server typecheck: Done -packages/web typecheck$ tsc --noEmit -packages/web typecheck: Done - -> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r run lint - -Scope: 6 of 7 workspace projects -packages/sdk lint$ eslint . -packages/sdk lint: Done -packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint -packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > stylelint src/index.css -packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > node scripts/assert-spacing-stylelint.mjs -packages/ui lint: SPC-2 stylelint controls passed (2 positive, 4 negative) -packages/ui lint: Done -packages/app lint$ eslint . -packages/app lint: Done -packages/mcp lint$ eslint . -packages/server lint$ eslint . -packages/mcp lint: Done -packages/server lint: Done -packages/web lint$ eslint . -packages/web lint: Done - -> open-book@0.0.0-workspace test /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r --if-present run test - -Scope: 6 of 7 workspace projects -packages/sdk test$ vitest run -packages/sdk test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk -packages/sdk test: Test Files 32 passed (32) -packages/sdk test: Tests 546 passed (546) -packages/sdk test: Start at 23:21:30 -packages/sdk test: Duration 22.72s (transform 2.10s, setup 0ms, import 4.26s, tests 6.54s, environment 8ms) -packages/sdk test: Done -packages/ui test$ vitest run -packages/ui test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui test: Test Files 244 passed (244) -packages/ui test: Tests 2319 passed (2319) -packages/ui test: Start at 23:21:54 -packages/ui test: Duration 719.48s (transform 20.81s, setup 0ms, import 251.50s, tests 212.69s, environment 158.24s) -packages/ui test: Done -packages/app test$ vitest run -packages/app test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/app -packages/app test: Test Files 2 passed (2) -packages/app test: Tests 7 passed (7) -packages/app test: Start at 23:33:54 -packages/app test: Duration 10.63s (transform 6.69s, setup 0ms, import 8.76s, tests 153ms, environment 1.04s) -packages/app test: Done -packages/server test$ vitest run -packages/mcp test$ node --import tsx scripts/listed.test.mts && tsx scripts/pages.test.mts && tsx scripts/suggestions.test.mts && tsx scripts/databases.test.mts && tsx scripts/assets.test.mts && tsx scripts/blocks.test.mts && tsx scripts/tables.test.mts && tsx scripts/forms.test.mts && tsx scripts/blockTypes.test.mts && tsx scripts/readme.test.mts && tsx scripts/endpoint.test.mts && tsx scripts/coverage.test.mts -packages/server test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server -packages/mcp test: ✅ ALL 3 MCP LISTED CONTRACT CHECKS PASSED -packages/mcp test: ✓ set_page_appearance applies in direct mode -packages/mcp test: ✓ valid gradient id resolves to curated css -packages/mcp test: ✓ read_page reflects persisted appearance -packages/mcp test: ✓ move_page reparents a page -packages/mcp test: ✓ list_pages exposes the new parent and order -packages/mcp test: ✓ get/set page properties round-trip -packages/mcp test: ✓ unknown page returns a typed safe error -packages/mcp test: ✓ moving into an own subtree is refused -packages/mcp test: ✓ invalid appearance enum is refused without path leakage -packages/mcp test: ✓ arbitrary gradient css is refused -packages/mcp test: ✓ http image cover is refused -packages/mcp test: ✓ javascript image cover is refused -packages/mcp test: ✓ https image cover is accepted -packages/mcp test: ✓ OpenBook asset image cover is accepted -packages/mcp test: ✓ computed/unknown property writes are typed refusals -packages/mcp test: ✓ read-only instance refuses writes with a typed error -packages/mcp test: ✓ read-only refusal leaves properties unchanged -packages/mcp test: ✅ ALL 17 API-10 PAGE TOOL CHECKS PASSED -packages/mcp test: OpenBook server up at http://127.0.0.1:4406 -packages/mcp test: Resolved suggest (instance suggest, page inherit) — writes create suggestions -packages/mcp test: ✓ upload_asset refuses suggest/read-only policy (bytes never become a suggestion) -packages/mcp test: ✓ update_block returns a "Suggested for review" result -packages/mcp test: ✓ update_block recorded exactly one suggestion -packages/mcp test: ✓ suggestion kind maps update_block → replace-text -packages/mcp test: ✓ suggestion is authored by the MCP client (authorKind ai) -packages/mcp test: ✓ suggestion targets the block -packages/mcp test: ✓ suggestion payload mirrors the agent (applyKind + before merge base) -packages/mcp test: ✓ the page text was NOT mutated (still original) -packages/mcp test: ✓ set_db_cell returns a "Suggested for review" result -packages/mcp test: ✓ set_db_cell recorded a set-cell suggestion on the host page -packages/mcp test: ✓ set-cell suggestion targets the db/row/property -packages/mcp test: ✓ the database cell was NOT mutated -packages/mcp test: ✓ all six API-9 database writes suggest under suggest policy -packages/mcp test: ✓ create_database creates its host page immediately under suggest policy -packages/mcp test: ✓ whitespace property name is refused under suggest policy -packages/mcp test: ✓ describe_database remains a read under suggest policy -packages/mcp test: ✓ suggested update/delete row did not mutate -packages/mcp test: ✓ API-10 page writes suggest and get_page_properties remains a read -packages/mcp test: ✓ suggested API-10 writes do not mutate -packages/mcp test: ✓ create_page applies immediately (page exists) -packages/mcp test: ✓ create_page recorded no suggestion -packages/mcp test: Resolved direct (instance policy = direct) — writes mutate directly -packages/mcp test: ✓ all six API-9 database writes apply under direct policy -packages/mcp test: ✓ whitespace property name is refused under direct policy -packages/mcp test: ✓ direct delete_row moved the row to trash -packages/mcp test: ✓ all four API-10 page tools succeed under direct policy -packages/mcp test: ✓ upload_asset applies under direct policy -packages/mcp test: ✓ update_block confirms a direct write -packages/mcp test: ✓ the page text WAS mutated under instance=direct -packages/mcp test: ✓ direct write recorded NO new suggestion -packages/mcp test: Page override (suggest) beats instance (direct) -packages/mcp test: ✓ page suggest override → a suggestion despite instance direct -packages/mcp test: ✓ page suggest override did NOT mutate -packages/mcp test: ✓ page suggest override recorded a new suggestion -packages/mcp test: Page override (direct) beats instance (suggest) -packages/mcp test: ✓ page direct override → a direct write despite instance suggest -packages/mcp test: ✓ page direct override mutated the page -packages/mcp test: ✓ page direct override recorded NO new suggestion -packages/mcp test: Retired OPENBOOK_MCP_ALLOW_DIRECT_EDITS — never enables direct, logs a deprecation -packages/mcp test: ✓ env var set + policy suggest → still a suggestion -packages/mcp test: ✓ env var did NOT force a direct write -packages/mcp test: ✓ env var write recorded a suggestion (not a mutation) -packages/mcp test: ✓ the connector logged a deprecation for the retired env var -packages/mcp test: Fail-safe: older server (agent-edits route absent) → suggest even under instance=direct -packages/mcp test: ✓ policy fetch failing (404) → a suggestion, not a direct write -packages/mcp test: ✓ fail-safe did NOT mutate despite instance=direct -packages/mcp test: ✓ fail-safe recorded a suggestion -packages/mcp test: ✓ all six API-9 database writes return typed refusals on a read-only instance -packages/mcp test: ✓ API-10 writes refuse read-only while get_page_properties remains readable -packages/mcp test: ✅ ALL 44 CHECKS PASSED — MCP writes honor the resolved agent-edits policy per write (env grant retired). -packages/mcp test: ✓ create_database returns page and database ids -packages/mcp test: ✓ update_database returns the new name -packages/mcp test: ✓ create_property returns text/number/select schemas -packages/mcp test: ✓ update_property returns rename and replacement options -packages/mcp test: ✓ update_row returns merged typed values -packages/mcp test: ✓ describe_database returns schema and counts -packages/mcp test: ✓ unknown database id is a typed error -packages/mcp test: ✓ unknown property id is a typed error -packages/mcp test: ✓ wrong property value is typed and leaks no paths -packages/mcp test: ✓ delete_row soft-deletes to trash -packages/mcp test: ✓ describeDatabaseTool is shared by agent and MCP -packages/mcp test: ✓ createDatabaseTool is shared by agent and MCP -packages/mcp test: ✓ updateDatabaseTool is shared by agent and MCP -packages/mcp test: ✓ createPropertyTool is shared by agent and MCP -packages/mcp test: ✓ updatePropertyTool is shared by agent and MCP -packages/mcp test: ✓ updateRowTool is shared by agent and MCP -packages/mcp test: ✓ deleteRowTool is shared by agent and MCP -packages/mcp test: ✅ ALL 17 CHECKS PASSED — API-9 database round-trip and negatives. -packages/mcp test: ✓ oversize base64 is refused before decode or page lookup -packages/mcp test: ✓ exactly 10 MiB passes the padding-aware pre-check -packages/mcp test: ✓ 10 MiB plus one byte is refused by the padding-aware pre-check -packages/mcp test: ✓ in-app agent refuses upload_asset without direct edit access -packages/mcp test: ✓ in-app agent refuses upload_asset after external-tool taint -packages/mcp test: ✓ PNG upload returns typed metadata without payload -packages/mcp test: ✓ image block round-trips its uploaded assetId -packages/mcp test: ✓ image alt and width update round-trip -packages/mcp test: ✓ htmlArtifact round-trips its inert assetId -packages/mcp test: ✓ disallowed MIME returns a typed error -packages/mcp test: ✓ malformed base64 returns a typed error -packages/mcp test: ✓ missing page returns a typed error -packages/mcp test: ✓ suggest/read-only policy refuses immediate upload -packages/mcp test: 13 asset checks passed. -packages/mcp test: OpenBook server up at http://127.0.0.1:4411 -packages/mcp test: API-1: tool catalogue exposes nested children + the new write tools -packages/mcp test: API-8: rich text inputs materialize as editor runs -packages/mcp test: ✓ update_block parses mini-markdown into bold and link runs -packages/mcp test: ✓ explicit runs round-trip exactly -packages/mcp test: ✓ plain true keeps marker bytes literal -packages/mcp test: ✓ unmarked strings preserve existing plain behavior -packages/mcp test: ✓ the catalogue includes delete_block and update_block_props -packages/mcp test: ✓ the catalogue includes move_block and insert_blocks -packages/mcp test: ✓ append_blocks advertises a recursive `children` array in its JSON Schema -packages/mcp test: ✓ append_blocks documents the table and columns shapes -packages/mcp test: API-1: a nested columns/group payload lands as a real tree -packages/mcp test: ✓ append_blocks confirms a direct write and counts the nested blocks -packages/mcp test: ✓ the tree contains columns → column → group → paragraph at increasing depth -packages/mcp test: ✓ a nested kit input kept its props -packages/mcp test: ✓ nested block ids are unique and addressable -packages/mcp test: ✓ read_page projects the nested text -packages/mcp test: API-7: move_block and insert_blocks apply directly at precise positions -packages/mcp test: ✓ move_block reparents directly using afterId -packages/mcp test: ✓ insert_blocks accepts a nested payload at index -packages/mcp test: ✓ direct move + insert produced B, nested insert, C inside the group -packages/mcp test: API-1: a table → row → cell payload lands as a 3×3 table -packages/mcp test: ✓ the table has 3 rows and 9 cells nested under it -packages/mcp test: ✓ every cell kept its text in payload order -packages/mcp test: ✓ the header row kept its props -packages/mcp test: ✓ move_block allows moving a whole table block -packages/mcp test: ✓ move_block refuses moving table internals in favour of table_* tools -packages/mcp test: API-1: structural caps are refused with an actionable message -packages/mcp test: ✓ a 9-level payload is refused (max 8) with an explicit depth error -packages/mcp test: ✓ an 8-level payload (exactly at the cap) is accepted -packages/mcp test: ✓ a 401-node payload is refused (max 400) counting nested children -packages/mcp test: API-1 (should-fix 1.1): the container parent/child contract is enforced (no silent drop) -packages/mcp test: ✓ children on a non-container leaf are refused, naming the offending type -packages/mcp test: ✓ a top-level row (no table parent) is refused — this is the wall-of-placeholders bug -packages/mcp test: ✓ a cell directly under a table (skipping row) is refused -packages/mcp test: ✓ NONE of the rejected structural payloads mutated the page -packages/mcp test: API-1 (should-fix 7.1/7.2): create_artifact_page accepts a NESTED payload and rejects a nested typo / bad structure -packages/mcp test: ✓ create_artifact_page confirms creation of a nested layout -packages/mcp test: ✓ create_artifact_page returned a new page id -packages/mcp test: ✓ the artifact page materialized columns → column → group → paragraph -packages/mcp test: ✓ create_artifact_page rejects a NESTED typo type, naming it -packages/mcp test: ✓ create_artifact_page rejects a top-level row (same structural guard as append_blocks) -packages/mcp test: API-4 (direct): update_block_props merges shallowly and null removes a key -packages/mcp test: ✓ update_block_props confirms a direct write on an image block -packages/mcp test: ✓ the passed props were merged and the untouched ones survived -packages/mcp test: ✓ a numeric image width is refused as mistyped -packages/mcp test: ✓ update_block_props reaches a NESTED block (inside a group) -packages/mcp test: ✓ an explicit null REMOVED the key (and did not store a null) -packages/mcp test: ✓ the block text is untouched by a props update -packages/mcp test: ✓ update_block_props on an unknown id is a clean error naming inspect_page_structure -packages/mcp test: ✓ update_block_props with no props is refused -packages/mcp test: ✓ update_block_props refuses the table `ord` key, pointing at the table tools -packages/mcp test: ✓ update_block_props refuses the cell `col` key -packages/mcp test: ✓ update_block_props refuses a `col:` column-registry key -packages/mcp test: ✓ update_block_props refuses a `colbg:` column-tint key -packages/mcp test: ✓ a refused table-key write left the block untouched -packages/mcp test: API-4 (direct): delete_block removes nested blocks, table rows, and whole containers -packages/mcp test: ✓ delete_block removes a nested table ROW directly -packages/mcp test: ✓ the row and its 3 cells are gone (subtree removed) -packages/mcp test: ✓ the sibling rows survived -packages/mcp test: ✓ delete_block removes a block nested inside a group -packages/mcp test: ✓ delete_block removes a whole container -packages/mcp test: ✓ only the image block remains -packages/mcp test: ✓ delete_block on an unknown id is a clean error (nothing deleted) -packages/mcp test: ✓ delete_block on an unknown page reports "Page not found" -packages/mcp test: API-4 (should-fix 4.1): deleting a table's last row/cell removes the table (keyed + legacy) -packages/mcp test: ✓ deleting the last row of a KEYED table succeeds -packages/mcp test: ✓ the keyed table is removed WHOLE, the sibling paragraph survives -packages/mcp test: ✓ deleting the last row of a LEGACY table succeeds -packages/mcp test: ✓ the legacy table is gone and the doc self-heals to one paragraph (never zero-root) -packages/mcp test: ✓ deleting the only cell of the only row succeeds -packages/mcp test: ✓ the 1×1 table is removed whole, the sibling paragraph survives -packages/mcp test: API-4 (should-fix 4.2): delete prunes empty containers and never leaves a zero-root doc -packages/mcp test: ✓ deleting the only block in a column succeeds -packages/mcp test: ✓ the emptied column is pruned and the single-column layout is unwrapped -packages/mcp test: ✓ deleting a page's only block succeeds -packages/mcp test: ✓ the page self-heals to exactly one paragraph -packages/mcp test: Policy gate: the new tools + nested payloads go through review under suggest -packages/mcp test: ✓ a nested append under suggest is queued, not applied -packages/mcp test: ✓ delete_block under suggest is queued, not applied -packages/mcp test: ✓ update_block_props under suggest is queued, not applied -packages/mcp test: ✓ move_block under suggest is queued, not applied -packages/mcp test: ✓ insert_blocks under suggest keeps a nested payload -packages/mcp test: ✓ five suggestions were recorded -packages/mcp test: ✓ move_block uses the move review kind and insert_blocks reuses insert -packages/mcp test: ✓ insert_blocks suggestion preserves recursive children -packages/mcp test: ✓ the queued nested payload kept its children (3 rows × 3 cells) -packages/mcp test: ✓ delete_block maps to the `delete` suggestion kind and targets the block -packages/mcp test: ✓ update_block_props maps to `replace-text` with applyKind set_block_props (the bridge's patchBlock) -packages/mcp test: ✓ both new suggestions are authored by the MCP client -packages/mcp test: ✓ the diff card shows a before/after for each -packages/mcp test: ✓ NOTHING was mutated under suggest (block still there, props unchanged) -packages/mcp test: ✓ a cap violation errors under suggest too, queueing nothing -packages/mcp test: ✓ insert_blocks enforces the same depth cap before queueing -packages/mcp test: ✅ ALL 80 CHECKS PASSED — nested children over MCP + delete_block / update_block_props. -packages/mcp test: OpenBook server up at http://127.0.0.1:4412 -packages/mcp test: Catalogue: the twelve table tools are exposed with descriptions -packages/mcp test: ✓ every table tool is registered -packages/mcp test: ✓ each documents that coordinates are RENDER order -packages/mcp test: ✓ table_insert_row documents the header-row refusal -packages/mcp test: ✓ the delete tools document that the last row/column removes the table -packages/mcp test: A table built by append_blocks is immediately inspectable + operable -packages/mcp test: ✓ inspect_table with no tableId lists the page's tables -packages/mcp test: ✓ the table reads back 3×3 with a header row -packages/mcp test: ✓ an append_blocks table has NO order keys yet (reported as unmigrated) -packages/mcp test: ✓ cells are reported in payload order with addressable ids -packages/mcp test: The header-row invariant (the editor hides insert-above on row 0) -packages/mcp test: ✓ inserting a row ABOVE the header row is refused with an explanation -packages/mcp test: ✓ the refusal changed nothing -packages/mcp test: table_insert_row: the first op migrates col:/ord without reshuffling -packages/mcp test: ✓ the insert reports a direct apply and the new size -packages/mcp test: ✓ order keys are now assigned (migrated) with 3 columns -packages/mcp test: ✓ the blank row landed at render position 1 and nothing else moved -packages/mcp test: ✓ the stored projection really carries col:/ord props -packages/mcp test: table_set_cell by index and by cell id -packages/mcp test: ✓ a cell id alone identifies the table AND the coordinates -packages/mcp test: ✓ both addressing styles wrote the cells they meant -packages/mcp test: Columns: insert, move, tint, delete -packages/mcp test: ✓ a column was inserted at render position 1 with a cell in every row -packages/mcp test: ✓ table_move_column reports success -packages/mcp test: ✓ the moved column is last and the others closed up -packages/mcp test: ✓ a column tint is stored as colbg: on the table -packages/mcp test: ✓ a column width is stored as colw: on the table -packages/mcp test: ✓ deleting a column by id removes its cells everywhere -packages/mcp test: ✓ the deleted column left no orphan colbg entry -packages/mcp test: ✓ the deleted column left no orphan colw entry -packages/mcp test: Rows: duplicate, move (by id), tint, delete -packages/mcp test: ✓ duplicate_row copies the row directly below with FRESH ids -packages/mcp test: ✓ the duplicated cells are distinct blocks -packages/mcp test: ✓ table_move_row addressed by row ID moved it to render position 1 -packages/mcp test: ✓ the header row stayed put -packages/mcp test: ✓ a row tint is the row block's bg prop -packages/mcp test: ✓ table_delete_row by id succeeds -packages/mcp test: ✓ the row is gone and the rest kept their render order -packages/mcp test: Bounds + unknown-id errors are clean and actionable -packages/mcp test: ✓ an out-of-range index names the table dimensions -packages/mcp test: ✓ an unknown cell id points at inspect_table -packages/mcp test: ✓ an unknown column id errors cleanly -packages/mcp test: ✓ an unknown tableId errors cleanly -packages/mcp test: ✓ omitting every way of naming the table is refused -packages/mcp test: ✓ a move target past the axis (counted with the row removed) is refused -packages/mcp test: ✓ every refusal left the table exactly as it was -packages/mcp test: A table cell also answers to the generic block tools (no regression) -packages/mcp test: ✓ update_block can still write a cell by its block id -packages/mcp test: The last row / column removes the WHOLE table (editor parity) -packages/mcp test: ✓ deleting the last row says the whole table block was removed -packages/mcp test: ✓ the table really is gone from the page -packages/mcp test: Policy gate: every table op queues a reviewable table-op suggestion -packages/mcp test: ✓ table_insert_row under suggest is queued, not applied -packages/mcp test: ✓ all five ops were queued -packages/mcp test: ✓ every one reviews as the `table-op` suggestion kind anchored on the TABLE block -packages/mcp test: ✓ each payload carries the bridge applyKind = the tool name -packages/mcp test: ✓ payloads carry the page, the table, and RESOLVED sorted coordinates -packages/mcp test: ✓ a node-targeting op also carries the STABLE id it resolved to (survives a reorder in review) -packages/mcp test: ✓ a cell op carries the cell id and a before→after for the diff card -packages/mcp test: ✓ authorship is the MCP client -packages/mcp test: ✓ NOTHING was mutated under suggest — same grid, still unmigrated -packages/mcp test: ✓ the header guard bites under suggest too, queueing nothing -packages/mcp test: ✓ bounds are validated BEFORE the policy branch -packages/mcp test: An accepted suggestion applies IDENTICALLY to a direct write -packages/mcp test: ✓ replaying the queued payload yields the same grid as the direct write -packages/mcp test: ✓ …and it really moved something -packages/mcp test: ✅ ALL 54 CHECKS PASSED — table structure tools over MCP (API-3). -packages/mcp test: FORM-7 read tools + capability redaction -packages/mcp test: ✓ list_forms recursively finds the nested form -packages/mcp test: ✓ list_forms returns summary fields and the database binding -packages/mcp test: ✓ list_forms never returns either submission key -packages/mcp test: ✓ get_form_schema returns the fields plus enabled/databaseId -packages/mcp test: ✓ get_form_schema recursively redacts the capability -packages/mcp test: ✓ inspect_page_structure still shows the nested form -packages/mcp test: ✓ inspect_page_structure redacts both key copies -packages/mcp test: FORM-7 update_block_props capability guard -packages/mcp test: ✓ update_block_props refuses a top-level submissionKey write -packages/mcp test: ✓ update_block_props refuses a nested schema.submissionKey smuggle -packages/mcp test: ✓ both refused probes leave both stored key copies unchanged -packages/mcp test: ✓ update_block_props still applies harmless form props directly -packages/mcp test: ✓ the merged-props success echo contains no submission key bytes -packages/mcp test: FORM-7 list_form_submissions filtering + cursor -packages/mcp test: ✓ list_form_submissions returns one marked row and a cursor -packages/mcp test: ✓ pagination returns exactly the two rows marked for this form -packages/mcp test: ✓ the last submissions page omits nextCursor -packages/mcp test: ✓ a cursor from outside the filtered form is rejected -packages/mcp test: ✓ a database hosted by another page is rejected -packages/mcp test: ✓ a schema-only database binding is not authoritative -packages/mcp test: FORM-7 strict field-op schemas + suggest mode -packages/mcp test: ✓ zod rejects an unknown field kind before the handler -packages/mcp test: ✓ zod rejects an empty update patch -packages/mcp test: ✓ zod rejects an unknown operation discriminator -packages/mcp test: ✓ zod rejects a non-http(s) confirmation redirect -packages/mcp test: ✓ update_form_field queues a suggestion on a suggest page -packages/mcp test: ✓ suggest-mode update_form_field leaves the block unchanged -packages/mcp test: ✓ the field suggestion targets the form block through set_block_props -packages/mcp test: ✓ the suggestion preserves the submission capability without exposing it in the tool result -packages/mcp test: ✓ set_form_settings also queues through the suggest policy -packages/mcp test: ✓ suggest-mode set_form_settings leaves settings unchanged -packages/mcp test: FORM-7 direct field operations + settings -packages/mcp test: ✓ add applies directly on a direct page with a 43-character key -packages/mcp test: ✓ update applies directly -packages/mcp test: ✓ reorder applies directly -packages/mcp test: ✓ remove applies directly -packages/mcp test: ✓ all direct operations landed in final order with the patch -packages/mcp test: ✓ direct field edits preserve both 43-character key copies -packages/mcp test: ✓ set_form_settings applies directly -packages/mcp test: ✓ settings synchronize outer gate props and nested schema -packages/mcp test: ✓ settings carry label/confirmation and accept maxSubmissions=0 -packages/mcp test: ✓ set_form_settings cannot rotate or corrupt the key -packages/mcp test: ✓ nullable settings clear optional values directly -packages/mcp test: ✓ cleared settings are removed rather than stored as null -packages/mcp test: ✅ ALL 40 CHECKS PASSED — FORM-7 tools, policy handling, pagination, validation, and key redaction verified. -packages/mcp test: OpenBook server up at http://127.0.0.1:4414 -packages/mcp test: API-2: the tool catalogue exposes list_block_types -packages/mcp test: ✓ list_block_types is registered -packages/mcp test: ✓ create_artifact_page advertises catalogue types in its schema (generated, not hand-written) -packages/mcp test: API-2: EVERY catalogued type is creatable via create_artifact_page (coverage) -packages/mcp test: ✓ one artifact page holding every catalogued type is accepted -packages/mcp test: ✓ stored page contains a "paragraph" block -packages/mcp test: ✓ stored page contains a "heading" block -packages/mcp test: ✓ stored page contains a "list" block -packages/mcp test: ✓ stored page contains a "todo" block -packages/mcp test: ✓ stored page contains a "quote" block -packages/mcp test: ✓ stored page contains a "callout" block -packages/mcp test: ✓ stored page contains a "code" block -packages/mcp test: ✓ stored page contains a "notes" block -packages/mcp test: ✓ stored page contains a "divider" block -packages/mcp test: ✓ stored page contains a "image" block -packages/mcp test: ✓ stored page contains a "htmlArtifact" block -packages/mcp test: ✓ stored page contains a "columns" block -packages/mcp test: ✓ stored page contains a "column" block -packages/mcp test: ✓ stored page contains a "table" block -packages/mcp test: ✓ stored page contains a "row" block -packages/mcp test: ✓ stored page contains a "cell" block -packages/mcp test: ✓ stored page contains a "group" block -packages/mcp test: ✓ stored page contains a "tabs" block -packages/mcp test: ✓ stored page contains a "tab" block -packages/mcp test: ✓ stored page contains a "accordion" block -packages/mcp test: ✓ stored page contains a "accordionsection" block -packages/mcp test: ✓ stored page contains a "slider" block -packages/mcp test: ✓ stored page contains a "number" block -packages/mcp test: ✓ stored page contains a "textfield" block -packages/mcp test: ✓ stored page contains a "longtext" block -packages/mcp test: ✓ stored page contains a "richtext" block -packages/mcp test: ✓ stored page contains a "toggle" block -packages/mcp test: ✓ stored page contains a "radio" block -packages/mcp test: ✓ stored page contains a "dropdown" block -packages/mcp test: ✓ stored page contains a "checklist" block -packages/mcp test: ✓ stored page contains a "choicecards" block -packages/mcp test: ✓ stored page contains a "searchselect" block -packages/mcp test: ✓ stored page contains a "tagfield" block -packages/mcp test: ✓ stored page contains a "location" block -packages/mcp test: ✓ stored page contains a "actionbutton" block -packages/mcp test: ✓ stored page contains a "kitchart" block -packages/mcp test: ✓ stored page contains a "statuslight" block -packages/mcp test: ✓ stored page contains a "progressbar" block -packages/mcp test: ✓ stored page contains a "formula" block -packages/mcp test: ✓ stored page contains a "linkcard" block -packages/mcp test: ✓ stored page contains a "tooltipcard" block -packages/mcp test: ✓ stored page contains a "dbview" block -packages/mcp test: ✓ stored page contains a "dbform" block -packages/mcp test: ✓ stored page contains a "form" block -packages/mcp test: API-2: unknown types are refused with a pointer at list_block_types -packages/mcp test: ✓ a typo'd type is refused naming the type -packages/mcp test: ✓ the refusal points at list_block_types -packages/mcp test: API-2: the table cell-count rule holds on both write paths -packages/mcp test: ✓ create_artifact_page refuses a ragged table -packages/mcp test: ✓ append_blocks refuses a ragged table -packages/mcp test: API-2: update_block_props validates declared prop VALUE types -packages/mcp test: ✓ a declared prop with the wrong value type is refused, naming prop and expectation -packages/mcp test: ✓ an invalid structured option is refused with a typed error naming opts -packages/mcp test: API-2: plugin block types — rejected while uninstalled, accepted once installed -packages/mcp test: ✓ an uninstalled plugin's type is refused as not installed -packages/mcp test: ✓ list_block_types lists every catalogued core + kit type -packages/mcp test: ✓ with no plugins installed, pluginBlocks is empty -packages/mcp test: ✓ list_block_types filters its payload to requested types -packages/mcp test: ✓ after installing the bundled ledger plugin, its declared blocks are listed -packages/mcp test: ✓ plugin entries carry a description -packages/mcp test: ✓ the installed plugin's namespaced type is now accepted -packages/mcp test: All 60 checks passed. -packages/mcp test: ✅ README covers all 50 registered MCP tools and agent reference sections -packages/mcp test: OpenBook sidecar up (socket + TCP) at http://127.0.0.1:4409 -packages/mcp test: ✓ the server advertises a stable instanceId -packages/mcp test: ✓ the foreign server has a DIFFERENT instanceId -packages/mcp test: Happy path: connector over the unified endpoint lists the SAME pages -packages/mcp test: ✓ connector lists the page the server API shows -packages/mcp test: ✓ list_pages inherits the client list filter (no MCP-side reimplementation) -packages/mcp test: ✓ search_notes inherits the client search filter -packages/mcp test: ✓ an unlisted page remains directly readable -packages/mcp test: Refusal: foreign responder on the target endpoint -packages/mcp test: ✓ connector exits non-zero on an identity mismatch -packages/mcp test: ✓ the error names the mismatch (never silently adopts the foreign server) -packages/mcp test: Refusal: nothing listening on the target port -packages/mcp test: ✓ connector exits non-zero when the endpoint is unreachable -packages/mcp test: ✓ the error is a clear reachability message -packages/mcp test: ✅ ALL 10 CHECKS PASSED — unified endpoint + instance verification. -packages/mcp test: ✓ paragraph -packages/mcp test: ✓ heading -packages/mcp test: ✓ list -packages/mcp test: ✓ todo -packages/mcp test: ✓ quote -packages/mcp test: ✓ callout -packages/mcp test: ✓ code -packages/mcp test: ✓ notes -packages/mcp test: ✓ divider -packages/mcp test: ✓ image -packages/mcp test: ✓ htmlArtifact -packages/mcp test: ✓ columns -packages/mcp test: ✓ column -packages/mcp test: ✓ table -packages/mcp test: ✓ row -packages/mcp test: ✓ cell -packages/mcp test: ✓ group -packages/mcp test: ✓ tabs -packages/mcp test: ✓ tab -packages/mcp test: ✓ accordion -packages/mcp test: ✓ accordionsection -packages/mcp test: ✓ slider -packages/mcp test: ✓ number -packages/mcp test: ✓ textfield -packages/mcp test: ✓ longtext -packages/mcp test: ✓ richtext -packages/mcp test: ✓ toggle -packages/mcp test: ✓ radio -packages/mcp test: ✓ dropdown -packages/mcp test: ✓ checklist -packages/mcp test: ✓ choicecards -packages/mcp test: ✓ searchselect -packages/mcp test: ✓ tagfield -packages/mcp test: ✓ location -packages/mcp test: ✓ actionbutton -packages/mcp test: ✓ kitchart -packages/mcp test: ✓ statuslight -packages/mcp test: ✓ progressbar -packages/mcp test: ✓ formula -packages/mcp test: ✓ linkcard -packages/mcp test: ✓ tooltipcard -packages/mcp test: ✓ dbview -packages/mcp test: ✓ dbform -packages/mcp test: ✓ form -packages/mcp test: ✓ openbook.ledger/journal-entry -packages/mcp test: ✓ openbook.ledger/trial-balance -packages/mcp test: ✓ openbook.ledger/account-register -packages/mcp test: ✓ openbook.ledger/bank-import -packages/mcp test: ✓ openbook.ledger/reconcile -packages/mcp test: ✓ openbook.ledger/balance-sheet -packages/mcp test: ✓ openbook.ledger/income-statement -packages/mcp test: ✓ openbook.ledger/period-close -packages/mcp test: ✓ openbook.ledger/beancount-export -packages/mcp test: All 44 catalogue types + 9 plugin blocks have MCP API coverage; exemptions: none. -packages/mcp test: Done -packages/server test: Test Files 97 passed (97) -packages/server test: Tests 1336 passed | 6 skipped (1342) -packages/server test: Start at 23:34:06 -packages/server test: Duration 3819.18s (transform 6.63s, setup 0ms, import 67.16s, tests 3724.62s, environment 19ms) -packages/server test: Done - -> open-book@0.0.0-workspace test:e2e /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/server run test:e2e && pnpm --filter @book.dev/mcp run test:e2e - - -> @book.dev/server@3.17.0 test:e2e /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server -> tsx scripts/e2e.mts - - -=== 1. EMBEDDED MODE (embedded PGlite) === - server up at http://127.0.0.1:4401 - ✓ health endpoint returns ok - -[embedded] API responses are non-cacheable - ✓ /api/pages sends Cache-Control: no-store - ✓ /api/trash sends Cache-Control: no-store - -[embedded] CRUD via HttpDataClient - ✓ create returns a uuid - ✓ create round-trips name - ✓ create round-trips values - ✓ create round-trips names - ✓ create sets timestamps - ✓ get returns the page - ✓ get round-trips data - ✓ get of unknown id -> null - ✓ list includes the page - ✓ list items carry no data payload - ✓ update keeps id - ✓ update replaces values - ✓ update bumps updatedAt - ✓ duplicate name allowed (distinct page) - ✓ both same-named pages are listed - ✓ rename changes the name - ✓ rename preserves data - ✓ delete returns true - ✓ get after delete -> null - ✓ second delete returns false - -[embedded] Page properties: owner, verification, backlinks - ✓ backlinks include the linking page - ✓ backlinks exclude the target itself - ✓ a never-linked page has no backlinks - ✓ owner persists - ✓ verification persists - ✓ a second property merges (owner preserved) - ✓ a content save preserves properties - ✓ a relation property counts as a backlink - ✓ the mention link is still a backlink too - ✓ backlink clears when the linker is trashed - -[embedded] Database via HttpDataClient - ✓ create database returns id - ✓ database is linked to its host page - ✓ database round-trips schema - ✓ host page reports hostedDatabaseId - ✓ host page keeps its own content - ✓ database is reachable via its host page - ✓ host page appears in the page list - ✓ row create returns a page id - ✓ row carries its database membership - ✓ rows are excluded from the page list - ✓ listRows returns both rows - ✓ row round-trips manual property - ✓ row projects exported cell value - ✓ rows list in creation order - ✓ reorderRows sets the manual order - ✓ new row appends at the bottom - ✓ sub-item carries its parentId - ✓ top-level rows have a null parentId - ✓ exports refresh after a content save - ✓ updateRow changes the title - ✓ updateRow changes a property - ✓ updateRow leaves exports intact - ✓ applyView sorts by title ascending - ✓ delete host page (soft) - ✓ host page hidden after delete - ✓ database survives a soft delete - ✓ host page restores - ✓ rows survive the round-trip - ✓ purge host page - ✓ database removed by cascade after purge - ✓ row page removed by cascade after purge - -[embedded] Nested pages via HttpDataClient - ✓ child records its parent - ✓ grandchild records its parent - ✓ top-level page has no parent - ✓ nested pages appear in the list with parentId - ✓ content save preserves the parent - ✓ delete parent (soft) - ✓ child hidden with the parent - ✓ grandchild hidden with the parent - ✓ parent is a trash root - ✓ nested child is not a separate trash root - ✓ restore parent - ✓ child restored with the parent - ✓ grandchild restored with the parent - ✓ parent left the trash - -[embedded] Page ordering & movePage - ✓ new children list in creation order (appended) - ✓ movePage returns the moved page - ✓ movePage reorders siblings - ✓ movePage re-parents the page - ✓ the re-nested page leaves its old group - ✓ the re-nested page joins its new parent - ✓ movePage rejects a cycle (409) - -[embedded] Whole-space backup: export / import - ✓ export includes live pages with data - ✓ export includes nested pages - ✓ copy import creates new pages - ✓ copy import suffixes clashing names - ✓ copy import: parent copied under a fresh id - ✓ copy import: nesting preserved - ✓ overwrite replaces by id (no new page) - ✓ overwrite applies the new content - -[embedded] Trash: restore, purge, empty - ✓ soft delete returns true - ✓ soft-deleted page is hidden - ✓ soft-deleted page is in the trash - ✓ a trashed name is freed for reuse - ✓ restore brings the page back - ✓ restore keeps the original name despite a live twin - ✓ restored page is visible again - ✓ purge a single trashed page - ✓ purging a missing page returns false - ✓ a purged page cannot be restored - ✓ delete a row (soft) - ✓ row leaves the database view - ✓ row appears in the trash - ✓ restore the row - ✓ row returns to the database view - ✓ emptyTrash purges remaining trash - ✓ trash is empty afterwards - -[embedded] optional local AI (mock engine) - ✓ ai defaults to off - ✓ lexical search works with AI off - ✓ lexical search ranks the budget note first - ✓ search returns a snippet - ✓ config accepts the mock provider - ✓ mock engine reports ready + embeddings - ✓ search upgrades to hybrid with an embedding engine - ✓ task breakdown returns parsed tasks - ✓ tasks are clean strings (no list markers) - ✓ completion streams tokens over SSE - ✓ completion stream closes with done - ✓ reindex counts pages - ✓ unknown provider rejected (400) - ✓ agent calls search_notes first - ✓ agent streams the tool result - ✓ agent ends with a grounded final answer - ✓ agent events arrive in order (tool → result → final) - ✓ agent rejects an empty conversation (400) - -[embedded] extensions (plugins API) - ✓ install returns the stored plugin - ✓ installed plugin is listed - ✓ signature survives the round-trip - ✓ stored package verifies against the trusted key - ✓ malformed id rejected (400) - ✓ missing entry file rejected (400) - ✓ disable persists - ✓ remove returns true - ✓ removed plugin is gone - -=== 2. PERSISTENCE ACROSS RESTART === - ✓ seeded a page before restart - server stopped - server restarted at http://127.0.0.1:4401 - ✓ page survived restart - ✓ data survived restart - ✓ manual order survived restart - -=== 3. HEADLESS MODE (Postgres wire protocol) === - postgres-compatible socket up at 127.0.0.1:5599 - headless server up at http://127.0.0.1:4402 - -[headless] CRUD via HttpDataClient - ✓ create returns a uuid - ✓ create round-trips name - ✓ create round-trips values - ✓ create round-trips names - ✓ create sets timestamps - ✓ get returns the page - ✓ get round-trips data - ✓ get of unknown id -> null - ✓ list includes the page - ✓ list items carry no data payload - ✓ update keeps id - ✓ update replaces values - ✓ update bumps updatedAt - ✓ duplicate name allowed (distinct page) - ✓ both same-named pages are listed - ✓ rename changes the name - ✓ rename preserves data - ✓ delete returns true - ✓ get after delete -> null - ✓ second delete returns false - -[headless] Page properties: owner, verification, backlinks - ✓ backlinks include the linking page - ✓ backlinks exclude the target itself - ✓ a never-linked page has no backlinks - ✓ owner persists - ✓ verification persists - ✓ a second property merges (owner preserved) - ✓ a content save preserves properties - ✓ a relation property counts as a backlink - ✓ the mention link is still a backlink too - ✓ backlink clears when the linker is trashed - -[headless] Database via HttpDataClient - ✓ create database returns id - ✓ database is linked to its host page - ✓ database round-trips schema - ✓ host page reports hostedDatabaseId - ✓ host page keeps its own content - ✓ database is reachable via its host page - ✓ host page appears in the page list - ✓ row create returns a page id - ✓ row carries its database membership - ✓ rows are excluded from the page list - ✓ listRows returns both rows - ✓ row round-trips manual property - ✓ row projects exported cell value - ✓ rows list in creation order - ✓ reorderRows sets the manual order - ✓ new row appends at the bottom - ✓ sub-item carries its parentId - ✓ top-level rows have a null parentId - ✓ exports refresh after a content save - ✓ updateRow changes the title - ✓ updateRow changes a property - ✓ updateRow leaves exports intact - ✓ applyView sorts by title ascending - ✓ delete host page (soft) - ✓ host page hidden after delete - ✓ database survives a soft delete - ✓ host page restores - ✓ rows survive the round-trip - ✓ purge host page - ✓ database removed by cascade after purge - ✓ row page removed by cascade after purge - -[headless] Nested pages via HttpDataClient - ✓ child records its parent - ✓ grandchild records its parent - ✓ top-level page has no parent - ✓ nested pages appear in the list with parentId - ✓ content save preserves the parent - ✓ delete parent (soft) - ✓ child hidden with the parent - ✓ grandchild hidden with the parent - ✓ parent is a trash root - ✓ nested child is not a separate trash root - ✓ restore parent - ✓ child restored with the parent - ✓ grandchild restored with the parent - ✓ parent left the trash - -[headless] Page ordering & movePage - ✓ new children list in creation order (appended) - ✓ movePage returns the moved page - ✓ movePage reorders siblings - ✓ movePage re-parents the page - ✓ the re-nested page leaves its old group - ✓ the re-nested page joins its new parent - ✓ movePage rejects a cycle (409) - -[headless] Trash: restore, purge, empty - ✓ soft delete returns true - ✓ soft-deleted page is hidden - ✓ soft-deleted page is in the trash - ✓ a trashed name is freed for reuse - ✓ restore brings the page back - ✓ restore keeps the original name despite a live twin - ✓ restored page is visible again - ✓ purge a single trashed page - ✓ purging a missing page returns false - ✓ a purged page cannot be restored - ✓ delete a row (soft) - ✓ row leaves the database view - ✓ row appears in the trash - ✓ restore the row - ✓ row returns to the database view - ✓ emptyTrash purges remaining trash - ✓ trash is empty afterwards - -=== 4. TRASH CLEANUP JOB === - ✓ janitor: page created - ✓ janitor: soft delete -OpenBook trash cleanup: purged 1 expired page(s) - ✓ janitor: cleanup job purged the expired page - ✓ janitor: purged page is gone for good - -=== 5. ACCESS-TOKEN AUTH === - ✓ tokenless request rejected (401) - ✓ tokenless write rejected (401) - ✓ token-bearing client can write - ✓ token-bearing client can read - ✓ raw ?token= authenticates - ✓ raw missing token is 401 - ✓ health stays open without a token - -=== 6. LEDGER EXPORT + VERIFY === - ✓ ledger: entry posted with entry number 1 - ✓ ledger: canonical CSV export is byte-stable - ✓ ledger: CSV carries the posting rows - ✓ ledger: verify route answers 200 - ✓ ledger: independent verifier reports a CLEAN book - -✅ ALL 256 CHECKS PASSED — embedded, persistence, headless, trash-cleanup, access-token, and ledger flows verified. - -> @book.dev/mcp@3.17.0 test:e2e /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp -> tsx scripts/e2e.mts - - -OpenBook server up at http://127.0.0.1:4402 - -MCP handshake + tool catalogue - ✓ handshake reports the openbook server - ✓ the registered tool set is non-trivial and duplicate-free - ✓ all 50 registered tools are listed (derived from src/server.ts) - ✓ tools carry descriptions - ✓ handshake advertises upload_asset - -Read tools - ✓ list_pages includes seeded pages - ✓ read_page returns title and body - ✓ read_page flags a missing page as an error - ✓ search_notes ranks the planning note first - -Write tools - ✓ create_page returns the new id - ✓ set_page_appearance is available through the handshake - ✓ move_page is available through the handshake - ✓ set_page_properties is available through the handshake - ✓ get_page_properties is available through the handshake - ✓ append_to_page queues a suggestion - ✓ append_to_page did not mutate the page - ✓ append_to_page recorded an insert suggestion - ✓ append_to_page proposes on a block-editor page - ✓ append_to_page did not mutate the block page - ✓ create_page allows a duplicate title (new page) - -Artifact tool - ✓ create_artifact_page returns the new id - ✓ the artifact is stamped for the block editor - ✓ all five blocks landed in order - ✓ read_page sees the artifact heading - ✓ unknown block types are rejected - -Database tools - ✓ describe_database is callable in the handshake - ✓ create_database is callable in the handshake - ✓ update_database is callable in the handshake - ✓ create_property is callable in the handshake - ✓ update_property is callable in the handshake - ✓ update_row is callable in the handshake - ✓ delete_row is callable in the handshake - ✓ list_database_rows lists the seeded row - ✓ create_database_row confirms - ✓ the new row shows up - ✓ list_database_rows flags a page without a database - -Inspection tools (T11) - ✓ inspect_page_structure shows the block tree - ✓ inspect_page_structure exposes block ids - ✓ get_kit_values reads the published input - ✓ get_kit_values reports a page with no kit values - ✓ list_db_views lists the database views - ✓ get_db_row reads the row by id - -Write tools (T11) — default = reviewable suggestions - ✓ set_kit_value queues a suggestion - ✓ set_kit_value did not mutate the value (still 3) - ✓ set_kit_value rejects an unknown input - ✓ update_block queues a suggestion - ✓ update_block did not mutate the heading - ✓ update_block rejects an unknown block id - ✓ append_blocks queues a suggestion - ✓ append_blocks did not mutate the page - ✓ append_blocks refuses legacy editor pages - ✓ move_block queues a suggestion - ✓ insert_blocks queues a nested suggestion - ✓ the artifact page collected the queued edit suggestions - ✓ set_db_cell queues a suggestion - ✓ set_db_cell did not mutate the cell - ✓ set_db_cell recorded a set-cell suggestion - ✓ set_db_cell rejects an unknown property - -Form tools (FORM-7) — redacted reads, suggest then direct - ✓ list_forms finds the seeded form with summary metadata - ✓ list_forms does not expose the submission key - ✓ get_form_schema returns the field and binding - ✓ get_form_schema redacts both key copies - ✓ inspect_page_structure redacts form capabilities - ✓ list_form_submissions returns only the sys_form_submission-marked row - ✓ update_form_field queues a suggestion under the default policy - ✓ suggest-mode update_form_field leaves the form unchanged - ✓ the form edit is a set_block_props suggestion targeting the form block - ✓ update_form_field applies directly under a page direct override - ✓ set_form_settings applies directly - ✓ direct field and settings edits are readable and still redacted - -✅ ALL 70 CHECKS PASSED — MCP handshake, catalogue, and every tool verified. - -``` - -
- -
-Initial sandboxed pnpm verify (stopped after confirmed failures; exit 130) - -```text - -> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e - - -> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> node --test eslint-rules/*.test.mjs - -✔ allows spacing-scale utilities and non-spacing arbitrary values (16.106917ms) -✔ flags arbitrary padding, margin, and gap values in className (5.420291ms) -✔ checks nested, template, and conventionally named hoisted class strings (2.09ms) -✔ allows paint-only hover changes and non-hover geometry (13.772042ms) -✔ flags every guarded hover-geometry utility family (14.331958ms) -✔ flags display swaps in either class ordering and nested class strings (3.465834ms) -ℹ tests 6 -ℹ suites 0 -ℹ pass 6 -ℹ fail 0 -ℹ cancelled 0 -ℹ skipped 0 -ℹ todo 0 -ℹ duration_ms 138.82 - -> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build - - -> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk -> tsc -p tsconfig.build.json - - -> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> vite build --config vite.viewer.config.js - -vite v8.0.14 building client environment for production... - -transforming...✓ 2076 modules transformed. -rendering chunks... -computing gzip size... -src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB - -✓ built in 512ms -vite v8.0.14 building client environment for production... - -transforming...✓ 356 modules transformed. -rendering chunks... -computing gzip size... -dist/style.css 190.24 kB │ gzip: 31.11 kB -dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB -dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB -dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB -dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB -dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB -dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB -dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB -dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB -dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB -dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB -dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB -dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB -dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB -dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB -dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB -dist/index.js 2,962.77 kB │ gzip: 813.80 kB - -[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. - -[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. - -✓ built in 444ms - -> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/chunk-N6662UMY.js 98.60 KB -ESM dist/index.js 136.00 B -ESM dist/bin.js 2.40 KB -ESM dist/chunk-N6662UMY.js.map 181.29 KB -ESM dist/bin.js.map 6.65 KB -ESM dist/index.js.map 71.00 B -ESM ⚡️ Build success in 11ms -DTS Build start -DTS ⚡️ Build success in 673ms -DTS dist/index.d.ts 1.84 KB - -> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/index.js 552.00 B -ESM dist/browser.js 30.24 KB -ESM dist/bin.js 215.00 B -ESM dist/chunk-Z5QDSVHR.js 415.58 KB -ESM dist/chunk-V6MBCKVR.js 422.16 KB -ESM dist/index.js.map 71.00 B -ESM dist/bin.js.map 556.00 B -ESM dist/browser.js.map 61.42 KB -ESM dist/chunk-V6MBCKVR.js.map 787.04 KB -ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB -ESM ⚡️ Build success in 42ms -DTS Build start -DTS ⚡️ Build success in 2136ms -DTS dist/index.d.ts 68.49 KB -DTS dist/browser.d.ts 13.43 KB -DTS dist/hub-CUJHMMTq.d.ts 136.66 KB - -> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/ui run check:gen - - -> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r --if-present run typecheck - -Scope: 6 of 7 workspace projects -packages/sdk typecheck$ tsc -p tsconfig.json --noEmit -packages/sdk typecheck: Done -packages/ui typecheck$ tsc --noEmit -packages/ui typecheck: Done -packages/app typecheck$ tsc --noEmit -packages/app typecheck: Done -packages/mcp typecheck$ tsc --noEmit -packages/server typecheck$ tsc --noEmit -packages/mcp typecheck: Done -packages/server typecheck: Done -packages/web typecheck$ tsc --noEmit -packages/web typecheck: Done - -> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r run lint - -Scope: 6 of 7 workspace projects -packages/sdk lint$ eslint . -packages/sdk lint: Done -packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint -packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > stylelint src/index.css -packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > node scripts/assert-spacing-stylelint.mjs -packages/ui lint: SPC-2 stylelint controls passed (2 positive, 4 negative) -packages/ui lint: Done -packages/app lint$ eslint . -packages/app lint: Done -packages/mcp lint$ eslint . -packages/server lint$ eslint . -packages/mcp lint: Done -packages/server lint: Done -packages/web lint$ eslint . -packages/web lint: Done - -> open-book@0.0.0-workspace test /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r --if-present run test - -Scope: 6 of 7 workspace projects -packages/sdk test$ vitest run -packages/sdk test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk -packages/sdk test: Test Files 32 passed (32) -packages/sdk test: Tests 546 passed (546) -packages/sdk test: Start at 21:47:29 -packages/sdk test: Duration 2.01s (transform 2.52s, setup 0ms, import 3.86s, tests 3.20s, environment 3ms) -packages/sdk test: Done -packages/ui test$ vitest run -packages/ui test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui test: Test Files 244 passed (244) -packages/ui test: Tests 2319 passed (2319) -packages/ui test: Start at 21:47:31 -packages/ui test: Duration 44.12s (transform 20.81s, setup 0ms, import 150.57s, tests 100.48s, environment 95.13s) -packages/ui test: Done -packages/app test$ vitest run -packages/app test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/app -packages/app test: Test Files 2 passed (2) -packages/app test: Tests 7 passed (7) -packages/app test: Start at 21:48:16 -packages/app test: Duration 3.30s (transform 2.15s, setup 0ms, import 2.97s, tests 66ms, environment 380ms) -packages/app test: Done -packages/mcp test$ node --import tsx scripts/listed.test.mts && tsx scripts/pages.test.mts && tsx scripts/suggestions.test.mts && tsx scripts/databases.test.mts && tsx scripts/assets.test.mts && tsx scripts/blocks.test.mts && tsx scripts/tables.test.mts && tsx scripts/forms.test.mts && tsx scripts/blockTypes.test.mts && tsx scripts/readme.test.mts && tsx scripts/endpoint.test.mts && tsx scripts/coverage.test.mts -packages/server test$ vitest run -packages/server test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server -packages/mcp test: ✅ ALL 3 MCP LISTED CONTRACT CHECKS PASSED -packages/mcp test: ✓ set_page_appearance applies in direct mode -packages/mcp test: ✓ valid gradient id resolves to curated css -packages/mcp test: ✓ read_page reflects persisted appearance -packages/mcp test: ✓ move_page reparents a page -packages/mcp test: ✓ list_pages exposes the new parent and order -packages/mcp test: ✓ get/set page properties round-trip -packages/mcp test: ✓ unknown page returns a typed safe error -packages/mcp test: ✓ moving into an own subtree is refused -packages/mcp test: ✓ invalid appearance enum is refused without path leakage -packages/mcp test: ✓ arbitrary gradient css is refused -packages/mcp test: ✓ http image cover is refused -packages/mcp test: ✓ javascript image cover is refused -packages/mcp test: ✓ https image cover is accepted -packages/mcp test: ✓ OpenBook asset image cover is accepted -packages/mcp test: ✓ computed/unknown property writes are typed refusals -packages/mcp test: ✓ read-only instance refuses writes with a typed error -packages/mcp test: ✓ read-only refusal leaves properties unchanged -packages/mcp test: ✅ ALL 17 API-10 PAGE TOOL CHECKS PASSED -packages/mcp test: OpenBook server up at http://127.0.0.1:4406 -packages/mcp test: Resolved suggest (instance suggest, page inherit) — writes create suggestions -packages/mcp test: ✓ upload_asset refuses suggest/read-only policy (bytes never become a suggestion) -packages/mcp test: ✓ update_block returns a "Suggested for review" result -packages/mcp test: ✓ update_block recorded exactly one suggestion -packages/mcp test: ✓ suggestion kind maps update_block → replace-text -packages/mcp test: ✓ suggestion is authored by the MCP client (authorKind ai) -packages/mcp test: ✓ suggestion targets the block -packages/mcp test: ✓ suggestion payload mirrors the agent (applyKind + before merge base) -packages/mcp test: ✓ the page text was NOT mutated (still original) -packages/mcp test: ✓ set_db_cell returns a "Suggested for review" result -packages/mcp test: ✓ set_db_cell recorded a set-cell suggestion on the host page -packages/mcp test: ✓ set-cell suggestion targets the db/row/property -packages/mcp test: ✓ the database cell was NOT mutated -packages/mcp test: ✓ all six API-9 database writes suggest under suggest policy -packages/mcp test: ✓ create_database creates its host page immediately under suggest policy -packages/mcp test: ✓ whitespace property name is refused under suggest policy -packages/mcp test: ✓ describe_database remains a read under suggest policy -packages/mcp test: ✓ suggested update/delete row did not mutate -packages/mcp test: ✓ API-10 page writes suggest and get_page_properties remains a read -packages/mcp test: ✓ suggested API-10 writes do not mutate -packages/mcp test: ✓ create_page applies immediately (page exists) -packages/mcp test: ✓ create_page recorded no suggestion -packages/mcp test: Resolved direct (instance policy = direct) — writes mutate directly -packages/mcp test: ✓ all six API-9 database writes apply under direct policy -packages/mcp test: ✓ whitespace property name is refused under direct policy -packages/mcp test: ✓ direct delete_row moved the row to trash -packages/mcp test: ✓ all four API-10 page tools succeed under direct policy -packages/mcp test: ✓ upload_asset applies under direct policy -packages/mcp test: ✓ update_block confirms a direct write -packages/mcp test: ✓ the page text WAS mutated under instance=direct -packages/mcp test: ✓ direct write recorded NO new suggestion -packages/mcp test: Page override (suggest) beats instance (direct) -packages/mcp test: ✓ page suggest override → a suggestion despite instance direct -packages/mcp test: ✓ page suggest override did NOT mutate -packages/mcp test: ✓ page suggest override recorded a new suggestion -packages/mcp test: Page override (direct) beats instance (suggest) -packages/mcp test: ✓ page direct override → a direct write despite instance suggest -packages/mcp test: ✓ page direct override mutated the page -packages/mcp test: ✓ page direct override recorded NO new suggestion -packages/mcp test: Retired OPENBOOK_MCP_ALLOW_DIRECT_EDITS — never enables direct, logs a deprecation -packages/mcp test: ✓ env var set + policy suggest → still a suggestion -packages/mcp test: ✓ env var did NOT force a direct write -packages/mcp test: ✓ env var write recorded a suggestion (not a mutation) -packages/mcp test: ✓ the connector logged a deprecation for the retired env var -packages/mcp test: Fail-safe: older server (agent-edits route absent) → suggest even under instance=direct -packages/mcp test: ✓ policy fetch failing (404) → a suggestion, not a direct write -packages/mcp test: ✓ fail-safe did NOT mutate despite instance=direct -packages/mcp test: ✓ fail-safe recorded a suggestion -packages/mcp test: ✓ all six API-9 database writes return typed refusals on a read-only instance -packages/mcp test: ✓ API-10 writes refuse read-only while get_page_properties remains readable -packages/mcp test: ✅ ALL 44 CHECKS PASSED — MCP writes honor the resolved agent-edits policy per write (env grant retired). -packages/mcp test: ✓ create_database returns page and database ids -packages/mcp test: ✓ update_database returns the new name -packages/mcp test: ✓ create_property returns text/number/select schemas -packages/mcp test: ✓ update_property returns rename and replacement options -packages/mcp test: ✓ update_row returns merged typed values -packages/mcp test: ✓ describe_database returns schema and counts -packages/mcp test: ✓ unknown database id is a typed error -packages/mcp test: ✓ unknown property id is a typed error -packages/mcp test: ✓ wrong property value is typed and leaks no paths -packages/mcp test: ✓ delete_row soft-deletes to trash -packages/mcp test: ✓ describeDatabaseTool is shared by agent and MCP -packages/mcp test: ✓ createDatabaseTool is shared by agent and MCP -packages/mcp test: ✓ updateDatabaseTool is shared by agent and MCP -packages/mcp test: ✓ createPropertyTool is shared by agent and MCP -packages/mcp test: ✓ updatePropertyTool is shared by agent and MCP -packages/mcp test: ✓ updateRowTool is shared by agent and MCP -packages/mcp test: ✓ deleteRowTool is shared by agent and MCP -packages/mcp test: ✅ ALL 17 CHECKS PASSED — API-9 database round-trip and negatives. -packages/mcp test: ✓ oversize base64 is refused before decode or page lookup -packages/mcp test: ✓ exactly 10 MiB passes the padding-aware pre-check -packages/mcp test: ✓ 10 MiB plus one byte is refused by the padding-aware pre-check -packages/mcp test: ✓ in-app agent refuses upload_asset without direct edit access -packages/mcp test: ✓ in-app agent refuses upload_asset after external-tool taint -packages/mcp test: ✓ PNG upload returns typed metadata without payload -packages/mcp test: ✓ image block round-trips its uploaded assetId -packages/mcp test: ✓ image alt and width update round-trip -packages/mcp test: ✓ htmlArtifact round-trips its inert assetId -packages/mcp test: ✓ disallowed MIME returns a typed error -packages/mcp test: ✓ malformed base64 returns a typed error -packages/mcp test: ✓ missing page returns a typed error -packages/mcp test: ✓ suggest/read-only policy refuses immediate upload -packages/mcp test: 13 asset checks passed. -packages/mcp test: OpenBook server up at http://127.0.0.1:4411 -packages/mcp test: API-1: tool catalogue exposes nested children + the new write tools -packages/mcp test: API-8: rich text inputs materialize as editor runs -packages/mcp test: ✓ update_block parses mini-markdown into bold and link runs -packages/mcp test: ✓ explicit runs round-trip exactly -packages/mcp test: ✓ plain true keeps marker bytes literal -packages/mcp test: ✓ unmarked strings preserve existing plain behavior -packages/mcp test: ✓ the catalogue includes delete_block and update_block_props -packages/mcp test: ✓ the catalogue includes move_block and insert_blocks -packages/mcp test: ✓ append_blocks advertises a recursive `children` array in its JSON Schema -packages/mcp test: ✓ append_blocks documents the table and columns shapes -packages/mcp test: API-1: a nested columns/group payload lands as a real tree -packages/mcp test: ✓ append_blocks confirms a direct write and counts the nested blocks -packages/mcp test: ✓ the tree contains columns → column → group → paragraph at increasing depth -packages/mcp test: ✓ a nested kit input kept its props -packages/mcp test: ✓ nested block ids are unique and addressable -packages/mcp test: ✓ read_page projects the nested text -packages/mcp test: API-7: move_block and insert_blocks apply directly at precise positions -packages/mcp test: ✓ move_block reparents directly using afterId -packages/mcp test: ✓ insert_blocks accepts a nested payload at index -packages/mcp test: ✓ direct move + insert produced B, nested insert, C inside the group -packages/mcp test: API-1: a table → row → cell payload lands as a 3×3 table -packages/mcp test: ✓ the table has 3 rows and 9 cells nested under it -packages/mcp test: ✓ every cell kept its text in payload order -packages/mcp test: ✓ the header row kept its props -packages/mcp test: ✓ move_block allows moving a whole table block -packages/mcp test: ✓ move_block refuses moving table internals in favour of table_* tools -packages/mcp test: API-1: structural caps are refused with an actionable message -packages/mcp test: ✓ a 9-level payload is refused (max 8) with an explicit depth error -packages/mcp test: ✓ an 8-level payload (exactly at the cap) is accepted -packages/mcp test: ✓ a 401-node payload is refused (max 400) counting nested children -packages/mcp test: API-1 (should-fix 1.1): the container parent/child contract is enforced (no silent drop) -packages/mcp test: ✓ children on a non-container leaf are refused, naming the offending type -packages/mcp test: ✓ a top-level row (no table parent) is refused — this is the wall-of-placeholders bug -packages/mcp test: ✓ a cell directly under a table (skipping row) is refused -packages/mcp test: ✓ NONE of the rejected structural payloads mutated the page -packages/mcp test: API-1 (should-fix 7.1/7.2): create_artifact_page accepts a NESTED payload and rejects a nested typo / bad structure -packages/mcp test: ✓ create_artifact_page confirms creation of a nested layout -packages/mcp test: ✓ create_artifact_page returned a new page id -packages/mcp test: ✓ the artifact page materialized columns → column → group → paragraph -packages/mcp test: ✓ create_artifact_page rejects a NESTED typo type, naming it -packages/mcp test: ✓ create_artifact_page rejects a top-level row (same structural guard as append_blocks) -packages/mcp test: API-4 (direct): update_block_props merges shallowly and null removes a key -packages/mcp test: ✓ update_block_props confirms a direct write on an image block -packages/mcp test: ✓ the passed props were merged and the untouched ones survived -packages/mcp test: ✓ a numeric image width is refused as mistyped -packages/mcp test: ✓ update_block_props reaches a NESTED block (inside a group) -packages/mcp test: ✓ an explicit null REMOVED the key (and did not store a null) -packages/mcp test: ✓ the block text is untouched by a props update -packages/mcp test: ✓ update_block_props on an unknown id is a clean error naming inspect_page_structure -packages/mcp test: ✓ update_block_props with no props is refused -packages/mcp test: ✓ update_block_props refuses the table `ord` key, pointing at the table tools -packages/mcp test: ✓ update_block_props refuses the cell `col` key -packages/mcp test: ✓ update_block_props refuses a `col:` column-registry key -packages/mcp test: ✓ update_block_props refuses a `colbg:` column-tint key -packages/mcp test: ✓ a refused table-key write left the block untouched -packages/mcp test: API-4 (direct): delete_block removes nested blocks, table rows, and whole containers -packages/mcp test: ✓ delete_block removes a nested table ROW directly -packages/mcp test: ✓ the row and its 3 cells are gone (subtree removed) -packages/mcp test: ✓ the sibling rows survived -packages/mcp test: ✓ delete_block removes a block nested inside a group -packages/mcp test: ✓ delete_block removes a whole container -packages/mcp test: ✓ only the image block remains -packages/mcp test: ✓ delete_block on an unknown id is a clean error (nothing deleted) -packages/mcp test: ✓ delete_block on an unknown page reports "Page not found" -packages/mcp test: API-4 (should-fix 4.1): deleting a table's last row/cell removes the table (keyed + legacy) -packages/mcp test: ✓ deleting the last row of a KEYED table succeeds -packages/mcp test: ✓ the keyed table is removed WHOLE, the sibling paragraph survives -packages/mcp test: ✓ deleting the last row of a LEGACY table succeeds -packages/mcp test: ✓ the legacy table is gone and the doc self-heals to one paragraph (never zero-root) -packages/mcp test: ✓ deleting the only cell of the only row succeeds -packages/mcp test: ✓ the 1×1 table is removed whole, the sibling paragraph survives -packages/mcp test: API-4 (should-fix 4.2): delete prunes empty containers and never leaves a zero-root doc -packages/mcp test: ✓ deleting the only block in a column succeeds -packages/mcp test: ✓ the emptied column is pruned and the single-column layout is unwrapped -packages/mcp test: ✓ deleting a page's only block succeeds -packages/mcp test: ✓ the page self-heals to exactly one paragraph -packages/mcp test: Policy gate: the new tools + nested payloads go through review under suggest -packages/mcp test: ✓ a nested append under suggest is queued, not applied -packages/mcp test: ✓ delete_block under suggest is queued, not applied -packages/mcp test: ✓ update_block_props under suggest is queued, not applied -packages/mcp test: ✓ move_block under suggest is queued, not applied -packages/mcp test: ✓ insert_blocks under suggest keeps a nested payload -packages/mcp test: ✓ five suggestions were recorded -packages/mcp test: ✓ move_block uses the move review kind and insert_blocks reuses insert -packages/mcp test: ✓ insert_blocks suggestion preserves recursive children -packages/mcp test: ✓ the queued nested payload kept its children (3 rows × 3 cells) -packages/mcp test: ✓ delete_block maps to the `delete` suggestion kind and targets the block -packages/mcp test: ✓ update_block_props maps to `replace-text` with applyKind set_block_props (the bridge's patchBlock) -packages/mcp test: ✓ both new suggestions are authored by the MCP client -packages/mcp test: ✓ the diff card shows a before/after for each -packages/mcp test: ✓ NOTHING was mutated under suggest (block still there, props unchanged) -packages/mcp test: ✓ a cap violation errors under suggest too, queueing nothing -packages/mcp test: ✓ insert_blocks enforces the same depth cap before queueing -packages/mcp test: ✅ ALL 80 CHECKS PASSED — nested children over MCP + delete_block / update_block_props. -packages/mcp test: OpenBook server up at http://127.0.0.1:4412 -packages/mcp test: Catalogue: the twelve table tools are exposed with descriptions -packages/mcp test: ✓ every table tool is registered -packages/mcp test: ✓ each documents that coordinates are RENDER order -packages/mcp test: ✓ table_insert_row documents the header-row refusal -packages/mcp test: ✓ the delete tools document that the last row/column removes the table -packages/mcp test: A table built by append_blocks is immediately inspectable + operable -packages/mcp test: ✓ inspect_table with no tableId lists the page's tables -packages/mcp test: ✓ the table reads back 3×3 with a header row -packages/mcp test: ✓ an append_blocks table has NO order keys yet (reported as unmigrated) -packages/mcp test: ✓ cells are reported in payload order with addressable ids -packages/mcp test: The header-row invariant (the editor hides insert-above on row 0) -packages/mcp test: ✓ inserting a row ABOVE the header row is refused with an explanation -packages/mcp test: ✓ the refusal changed nothing -packages/mcp test: table_insert_row: the first op migrates col:/ord without reshuffling -packages/mcp test: ✓ the insert reports a direct apply and the new size -packages/mcp test: ✓ order keys are now assigned (migrated) with 3 columns -packages/mcp test: ✓ the blank row landed at render position 1 and nothing else moved -packages/mcp test: ✓ the stored projection really carries col:/ord props -packages/mcp test: table_set_cell by index and by cell id -packages/mcp test: ✓ a cell id alone identifies the table AND the coordinates -packages/mcp test: ✓ both addressing styles wrote the cells they meant -packages/mcp test: Columns: insert, move, tint, delete -packages/mcp test: ✓ a column was inserted at render position 1 with a cell in every row -packages/mcp test: ✓ table_move_column reports success -packages/mcp test: ✓ the moved column is last and the others closed up -packages/mcp test: ✓ a column tint is stored as colbg: on the table -packages/mcp test: ✓ a column width is stored as colw: on the table -packages/mcp test: ✓ deleting a column by id removes its cells everywhere -packages/mcp test: ✓ the deleted column left no orphan colbg entry -packages/mcp test: ✓ the deleted column left no orphan colw entry -packages/mcp test: Rows: duplicate, move (by id), tint, delete -packages/mcp test: ✓ duplicate_row copies the row directly below with FRESH ids -packages/mcp test: ✓ the duplicated cells are distinct blocks -packages/mcp test: ✓ table_move_row addressed by row ID moved it to render position 1 -packages/mcp test: ✓ the header row stayed put -packages/mcp test: ✓ a row tint is the row block's bg prop -packages/mcp test: ✓ table_delete_row by id succeeds -packages/mcp test: ✓ the row is gone and the rest kept their render order -packages/mcp test: Bounds + unknown-id errors are clean and actionable -packages/mcp test: ✓ an out-of-range index names the table dimensions -packages/mcp test: ✓ an unknown cell id points at inspect_table -packages/mcp test: ✓ an unknown column id errors cleanly -packages/mcp test: ✓ an unknown tableId errors cleanly -packages/mcp test: ✓ omitting every way of naming the table is refused -packages/mcp test: ✓ a move target past the axis (counted with the row removed) is refused -packages/mcp test: ✓ every refusal left the table exactly as it was -packages/mcp test: A table cell also answers to the generic block tools (no regression) -packages/mcp test: ✓ update_block can still write a cell by its block id -packages/mcp test: The last row / column removes the WHOLE table (editor parity) -packages/mcp test: ✓ deleting the last row says the whole table block was removed -packages/mcp test: ✓ the table really is gone from the page -packages/mcp test: Policy gate: every table op queues a reviewable table-op suggestion -packages/mcp test: ✓ table_insert_row under suggest is queued, not applied -packages/mcp test: ✓ all five ops were queued -packages/mcp test: ✓ every one reviews as the `table-op` suggestion kind anchored on the TABLE block -packages/mcp test: ✓ each payload carries the bridge applyKind = the tool name -packages/mcp test: ✓ payloads carry the page, the table, and RESOLVED sorted coordinates -packages/mcp test: ✓ a node-targeting op also carries the STABLE id it resolved to (survives a reorder in review) -packages/mcp test: ✓ a cell op carries the cell id and a before→after for the diff card -packages/mcp test: ✓ authorship is the MCP client -packages/mcp test: ✓ NOTHING was mutated under suggest — same grid, still unmigrated -packages/mcp test: ✓ the header guard bites under suggest too, queueing nothing -packages/mcp test: ✓ bounds are validated BEFORE the policy branch -packages/mcp test: An accepted suggestion applies IDENTICALLY to a direct write -packages/mcp test: ✓ replaying the queued payload yields the same grid as the direct write -packages/mcp test: ✓ …and it really moved something -packages/mcp test: ✅ ALL 54 CHECKS PASSED — table structure tools over MCP (API-3). -packages/mcp test: FORM-7 read tools + capability redaction -packages/mcp test: ✓ list_forms recursively finds the nested form -packages/mcp test: ✓ list_forms returns summary fields and the database binding -packages/mcp test: ✓ list_forms never returns either submission key -packages/mcp test: ✓ get_form_schema returns the fields plus enabled/databaseId -packages/mcp test: ✓ get_form_schema recursively redacts the capability -packages/mcp test: ✓ inspect_page_structure still shows the nested form -packages/mcp test: ✓ inspect_page_structure redacts both key copies -packages/mcp test: FORM-7 update_block_props capability guard -packages/mcp test: ✓ update_block_props refuses a top-level submissionKey write -packages/mcp test: ✓ update_block_props refuses a nested schema.submissionKey smuggle -packages/mcp test: ✓ both refused probes leave both stored key copies unchanged -packages/mcp test: ✓ update_block_props still applies harmless form props directly -packages/mcp test: ✓ the merged-props success echo contains no submission key bytes -packages/mcp test: FORM-7 list_form_submissions filtering + cursor -packages/mcp test: ✓ list_form_submissions returns one marked row and a cursor -packages/mcp test: ✓ pagination returns exactly the two rows marked for this form -packages/mcp test: ✓ the last submissions page omits nextCursor -packages/mcp test: ✓ a cursor from outside the filtered form is rejected -packages/mcp test: ✓ a database hosted by another page is rejected -packages/mcp test: ✓ a schema-only database binding is not authoritative -packages/mcp test: FORM-7 strict field-op schemas + suggest mode -packages/mcp test: ✓ zod rejects an unknown field kind before the handler -packages/mcp test: ✓ zod rejects an empty update patch -packages/mcp test: ✓ zod rejects an unknown operation discriminator -packages/mcp test: ✓ zod rejects a non-http(s) confirmation redirect -packages/mcp test: ✓ update_form_field queues a suggestion on a suggest page -packages/mcp test: ✓ suggest-mode update_form_field leaves the block unchanged -packages/mcp test: ✓ the field suggestion targets the form block through set_block_props -packages/mcp test: ✓ the suggestion preserves the submission capability without exposing it in the tool result -packages/mcp test: ✓ set_form_settings also queues through the suggest policy -packages/mcp test: ✓ suggest-mode set_form_settings leaves settings unchanged -packages/mcp test: FORM-7 direct field operations + settings -packages/mcp test: ✓ add applies directly on a direct page with a 43-character key -packages/mcp test: ✓ update applies directly -packages/mcp test: ✓ reorder applies directly -packages/mcp test: ✓ remove applies directly -packages/mcp test: ✓ all direct operations landed in final order with the patch -packages/mcp test: ✓ direct field edits preserve both 43-character key copies -packages/mcp test: ✓ set_form_settings applies directly -packages/mcp test: ✓ settings synchronize outer gate props and nested schema -packages/mcp test: ✓ settings carry label/confirmation and accept maxSubmissions=0 -packages/mcp test: ✓ set_form_settings cannot rotate or corrupt the key -packages/mcp test: ✓ nullable settings clear optional values directly -packages/mcp test: ✓ cleared settings are removed rather than stored as null -packages/mcp test: ✅ ALL 40 CHECKS PASSED — FORM-7 tools, policy handling, pagination, validation, and key redaction verified. -packages/mcp test: OpenBook server up at http://127.0.0.1:4414 -packages/mcp test: API-2: the tool catalogue exposes list_block_types -packages/mcp test: ✓ list_block_types is registered -packages/mcp test: ✓ create_artifact_page advertises catalogue types in its schema (generated, not hand-written) -packages/mcp test: API-2: EVERY catalogued type is creatable via create_artifact_page (coverage) -packages/mcp test: ✓ one artifact page holding every catalogued type is accepted -packages/mcp test: ✓ stored page contains a "paragraph" block -packages/mcp test: ✓ stored page contains a "heading" block -packages/mcp test: ✓ stored page contains a "list" block -packages/mcp test: ✓ stored page contains a "todo" block -packages/mcp test: ✓ stored page contains a "quote" block -packages/mcp test: ✓ stored page contains a "callout" block -packages/mcp test: ✓ stored page contains a "code" block -packages/mcp test: ✓ stored page contains a "notes" block -packages/mcp test: ✓ stored page contains a "divider" block -packages/mcp test: ✓ stored page contains a "image" block -packages/mcp test: ✓ stored page contains a "htmlArtifact" block -packages/mcp test: ✓ stored page contains a "columns" block -packages/mcp test: ✓ stored page contains a "column" block -packages/mcp test: ✓ stored page contains a "table" block -packages/mcp test: ✓ stored page contains a "row" block -packages/mcp test: ✓ stored page contains a "cell" block -packages/mcp test: ✓ stored page contains a "group" block -packages/mcp test: ✓ stored page contains a "tabs" block -packages/mcp test: ✓ stored page contains a "tab" block -packages/mcp test: ✓ stored page contains a "accordion" block -packages/mcp test: ✓ stored page contains a "accordionsection" block -packages/mcp test: ✓ stored page contains a "slider" block -packages/mcp test: ✓ stored page contains a "number" block -packages/mcp test: ✓ stored page contains a "textfield" block -packages/mcp test: ✓ stored page contains a "longtext" block -packages/mcp test: ✓ stored page contains a "richtext" block -packages/mcp test: ✓ stored page contains a "toggle" block -packages/mcp test: ✓ stored page contains a "radio" block -packages/mcp test: ✓ stored page contains a "dropdown" block -packages/mcp test: ✓ stored page contains a "checklist" block -packages/mcp test: ✓ stored page contains a "choicecards" block -packages/mcp test: ✓ stored page contains a "searchselect" block -packages/mcp test: ✓ stored page contains a "tagfield" block -packages/mcp test: ✓ stored page contains a "location" block -packages/mcp test: ✓ stored page contains a "actionbutton" block -packages/mcp test: ✓ stored page contains a "kitchart" block -packages/mcp test: ✓ stored page contains a "statuslight" block -packages/mcp test: ✓ stored page contains a "progressbar" block -packages/mcp test: ✓ stored page contains a "formula" block -packages/mcp test: ✓ stored page contains a "linkcard" block -packages/mcp test: ✓ stored page contains a "tooltipcard" block -packages/mcp test: ✓ stored page contains a "dbview" block -packages/mcp test: ✓ stored page contains a "dbform" block -packages/mcp test: ✓ stored page contains a "form" block -packages/mcp test: API-2: unknown types are refused with a pointer at list_block_types -packages/mcp test: ✓ a typo'd type is refused naming the type -packages/mcp test: ✓ the refusal points at list_block_types -packages/mcp test: API-2: the table cell-count rule holds on both write paths -packages/mcp test: ✓ create_artifact_page refuses a ragged table -packages/mcp test: ✓ append_blocks refuses a ragged table -packages/mcp test: API-2: update_block_props validates declared prop VALUE types -packages/mcp test: ✓ a declared prop with the wrong value type is refused, naming prop and expectation -packages/mcp test: ✓ an invalid structured option is refused with a typed error naming opts -packages/mcp test: API-2: plugin block types — rejected while uninstalled, accepted once installed -packages/mcp test: ✓ an uninstalled plugin's type is refused as not installed -packages/mcp test: ✓ list_block_types lists every catalogued core + kit type -packages/mcp test: ✓ with no plugins installed, pluginBlocks is empty -packages/mcp test: ✓ list_block_types filters its payload to requested types -packages/mcp test: ✓ after installing the bundled ledger plugin, its declared blocks are listed -packages/mcp test: ✓ plugin entries carry a description -packages/mcp test: ✓ the installed plugin's namespaced type is now accepted -packages/mcp test: All 60 checks passed. -packages/mcp test: ✅ README covers all 50 registered MCP tools and agent reference sections -packages/mcp test: OpenBook sidecar up (socket + TCP) at http://127.0.0.1:4409 -packages/mcp test: ✓ the server advertises a stable instanceId -packages/mcp test: ✓ the foreign server has a DIFFERENT instanceId -packages/mcp test: Happy path: connector over the unified endpoint lists the SAME pages -packages/mcp test: ✓ connector lists the page the server API shows -packages/mcp test: ✓ list_pages inherits the client list filter (no MCP-side reimplementation) -packages/mcp test: ✓ search_notes inherits the client search filter -packages/mcp test: ✓ an unlisted page remains directly readable -packages/mcp test: Refusal: foreign responder on the target endpoint -packages/mcp test: ✓ connector exits non-zero on an identity mismatch -packages/mcp test: ✓ the error names the mismatch (never silently adopts the foreign server) -packages/mcp test: Refusal: nothing listening on the target port -packages/mcp test: ✓ connector exits non-zero when the endpoint is unreachable -packages/mcp test: ✓ the error is a clear reachability message -packages/mcp test: ✅ ALL 10 CHECKS PASSED — unified endpoint + instance verification. -packages/mcp test: ✓ paragraph -packages/mcp test: ✓ heading -packages/mcp test: ✓ list -packages/mcp test: ✓ todo -packages/mcp test: ✓ quote -packages/mcp test: ✓ callout -packages/mcp test: ✓ code -packages/mcp test: ✓ notes -packages/mcp test: ✓ divider -packages/mcp test: ✓ image -packages/mcp test: ✓ htmlArtifact -packages/mcp test: ✓ columns -packages/mcp test: ✓ column -packages/mcp test: ✓ table -packages/mcp test: ✓ row -packages/mcp test: ✓ cell -packages/mcp test: ✓ group -packages/mcp test: ✓ tabs -packages/mcp test: ✓ tab -packages/mcp test: ✓ accordion -packages/mcp test: ✓ accordionsection -packages/mcp test: ✓ slider -packages/mcp test: ✓ number -packages/mcp test: ✓ textfield -packages/mcp test: ✓ longtext -packages/mcp test: ✓ richtext -packages/mcp test: ✓ toggle -packages/mcp test: ✓ radio -packages/mcp test: ✓ dropdown -packages/mcp test: ✓ checklist -packages/mcp test: ✓ choicecards -packages/mcp test: ✓ searchselect -packages/mcp test: ✓ tagfield -packages/mcp test: ✓ location -packages/mcp test: ✓ actionbutton -packages/mcp test: ✓ kitchart -packages/mcp test: ✓ statuslight -packages/mcp test: ✓ progressbar -packages/mcp test: ✓ formula -packages/mcp test: ✓ linkcard -packages/mcp test: ✓ tooltipcard -packages/mcp test: ✓ dbview -packages/mcp test: ✓ dbform -packages/mcp test: ✓ form -packages/mcp test: ✓ openbook.ledger/journal-entry -packages/mcp test: ✓ openbook.ledger/trial-balance -packages/mcp test: ✓ openbook.ledger/account-register -packages/mcp test: ✓ openbook.ledger/bank-import -packages/mcp test: ✓ openbook.ledger/reconcile -packages/mcp test: ✓ openbook.ledger/balance-sheet -packages/mcp test: ✓ openbook.ledger/income-statement -packages/mcp test: ✓ openbook.ledger/period-close -packages/mcp test: ✓ openbook.ledger/beancount-export -packages/mcp test: All 44 catalogue types + 9 plugin blocks have MCP API coverage; exemptions: none. -packages/mcp test: Done -packages/server test: ❯ src/ableOidc.test.ts (22 tests | 1 failed) 82811ms -packages/server test: × uses last-good discovery while offline and exempts both routes from access/guest/PAT gates 3946ms -packages/server test: ❯ src/mirror.integration.test.ts (3 tests | 1 failed) 18588ms -packages/server test: × mirrors live edits, syncs multiple clients, re-imports external edits, and resolves conflicts DB-wins 11316ms -packages/server test: Failed -/Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server: - ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @book.dev/server@3.17.0 test: `vitest run` -Exit status 130 - ELIFECYCLE  Test failed. See above for more details. - ELIFECYCLE  Command failed with exit code 130. - -``` - -
- -
-Unsandboxed pnpm verify (SDK property-test timeout; exit 1) - -```text - -> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e - - -> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> node --test eslint-rules/*.test.mjs - -✔ allows spacing-scale utilities and non-spacing arbitrary values (260.361375ms) -✔ flags arbitrary padding, margin, and gap values in className (104.49275ms) -✔ checks nested, template, and conventionally named hoisted class strings (8.074959ms) -✔ allows paint-only hover changes and non-hover geometry (281.659458ms) -✔ flags every guarded hover-geometry utility family (241.359375ms) -✔ flags display swaps in either class ordering and nested class strings (33.757792ms) -ℹ tests 6 -ℹ suites 0 -ℹ pass 6 -ℹ fail 0 -ℹ cancelled 0 -ℹ skipped 0 -ℹ todo 0 -ℹ duration_ms 2950.026541 - -> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build - - -> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk -> tsc -p tsconfig.build.json - - -> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> vite build --config vite.viewer.config.js - -vite v8.0.14 building client environment for production... - -transforming...✓ 2076 modules transformed. -rendering chunks... -computing gzip size... -src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB - -✓ built in 5.76s -vite v8.0.14 building client environment for production... - -transforming...[PLUGIN_TIMINGS] Your build spent significant time in plugin `vite:worker`. See https://rolldown.rs/options/checks#plugintimings for more details. - -✓ 356 modules transformed. -rendering chunks... -computing gzip size... -dist/style.css 190.24 kB │ gzip: 31.11 kB -dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB -dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB -dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB -dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB -dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB -dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB -dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB -dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB -dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB -dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB -dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB -dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB -dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB -dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB -dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB -dist/index.js 2,962.77 kB │ gzip: 813.80 kB - -[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. - -[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. - -✓ built in 6.65s - -> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/bin.js 2.40 KB -ESM dist/index.js 136.00 B -ESM dist/chunk-N6662UMY.js 98.60 KB -ESM dist/index.js.map 71.00 B -ESM dist/bin.js.map 6.65 KB -ESM dist/chunk-N6662UMY.js.map 181.29 KB -ESM ⚡️ Build success in 103ms -DTS Build start -DTS ⚡️ Build success in 6416ms -DTS dist/index.d.ts 1.84 KB - -> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/index.js 552.00 B -ESM dist/bin.js 215.00 B -ESM dist/chunk-Z5QDSVHR.js 415.58 KB -ESM dist/chunk-V6MBCKVR.js 422.16 KB -ESM dist/browser.js 30.24 KB -ESM dist/bin.js.map 556.00 B -ESM dist/index.js.map 71.00 B -ESM dist/browser.js.map 61.42 KB -ESM dist/chunk-V6MBCKVR.js.map 787.04 KB -ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB -ESM ⚡️ Build success in 432ms -DTS Build start -DTS ⚡️ Build success in 25736ms -DTS dist/index.d.ts 68.49 KB -DTS dist/browser.d.ts 13.43 KB -DTS dist/hub-CUJHMMTq.d.ts 136.66 KB - -> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/ui run check:gen - - -> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r --if-present run typecheck - -Scope: 6 of 7 workspace projects -packages/sdk typecheck$ tsc -p tsconfig.json --noEmit -packages/sdk typecheck: Done -packages/ui typecheck$ tsc --noEmit -packages/ui typecheck: Done -packages/app typecheck$ tsc --noEmit -packages/app typecheck: Done -packages/mcp typecheck$ tsc --noEmit -packages/server typecheck$ tsc --noEmit -packages/mcp typecheck: Done -packages/server typecheck: Done -packages/web typecheck$ tsc --noEmit -packages/web typecheck: Done - -> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r run lint - -Scope: 6 of 7 workspace projects -packages/sdk lint$ eslint . -packages/sdk lint: Done -packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint -packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > stylelint src/index.css -packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > node scripts/assert-spacing-stylelint.mjs -packages/ui lint: SPC-2 stylelint controls passed (2 positive, 4 negative) -packages/ui lint: Done -packages/app lint$ eslint . -packages/app lint: Done -packages/mcp lint$ eslint . -packages/server lint$ eslint . -packages/mcp lint: Done -packages/server lint: Done -packages/web lint$ eslint . -packages/web lint: Done - -> open-book@0.0.0-workspace test /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r --if-present run test - -Scope: 6 of 7 workspace projects -packages/sdk test$ vitest run -packages/sdk test: RUN v4.1.7 /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk -packages/sdk test: ❯ src/money.test.ts (27 tests | 1 failed) 7156ms -packages/sdk test: × round-trips 1e6 seeded random amounts with zero drift 6046ms -packages/sdk test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ -packages/sdk test: FAIL src/money.test.ts > parse ↔ format round-trip (property) > round-trips 1e6 seeded random amounts with zero drift -packages/sdk test: Error: Test timed out in 5000ms. -packages/sdk test: If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". -packages/sdk test: ❯ src/money.test.ts:38:3 -packages/sdk test: 36| -packages/sdk test: 37| describe('parse ↔ format round-trip (property)', () => { -packages/sdk test: 38| it('round-trips 1e6 seeded random amounts with zero drift', () => { -packages/sdk test: | ^ -packages/sdk test: 39| const rand = mulberry32(0x1ed6e12); -packages/sdk test: 40| // Deterministic edges first: zero, ±unit, boundary-of-grouping, n… -packages/sdk test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ -packages/sdk test: Test Files 1 failed | 31 passed (32) -packages/sdk test: Tests 1 failed | 545 passed (546) -packages/sdk test: Start at 23:07:57 -packages/sdk test: Duration 8.62s (transform 10.81s, setup 0ms, import 16.65s, tests 12.01s, environment 42ms) -packages/sdk test: Failed -/Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk: - ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @book.dev/sdk@3.17.0 test: `vitest run` -Exit status 1 - ELIFECYCLE  Test failed. See above for more details. - ELIFECYCLE  Command failed with exit code 1. - -``` - -
- -
-One-worker unsandboxed pnpm verify (UI lint-stage process exit during low disk space; exit 1) - -```text - -> open-book@0.0.0-workspace verify /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm run test:eslint-rules && pnpm run build:libs && pnpm run check:gen && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run test:e2e - - -> open-book@0.0.0-workspace test:eslint-rules /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> node --test eslint-rules/*.test.mjs - -✔ allows spacing-scale utilities and non-spacing arbitrary values (342.133917ms) -✔ flags arbitrary padding, margin, and gap values in className (169.646708ms) -✔ checks nested, template, and conventionally named hoisted class strings (52.958584ms) -✔ allows paint-only hover changes and non-hover geometry (244.081333ms) -✔ flags every guarded hover-geometry utility family (190.840042ms) -✔ flags display swaps in either class ordering and nested class strings (77.12425ms) -ℹ tests 6 -ℹ suites 0 -ℹ pass 6 -ℹ fail 0 -ℹ cancelled 0 -ℹ skipped 0 -ℹ todo 0 -ℹ duration_ms 2477.402459 - -> open-book@0.0.0-workspace build:libs /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/sdk run build && pnpm --filter @book.dev/ui run build && pnpm --filter @book.dev/mcp run build && pnpm --filter @book.dev/server run build - - -> @book.dev/sdk@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/sdk -> tsc -p tsconfig.build.json - - -> @book.dev/ui@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && pnpm run build:viewer && vite build && tsc - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> @book.dev/ui@3.17.0 build:viewer /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> vite build --config vite.viewer.config.js - -vite v8.0.14 building client environment for production... - -transforming...✓ 2076 modules transformed. -rendering chunks... -computing gzip size... -src/export/vendor/openbook-viewer.js 1,727.92 kB │ gzip: 486.47 kB - -✓ built in 2.88s -vite v8.0.14 building client environment for production... - -transforming...✓ 356 modules transformed. -rendering chunks... -computing gzip size... -dist/style.css 190.24 kB │ gzip: 31.11 kB -dist/rolldown-runtime-Dy4uBu1J.js 0.24 kB │ gzip: 0.21 kB -dist/emoji-Bmft6RPl.js 0.30 kB │ gzip: 0.23 kB -dist/databaseMapLeaflet-8LYcHcR6.js 2.70 kB │ gzip: 1.37 kB -dist/pageCover-DG_o7L9m.js 3.06 kB │ gzip: 1.27 kB -dist/chartData-DQo78QVa.js 3.41 kB │ gzip: 1.44 kB -dist/exportSite-BhXgGHTe.js 5.19 kB │ gzip: 2.14 kB -dist/toPdf-DYGZU1B5.js 5.94 kB │ gzip: 2.52 kB -dist/themes-CDtwgOr5.js 9.86 kB │ gzip: 2.99 kB -dist/quickjsVm-BLKBOeOk.js 10.75 kB │ gzip: 3.58 kB -dist/EmojiGrid-wB896zFy.js 10.84 kB │ gzip: 4.36 kB -dist/exportBlocks-CNF_rL3d.js 36.70 kB │ gzip: 10.00 kB -dist/lucideIcons-DzkS5mW9.js 83.10 kB │ gzip: 25.98 kB -dist/pageIcon-Dn-g2w6N.js 355.97 kB │ gzip: 110.27 kB -dist/scope-DjPmYdi5.js 786.80 kB │ gzip: 329.50 kB -dist/openbook-viewer-DleN9qK4.js 1,738.48 kB │ gzip: 487.23 kB -dist/index.js 2,962.77 kB │ gzip: 813.80 kB - -[INEFFECTIVE_DYNAMIC_IMPORT] src/plugins/index.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/blockeditor/MissingPluginBlock.tsx, src/components/ExtensionsSettings.tsx, src/components/PluginBoot.tsx, src/components/useAppCommands.ts, src/screens/BlockPageDocument.tsx, dynamic import will not move module into another chunk. - -[INEFFECTIVE_DYNAMIC_IMPORT] src/export/toHtml.ts is dynamically imported by src/screens/BlockPageDocument.tsx but also statically imported by src/index.ts, dynamic import will not move module into another chunk. - -✓ built in 2.91s - -> @book.dev/mcp@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/mcp/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/bin.js 2.40 KB -ESM dist/index.js 136.00 B -ESM dist/chunk-N6662UMY.js 98.60 KB -ESM dist/index.js.map 71.00 B -ESM dist/bin.js.map 6.65 KB -ESM dist/chunk-N6662UMY.js.map 181.29 KB -ESM ⚡️ Build success in 65ms -DTS Build start -DTS ⚡️ Build success in 3852ms -DTS dist/index.d.ts 1.84 KB - -> @book.dev/server@3.17.0 build /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server -> tsup - -CLI Building entry: {"bin":"src/bin.ts","index":"src/index.ts","browser":"src/browser.ts"} -CLI Using tsconfig: tsconfig.json -CLI tsup v8.5.1 -CLI Using tsup config: /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/server/tsup.config.ts -CLI Target: node18 -CLI Cleaning output folder -ESM Build start -ESM dist/index.js 552.00 B -ESM dist/bin.js 215.00 B -ESM dist/browser.js 30.24 KB -ESM dist/chunk-Z5QDSVHR.js 415.58 KB -ESM dist/chunk-V6MBCKVR.js 422.16 KB -ESM dist/bin.js.map 556.00 B -ESM dist/index.js.map 71.00 B -ESM dist/browser.js.map 61.42 KB -ESM dist/chunk-Z5QDSVHR.js.map 1005.77 KB -ESM dist/chunk-V6MBCKVR.js.map 787.04 KB -ESM ⚡️ Build success in 260ms -DTS Build start -DTS ⚡️ Build success in 10979ms -DTS dist/index.d.ts 68.49 KB -DTS dist/browser.d.ts 13.43 KB -DTS dist/hub-CUJHMMTq.d.ts 136.66 KB - -> open-book@0.0.0-workspace check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm --filter @book.dev/ui run check:gen - - -> @book.dev/ui@3.17.0 check:gen /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> pnpm run gen:bundled-plugins && (git diff --exit-code -- src/plugins/bundled.gen.ts src/export/ledgerFolds.gen || (echo 'Committed .gen mirror is STALE: run pnpm --filter @book.dev/ui run gen:bundled-plugins and commit the result.' >&2 && exit 1)) - - -> @book.dev/ui@3.17.0 gen:bundled-plugins /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -> node --import tsx scripts/bundlePlugins.ts - -[bundlePlugins] OPENBOOK_REGISTRY_PRIVATE_KEY is unset — signing with the committed TEST-ONLY key (scripts/test-registry-key.json). Fine for dev/test; production builds must set the secret (docs/plugin-signing.md). -wrote src/plugins/bundled.gen.ts (1 plugin(s), signed by OpenBook Test Registry [test]) -wrote src/export/ledgerFolds.gen (2 fold module(s)) - -> open-book@0.0.0-workspace typecheck /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r --if-present run typecheck - -Scope: 6 of 7 workspace projects -packages/sdk typecheck$ tsc -p tsconfig.json --noEmit -packages/sdk typecheck: Done -packages/ui typecheck$ tsc --noEmit -packages/ui typecheck: Done -packages/app typecheck$ tsc --noEmit -packages/app typecheck: Done -packages/server typecheck$ tsc --noEmit -packages/mcp typecheck$ tsc --noEmit -packages/mcp typecheck: Done -packages/server typecheck: Done -packages/web typecheck$ tsc --noEmit -packages/web typecheck: Done - -> open-book@0.0.0-workspace lint /Users/eliot/Workspaces/OpenBook-wt-meet-1 -> pnpm -r run lint - -Scope: 6 of 7 workspace projects -packages/sdk lint$ eslint . -packages/sdk lint: Done -packages/ui lint$ eslint . && pnpm run lint:css && pnpm run test:stylelint -packages/ui lint: > @book.dev/ui@3.17.0 lint:css /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > stylelint src/index.css -packages/ui lint: > @book.dev/ui@3.17.0 test:stylelint /Users/eliot/Workspaces/OpenBook-wt-meet-1/packages/ui -packages/ui lint: > node scripts/assert-spacing-stylelint.mjs - -Node.js v24.14.1 - -Node.js v24.14.1 - -``` - -
From 191543ea19688bd0ecd63ac79507fa9182200247 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 00:44:49 +0800 Subject: [PATCH 09/32] docs(sdk): fix asset allowlist comment (MEET-1) --- packages/sdk/src/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/database.ts b/packages/sdk/src/database.ts index 609b3e02..1a20e4db 100644 --- a/packages/sdk/src/database.ts +++ b/packages/sdk/src/database.ts @@ -865,7 +865,7 @@ export function projectExports(snapshot: Pick) * an `image/*` `data:` URL (inline bytes an `` shows directly), or a URL * whose extension names a known image type. Display-only — a `data:` URL in an * `` never executes script, and this is separate from the asset store's - * upload allowlist (which excludes `svg+xml`; see the server's `ASSET_IMAGE_MIMES`). + * upload allowlist (which excludes `svg+xml`; see the server's `ASSET_MIMES`). */ export function isImageUrl(url: string): boolean { const u = url.trim(); From 79f576aa721778b5b6d5e9c7c4b1fe647fc33cea Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 00:45:38 +0800 Subject: [PATCH 10/32] docs: record green serialized verification for MEET-2 --- _report.md | 56 +++++++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/_report.md b/_report.md index 8f38dca2..b58b08c7 100644 --- a/_report.md +++ b/_report.md @@ -1,35 +1,39 @@ -MEET-2 implemented and committed. Full verification is BLOCKED by host disk exhaustion (`No space left on device`); it is NOT green. No push. The implementation provides request-scoped asset transcription, independent audio configuration, OpenAI-compatible multipart backend, SDK client, access/paid gates, write-only credentials, and usage attribution. +MEET-2 PASS. Implemented and committed; full foreground verification is GREEN. No push. Request-scoped asset transcription, independent audio configuration, OpenAI-compatible multipart backend, SDK client, access/paid gates, write-only credentials, and usage attribution are complete. -Implementation commit: `69f01e0074451e11e58c370184649c24c1a6dbd2` (`feat(server,sdk): add asset transcription service and backend (MEET-2)`). Verification head: `5c0497889afe14d6e713ad12e02d6a8c47448546` (includes the OIDC fixture correction). The report is committed separately; final branch HEAD is reported in the handoff. +Implementation commit: `69f01e0074451e11e58c370184649c24c1a6dbd2`. OIDC fixture correction: `5c0497889afe14d6e713ad12e02d6a8c47448546`. Verified head: `fb162867fd1d4f9319da4de74984153531a523a8`; this report-only update is committed afterward, with final branch HEAD supplied in the handoff. -Verification (all commands foreground): +Final verification: `VITEST_MAX_WORKERS=1 pnpm verify`, foreground, outside the sandbox, completed **2026-10-04 (Asia/Singapore), exit 0**. Single-worker execution was requested by the manager after confirming the unchanged SDK money test's host-load timeout. No test assertions, counts, or timeouts changed. -- MEET-2 focused suite: **10/10 passed**; server typecheck and changed-file lint passed. -- First `pnpm verify`: builds, generated-file check, all typechecks/lint, SDK **546**, UI **2319**, desktop **7**, and MCP contract tests passed. Server suite reported the expired OIDC fixture and mirror timeout; stopped that already-failed run after diagnosis. Server suite did not finish; end-to-end tests were not reached. -- Corrected OIDC fixture: **22/22 passed**. Mirror tests unchanged outside sandbox: **3/3 passed**. -- Second `pnpm verify` (outside sandbox): builds, generated-file check, all typechecks/lint passed; SDK money property test hit its five-second timeout under load (**545 passed, 1 timed out**). Unchanged SDK with `VITEST_MAX_WORKERS=1`: **546/546 passed**. -- Third `VITEST_MAX_WORKERS=1 pnpm verify` (outside sandbox): blocked rebuilding viewer by **ENOSPC**. `df -h .` reported **100% capacity, 133 MiB available** immediately afterward. No tests/assertions disabled, no timeouts increased. - -Relevant foreground output: +| Final stage | Result | +| --- | --- | +| ESLint rule tests | 6 passed | +| SDK/UI/MCP/server builds; generated-file check | Passed | +| All workspace typechecks and lint | Passed | +| SDK | 32 files; 546 tests passed | +| UI | 244 files; 2,319 tests passed | +| Desktop | 2 files; 7 tests passed | +| Server (includes MEET-2 tests) | 98 files; 1,336 tests passed, 6 skipped | +| Vitest total | 376 files; 4,208 passed, 6 skipped | +| MCP contract scripts | All passed: listed 3, pages 17, suggestions 44, databases 17, assets 13, blocks 80, tables 54, forms 40, block types 60, endpoint 10; README covers 50 tools; coverage includes 44 catalogue types + 9 plugin blocks | +| Server end-to-end | 256/256 checks passed | +| MCP end-to-end | 70/70 checks passed | + +Final foreground output excerpt: ```text -transcription.test.ts: Test Files 1 passed (1); Tests 10 passed (10) -ableOidc.test.ts: Test Files 1 passed (1); Tests 22 passed (22) -mirror.integration.test.ts (outside sandbox): Tests 3 passed (3) -SDK (VITEST_MAX_WORKERS=1): Test Files 32 passed (32); Tests 546 passed (546) - -Second full attempt: -FAIL src/money.test.ts > round-trips 1e6 seeded random amounts with zero drift -Error: Test timed out in 5000ms. - -Third full attempt: -Failed to write file in packages/ui/src/export/vendor/openbook-viewer.js -Caused by: No space left on device (os error 28) -ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @book.dev/ui build -Exit status 1 +packages/sdk test: Test Files 32 passed (32) +packages/sdk test: Tests 546 passed (546) +packages/ui test: Test Files 244 passed (244) +packages/ui test: Tests 2319 passed (2319) +packages/app test: Test Files 2 passed (2) +packages/app test: Tests 7 passed (7) +packages/server test: Test Files 98 passed (98) +packages/server test: Tests 1336 passed | 6 skipped (1342) +✅ ALL 256 CHECKS PASSED — embedded, persistence, headless, trash-cleanup, access-token, and ledger flows verified. +✅ ALL 70 CHECKS PASSED — MCP handshake, catalogue, and every tool verified. ``` -Resume after freeing host disk capacity: rebuild viewer/server/MCP via the normal `build:libs` stage and rerun foreground `VITEST_MAX_WORKERS=1 pnpm verify` outside the sandbox. The last failed build may have left an incomplete ignored viewer artifact. Do not treat partial checks as full verification. Logs remain at `/tmp/meet2-verify.log`, `/tmp/meet2-verify-final.log`, and `/tmp/meet2-verify-serial.log`. +Full log: `/tmp/meet2-manager-serial-verify.log`. Focused MEET-2 suite previously passed 10/10. Earlier disk exhaustion and the unchanged money-test timeout are resolved for this final run; the viewer/server/MCP bundles were rebuilt successfully. No tests were disabled or weakened. Criterion → test map (`packages/server/src/transcription.test.ts`): @@ -66,4 +70,4 @@ Verification follow-up: the first sandboxed run found an existing OIDC PAT fixtu Deviations/limitations: Local implementation remains the planned MEET-3 hook; no MEET-1 MIME changes. Cloud errors do not trigger a silent local fallback. Tests use mocked upstream fetch, not a live paid service. Backend timeout is five minutes. No existing tests deleted or weakened. -Open blocker: host disk capacity; full verification and end-to-end checks remain outstanding. No interface questions. MEET-3 should wire its resolver at AiService construction; MEET-5 should render `apiKeySet` and surface the actionable errors. +Open blockers: none. Full verification and end-to-end checks passed. No interface questions. MEET-3 should wire its resolver at AiService construction; MEET-5 should render `apiKeySet` and surface the actionable errors. From bbccdbc1d976fb722a8372e82604a3dc31a31b2c Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 00:51:23 +0800 Subject: [PATCH 11/32] chore: drop worker artifacts (MEET-2) --- _brief.md | 29 ---------------------- _report.md | 73 ------------------------------------------------------ 2 files changed, 102 deletions(-) delete mode 100644 _brief.md delete mode 100644 _report.md diff --git a/_brief.md b/_brief.md deleted file mode 100644 index eac54b32..00000000 --- a/_brief.md +++ /dev/null @@ -1,29 +0,0 @@ -# MEET-2 — Transcription service + OpenAI-compatible backend - -You are a Worker agent on the OpenBook team. Work ONLY in this worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-2`, branch `feat/meet-2-transcribe`). Do NOT push. Do NOT touch main. Conventional commits (`feat(server,sdk): … (MEET-2)`), committed incrementally. - -## Task -Add a transcription capability to the AI layer. No engine has audio today. Key anchors (recon-time line hints — trust the code): -- Engines: `packages/server/src/ai/providers.ts` — `MockEngine` :108, `OpenAiCompatEngine` :196, `AnthropicEngine` :620, factory `createEngine` :821. -- Config: `AiConfig` `packages/sdk/src/ai.ts:66`, persisted in DB `settings` key `'ai'` (`ai/service.ts:115-123`); API keys are write-only over the wire (`resolveKey` service.ts:31-37 — blank keeps, null clears). Preserve those semantics for any new key field. -- Routes: `packages/server/src/ai/routes.ts` — `aiComplete` :137 is the request-scoped shape to mirror; paid-provider gate `requirePaidInferenceAccess` :412; usage logging `ai/usage.ts`. -- Access: default-deny via `access.ts` (`requireAccess`); unreadable → 404. - -## Design (contract for MEET-3 local whisper and MEET-5 UI — keep the interface clean) -1. Extend `AiConfig` with a `transcription` section: `{ provider: 'off' | 'local' | 'openai-compat', baseUrl?, model?, apiKey? }`. **Resolution order: explicit cloud config > local (arrives in MEET-3; stub the enum/dispatch now) > clear actionable error pointing at Settings → AI.** Local is the product default — cloud only when explicitly configured. -2. New `transcribe` capability speaking the OpenAI-compatible `POST /v1/audio/transcriptions` multipart shape (works for OpenAI cloud and local servers like faster-whisper/speaches — ONE code path). Request `verbose_json`/segments where available. -3. Server route (under ai/routes.ts): POST taking `{ assetId, pageId }` (bytes already in the asset store — fetch via the store with the caller's read access enforced), returning `{ text, segments?: [{start, end, text}], durationMs }`. Request-scoped like aiComplete. Do NOT build a job queue. -4. Cloud transcription goes through `requirePaidInferenceAccess`; `local` will be ungated. Log usage rows. -5. sdk: `transcribeAsset(assetId, pageId)` on the HTTP client (pattern near `client.ts:1571`). `LocalDataClient` (`server/localClient.ts:888-932`) keeps rejecting AI calls — extend its rejection list to the new method. - -## Acceptance (each maps to a test) -- MockEngine grows a deterministic `transcribe` for tests. -- Unit tests: config round-trip incl. write-only key semantics; route happy path via mock; paid-gate enforced for cloud provider; 404 on missing/unreadable asset; `off`/unconfigured → actionable 4xx error. -- NOTE: audio MIME allowlist work is MEET-1 (parallel branch) — do not depend on it; transcribe takes the asset bytes regardless of stored MIME. - -## Definition of done -- `pnpm verify` green FOREGROUND in this worktree, output in report. (Provisioned; artifact-failure symptom → rebuild viewer/server/mcp bundles.) -- All committed, not pushed. Write `_report.md` (outcome first, head sha, criterion→test map, interface summary for MEET-3/MEET-5 consumers, deviations, open questions) and reply with it. Terse. - -## Rules -Never poll external state. Stuck after a real attempt → commit + report where wedged. Never delete/weaken existing tests (reviewers check the commit RANGE). diff --git a/_report.md b/_report.md deleted file mode 100644 index b58b08c7..00000000 --- a/_report.md +++ /dev/null @@ -1,73 +0,0 @@ -MEET-2 PASS. Implemented and committed; full foreground verification is GREEN. No push. Request-scoped asset transcription, independent audio configuration, OpenAI-compatible multipart backend, SDK client, access/paid gates, write-only credentials, and usage attribution are complete. - -Implementation commit: `69f01e0074451e11e58c370184649c24c1a6dbd2`. OIDC fixture correction: `5c0497889afe14d6e713ad12e02d6a8c47448546`. Verified head: `fb162867fd1d4f9319da4de74984153531a523a8`; this report-only update is committed afterward, with final branch HEAD supplied in the handoff. - -Final verification: `VITEST_MAX_WORKERS=1 pnpm verify`, foreground, outside the sandbox, completed **2026-10-04 (Asia/Singapore), exit 0**. Single-worker execution was requested by the manager after confirming the unchanged SDK money test's host-load timeout. No test assertions, counts, or timeouts changed. - -| Final stage | Result | -| --- | --- | -| ESLint rule tests | 6 passed | -| SDK/UI/MCP/server builds; generated-file check | Passed | -| All workspace typechecks and lint | Passed | -| SDK | 32 files; 546 tests passed | -| UI | 244 files; 2,319 tests passed | -| Desktop | 2 files; 7 tests passed | -| Server (includes MEET-2 tests) | 98 files; 1,336 tests passed, 6 skipped | -| Vitest total | 376 files; 4,208 passed, 6 skipped | -| MCP contract scripts | All passed: listed 3, pages 17, suggestions 44, databases 17, assets 13, blocks 80, tables 54, forms 40, block types 60, endpoint 10; README covers 50 tools; coverage includes 44 catalogue types + 9 plugin blocks | -| Server end-to-end | 256/256 checks passed | -| MCP end-to-end | 70/70 checks passed | - -Final foreground output excerpt: - -```text -packages/sdk test: Test Files 32 passed (32) -packages/sdk test: Tests 546 passed (546) -packages/ui test: Test Files 244 passed (244) -packages/ui test: Tests 2319 passed (2319) -packages/app test: Test Files 2 passed (2) -packages/app test: Tests 7 passed (7) -packages/server test: Test Files 98 passed (98) -packages/server test: Tests 1336 passed | 6 skipped (1342) -✅ ALL 256 CHECKS PASSED — embedded, persistence, headless, trash-cleanup, access-token, and ledger flows verified. -✅ ALL 70 CHECKS PASSED — MCP handshake, catalogue, and every tool verified. -``` - -Full log: `/tmp/meet2-manager-serial-verify.log`. Focused MEET-2 suite previously passed 10/10. Earlier disk exhaustion and the unchanged money-test timeout are resolved for this final run; the viewer/server/MCP bundles were rebuilt successfully. No tests were disabled or weakened. - -Criterion → test map (`packages/server/src/transcription.test.ts`): - -| Criterion | Test | -| --- | --- | -| Config persistence, write-only keys, blank/omitted preserve, replacement, null clear | round-trips and redacts keys… | -| Deterministic mock; route success; usage row | returns the deterministic mock result… | -| Cloud paid gate independent of chat provider | denies cloud to claimed-instance guests… | -| Authenticated cloud, unknown cost, sanitized errors | allows authenticated cloud transcription… | -| Local default, ungated local, cloud precedence | defaults to local… | -| Missing/unreadable/unreferenced/unrelated assets, invalid/missing pages → 404 | returns identical 404s… | -| Off/unconfigured/local unavailable → actionable 400; malformed body | gives actionable 400s… | -| Multipart/auth/URL normalization/exact bytes/timing | uses multipart verbose JSON… | -| Text-only JSON, validated segments, duration fallback, provider failure | accepts text-only JSON… | -| HTTP SDK and LocalDataClient rejection | HTTP client posts the contract… | - -MEET-5 contract: - -- `DataClient.transcribeAsset(assetId: string, pageId: string): Promise`; HTTP `POST /api/ai/transcribe` with JSON `{assetId, pageId}`. Assets must already exist and reference that page; the caller must read both. Stored MIME is passed through, with no MIME allowlist dependency. -- Result `{text: string, segments?: Array<{start: number, end: number, text: string}>, durationMs: number}`. Segment offsets use **seconds**; duration uses **milliseconds**. Duration comes from the backend, otherwise maximum segment end, otherwise `0` (unknown). No queue, streaming, persistence, or page mutation. -- Errors: `400` missing arguments or disabled/unavailable local configuration (points to Settings → AI); `404` missing/unreadable/unrelated asset/page; `403` claimed-instance guest using cloud; `502` upstream failure with sanitized message. Existing application authentication/request gates still apply. -- `AiConfig.transcription?: {provider: 'off' | 'local' | 'openai-compat', baseUrl?: string, model?: string, apiKey?: string | null, apiKeySet?: boolean}`. Save through existing `aiSetConfig`, read through `aiStatus`. Missing section means local; `off` explicitly disables. Omitted section on save preserves prior audio settings. Present section replaces settings while preserving an omitted/blank key. Nonempty key replaces (trimmed); `null` clears. Responses remove keys and expose only `apiKeySet`; that flag is never persisted. -- Explicit `openai-compat` opts into the paid gate, even for a user-managed local URL. Defaults: `https://api.openai.com`, `whisper-1`. No chat key/config inheritance. Optional bearer key; base URL can include `/v1` and/or trailing slash. One multipart path requests `verbose_json` plus segment timestamps. JSON responses without segments are accepted. No automatic retry/downgrade on a provider rejecting verbose JSON. - -MEET-3 contract: - -- Implement `TranscriptionEngine` from `packages/server/src/ai/providers.ts`: `transcribe(bytes: Uint8Array, opts?: TranscribeOptions): Promise`. Options: `{filename?: string, mime?: string, signal?: AbortSignal}`. Respect cancellation; return the units above. -- Inject the optional third `AiService` constructor argument: `() => Promise`. Resolve/start the managed local backend lazily. Return `null` when unavailable. MEET-3 owns backend process/model lifecycle; MEET-2 does not dispose the injected engine per request. -- Reuse `new OpenAiCompatEngine(baseUrl, model)` for a local OpenAI-compatible server. The local resolver result is classified `local` and is ungated; omitted transcription config selects this resolver. `transcriptionBackend()` captures one backend/provider/model per request before the paid gate. Explicit `off` disables; explicit cloud wins; local resolver comes next; chat `mock` supplies deterministic fallback for tests/demos; otherwise actionable `400`. -- `AiEngine.transcribe` is optional, so chat-only engines need no changes. The endpoint never calls chat readiness/model probes. -- Successful requests log one `kind: 'transcribe'` row under `openai-compat`, `local`, or `mock` with server-resolved principal/model. Audio token counts are `0` (unreported); cloud cost is `null`, local/mock cost `0`. No audio-minute pricing/schema migration. The local model label is configured `transcription.model` or `local`. - -Verification follow-up: the first sandboxed run found an existing OIDC PAT fixture expired on September 8, 2026. Commit `5c049788` makes its expiry relative to the real database clock, preserving every assertion; all 22 OIDC tests passed afterward. Existing mirror integration tests also hit filesystem-watcher `EMFILE` errors and a timeout in the sandbox. All 3 passed unchanged outside the sandbox. Stopped the already-failed sandboxed run and restarted full foreground verification outside the sandbox. No tests or assertions disabled, no timeout increased. - -Deviations/limitations: Local implementation remains the planned MEET-3 hook; no MEET-1 MIME changes. Cloud errors do not trigger a silent local fallback. Tests use mocked upstream fetch, not a live paid service. Backend timeout is five minutes. No existing tests deleted or weakened. - -Open blockers: none. Full verification and end-to-end checks passed. No interface questions. MEET-3 should wire its resolver at AiService construction; MEET-5 should render `apiKeySet` and surface the actionable errors. From 888f8956cc347a544912c342c7f3d8d77d1c08f6 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 01:31:01 +0800 Subject: [PATCH 12/32] feat(sdk,mcp): register meeting block contract and validators (MEET-4) --- _brief.md | 32 +++++++++++++ docs/audits/block-api-coverage.md | 1 + docs/meeting-block.md | 44 ++++++++++++++++++ packages/mcp/README.md | 1 + packages/mcp/scripts/blockTypes.test.mts | 16 +++++++ packages/mcp/src/server.ts | 11 +++-- packages/sdk/src/blockCatalogue.test.ts | 46 +++++++++++++++++++ packages/sdk/src/blockCatalogue.ts | 39 +++++++++++++--- packages/sdk/src/blockPropSchemas.ts | 24 ++++++++++ packages/sdk/src/index.ts | 1 + packages/server/src/ableOidc.test.ts | 4 +- packages/server/src/ai/agent.ts | 3 ++ .../server/src/ai/agentBlockTypes.test.ts | 25 ++++++++++ .../blockeditor/MeetingBlockPlaceholder.tsx | 14 ++++++ packages/ui/src/blockeditor/kit/index.ts | 3 +- packages/ui/src/blockeditor/model.ts | 2 +- .../blockeditor/registryCatalogue.test.tsx | 11 ++++- 17 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 _brief.md create mode 100644 docs/meeting-block.md create mode 100644 packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx diff --git a/_brief.md b/_brief.md new file mode 100644 index 00000000..81d6e5cf --- /dev/null +++ b/_brief.md @@ -0,0 +1,32 @@ +# MEET-4 — Block catalogue + prop schemas + MCP surface for `meeting` + +You are a Worker agent on the OpenBook team. Work ONLY in this worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-4`, branch `feat/meet-4-meeting-catalogue`). Do NOT push. Do NOT touch main. Conventional commits (`feat(sdk,mcp): … (MEET-4)`). + +## Task +Register a new `meeting` block type in the single-source catalogue so sdk/mcp/server stay drift-free. The UI view lands later (MEET-5) — your job is the type system + MCP/agent surface + docs. Anchors (recon-time hints — trust the code): +- `CATALOGUE_LITERAL` `packages/sdk/src/blockCatalogue.ts:62-113` — entries `{type, category, nature, parent?, props, kitValue?, hint}`. Study how `form` (kit) is declared; mirror its decisions (commit 63969ae3 / FORM-3 is the template epic). +- Prop schemas: `packages/sdk/src/blockPropSchemas.ts` `fields` :51-75 + `CATALOGUE_LITERAL` entry; exports `BLOCK_PROP_SCHEMAS`/`BLOCK_PROP_JSON_SCHEMAS`. +- MCP: `packages/mcp/src/server.ts` picks types up from the catalogue; update `mcp/README.md` prop table (enforced by `mcp/scripts/readme.test.mts` + `blockTypes.test.mts`); regenerate `docs/audits/block-api-coverage.md` via `mcp/scripts/coverage.test.mts` (EXEMPT entries need a written reason — avoid unless truly necessary). +- Server agent validators: `server/src/ai/agentBlockTypes.test.ts`. +- IMPORTANT: ui's `registryCatalogue.test.tsx` requires catalogue kit entries ↔ registered custom blocks to match BOTH directions. The ui view doesn't exist yet. If adding `meeting` as a kit entry breaks that guard, register a minimal placeholder kit registration the way the catalogue/tests structurally require OR structure the entry so the guard passes — pick the smallest honest solution and DOCUMENT it in your report (MEET-5 replaces it). Breaking `pnpm verify` is not acceptable. + +## Representation decision (you make it, then document it — MEET-5/6/7 build against it) +Derive prop shapes from what the component will need, favoring existing catalogue idioms (existing tests/conventions win over this brief): +- `status`: 'idle' | 'recording' | 'processing' | 'done' +- audio chunks: ordered list of `{assetId, durationMs, startedAtMs?}` (or parallel arrays if object-arrays aren't idiomatic — check how other blocks store structured props) +- transcript segments: `{startMs, endMs, text}` list — decide props vs child blocks after reading how `form` stores structured kitValue; prefer props unless size/CRDT concerns say otherwise; justify in one paragraph. +- `summary` (string/rich), `startedAt`, `title?` +- manual notes: child blocks (the block should be a container or have a notes container slot — follow the catalogue's `nature`/`parent` idioms, see how form/kit blocks nest). +Write the final representation contract as a short `docs/`-level or in-code doc comment AND in `_report.md` — MEET-5 consumes it verbatim. + +## Acceptance +- `list_block_types` returns `meeting` with propsSchema; `add_blocks` / `update_block_props` validate it; invalid props rejected (test). +- All drift guards green: mcp readme/blockTypes/coverage tests, server agentBlockTypes test, ui registryCatalogue guard. +- Coverage matrix regenerated and committed. + +## Definition of done +- `pnpm verify` green FOREGROUND here, output in report. (Provisioned; artifact-failure symptom → rebuild viewer/server/mcp bundles.) +- All committed, not pushed. `_report.md`: outcome first, head sha, criterion→test map, THE REPRESENTATION CONTRACT, deviations, open questions. Terse. + +## Rules +Never poll external state. Stuck after a real attempt → commit + report. Never delete/weaken existing tests (reviewers check the commit RANGE). diff --git a/docs/audits/block-api-coverage.md b/docs/audits/block-api-coverage.md index 615fa00b..7a19438c 100644 --- a/docs/audits/block-api-coverage.md +++ b/docs/audits/block-api-coverage.md @@ -46,6 +46,7 @@ | tooltipcard | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | dbview | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | dbform | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | +| meeting | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | form | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ | | openbook.ledger/journal-entry | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ | | openbook.ledger/trial-balance | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ | diff --git a/docs/meeting-block.md b/docs/meeting-block.md new file mode 100644 index 00000000..4e52a824 --- /dev/null +++ b/docs/meeting-block.md @@ -0,0 +1,44 @@ +# Meeting block — THE REPRESENTATION CONTRACT (MEET-4) + +`meeting` is a `kit` block with `nature: 'container'`, no required parent, and +`kitValue: false`. It publishes no reactive value. Its `children` are ordinary +manual-note blocks (paragraphs, todos, headings, groups, etc.), not transcript +segments. No dedicated notes slot or child-only type is required. + +All top-level props are optional. Missing status means `idle`; missing arrays +mean empty lists; missing summary/title mean empty text; missing startedAt means +not started. These are consumer defaults, not schema-inserted persisted values. +As with other block props, patches shallow-merge; `null` removes a top-level key. +Unknown top-level props remain allowed for forward compatibility. + +| Prop | Stored shape and meaning | +| --- | --- | +| `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. | +| `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. | +| `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. | +| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. | +| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. | +| `title` | Optional plain string. | + +All durations and offsets are finite nonnegative numbers in milliseconds; +fractional milliseconds are accepted. Every listed nested field is required +except `startedAtMs`; nested objects reject extra keys and null fields. Empty +arrays and empty transcript text are valid. Cross-field `endMs >= startMs` is +checked at runtime and described in the JSON schema (standard JSON Schema cannot +express a comparison to a sibling field). Asset existence, timeline sorting, +status transitions, and transcription size limits are producer responsibilities. + +Audio chunks and transcript segments use structured props, following existing +kit option/rich-run arrays and the form's structured schema prop. They are +machine-produced snapshots, while manual notes need independently editable CRDT +children. Each array is one prop value: updates replace the whole array, with +no per-segment merge guarantee. Recording/transcription producers should batch +updates and serialize writes to avoid concurrent array replacement losing data. +This keeps the first representation small and consistent; large-transcript +storage migration, if needed later, must explicitly version this contract. + +MEET-4 registers a minimal meeting shell through `registerArtifactKit` in both +editor and viewer hosts. It displays title, status, and saved note-block count; +notes remain stored but are not yet rendered/editable inside the shell. It has +no slash-menu entry or recording controls. MEET-5 replaces this renderer and +must render the existing child blocks rather than migrate them into props. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ce54205f..9d0002ff 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -93,6 +93,7 @@ Call `list_block_types` for the machine-readable source of truth: every entry ha | `tooltipcard` | `term:string`, `tip:string` | | `dbview` | `pageId:string` | | `dbform` | `databaseId:string`, `viewId:string` | +| `meeting` | `status:"idle"/"recording"/"processing"/"done"`, `audioChunks:[{assetId:string,durationMs:number,startedAtMs?:number}]`, `transcript:[{startMs:number,endMs:number,text:string}]`, `summary:string`, `startedAt:number`, `title:string`; container children are manual notes; [representation contract](../../docs/meeting-block.md) | | `form` | `formId:string`, `submissionKey:string`, `enabled:boolean`, `databaseId:string`, `schema:object`, `label:string`, `description:string` | Shared input/frame props are `name`, `label`, and `description` strings plus `compact` and `interactive` booleans. A structured option's `value` defaults to a slug of its `label`; `selected` contains those string values. diff --git a/packages/mcp/scripts/blockTypes.test.mts b/packages/mcp/scripts/blockTypes.test.mts index 85f78bba..230b5b1f 100644 --- a/packages/mcp/scripts/blockTypes.test.mts +++ b/packages/mcp/scripts/blockTypes.test.mts @@ -157,6 +157,22 @@ async function main(): Promise { check('an invalid structured option is refused with a typed error naming opts', isError(badOpts) && /"opts"/.test(resultText(badOpts)) && /object/i.test(resultText(badOpts))); + const meetingProps = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], summary: 'Agreed', startedAt: 1234, title: 'Review'}; + const meetingPage = await seed.savePage({name: 'Meeting target', data: {editor: 'blocks', blockdoc: {blocks: [{id: 'meeting1', type: 'meeting', children: [{id: 'note1', type: 'paragraph', text: [{t: 'Notes'}]}]}]}, editorjs: {blocks: []}, values: [], names: []}}); + const meetingUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props: meetingProps}}); + check('meeting structured props update is accepted', !isError(meetingUpdate)); + const meetingCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props: meetingProps, children: [{type: 'paragraph', text: 'Manual notes'}]}]}}); + check('meeting creation with manual note children is accepted', !isError(meetingCreate)); + for (const props of [{status: 'paused'}, {audioChunks: [{assetId: 'a', durationMs: -1}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}]) { + const badCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props}]}}); + const badUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props}}); + check(`meeting rejects invalid creation and update: ${JSON.stringify(props)}`, isError(badCreate) && isError(badUpdate)); + } + const meetingListing = JSON.parse(resultText(await mcp.client.callTool({name: 'list_block_types', arguments: {types: ['meeting']}}))); + assert.equal(meetingListing.blocks.length, 1); + assert.deepEqual(meetingListing.blocks[0].propsSchema.properties.transcript.items.required, ['startMs', 'endMs', 'text']); + check('meeting listing publishes container nature and structured propsSchema', meetingListing.blocks[0].nature === 'container'); + console.log('\nAPI-2: plugin block types — rejected while uninstalled, accepted once installed'); const uninstalled = await mcp.client.callTool({ name: 'create_artifact_page', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index f5fdb6a1..1942e7cd 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -25,6 +25,7 @@ import { findUnknownBlockType, FORM_FIELD_KINDS, invalidBlockProps, + invalidBlockTreeProps, insertBlocks, isHttpUrl, KIT_VALUE_BLOCK_TYPES, @@ -609,7 +610,7 @@ function nestedBlockSchema(typeDesc: string, propsDesc: string): z.ZodType - blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES}); + blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES}) ?? invalidBlockTreeProps(blocks); /** * The write-tool kind an MCP mutation maps to (the same identifiers the in-app diff --git a/packages/sdk/src/blockCatalogue.test.ts b/packages/sdk/src/blockCatalogue.test.ts index 0ef41ead..4310c0e1 100644 --- a/packages/sdk/src/blockCatalogue.test.ts +++ b/packages/sdk/src/blockCatalogue.test.ts @@ -17,6 +17,7 @@ import { CONTAINER_BLOCK_TYPES, findUnknownBlockType, invalidBlockProps, + invalidBlockTreeProps, isPluginBlockType, KNOWN_BLOCK_TYPE_IDS, MAX_BLOCK_DEPTH, @@ -242,3 +243,48 @@ describe('generated tool text', () => { for (const type of KNOWN_BLOCK_TYPE_IDS) expect(guidance).toContain(type); }); }); + + +describe('meeting representation contract (MEET-4)', () => { + it('is a kit container with typed structured props and no reactive value', () => { + expect(blockTypeInfo('meeting')).toMatchObject({category: 'kit', nature: 'container', kitValue: false}); + expect(blockTreeError([{type: 'meeting', children: [{type: 'paragraph', text: 'Manual notes'}]}])).toBeNull(); + for (const status of ['idle', 'recording', 'processing', 'done']) { + expect(invalidBlockProps('meeting', {status, startedAt: 1234, title: 'Review', summary: 'Agreed.', + audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}, {assetId: 'audio-2', durationMs: 250}], + transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], + })).toBeNull(); + } + expect(invalidBlockProps('meeting', {})).toBeNull(); + expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, summary: null, startedAt: null, title: null})).toBeNull(); + }); + + it.each([ + {status: 'paused'}, {startedAt: -1}, {startedAt: '2026-01-01'}, {summary: []}, {title: 1}, + {audioChunks: ['asset']}, {audioChunks: [{assetId: 'a'}]}, {audioChunks: [{assetId: '', durationMs: 1}]}, + {audioChunks: [{assetId: 'a', durationMs: -1}]}, {audioChunks: [{assetId: 'a', durationMs: 1, startedAtMs: -1}]}, + {audioChunks: [{assetId: 'a', durationMs: Infinity}]}, {audioChunks: [{assetId: 'a', durationMs: 1, extra: true}]}, + {transcript: [{startMs: 0, text: 'Missing end'}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}, + {transcript: [{startMs: -1, endMs: 1, text: 'Negative'}]}, {transcript: [{startMs: 0, endMs: 1, text: 1}]}, + ])('rejects malformed props: %j', (props) => { + expect(invalidBlockProps('meeting', props)).toContain('Invalid prop'); + }); + + it('publishes nested item schemas and required fields to agents', () => { + const {blocks} = JSON.parse(blockCatalogueText([], ['meeting'])); + expect(blocks).toHaveLength(1); + expect(blocks[0].propsSchema.properties.status.enum).toEqual(['idle', 'recording', 'processing', 'done']); + expect(blocks[0].propsSchema.properties.audioChunks.items.required).toEqual(['assetId', 'durationMs']); + expect(blocks[0].propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']); + }); +}); + + +describe('creation prop validation', () => { + it('validates nested blocks with the same schemas as prop updates', () => { + expect(invalidBlockTreeProps([{type: 'group', children: [{type: 'meeting', props: {status: 'paused'}}]}])).toContain('Invalid prop "status"'); + expect(invalidBlockTreeProps([{type: 'heading', props: {level: 'two'}}])).toContain('Invalid prop "level"'); + expect(invalidBlockTreeProps([{type: 'meeting', props: []}])).toContain('expected an object'); + expect(invalidBlockTreeProps([{type: 'meeting'}, {type: 'plugin/widget', props: {anything: true}}, {type: 'meeting', props: {title: null, future: {x: 1}}}])).toBeNull(); + }); +}); diff --git a/packages/sdk/src/blockCatalogue.ts b/packages/sdk/src/blockCatalogue.ts index ae2dc957..2c86bf60 100644 --- a/packages/sdk/src/blockCatalogue.ts +++ b/packages/sdk/src/blockCatalogue.ts @@ -31,7 +31,7 @@ export {BLOCK_PROP_JSON_SCHEMAS, BLOCK_PROP_SCHEMAS} from './blockPropSchemas'; /** How a block stores content: `container` blocks carry child blocks in * `children`, `text` blocks carry rich text in `text`, `void` blocks carry - * only `props` (all kit widgets are void). */ + * only `props`. Kit blocks may also be containers. */ export type BlockNature = 'container' | 'text' | 'void'; /** The value shapes per-type prop validation understands. Deliberately coarse @@ -109,6 +109,7 @@ const CATALOGUE_LITERAL = [ {type: 'tooltipcard', category: 'kit', nature: 'void', props: {term: 'string', tip: 'string'}, hint: '{term,tip}'}, {type: 'dbview', category: 'kit', nature: 'void', props: {pageId: 'string'}, hint: 'embedded live database view {pageId} — the page hosting the database'}, {type: 'dbform', category: 'kit', nature: 'void', props: {databaseId: 'string', viewId: 'string'}, hint: 'embedded database form {databaseId,viewId} — a live reference, never a copied schema or capability'}, + {type: 'meeting', category: 'kit', nature: 'container', kitValue: false, props: {status: 'string', audioChunks: 'array', transcript: 'array', summary: 'string', startedAt: 'number', title: 'string'}, hint: 'meeting recording {status?,audioChunks?:[{assetId,durationMs,startedAtMs?}],transcript?:[{startMs,endMs,text}],summary?,startedAt?,title?}; times in ms, startedAt is Unix epoch; children hold manual notes; see docs/meeting-block.md'}, {type: 'form', category: 'kit', nature: 'void', kitValue: false, props: {formId: 'string', submissionKey: 'string', enabled: 'boolean', databaseId: 'string', schema: 'object', label: 'string', description: 'string'}, hint: 'public form definition {formId,submissionKey,enabled,databaseId?,schema}'}, ] as const satisfies readonly BlockTypeInfo[]; @@ -134,9 +135,9 @@ export const KIT_VALUE_BLOCK_TYPES: ReadonlySet = new Set( BLOCK_TYPE_CATALOGUE.filter((entry) => entry.kitValue === true).map((entry) => entry.type), ); -/** Core types whose `children` hold ordinary blocks. */ -export const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set( - BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type as CoreBlockType), +/** Catalogued types whose `children` hold ordinary blocks (including kit containers). */ +export const CONTAINER_BLOCK_TYPES: ReadonlySet = new Set( + BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type), ); /** Core types that carry editable rich text. */ @@ -284,7 +285,7 @@ export function blockTreeError( structural = parentType ? `A "${type}" block must be a direct child of a "${needs}" block, not a "${parentType}" block (at "${here}").` : `A "${type}" block can't be top-level — it belongs directly inside a "${needs}" block (at "${here}").`; - } else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type as CoreBlockType)) { + } else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type)) { structural = `A "${type}" block can't hold children — only container blocks (${[...CONTAINER_BLOCK_TYPES].join(', ')}) do (at "${here}"). The nested blocks would be dropped.`; } else if (type === 'table' && hasChildren) { structural = raggedTableError(children, here); @@ -339,6 +340,30 @@ export function invalidBlockProps(type: string, props: Record): return `Invalid prop "${prop}" of a "${type}" block: ${issue.message} (got ${clipJson(props[prop])}).`; } +/** Validate declared props throughout a creation payload. Call blockTreeError + * first to enforce structure/size limits. Plugin and unknown props retain the + * same permissive behavior as update_block_props. */ +export function invalidBlockTreeProps(blocks: readonly unknown[], depth = 1): string | null { + if (depth > TYPE_WALK_MAX_DEPTH) return null; // structure validation owns depth errors + for (const raw of blocks) { + if (!raw || typeof raw !== 'object') continue; + const block = raw as {type?: unknown; props?: unknown; children?: unknown}; + const type = String(block.type ?? ''); + if (block.props !== undefined) { + if (!block.props || typeof block.props !== 'object' || Array.isArray(block.props)) { + return `Invalid props of a "${type}" block: expected an object.`; + } + const error = invalidBlockProps(type, block.props as Record); + if (error) return error; + } + if (Array.isArray(block.children)) { + const error = invalidBlockTreeProps(block.children, depth + 1); + if (error) return error; + } + } + return null; +} + const clipJson = (v: unknown): string => { const s = JSON.stringify(v) ?? String(v); return s.length > 40 ? `${s.slice(0, 40)}…` : s; @@ -388,10 +413,10 @@ export function addBlocksGuidance(): string { 'Append rich blocks to a page — text, layouts, tables, media, interactive inputs, and charts. User approves before they are added.', 'Each block is {type, text?, props?, children?}. `text` is a plain string (or rich runs [{"t","a":{b,i,u,s,c,a}}]); `children` nests blocks inside containers. Call list_block_types for the full catalogue including installed plugin blocks.', `TEXT: ${core.filter((e) => e.nature === 'text' && !e.parent).map(hinted).join('; ')}.`, - `CONTAINERS (use children): ${core.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`, + `CONTAINERS (use children): ${BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`, `MEDIA/OTHER: ${core.filter((e) => e.nature === 'void').map(hinted).join('; ')}.`, `INPUTS (each publishes props.name into the reactive scope): ${kit.filter((e) => e.kitValue).map(hinted).join('; ')}.`, - `REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue).map(hinted).join('; ')}.`, + `REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue && e.nature !== 'container').map(hinted).join('; ')}.`, 'Example: a budget widget → [{"type":"heading","text":"Budget","props":{"level":2}},{"type":"columns","children":[{"type":"column","props":{"span":5},"children":[{"type":"slider","props":{"name":"spent","label":"Spent","value":80,"min":0,"max":200}},{"type":"number","props":{"name":"budget","label":"Budget","value":120}}]},{"type":"column","props":{"span":7},"children":[{"type":"kitchart","props":{"kind":"bar","title":"Spent vs budget","labels":"Spent, Budget","source":"[spent, budget]"}},{"type":"statuslight","props":{"label":"On track","source":"budget - spent","okAt":0,"warnAt":-20}}]}]}].', ].join('\n'); } diff --git a/packages/sdk/src/blockPropSchemas.ts b/packages/sdk/src/blockPropSchemas.ts index 0daf2dcf..56e07684 100644 --- a/packages/sdk/src/blockPropSchemas.ts +++ b/packages/sdk/src/blockPropSchemas.ts @@ -43,6 +43,22 @@ const opts = array(option, 'Structured selectable options.'); const attrs = object({b: boolean(), i: boolean(), u: boolean(), s: boolean(), c: boolean(), a: string('Safe http, https, or mailto link.')}); const run = object({t: string('Run text.'), a: attrs}, 'One rich-text run.', ['t']); const runs = array(run, 'Rich-text runs.'); +// MEET-4 representation contract: docs/meeting-block.md. Nested objects are +// strict; top-level props retain the catalogue's nullable patch semantics. +const audioChunk = object({ + assetId: {schema: z.string().min(1).max(512), json: {type: 'string', minLength: 1, maxLength: 512}}, + durationMs: number('Chunk duration in milliseconds.', 0), + startedAtMs: number('Offset from the meeting start in milliseconds.', 0), +}, 'One audio asset in capture order.', ['assetId', 'durationMs']); +const transcriptSegment = object({ + startMs: number('Inclusive offset from the meeting start in milliseconds.', 0), + endMs: number('Exclusive offset from the meeting start in milliseconds; must be >= startMs.', 0), + text: string('Plain transcript text.'), +}, 'One transcript segment in display order.', ['startMs', 'endMs', 'text']); +transcriptSegment.schema = transcriptSegment.schema.refine( + (segment) => segment.endMs >= segment.startMs, + {message: 'endMs must be greater than or equal to startMs', path: ['endMs']}, +); const common = {bg: text}; const frame = {name: text, label: text, description: text, compact: boolean(), interactive: boolean()}; const inputText = {...frame, value: text, placeholder: text}; @@ -71,6 +87,14 @@ const fields = { formula: {...frame, source: expression('Expression evaluated over the reactive scope.')}, linkcard: {title: text, url: text, description: text}, tooltipcard: {term: text, tip: text}, dbview: {pageId: id}, dbform: {databaseId: id, viewId: id}, + meeting: { + status: enumeration(['idle', 'recording', 'processing', 'done'], 'Absent means idle.'), + audioChunks: array(audioChunk, 'Audio chunks in capture order; replace the entire array when updating.'), + transcript: array(transcriptSegment, 'Plain-text segments in display order; replace the entire array when updating.'), + summary: string('Plain-text summary.'), + startedAt: number('Meeting start as Unix epoch milliseconds.', 0), + title: text, + }, form: {formId: id, submissionKey: text, enabled: boolean(), databaseId: id, schema: freeObject, label: text, description: text}, } satisfies Record>; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6ca60969..325bb884 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -554,6 +554,7 @@ export { unknownBlockTypeMessage, blockTreeError, invalidBlockProps, + invalidBlockTreeProps, blockCatalogueText, addBlocksGuidance, type BlockNature, diff --git a/packages/server/src/ableOidc.test.ts b/packages/server/src/ableOidc.test.ts index 13ee3512..cca5c906 100644 --- a/packages/server/src/ableOidc.test.ts +++ b/packages/server/src/ableOidc.test.ts @@ -568,7 +568,9 @@ describe('able OIDC relying party', () => { issuer: 'local', scope: 'read', createdBy: 'test', - expiresAt: new Date(NOW + 60_000), + // PAT expiry uses the database wall clock, not the injected OIDC clock. + // Keep this valid-PAT fixture valid after the fixed OIDC date has passed. + expiresAt: new Date(Date.now() + 60_000), }); const app = createApp(store, undefined, new PageHub(), { ableOidc: {...idp.options, discoveryTtlMs: 0}, diff --git a/packages/server/src/ai/agent.ts b/packages/server/src/ai/agent.ts index d63033e7..a3f57675 100644 --- a/packages/server/src/ai/agent.ts +++ b/packages/server/src/ai/agent.ts @@ -19,6 +19,7 @@ import { CONTAINER_BLOCK_TYPES, findUnknownBlockType, invalidBlockProps, + invalidBlockTreeProps, KIT_VALUE_BLOCK_TYPES, providerSettings, movePageTool, @@ -1060,6 +1061,8 @@ export class AgentRunner { if (structural) return structural; const bad = unknownBlockTypeMessage(findUnknownBlockType(blocks, {installedPluginIds: await this.installedPluginIds()})); if (bad) return bad; + const propError = invalidBlockTreeProps(blocks); + if (propError) return propError; const normalizeBlockText = (value: unknown): unknown => { if (!value || typeof value !== 'object' || Array.isArray(value)) return value; const block = value as Record; diff --git a/packages/server/src/ai/agentBlockTypes.test.ts b/packages/server/src/ai/agentBlockTypes.test.ts index eef33a35..c337fa50 100644 --- a/packages/server/src/ai/agentBlockTypes.test.ts +++ b/packages/server/src/ai/agentBlockTypes.test.ts @@ -261,3 +261,28 @@ describe('list_block_types', () => { expect(afterCatalogue.pluginBlocks.every((entry: {category: string}) => entry.category === 'plugin')).toBe(true); }); }); + + +describe('meeting agent surface (MEET-4)', () => { + it('accepts notes and structured props, rejects malformed creation and updates', async () => { + const page = await store.upsertPage({name: `meeting-${seq}`, data: { + editor: 'blocks', blockdoc: {blocks: [{id: 'meeting1', type: 'meeting'}]}, editorjs: {blocks: []}, values: [], names: [], + }}); + const props = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}]}; + const added = await runTool('add_blocks', {pageId: page.id, blocks: [{type: 'meeting', props, children: [{type: 'paragraph', text: 'Notes'}]}]}); + expect(added.result).toContain('SUGGESTED for review'); + expect((await runTool('update_block_props', {pageId: page.id, blockId: 'meeting1', props})).result).toContain('SUGGESTED for review'); + for (const invalid of [{status: 'paused'}, {audioChunks: [{assetId: 'a', durationMs: -1}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}]) { + for (const [tool, args] of [ + ['add_blocks', {pageId: page.id, blocks: [{type: 'meeting', props: invalid}]}], + ['update_block_props', {pageId: page.id, blockId: 'meeting1', props: invalid}], + ] as const) { + const response = await runTool(tool, args); + expect(response.result).toContain('Invalid prop'); + expect(response.events.some((event) => event.type === 'suggestions')).toBe(false); + } + } + const listed = JSON.parse((await runTool('list_block_types', {types: ['meeting']})).result); + expect(listed.blocks.find((block: {type: string}) => block.type === 'meeting').propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']); + }); +}); diff --git a/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx b/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx new file mode 100644 index 00000000..c2ee56a1 --- /dev/null +++ b/packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx @@ -0,0 +1,14 @@ +import {blockChildren, blockProp} from './model'; +import type {CustomBlockDef, CustomBlockProps} from './registry'; + +/** MEET-5 replaces this shell. The container model already preserves notes; + * this placeholder deliberately offers no recording controls or slash entry. */ +const MeetingBlockPlaceholder = ({block}: CustomBlockProps) => ( +
+ {blockProp(block, 'title') || 'Meeting'} +

{blockProp(block, 'status') || 'idle'} — Meeting view coming soon.

+

{blockChildren(block)?.length ?? 0} note blocks saved.

+
+); + +export const MEETING_BLOCK: CustomBlockDef = {type: 'meeting', render: MeetingBlockPlaceholder}; diff --git a/packages/ui/src/blockeditor/kit/index.ts b/packages/ui/src/blockeditor/kit/index.ts index f18f6aa5..1c9dbda5 100644 --- a/packages/ui/src/blockeditor/kit/index.ts +++ b/packages/ui/src/blockeditor/kit/index.ts @@ -4,6 +4,7 @@ import {INPUT2_BLOCKS} from './inputs2'; import {PROGRESS_BLOCKS} from './progress'; import {CHART_BLOCKS} from './charts'; import {CARD_BLOCKS} from './cards'; +import {MEETING_BLOCK} from '../MeetingBlockPlaceholder'; export {evalExpr, formatValue, inputScope, INPUT_TYPES} from './scope'; export {CHART_KINDS} from './charts'; @@ -17,7 +18,7 @@ export {CHART_KINDS} from './charts'; * out of reusable, collaborative blocks. */ export function registerArtifactKit(): void { - for (const def of [...INPUT_BLOCKS, ...INPUT2_BLOCKS, ...PROGRESS_BLOCKS, ...CHART_BLOCKS, ...CARD_BLOCKS]) { + for (const def of [...INPUT_BLOCKS, ...INPUT2_BLOCKS, ...PROGRESS_BLOCKS, ...CHART_BLOCKS, ...CARD_BLOCKS, MEETING_BLOCK]) { const d = def as unknown as CustomBlockDef; // Tag the built-ins so the slash menu files them under "Interactive blocks" // (third-party plugin blocks fall through to "Extensions"). diff --git a/packages/ui/src/blockeditor/model.ts b/packages/ui/src/blockeditor/model.ts index 77191ff9..51e44df8 100644 --- a/packages/ui/src/blockeditor/model.ts +++ b/packages/ui/src/blockeditor/model.ts @@ -94,7 +94,7 @@ export const TEXT_BLOCKS: ReadonlySet = TEXT_BLOCK_TYPES; /** Block types whose `children` hold ordinary blocks — the catalogue's * `container` nature. */ -export const CONTAINER_BLOCKS: ReadonlySet = CONTAINER_BLOCK_TYPES; +export const CONTAINER_BLOCKS: ReadonlySet = CONTAINER_BLOCK_TYPES; export type BlockMap = Y.Map; diff --git a/packages/ui/src/blockeditor/registryCatalogue.test.tsx b/packages/ui/src/blockeditor/registryCatalogue.test.tsx index 24adc0b3..e0d85802 100644 --- a/packages/ui/src/blockeditor/registryCatalogue.test.tsx +++ b/packages/ui/src/blockeditor/registryCatalogue.test.tsx @@ -27,7 +27,7 @@ import {registeredBlockTypes} from './registry'; import {registerArtifactKit} from './kit'; import {registerReactiveBlocks} from './reactiveBlocks'; import {INPUT_TYPES, inputValue} from './kit/scope'; -import {CONTAINER_BLOCKS, createDoc, rootBlocks, TEXT_BLOCKS} from './model'; +import {blockToJSON, CONTAINER_BLOCKS, createDoc, rootBlocks, TEXT_BLOCKS} from './model'; import {registerDatabaseBlock} from '@/components/database/InlineDatabaseBlock'; import {registerDatabaseFormBlock} from '@/components/database/DatabaseFormBlock'; import {registerFormBlock} from './FormBlockView'; @@ -62,6 +62,15 @@ describe('registry ↔ catalogue drift guard', () => { expect(KNOWN_BLOCK_TYPE_IDS.size).toBe(BLOCK_TYPE_CATALOGUE.length); }); + it('meeting container notes and structured props survive the UI model round trip', () => { + const props = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}]}; + const doc = createDoc([{type: 'meeting', props, children: [{type: 'paragraph', text: 'Manual notes'}]}]); + const json = blockToJSON(rootBlocks(doc).get(0)); + expect(json.props).toEqual(props); + expect(json.children).toHaveLength(1); + expect(json.children?.[0]).toMatchObject({type: 'paragraph', text: [{t: 'Manual notes'}]}); + }); + it('the model\'s nature sets ARE the catalogue\'s', () => { expect(TEXT_BLOCKS).toBe(TEXT_BLOCK_TYPES); expect(CONTAINER_BLOCKS).toBe(CONTAINER_BLOCK_TYPES); From 1f97ba060841c77e65943d0d7c998fb4d09c574d Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 01:33:05 +0800 Subject: [PATCH 13/32] docs: record MEET-4 verification and representation contract --- _report.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 _report.md diff --git a/_report.md b/_report.md new file mode 100644 index 00000000..12e57ec2 --- /dev/null +++ b/_report.md @@ -0,0 +1,87 @@ +# MEET-4 — Drew + +MEET-4 complete. `pnpm verify` passed in the foreground (exit 0); implementation and report committed separately. Not pushed. + +Implementation HEAD: `888f8956cc347a544912c342c7f3d8d77d1c08f6`. The following report-only commit records this verified implementation SHA; final HEAD is the report commit. + +## Criterion → test map + +| Criterion | Evidence | +| --- | --- | +| Catalogue, schemas, defaults/patch semantics, invalid nested values | `packages/sdk/src/blockCatalogue.test.ts` | +| `list_block_types` exposes meeting + propsSchema; MCP creation/update acceptance and rejection | `packages/mcp/scripts/blockTypes.test.mts` (67 checks) | +| Agent `add_blocks` and `update_block_props` validate before suggesting | `packages/server/src/ai/agentBlockTypes.test.ts` (13 tests) | +| Runtime kit registry matches catalogue; notes/props survive UI serialization | `packages/ui/src/blockeditor/registryCatalogue.test.tsx` | +| README and generated matrix drift guards | `packages/mcp/scripts/readme.test.mts`, `coverage.test.mts`; 45 catalogue types + 9 plugin blocks, no exemptions | +| Workspace definition of done | `pnpm verify`, foreground, exit 0 (outside sandbox for filesystem watchers) | + +## Validation output + +Full output: [verification log](/Users/eliot/.bb/thread-storage/meet-4-verify-888f8956.log). Command: `pnpm verify`, foreground, outside sandbox; exit **0**. + +```text +SDK: 32 files, 565 tests passed +UI: 244 files, 2320 tests passed +App: 2 files, 7 tests passed +Server: 97 files, 1327 passed / 6 existing skipped (1333) +MCP: All 45 catalogue types + 9 plugin blocks have MCP API coverage; exemptions: none. +Server e2e: ALL 256 CHECKS PASSED +MCP e2e: ALL 70 CHECKS PASSED +``` + +ESLint-rule tests, builds, generated-file guard, workspace typechecks and lint also passed. Pre-commit ESLint/typechecks and commitlint passed. + +## THE REPRESENTATION CONTRACT + +`meeting` is a `kit` block with `nature: 'container'`, no required parent, and +`kitValue: false`. It publishes no reactive value. Its `children` are ordinary +manual-note blocks (paragraphs, todos, headings, groups, etc.), not transcript +segments. No dedicated notes slot or child-only type is required. + +All top-level props are optional. Missing status means `idle`; missing arrays +mean empty lists; missing summary/title mean empty text; missing startedAt means +not started. These are consumer defaults, not schema-inserted persisted values. +As with other block props, patches shallow-merge; `null` removes a top-level key. +Unknown top-level props remain allowed for forward compatibility. + +| Prop | Stored shape and meaning | +| --- | --- | +| `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. | +| `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. | +| `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. | +| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. | +| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. | +| `title` | Optional plain string. | + +All durations and offsets are finite nonnegative numbers in milliseconds; +fractional milliseconds are accepted. Every listed nested field is required +except `startedAtMs`; nested objects reject extra keys and null fields. Empty +arrays and empty transcript text are valid. Cross-field `endMs >= startMs` is +checked at runtime and described in the JSON schema (standard JSON Schema cannot +express a comparison to a sibling field). Asset existence, timeline sorting, +status transitions, and transcription size limits are producer responsibilities. + +Audio chunks and transcript segments use structured props, following existing +kit option/rich-run arrays and the form's structured schema prop. They are +machine-produced snapshots, while manual notes need independently editable CRDT +children. Each array is one prop value: updates replace the whole array, with +no per-segment merge guarantee. Recording/transcription producers should batch +updates and serialize writes to avoid concurrent array replacement losing data. +This keeps the first representation small and consistent; large-transcript +storage migration, if needed later, must explicitly version this contract. + +MEET-4 registers a minimal meeting shell through `registerArtifactKit` in both +editor and viewer hosts. It displays title, status, and saved note-block count; +notes remain stored but are not yet rendered/editable inside the shell. It has +no slash-menu entry or recording controls. MEET-5 replaces this renderer and +must render the existing child blocks rather than migrate them into props. + +## Deviations and open questions + +- The first full verification attempt hit transient `EADDRINUSE` on MCP port 4410. The listener had exited on inspection; no process was killed and no test was weakened. +- The new negative test exposed that creation paths previously validated structure but skipped props. Added shared SDK `invalidBlockTreeProps` to MCP creation/append/insert and agent `add_blocks`, validating all declared schemas recursively before writes or suggestions. Existing update validation remains shared. +- Full verification exposed an unrelated dated fixture in `ableOidc.test.ts`: its purportedly valid PAT expired at fixed September 8, 2026 time, while SQL validates against `now()`. Reproduced the 401-versus-302 failure; changed only the fixture to `Date.now() + 60_000`, preserving every assertion. The focused regression passed afterward. +- A later full run ended without a normal summary after export fixture failures and zero-test collection failures while the host disk was nearly full. The export suite (`bookFolder.test.ts`) passed all 12 tests unchanged in isolation; both end-to-end suites also passed (256 server / 70 MCP checks). Restarted full verification, including bundle rebuilds. +- Diagnosed filesystem-watch failures: the unchanged mirror integration suite produced `EMFILE` inside the sandbox despite a 1,048,575 descriptor limit, then passed all 3 tests outside it. Stopped only this worktree’s already-failed sandboxed test process and reran full foreground verification outside the sandbox after automatic approval. No tests or assertions were disabled. +- Minimal kit registration is intentional, not a drift-guard exemption. MEET-5 replaces the placeholder; notes are preserved but not yet displayed/editable in it. +- No unresolved MEET-4 questions. MEET-5/6/7 must follow the contract above, including epoch vs relative timestamps and whole-array replacement. Recording controls, transcription transport, and large-transcript storage policy remain later work. From aadffde8089600830befc4fd98d7eb13148cdaef Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Sun, 4 Oct 2026 01:52:18 +0800 Subject: [PATCH 14/32] feat(ui): add meeting recording, transcription and notes (MEET-5) --- _brief-meet5.md | 45 ++++ _brief.md | 32 --- _report.md | 87 ------- packages/sdk/src/client.ts | 2 + packages/server/src/localClient.ts | 2 + packages/ui/src/blockeditor/BlockEditor.tsx | 5 +- .../blockeditor/MeetingBlockPlaceholder.tsx | 14 -- .../ui/src/blockeditor/MeetingBlockView.tsx | 96 +++++++ packages/ui/src/blockeditor/SlashMenu.tsx | 2 +- .../__tests__/meetingRecorder.test.tsx | 237 ++++++++++++++++++ packages/ui/src/blockeditor/exportBlocks.ts | 30 ++- packages/ui/src/blockeditor/kit/index.ts | 2 +- .../ui/src/blockeditor/meetingRecorder.ts | 222 ++++++++++++++++ packages/ui/src/blockeditor/registry.tsx | 2 + .../ui/src/components/AssetBridgeHost.tsx | 1 + .../ui/src/components/history/VersionDiff.tsx | 2 + packages/ui/src/i18n/messages/de.ts | 36 +++ packages/ui/src/i18n/messages/en.ts | 36 +++ packages/ui/src/i18n/messages/ja.ts | 36 +++ packages/ui/src/i18n/messages/zh.ts | 36 +++ packages/ui/src/index.css | 15 ++ packages/ui/src/lib/assetBridge.ts | 6 + 22 files changed, 808 insertions(+), 138 deletions(-) create mode 100644 _brief-meet5.md delete mode 100644 _brief.md delete mode 100644 _report.md delete mode 100644 packages/ui/src/blockeditor/MeetingBlockPlaceholder.tsx create mode 100644 packages/ui/src/blockeditor/MeetingBlockView.tsx create mode 100644 packages/ui/src/blockeditor/__tests__/meetingRecorder.test.tsx create mode 100644 packages/ui/src/blockeditor/meetingRecorder.ts diff --git a/_brief-meet5.md b/_brief-meet5.md new file mode 100644 index 00000000..2ba8a8fd --- /dev/null +++ b/_brief-meet5.md @@ -0,0 +1,45 @@ +# MEET-5 — Recorder UI: meeting block view, progressive transcript, manual notes + +You are Ellis, an OpenBook Worker agent. Work in THIS worktree (`/Users/eliot/Workspaces/OpenBook-wt-meet-4`). Do NOT push. Conventional commits (`feat(ui): … (MEET-5)`), committed incrementally. DISK IS NEARLY FULL (~1.5 GiB free): do not download models or create worktrees; clean any large temp artifacts you produce. + +## Branch setup (do this first, carefully) +1. `git checkout -b feat/meet-5-recorder-ui` (from current HEAD = feat/meet-4-meeting-catalogue). +2. `git merge feat/meet-1-audio-assets` then `git merge feat/meet-2-transcribe`. Expected conflicts are trivial: + - `packages/server/src/ableOidc.test.ts`: all three branches contain the identical code line `expiresAt: new Date(Date.now() + 60_000)`; conflicts are comment-only. Resolve keeping MEET-2's comment wording (PAT validation uses the database clock, not the injected OIDC clock). + - Possible add/add in `packages/sdk/src/index.ts` export lines — keep BOTH exports, watch for duplicated re-export lines (known trap). + - `_brief.md`/`_report.md` conflicts: resolve by DELETING these files (they're being stripped from all branches). +3. After the merges, run `pnpm build:libs` (semantic-conflict check) before starting feature work. + +## What you're building +The real `meeting` block UI, replacing MEET-4's placeholder shell (registered via registerArtifactKit — find it and replace its renderer; keep registration/drift-guard structure intact). + +### Contracts you MUST follow (already merged into your branch) +- **Block props (MEET-4 contract, in `_report.md` at git ref 1f97ba06 — read `git show 1f97ba06:_report.md`):** props `status ('idle'|'recording'|'processing'|'done')`, `audioChunks [{assetId, durationMs, startedAtMs?}]` (startedAtMs = offset from meeting start), `transcript [{startMs, endMs, text}]` (offsets relative to meeting start, ms), `summary` (plain string), `startedAt` (epoch ms), `title?`. Arrays replace wholesale — batch and serialize your writes (single writer: the recording client). Manual notes = ordinary CRDT child blocks; MEET-4's shell stored them — render them as an editable notes region (children editing inside a kit block: see how form/KitFrame handles nested content; follow useKitLock + editor.readOnly semantics, BlockEditor.tsx ~:2429-2449). +- **Transcription API (MEET-2):** `client.transcribeAsset(assetId, pageId)` → `{text, segments?[{start,end,text}] (SECONDS), durationMs}`. Convert segment seconds → ms and offset by the chunk's startedAtMs when appending to `transcript`. Errors: 400 (unconfigured → show actionable copy pointing at Settings → AI), 403 (guest/paid gate), 404, 502. LocalDataClient rejects AI — degrade gracefully (recording + notes still work; transcription affordance disabled with clear copy). +- **Audio upload (MEET-1):** audio mimes webm/ogg/mpeg/mp4/wav accepted by the asset store; 10 MiB/asset cap. Upload chunks via the assetBridge (ui/src/lib/assetBridge.ts; ImageBlockView.tsx is the bytes↔objectURL reference). + +### Recording behavior +- getUserMedia + MediaRecorder. **Restart MediaRecorder per ~45s chunk so every chunk is a standalone playable file** (do NOT use one recorder with timeslice — those blobs aren't independently decodable). Prefer `audio/webm;codecs=opus`, probe with MediaRecorder.isTypeSupported and fall back (Safari → audio/mp4). +- Controls: record / pause / resume / stop; elapsed timer; per-chunk progress. Status prop transitions idle→recording→processing→done. +- Per chunk pipeline: finalize blob → upload asset → append to audioChunks prop → transcribeAsset → append converted segments to transcript prop. Transcript grows during the meeting. Chunk upload MUST succeed/retry even if transcription fails (flag untranscribed chunks, offer per-chunk retry). Exponential backoff; never lose audio; ephemeral state (recorder handle, retry queue, errors) in React state, durable state in props within editor.doc.transact. +- Mic-permission denied → graceful copy, no crash. Playback: simple per-chunk