From cd2f50daec7cbafe08fa8e46606a6bfd421081ab Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:45:31 -0500 Subject: [PATCH] Untested: add library write tools and served blob integration (#1793) --- packages/bus/tests/library-write.test.mjs | 23 +++++++- packages/harness/src/tools.mjs | 13 +++++ .../tests/library-write-tools.test.mjs | 54 +++++++++++++++++++ packages/library/src/sync.mjs | 3 +- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 packages/harness/tests/library-write-tools.test.mjs diff --git a/packages/bus/tests/library-write.test.mjs b/packages/bus/tests/library-write.test.mjs index 435b274a7..2487b4097 100644 --- a/packages/bus/tests/library-write.test.mjs +++ b/packages/bus/tests/library-write.test.mjs @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'nod import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { connect } from 'node:net'; -import { startBroker } from '../src/runtime.mjs'; +import { startBroker, createLibraryLookup } from '../src/runtime.mjs'; import { Store } from '../src/store.mjs'; import { Broker } from '../src/broker.mjs'; import { Client } from '../src/client.mjs'; @@ -309,3 +309,24 @@ test('C2-02 mos-1520: failed notice insert logs metadata and returns outcome-unk assert.deepEqual(f.logs, [{ event: 'library-notice-store-failed', code: 'outcome-unknown', business: 'acme', instance: 'bean', op: 'write', commit: bare.remoteHead() }]); }); + + +test('C2-02 + C2-05c: broker get blob feeds write/delete and new served lookups immediately', async t => { + const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects }); + f.library.lookup = createLibraryLookup({ dataRoot: bare.dataRoot, + sync: context => sync({ ...context, dataRoot: bare.dataRoot }, { credential: bare.credential, command: bare.command }) }); + f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot }, + { ...options, credential: { ...options.credential, file: bare.file }, command: bare.command }); + const query = { universe: 'harbor', path: PATH, scope: 'supporting' }; + const before = await f.call('bean', 'library.get', query); + assert.equal(before.blob, git(bare.mirror, 'rev-parse', `served:${PATH}`)); + const saved = await f.call('bean', 'library.doc.write', { ...args(), base: before.blob, content: 'new served text\n' }); + const after = await f.call('bean', 'library.get', query); + assert.equal(after.commit, saved.commit); + assert.equal(after.blob, git(bare.mirror, 'rev-parse', `${saved.commit}:${PATH}`)); + assert.notEqual(after.blob, before.blob); + assert.ok(after.lines.includes('new served text')); + assert.equal(await f.call('bean', 'library.get', { universe: 'harbor', path: PATH }), null); + await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base: after.blob }); + assert.equal(await f.call('bean', 'library.get', query), null); +}); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 53bc3c32d..4c165bd0a 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -249,6 +249,19 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project ...base, book, scope: manuscriptScope, chapter: str("Chapter filename without extension"), from: line, to: line, }, ["universe", "book"])); add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"])); + // C2-02: write tools carry only the note/base/content. The broker supplies the writer, + // credential and fixed commit message; a creative within-role grant is required. + if (CREATIVE_DEFINITIONS.includes(resolved.definition) && resolved.limits.authority.withinRole.includes("library.write")) { + const writeBase = { + universe, + path: str("A .md path under this universe's human-marked supporting folder"), + base: { type: ["string", "null"], pattern: "^[a-f0-9]{40}$", description: "The blob from library_get, or null to create; never the commit id" }, + }; + add("library_doc_write", "library.doc.write", "library.write", "Create or edit one supporting .md note using the blob from library_get. One commit by your identity, with a fixed human notice. A stale base refuses; agent requests retain the 64 KiB envelope limit.", obj({ + ...writeBase, content: str("UTF-8 note text without NUL; at most 1 MiB and bounded by the agent socket envelope"), + }, ["universe", "path", "base", "content"])); + add("library_doc_delete", "library.doc.delete", "library.write", "Delete one existing regular supporting .md note using the blob from library_get. One commit and a fixed human notice. A stale base or nonregular/missing target refuses.", obj(writeBase, ["universe", "path", "base"])); + } } add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({ project: str("Project ID from the business file"), diff --git a/packages/harness/tests/library-write-tools.test.mjs b/packages/harness/tests/library-write-tools.test.mjs new file mode 100644 index 000000000..4cd294223 --- /dev/null +++ b/packages/harness/tests/library-write-tools.test.mjs @@ -0,0 +1,54 @@ +// Row 288 M3: creative write tools and independent closed-schema coverage. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { typedTools, untypedActions } from '../src/tools.mjs'; +import { resolvedFor } from './helpers.mjs'; + +const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe']; +const WRITES = ['library_doc_write', 'library_doc_delete']; +const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity']; +const role = (t, definition) => resolvedFor(t, 'bean', doc => { + doc.roles.bean = { definition, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } }; +}); + +test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => { + for (const definition of CREATIVE) { + const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true }); + const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES]; + assert.deepEqual(tools.map(tool => tool.name), expected, definition); + assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8); + assert.equal(untypedActions(resolved, tools).includes('library.write'), false); + } +}); + +test('C2-02 schemas: both write tools require universe/path/base, close keys, map verb and action', t => { + const tools = typedTools({ resolved: role(t, 'ideas'), library: true }); + for (const [name, verb, required] of [ + ['library_doc_write', 'library.doc.write', ['universe', 'path', 'base', 'content']], + ['library_doc_delete', 'library.doc.delete', ['universe', 'path', 'base']], + ]) { + const tool = tools.find(tool => tool.name === name); + assert.ok(tool, name); assert.equal(tool.verb, verb); assert.equal(tool.action, 'library.write'); + assert.equal(tool.parameters.type, 'object'); assert.equal(tool.parameters.additionalProperties, false); + assert.deepEqual(tool.parameters.required, required); + assert.deepEqual(Object.keys(tool.parameters.properties).sort(), [...required].sort()); + assert.equal(tool.parameters.properties.universe.type, 'string'); + assert.equal(tool.parameters.properties.path.type, 'string'); + assert.deepEqual(tool.parameters.properties.base.type, ['string', 'null']); + assert.equal(tool.parameters.properties.base.pattern, '^[a-f0-9]{40}$'); + if (name === 'library_doc_write') assert.equal(tool.parameters.properties.content.type, 'string'); + for (const field of ['repo', 'commit', 'business', 'accept', 'project', 'identity', 'author', 'credential', 'agent', 'message']) + assert.equal(Object.hasOwn(tool.parameters.properties, field), false, `${name} ${field}`); + } +}); + +test('C2-02 tools: no library, noncreative or no within-role write grant exposes neither write tool', t => { + const creative = role(t, 'ideas'); + assert.equal(typedTools({ resolved: creative }).some(tool => WRITES.includes(tool.name)), false); + for (const instance of ['coder', 'reviewer', 'pm', 'chief']) + assert.equal(typedTools({ resolved: resolvedFor(t, instance), library: true }).some(tool => WRITES.includes(tool.name)), false); + const narrowed = structuredClone(creative); + narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write'); + narrowed.limits.authority.crossRole.push('library.write'); + assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]); +}); diff --git a/packages/library/src/sync.mjs b/packages/library/src/sync.mjs index 97fd53de1..5f94f5c80 100644 --- a/packages/library/src/sync.mjs +++ b/packages/library/src/sync.mjs @@ -14,6 +14,7 @@ import { SAFE, git } from '../../project/src/git.mjs'; import { checkMapping } from './mapping.mjs'; import { LibraryError } from './problems.mjs'; import { readLibrary } from './reader.mjs'; +import { indexFor } from './index-build.mjs'; import { SYNC_STATE_FILE, ensureMirror, withLibraryLock, servedCommit } from './mirror.mjs'; export const ASKPASS = fileURLToPath(new URL('../bin/askpass', import.meta.url)); @@ -262,7 +263,7 @@ export async function publishLibraryWrite(mirror, { mapping, commit, previous, r const state = await readSyncState({ branch: mapping.branch }); if (state.diverged !== null) fail('library-diverged'); if (state.served !== previous) fail('library-state-invalid'); - const read = await readLibrary({ repo: mirror, commit, mapping }); + const read = await indexFor({ repo: mirror, commit, mapping }); const at = now().toISOString(); const next = { ...state, served: commit, lastPush: { commit, at }, lastError: null, problems: read.problems.map(p => ({ ...p })) };