Files
jason.woltjeandClaude Opus 5.5 f1ee9a9895 bus, business: agents land approved merges into the business working repository (row 137, #1631)
Row 137 round 4, Darkwing's candidate (agents/darkwing/work/queue-137/r4/,
build.patch 0cabe175, manifest 548ee477, 31 paths, base 63c82f1c4).
Filbert approved round 4 (comment 28809) and Dewey approved it (28816)
after round 3 changes (28801, 28805). Darkwing's round 4 ran 9/9 mutant
kills, bus 317, seat 150, land-work 86.

Round 5 is a rebase only (agents/sage/work/queue-137-r5/, build.patch
d6cbaaa8, manifest f73598fe). Row 150 changed docs/TOOLS.md, broker.mjs
and cli/README.md under it. The round 4 patch applied unchanged onto
435d1c616, and with index and @@ lines removed the two patches are
byte-identical. Filbert checked the interdiff (comment 28818).

A landing moves refs/heads/main in <dataRoot>/working/<business>/
<project>.git, never the project root or canonical refactor. The seed now
fetches the root as refs/heads/root and fast-forwards main only when it
is an ancestor, so a landed main survives the next push. Only a
cross-role merge decision lands. Its squash names the real channel:
"via the CLI" for the human, and "as its arbiter, via the broker" for an
arbiter's decision.resolve. Every other class refuses not-authorized
before the human check.

This carries row 139's two items. REQ-LAND-1: a gated merge decision
never lands. REQ-DEC-2: the host land op accepts an arbiter-resolved
merge. land-work's read.mjs stays human-only, and land-work.sh still
accepts only a human CLI resolution.

Sage's landing gate ran on 435d1c616 plus the candidate: 25 steps,
all rc 0 (15 packages, 10 script suites), 20:51:00Z to 20:55:34Z,
network sage-r137_default removed. Cherry-picked onto 5d98fb194, which
added only a docs/SESSIONS.md line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:56:00 -05:00

108 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
// land-work's read of the bus (row 116, #1610). The checks themselves are in
// packages/bus/src/landing.mjs, which the broker's agent landings share
// (row 137, #1631); every query there is a SELECT.
//
// node scripts/land-work/read.mjs <dataRoot> <business-file> <decision-id> <base>
//
// It opens <dataRoot>/bus/bus.sqlite with readOnly: true and checks the
// schema the way the Store does before reading anything. Output is one
// "key<TAB>value" line per fact land-work needs, or one
// "refuse<TAB>reason<TAB>detail" line and exit 2. A missing or unsafe
// database or business file exits 3.
//
// Schema version and digest follow the Store exports; a schema bump has one authority.
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { schemaDigest, SCHEMA_SHA256, SCHEMA_VERSION } from '../../packages/bus/src/store.mjs';
import { LandingRefusal, readLanding as shared } from '../../packages/bus/src/landing.mjs';
const refuse = (reason, detail) => {
throw new LandingRefusal(reason, detail);
};
export function openBus(dataRoot) {
const path = join(dataRoot, 'bus', 'bus.sqlite');
let s;
try {
s = lstatSync(path);
} catch {
throw Object.assign(new Error(`no bus database at ${path}`), { exit: 3 });
}
if (s.isSymbolicLink() || !s.isFile() || s.uid !== process.getuid())
throw Object.assign(new Error(`unsafe bus database ${path}`), { exit: 3 });
const db = new DatabaseSync(path, { readOnly: true, timeout: 5000 });
try {
checkSchema(db);
} catch (e) {
db.close();
throw e;
}
return db;
}
// The Store's own open check (store.mjs), against a reference built from the
// same schema.sql the import has already verified against SCHEMA_SHA256.
export function checkSchema(db) {
const ddl = readFileSync(new URL('../../packages/bus/schema.sql', import.meta.url), 'utf8');
if (createHash('sha256').update(ddl).digest('hex') !== SCHEMA_SHA256)
refuse('bus-schema', 'packages/bus/schema.sql does not match SCHEMA_SHA256');
const reference = new DatabaseSync(':memory:');
reference.exec(ddl);
const expected = schemaDigest(reference);
reference.close();
const meta = (key) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value;
let digest;
try {
digest = schemaDigest(db);
} catch (e) {
refuse('bus-schema', `cannot read the schema: ${e.message}`);
}
if (digest !== expected) refuse('bus-schema', `schema digest ${digest.slice(0, 12)}, the store expects ${expected.slice(0, 12)}`);
if (meta('schema_version') !== SCHEMA_VERSION || meta('schema_digest') !== expected)
refuse('bus-schema', `meta says version ${meta('schema_version')}, digest ${String(meta('schema_digest')).slice(0, 12)}`);
}
// land-work lands only on refactor (#1610): the base it was given must be
// refactor, and the raise must target it.
export function readLanding(db, business, id, base) {
if (base !== 'refactor') refuse('target-branch', `the base is ${base}, land-work lands only on refactor`);
const q = { get: (sql, ...p) => db.prepare(sql).get(...p), all: (sql, ...p) => db.prepare(sql).all(...p) };
return shared(q, business, id, { into: 'refactor' });
}
function main([dataRoot, businessFile, id, base]) {
const out = (k, v) => process.stdout.write(`${k}\t${String(v ?? '').replace(/[\t\n\r]/g, ' ')}\n`);
let business;
try {
business = JSON.parse(readFileSync(businessFile, 'utf8'));
} catch (e) {
process.stderr.write(`land-work: cannot read the business file ${businessFile}: ${e.message}\n`);
return 3;
}
if (typeof business?.id !== 'string' || typeof business?.human !== 'string' || !business.human) {
process.stderr.write(`land-work: ${businessFile} names no business id and human\n`);
return 3;
}
let db;
try {
db = openBus(dataRoot);
for (const [k, v] of Object.entries(readLanding(db, business, id, base))) out(k, v);
return 0;
} catch (e) {
if (e instanceof LandingRefusal) {
out('refuse', `${e.reason}\t${e.message}`);
return 2;
}
process.stderr.write(`land-work: ${e.message}\n`);
return e.exit ?? 1;
} finally {
db?.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = main(process.argv.slice(2));