fix(project): accept JSON strings for records
This commit is contained in:
1 parent
13f81354e8
commit
a7417a1f2e
9 files changed
+97
-7
No files matched your search
@@ -6299,3 +6299,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
|
||||
|
||||
2026-11-06 UTC | #797 stall-guard integration merge | Before: stall-guard tip 6b2c925b9 and integration 5255e4e74 each supplied a Claude activity route; managed Pi also had two potential marker producers. After: BASE's Claude stream helper is authoritative, the Pi monitor is retained and its extension duplicate is disabled for managed turns, runner metadata suppression and newline filtering coexist, instance-running-only cooldown remains, and known/unknown child-signal receipts survive on both paths. Claude empty-delta handling follows BASE's selected helper; helper SHA pin updated. Node 26 final harness 309 pass, CLI main/lane/release 885/61/251 pass with one FED R7 skip, separately verified guard E2Es 11 pass, bus excluding project-files 1615 pass. Bus project-files had 5 pass then timeout/cancellation on both candidate and exact BASE; gate is not green. One earlier harness run failed the known Claude row-66 timing assertion (exact BASE full harness 277/277 and two file runs 14/14; older-base reproduction recorded by prior review); final rerun passed. An initial signal test invoked the removed Claude monitor and was adapted. Queue refused linked worktree; canonical queue read returned nothing. Local merge candidate only, no push or deployment. Full counts, caveats and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md and stall-guard-int-evidence/.
|
||||
2026-11-06 UTC | #797 supervisor schema guard implementation | Before: the 3s-only accounting guard refused the 3t archive store. After: the lead-authorized closed 3s/3t fallback preserves 3s compatibility and accepts 3t; store.mjs performs load-time file reads and in-memory schema/migration execution, so it is not imported into the supervisor. The real-Store current-version regression failed on BASE with test-store-schema-refused; synthetic 3s accepts, 3r/3u/missing refuse. DDL diff adds only project_archives objects. README documents owner diagnosis and restoration or campaign end without reset. Sequential Node 26 whole-file gates: budget 102/0, isolated identity 8/0, browser 13/0, lifecycle 2/0; no skips. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/; local candidate for independent review, no push or deployment.
|
||||
|
||||
2026-11-06 UTC | #797 write_record JSON object compatibility — implementation and verification | Before: JSON-encoded requirements and decisions were refused and project-record-invalid detail was lost before the agent. After: strict object parsing preserves object-form bytes, and only record.write project-record-invalid details reach the tool result; docs remain Markdown. Exact-base negative control: seven behavioral failures. Node 26 serial gates: touched file 7/7, project 50/50, harness 316/316, bus excluding project-files 1642/1642, CLI 1204 pass and one federation acceptance skip reproduced on base. An initial candidate focused run failed one test due to a duplicate code prefix; corrected and rerun green. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Linked-worktree queue refused; local candidate for independent review, no push or deployment.
|
||||
@@ -2096,3 +2096,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-06 UTC | ui797 stall-guard review-fix implementation sub-agent | #797 three corrections on feat/ui797-stall-guard-r2 from 3e754c96a | Adapter stdout parity, signal-specific child-death receipts, instance-running-only cooldown; Node 26 gates and negative controls recorded in /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-r2-report.md. Local commits only; queue refused linked worktree; independent review pending. No push, merge, deployment, live-host contact, model call, secret, role, policy, contract or schema changes.
|
||||
2026-11-06 UTC | ui797 stall-guard integration implementation sub-agent | #797 integration of 5255e4e74 into 6b2c925b9 | Reconciled Claude/Pi activity sources, retained cooldown and signal receipts; final Node 26 harness 309 pass, CLI 1197 pass/1 base-reproduced skip, six guard E2Es within 11 pass, bus 1615 pass plus project-files 5 pass/1 timeout also on exact BASE; earlier row-66 timing failure and all comparisons retained. Local merge candidate for independent review; no push, deployment, real model call or protected-host contact. Report: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md.
|
||||
2026-11-06 UTC | supervisor-3t implementation sub-agent | #797 closed 3s/3t supervisor guard and recovery guidance above 37f38d8e1 | Real-store regression red on BASE; sequential whole-file gates 125 pass, 0 fail/skip, evidence in /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/. Queue refused linked worktree; one local commit for independent review, no push, deployment or live access.
|
||||
2026-11-06 UTC | Terra implementation sub-agent | #797 write_record JSON object compatibility on fix/write-record-object from 13f81354e | Strict parsed JSON objects and scoped agent-visible validation details; exact-base seven-test negative, Node 26 serial gates and base-reproduced CLI skip recorded in /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Queue refused linked worktree; local commit for independent review, no push or deployment.
|
||||
@@ -238,7 +238,7 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
|
||||
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
@@ -6086,7 +6086,9 @@ export class Broker {
|
||||
return result;
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
|
||||
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure
|
||||
? new BusError(e.code, verb === 'record.write' && e.code === 'project-record-invalid'
|
||||
? e.message.slice(`${e.code}: `.length) : undefined) : e, request);
|
||||
}
|
||||
}
|
||||
// Row 148 (#1642): the Console's reads and saves of the spec and docs. Human only: an agent
|
||||
|
||||
@@ -41,7 +41,7 @@ export class Client {
|
||||
const r = JSON.parse(input.toString('utf8'));
|
||||
if (r.ok === true) end(null, r.result);
|
||||
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
|
||||
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
|
||||
// Preserve a bounded reason when an explicitly scoped refusal has one.
|
||||
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
|
||||
else end(new BusError('invalid-response'));
|
||||
} catch {
|
||||
|
||||
@@ -10,7 +10,7 @@ const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll
|
||||
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
|
||||
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
|
||||
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
|
||||
@@ -358,7 +358,9 @@ export function toolServer({ path, tools, client, local }) {
|
||||
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, args);
|
||||
answer({ ok: true, result });
|
||||
} catch (e) {
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed" });
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
|
||||
...(tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
|
||||
? { reason: e.reason.slice(0, 300) } : {}) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +322,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
path: str("Named docs Markdown or requirement/decision JSON path", {
|
||||
pattern: "^(docs/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.md|(requirements|decisions)/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.json)$",
|
||||
}),
|
||||
record: { type: ["string", "object"], description: "Markdown string or JSON record. For requirements/REQ-1.json use {\"recordVersion\":1,\"id\":\"REQ-1\",\"title\":\"Title\",\"text\":\"Requirement text\",\"status\":\"draft\"}; exactly these five fields, id matching filename, status draft, accepted or complete. Title and text must be nonempty." },
|
||||
record: { type: ["string", "object"], description: "JSON object for requirements/ and decisions/ paths; Markdown string for docs/ paths. For requirements/REQ-1.json use {\"recordVersion\":1,\"id\":\"REQ-1\",\"title\":\"Title\",\"text\":\"Requirement text\",\"status\":\"draft\"}; exactly these five fields, id matching filename, status draft, accepted or complete. Title and text must be nonempty." },
|
||||
decision: COMMON.decision,
|
||||
}, ["project", "path", "record"]));
|
||||
}
|
||||
@@ -429,7 +429,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
||||
if (!input.includes("\n")) return;
|
||||
try {
|
||||
const r = JSON.parse(input);
|
||||
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: already running; wait for the existing run" : `refused: ${r.error}`));
|
||||
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: already running; wait for the existing run" : `refused: ${r.error}${tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`));
|
||||
} catch {
|
||||
end(unknown("tool reply isn't JSON"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fixture, git } from '../../bus/tests/spec-fixtures.mjs';
|
||||
import { serve } from '../../bus/src/server.mjs';
|
||||
import { Client } from '../../bus/src/client.mjs';
|
||||
import { toolServer } from '../src/runner.mjs';
|
||||
import { callTool, typedTools } from '../src/tools.mjs';
|
||||
|
||||
const requirement = { recordVersion: 1, id: 'REQ-1', title: 'Title', text: 'Text', status: 'draft' };
|
||||
const decision = { recordVersion: 1, id: 'DEC-1', question: 'Which?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
|
||||
recommendation: 'yes', status: 'open', choice: null };
|
||||
|
||||
async function setup(t) {
|
||||
const f = await fixture(t);
|
||||
const busPath = join(f.root, 'bus.sock');
|
||||
const bus = await serve({ broker: f.broker, path: busPath });
|
||||
t.after(() => bus.close());
|
||||
const client = new Client({ path: busPath, cap: f.coder });
|
||||
const tools = typedTools({ resolved: { definition: 'coder', limits: { tools: [], authority: {
|
||||
withinRole: ['record.write'], crossRole: [],
|
||||
} } } });
|
||||
const path = join(f.root, 'tools.sock');
|
||||
const server = await toolServer({ path, tools, client, local: {} });
|
||||
t.after(() => server.close());
|
||||
return { ...f, write: (recordPath, record) => callTool(path, 'write_record', { project: 'stack', path: recordPath, record }) };
|
||||
}
|
||||
|
||||
for (const [kind, record] of [['requirements', requirement], ['decisions', decision]]) {
|
||||
test(`JSON string ${kind} records commit the same bytes as object records`, async t => {
|
||||
const object = await setup(t), string = await setup(t);
|
||||
const path = `${kind}/${record.id}.json`;
|
||||
const written = await object.write(path, record);
|
||||
const parsed = await string.write(path, JSON.stringify(record));
|
||||
assert.match(parsed.commit, /^[0-9a-f]{40}$/);
|
||||
const expected = git(object.repo, 'show', `${written.commit}:${path}`) + '\n';
|
||||
assert.equal(readFileSync(join(string.repo, path), 'utf8'), expected);
|
||||
assert.equal(git(string.repo, 'show', `${parsed.commit}:${path}`) + '\n', expected);
|
||||
const doc = '# Markdown remains text\n';
|
||||
await string.write('docs/record.md', doc);
|
||||
assert.equal(readFileSync(join(string.repo, 'docs/record.md'), 'utf8'), doc);
|
||||
await assert.rejects(string.write('docs/object.md', { text: 'not Markdown' }), /project-record-invalid/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, value, detail] of [
|
||||
['unparseable', 'secret-marker {', /unparseable string/],
|
||||
['array', '["secret-marker"]', /array/],
|
||||
['number', '42', /number/],
|
||||
['null', 'null', /null/],
|
||||
]) {
|
||||
test(`agent sees safe detail for ${name} JSON record`, async t => {
|
||||
const f = await setup(t);
|
||||
await assert.rejects(f.write('requirements/REQ-1.json', value), e => {
|
||||
assert.match(e.message, /refused: project-record-invalid/);
|
||||
assert.match(e.message, detail);
|
||||
assert.doesNotMatch(e.message, /secret-marker/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('agent sees validation detail while other refusal codes remain code-only', async t => {
|
||||
const f = await setup(t);
|
||||
await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }),
|
||||
/refused: project-record-invalid: requirement\.title: too short/);
|
||||
await assert.rejects(f.write('spec/secret-marker.md', '# text'), { message: 'refused: project-spec-human-only' });
|
||||
const path = join(f.root, 'other-tools.sock');
|
||||
const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {},
|
||||
client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } });
|
||||
t.after(() => server.close());
|
||||
await assert.rejects(callTool(path, 'write_record', {}), { message: 'refused: project-busy' });
|
||||
});
|
||||
@@ -167,6 +167,14 @@ function recordBytes(path, record) {
|
||||
if (typeof record !== 'string' || !record.length) throw new ProjectError('project-record-invalid', 'document must be nonempty Markdown');
|
||||
text = record;
|
||||
} else {
|
||||
if (typeof record === 'string') {
|
||||
try { record = JSON.parse(record); }
|
||||
catch { throw new ProjectError('project-record-invalid', 'JSON record must be an object (got unparseable string)'); }
|
||||
if (record === null || typeof record !== 'object' || Array.isArray(record)) {
|
||||
const type = record === null ? 'null' : Array.isArray(record) ? 'array' : typeof record;
|
||||
throw new ProjectError('project-record-invalid', `JSON record must be an object (got ${type})`);
|
||||
}
|
||||
}
|
||||
const kind = match[1] === 'requirements' ? 'requirement' : 'decision';
|
||||
const problems = validateRecord(kind, record);
|
||||
if (problems.length || record.id !== match[2])
|
||||
|
||||
Reference in new issue
Block a user