From 8830030412303b39f4c3e210beec2f47b1b2523b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 3 Nov 2026 20:41:46 -0600 Subject: [PATCH] fix(bus): confine bootstrap writes, keep unproven partials, recheck freshness per effect, wire host preparation (#1839) --- BUILD-LOG.md | 2 + docs/SESSIONS.md | 2 + packages/bus/src/process.mjs | 3 +- packages/bus/src/request-bootstrap.mjs | 157 ++++++++-- packages/bus/src/request-host.mjs | 66 ++++ packages/bus/src/request-lifecycle.mjs | 16 +- packages/bus/src/runtime.mjs | 13 +- packages/bus/tests/request-bootstrap.test.mjs | 282 +++++++++++++++++- packages/bus/tests/request-intake.test.mjs | 27 +- packages/business/src/project-bootstrap.mjs | 11 +- .../business/tests/project-bootstrap.test.mjs | 23 +- 11 files changed, 555 insertions(+), 47 deletions(-) create mode 100644 packages/bus/src/request-host.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index fed78f4d7..1c42864e6 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6003,3 +6003,5 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr 2026-11-04T00:20:48Z | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 / Gitea #1839 lifecycle Task 1 landing | Review R1 (GPT-6.1 Sol): Needs fixes, I1 PM self-citation reachability, I2 existing route lost after setup waits, I3 stale association confirmed after async admission. I1 deferred to tracker #799 as a security follow-up (MVP rule). Fix round 1 (Sonnet 5.5 xhigh) bec358328: RequestState.route from selection.staged; staleCode recheck before admission and inside the recording transaction for propose and resume; 12 regressions that fail on 3b071cac7. Gates on bec358328: intake 38/38, full bus 1445/1445, business 296/296, zero skips (/mnt/storage/scratch/tmp/nlR5yC). Review R2 (GPT-6.1 Sol): I2 resolved; I3 partly resolved, an in-process change in the continuation after the final awaited inventory read still reaches proposal or confirmation (repro /mnt/storage/scratch/tmp/C5CJ6q). Lead decision: land 3b071cac7+bec358328 without a second round; residual is tracker #800, and the Task 2 executor must re-verify the association at execution. Tradeoff: confirmation records consent, not freshness, until #800 lands. Plan doc RequestState gains route. 2026-11-04T01:50:11Z | Claude Sonnet 5.5 implementation sub-agent, t3/request-lifecycle-bootstrap | #797 / Gitea #1839 lifecycle Task 2 after | Protected project bootstrap that becomes usable at runtime, on 138171ca0. Organization writer: addProject (private exact-add path under the existing lock and receipt discipline, input and serialized bytes both compared against the plan, replacement/removal/library/unrelated-key changes and a mismatched receipt refused, stale revision refused), hold (sync check under the lock), findAddition; write still refuses every project change (organization-projects-protected). New bootstrap executor journals reserve, seed, working, mapping, records, intent, chat and activate as separate receipts bootstrap::; the code seed is published before the mapping and records init runs under the published mapping; interrupted effects are compared by deterministic path, stored commit and exact bytes and retained as bootstrap-uncertain, never removed or rewound. Original displayed intent is saved with writeHumanFile as the decision owner, writeRecord still cannot write spec/. Tracker: the product file omits tracker.project, the board lives in the immutable plan and admission projection, trackerFor skips reserved products and a fresh boot verifies product IDs. Runtime: activateManagedProject reloads only a receipt-backed additive root inside /products after revalidating the full business, prior mappings, credentials, bots, libraries and roles; broker admission and the launcher (via host IPC request.admission) refuse a pending product until activation; restart rebuilds readiness from receipts. initializeWorking (host-only, seat/working.mjs) seeds the working repository under the existing repo lock with no work ref or push authority. #800: the bound organization revision and association are re-verified synchronously inside organization.hold at the start of execution; a mismatch records setup wait request-plan-stale and performs no effect (regression: an_organization_change_after_confirmation_waits_without_effect and four sibling stale cases). Schema change was needed and is append-only: migration 3o to 3p widens the request_events kind CHECK with bootstrap.stale and bootstrap.completed (old table renamed away, new table created under its final name, rows and seq copied, guards recreated); 13 migration-pin tests moved 3o to 3p the way Task 1 did for 3n to 3o, with a new 3o_to_3p test (rows and seq kept, guards intact, the 3o engine refuses the migrated database). RED: project-bootstrap suite failed on writer.hold missing; bus suite failed 9/10 on the kind CHECK before 3p. Corrections: the first full bus run exposed the CHECK; a test expectation for a replayed revision named the revision guard when the append-only trigger fires first (test fixed, schema unchanged); the first-boot test opened a second broker before closing the first (writer-locked, fixture fixed). GREEN: brief command 18/18, kept-green trio 49/49, business 304/304 (was 296), bus 1456/1456 (was 1445), project+launcher 80/80, stack-release 225/225, related cli suites 422/422, seat 223/223, zero skips. Logs /mnt/storage/scratch/tmp/BKItrk/. Not executed on any running instance; local commit only, no push/merge/PR/deployment. + +2026-11-04T02:45Z | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | One fix round on c55849806 for review findings C1, C2, I1, I2, I3, I4; Minor items, Tasks 3-8, roles/*.json, ROUTED and all migrations untouched (no schema change). C1: seeding no longer follows a committed .mosaic link. Linked ancestors and non-regular metadata are refused in the source and in the clone, and the seed and the marker temp are written with O_EXCL|O_NOFOLLOW and renamed, so a planted link is never followed. C2: the clone goes into an exclusively created code.tmp backed by a code.tmp.claim receipt; an unclaimed or foreign-content code.tmp is kept untouched with bootstrap-uncertain, a claimed clone left by a kill is adopted exactly once, and no deterministic path is removed recursively. I1: the #800 freshness check runs before every effect including recovery effects; a mismatch appends setup wait request-plan-stale and does nothing further, evidence kept. I2: managedState classifies by layout (entry root and repo match the managed paths) and reads readiness from the marker and the committed activate receipt, so a missing bootstrap.json no longer admits an unfinished product; the launcher and trackerFor already call managedState and needed no change. I3: new packages/bus/src/request-host.mjs wires preparePlans and admitPlan in the real host from the organization document and revision, the booted tracker config and the staff the organization names; a missing tracker root or staff is a named setup wait (request-tracker-unavailable, request-staff-unavailable, request-organization-unavailable, request-organization-changed) and creates nothing; process.mjs now forwards trackers to startBroker. A normal boot with a tracker root and named staff reaches a bootstrap selection and bootstraps the project. I4: the Task 1 existing-project comparison was reversed and is fixed in localPlan with regressions in request-intake. RED/GREEN: each regression was failing on c55849806 and passes now; the regression set was mutation-checked by reverting each guard, with two exceptions recorded honestly: the executor-level metadataProblem mutant (c1-exec) still passes because git status is a second guard, and the i3 admitPlan unit test was not mutation-checked. Corrections: the committed project-file link test first failed at boot because the config loader refuses the link (test now commits the link after boot); the c2-claim mutant first survived because clone verification is a second guard (two unclaimed-code.tmp tests added); boot recovery rewrote a complete marker for a deleted reserved one, fixed by requiring the marker to prove ownership before every post-reserve effect; the i2 assertion on admission revision null was dropped because that Minor item is out of scope. First full bus run had one failure, model-final-completion 656 FIFO niceness (startNice 16 !== 19), unrelated to this change; the file passed 21/21 three times alone and the full rerun was 1477/1477. GREEN: brief trio 79/79, kept-green trio 49/49, bus 1477/1477 (was 1456), business 305/305 (was 304), project+seat 267/267, stack-release 225/225, zero skips. Limits: the selected tracker board is unverified until the first task is created; after a post-effect stale wait nothing resolves automatically, the request waits and each resume re-checks; a kill mid-clone leaves code.tmp kept and uncertain, never deleted; the prepareExisting/listProjects wiring is out of scope; admission revision stays null. Logs /mnt/storage/scratch/tmp/fix797/. Not executed on any running instance; local commit only, no push/merge/PR/deployment. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 842d54129..6c86b1c4a 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1994,3 +1994,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 lifecycle Task 1 review, fix round and landing | R1 needs fixes, fix bec358328, R2 I2 resolved, I3 residual to #800, I1 to #799; landed on refactor 2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/request-lifecycle-bootstrap | #797 / Gitea #1839 lifecycle Task 2 | Protected project bootstrap: exact-add organization writer, journaled bootstrap executor (reserve, seed, working repo, mapping, records, original intent as owner, chat, activate), runtime activation of a receipt-backed additive product root, host request.admission gate for the launcher, #800 stale re-verify inside the organization lock (request-plan-stale), append-only 3o to 3p migration. Business 304/304, bus 1456/1456, brief 18/18, project+launcher 80/80, stack-release 225/225, zero skips; local commit only, no push. + +2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | C1 link-confined seed and marker writes, C2 receipt-backed code.tmp kept as bootstrap-uncertain when unproven, I1 freshness recheck before every effect, I2 layout-based managed classification, I3 host preparePlans/admitPlan wired (named setup waits), I4 existing-project comparison. Brief 79/79, kept-green 49/49, bus 1477/1477, business 305/305, project+seat 267/267, stack-release 225/225, zero skips; no schema change; local commit only, no push. diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs index 224317c31..2ad612a1f 100644 --- a/packages/bus/src/process.mjs +++ b/packages/bus/src/process.mjs @@ -148,7 +148,8 @@ if (!process.send) { const { tasksAdapter } = await import('../../tasks/src/adapter.mjs'); config.tasks = tasksAdapter({ trackers }); } - runtime = await startBroker(config); + // The adapter above and the host's new-project preparation each read the tracker settings; only the adapter is built here. + runtime = await startBroker({ ...config, ...(trackers ? { trackers } : {}) }); if (closing) { await runtime.close(); return; diff --git a/packages/bus/src/request-bootstrap.mjs b/packages/bus/src/request-bootstrap.mjs index cf44e3134..48dbffbc2 100644 --- a/packages/bus/src/request-bootstrap.mjs +++ b/packages/bus/src/request-bootstrap.mjs @@ -5,7 +5,7 @@ // so a restart re-drives it from the receipts and the on-disk evidence without doing any step twice: // // reserve re-verify the plan against the world (#800), then reserve the product paths and a marker -// seed clone the source commit, drop its remote, commit the one project-file change +// seed clone the source commit into a claimed temporary checkout, drop its remote, commit the one project-file change // working seed the working repository from that commit // mapping publish the one organization mapping through the private exact-add writer // records initialize the records repository under that mapping @@ -15,9 +15,12 @@ // // Every inspection compares deterministic paths, the stored commits and exact bytes. What it cannot prove // is kept as it is and reported as `bootstrap-uncertain`; nothing unrelated is removed and no commit is rewound. +// Nothing is ever deleted recursively, and every write goes through an exclusively created file and a rename, +// inside directories that are verified not to be links, so a link in a source checkout cannot redirect a write. +// Before every effect the organization revision and the chat association are checked again (#800). import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'; import { dirname, join, sep } from 'node:path'; import { digest, refuse, validatePlan } from './request-store.mjs'; import { gitEnv, runGit } from '../../seat/src/checkout.mjs'; @@ -35,13 +38,33 @@ const within = (parent, path) => path === parent || path.startsWith(parent + sep const overlaps = (a, b) => within(a, b) || within(b, a); const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x); -// The checked-out commit of a repository root, read synchronously so the stale check has no await in it. -function headOf(root) { +// One synchronous git read, so the stale check has no await in it. null when git refuses. +function gitSync(root, args) { try { - return execFileSync('git', [...SAFE, '-C', root, 'rev-parse', '--verify', 'HEAD^{commit}'], + return execFileSync('git', [...SAFE, '-C', root, ...args], { env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(root) }, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } catch { return null; } } +// The checked-out commit of a repository root. +const headOf = root => gitSync(root, ['rev-parse', '--verify', 'HEAD^{commit}']); + +// The one metadata file the seed rewrites. `.mosaic` must be a real directory and `project.json` a regular file in it, +// never a link, so neither a read nor a write can leave the checkout (C1). +const PROJECT_FILE = '.mosaic/project.json'; +function metadataProblem(root) { + const dir = exists(join(root, '.mosaic')); + if (!dir || dir.isSymbolicLink() || !dir.isDirectory()) return '.mosaic is not a directory'; + const file = exists(join(root, PROJECT_FILE)); + if (!file || file.isSymbolicLink() || !file.isFile()) return `${PROJECT_FILE} is not a regular file`; + return null; +} +// The same rule on the committed tree: a tree for `.mosaic` and a regular blob for the project file. +function committedMetadataProblem(root, commit) { + const dir = gitSync(root, ['ls-tree', commit, '--', '.mosaic']), file = gitSync(root, ['ls-tree', commit, '--', PROJECT_FILE]); + if (!dir?.startsWith('040000 tree ')) return '.mosaic is not a committed directory'; + if (!/^100(644|755) blob /.test(file ?? '')) return `${PROJECT_FILE} is not a committed regular file`; + return null; +} // The chat project id is a function of the plan, so a retry computes the same one. export function chatProjectId(request, planDigest) { @@ -56,7 +79,7 @@ export async function prepareBootstrap({ business, request, sourceProject, proje const source = business?.projects?.[sourceProject]; if (!plain(source) || source.kind === 'library' || typeof source.root !== 'string') refuse('request-project-refused'); const commit = headOf(source.root); - if (!commit) refuse('request-project-refused'); + if (!commit || metadataProblem(source.root) || committedMetadataProblem(source.root, commit)) refuse('request-project-refused'); const factory = loadProject(source.root); // The plan carries variables only, so a project file with role overrides cannot be reproduced from it. if (Object.keys(factory.roles).length) refuse('request-project-refused'); @@ -90,6 +113,42 @@ export async function executeBootstrap({ binding, owner, journal, services }) { const m = readMarker(marker); return m !== null && m.version === 1 && m.project === project && m.request === request && m.plan_digest === planDigest; }; + const entry = { root: here.root, repo: here.repo }; + const receipt = { request, plan_digest: planDigest, project, data_root: dataRoot }; + + // A path is confined when it and every ancestor up to the data root are real directories, never links (C1). + const confined = path => { + for (let p = path; p !== dataRoot; p = dirname(p)) { + if (!within(dataRoot, p)) return false; + const st = exists(p); + if (!st || st.isSymbolicLink() || !st.isDirectory()) return false; + } + return true; + }; + // Writes through an exclusively created sibling and a rename, so a link sitting at `file` is replaced and never + // followed. `stage` creates the sibling and returns the rename; a `temp` left by an earlier attempt is this plan's + // own only if it is a regular file with exactly these bytes, otherwise nothing is touched and the step is uncertain. + const stage = (file, temp, bytes) => { + if (!confined(dirname(file)) || !confined(dirname(temp))) uncertain(); + const old = exists(temp); + if (old) { if (!old.isFile() || readFileSync(temp, 'utf8') !== bytes) uncertain(); unlinkSync(temp); } + const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { writeSync(fd, bytes); } finally { closeSync(fd); } + return () => renameSync(temp, file); + }; + const publish = (file, temp, bytes) => stage(file, temp, bytes)(); + // #800 before every effect, not only the first: the organization is still at the revision this plan expects (the + // confirmed one before the mapping, this plan's own post-add one after it), the mapping is the plan's, and the chat + // association is not another's. Synchronous and under the organization lock, so no write lands between the check + // and the effect after it. A mismatch keeps what is done and performs nothing further. + const fresh = () => writer.hold(business, ({ revision, document }) => { + const stale = () => refuse('request-plan-stale'); + if (document.id !== business || document.human !== owner) stale(); + const added = writer.findAddition(business, { request, plan_digest: planDigest, project }); + if (added) { if (revision !== added.after || canonicalJson(document.projects?.[project]) !== canonicalJson(entry)) stale(); } + else if (revision !== plan.organization_revision || Object.hasOwn(document.projects ?? {}, project)) stale(); + if (services.chat.state({ business, project, name: plan.name, id: chatId }) === 'conflict') stale(); + }); const git = (cwd, args, extra = {}, file = false) => runGit([...SAFE, ...(file ? ['-c', 'protocol.file.allow=always'] : []), ...(cwd ? ['-C', cwd] : []), ...args], { ...gitEnv(), ...(cwd ? { GIT_CEILING_DIRECTORIES: dirname(cwd) } : {}), ...extra }); @@ -101,11 +160,17 @@ export async function executeBootstrap({ binding, owner, journal, services }) { const op = base(name), last = journal.effects(business, request).filter(r => r.op === op).at(-1); if (last?.stage === 'committed') return last.result; if (last?.stage === 'refused') refuse(last.code ?? 'bootstrap-refused'); + if (name !== 'reserve') fresh(); const header = { version: 1, business, request, op, kind: 'bootstrap', digest: planDigest }; journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null }); let result; - try { result = await run({ recovering: last !== undefined }); } - catch (e) { + try { + // Every effect after the reservation builds inside a product directory the marker proves is this plan's. A marker that + // is gone, unreadable or another plan's means ownership can no longer be shown, so nothing further is done and nothing + // is rewritten: the step is uncertain and the product stays unadmitted. + if (name !== 'reserve' && !markerOwns()) uncertain(); + result = await run({ recovering: last !== undefined }); + } catch (e) { if (!(e instanceof Uncertain)) throw e; journal.finishEffect({ ...header, stage: 'uncertain', result: null, code: 'bootstrap-uncertain' }); refuse('bootstrap-uncertain'); @@ -149,18 +214,23 @@ export async function executeBootstrap({ binding, owner, journal, services }) { // 2. seed. The checkout is the source commit plus one commit that changes only the project file. const seedBytes = JSON.stringify({ projectVersion: 1, id: project, vars: plan.project_vars }, null, 2) + '\n'; - const seedFile = join(here.root, '.mosaic/project.json'); + const seedFile = join(here.root, PROJECT_FILE), seedTemp = join(product, 'project.json.seed'); async function seeded() { - const code = here.root, head = await out(code, ['rev-parse', 'HEAD']), remotes = await out(code, ['remote']); + const code = here.root; + if (!confined(code)) uncertain(); + if (metadataProblem(code)) refuse('bootstrap-metadata-refused'); + const head = await out(code, ['rev-parse', 'HEAD']), remotes = await out(code, ['remote']); const status = await out(code, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']); if (!['', 'origin'].includes(remotes)) uncertain(); if (head === plan.source.commit) { // Cloned, or seeded but not yet committed. The only change allowed is the exact project file. const onlyFile = status === '' || (/^( M|M ) \.mosaic\/project\.json$/.test(status) && readFileSync(seedFile, 'utf8') === seedBytes); if (!onlyFile) uncertain(); + fresh(); if (remotes === 'origin') await out(code, ['remote', 'remove', 'origin']); - if (status === '') writeFileSync(seedFile, seedBytes); - await out(code, ['add', '--', '.mosaic/project.json']); + if (status === '') publish(seedFile, seedTemp, seedBytes); + await out(code, ['add', '--', PROJECT_FILE]); + fresh(); await out(code, ['commit', '-q', '-m', `Bootstrap ${project} from ${plan.source.project}`], identity(human())); return { commit: await out(code, ['rev-parse', 'HEAD']), base: plan.source.commit }; } @@ -169,17 +239,51 @@ export async function executeBootstrap({ binding, owner, journal, services }) { status === '' && remotes === '' && readFileSync(seedFile, 'utf8') === seedBytes) return { commit: head, base: plan.source.commit }; return uncertain(); } - const seed = async () => { - const code = exists(here.root), tmp = join(product, 'code.tmp'); - if (code) { if (!code.isDirectory() || code.isSymbolicLink() || !markerOwns()) uncertain(); return seeded(); } + // The temporary checkout is this plan's only through a claim file made exclusively beside it, and only while the + // plan's own seed receipt says an earlier attempt began. A path that is already there on the first attempt, or + // that holds anything but an empty directory or a clean clone of exactly the source commit, is not ours to build + // in or remove: it is kept as it is and the step reports `bootstrap-uncertain` (C2). + const tmp = join(product, 'code.tmp'), claimFile = join(product, 'code.tmp.claim'); + const claimBytes = JSON.stringify({ version: 1, kind: 'bootstrap-clone', request, plan_digest: planDigest, project, path: 'code.tmp' }) + '\n'; + const claimed = () => { const st = exists(claimFile); return Boolean(st && st.isFile() && readFileSync(claimFile, 'utf8') === claimBytes); }; + const dropClaim = () => { if (claimed()) unlinkSync(claimFile); }; + async function clone(recovering) { const document = writer.read(business).document, source = document.projects?.[plan.source.project]; - if (!markerOwns() || !plain(source) || source.kind === 'library' || (source.remote ?? null) !== plan.source.remote) uncertain(); - if (exists(tmp)) rmSync(tmp, { recursive: true, force: true }); - await out(null, ['clone', '-q', '--no-hardlinks', '--template=', '--', source.root, tmp], {}, true); - if (await out(tmp, ['rev-parse', 'HEAD']) !== plan.source.commit) refuse('request-plan-stale'); - await out(tmp, ['remote', 'remove', 'origin']); + if (!markerOwns() || !confined(product) || !plain(source) || source.kind === 'library' || (source.remote ?? null) !== plan.source.remote) uncertain(); + const held = exists(tmp), claim = exists(claimFile); + if (!recovering && (held || claim)) uncertain(); + if (claim && !claimed()) uncertain(); + if (held && (!claim || held.isSymbolicLink() || !held.isDirectory())) uncertain(); + if (!held) { + try { + if (!claim) writeFileSync(claimFile, claimBytes, { flag: 'wx', mode: 0o600 }); + mkdirSync(tmp, { mode: 0o700 }); + } catch (e) { if (e.code === 'EEXIST') uncertain(); throw e; } + } + if (!held || readdirSync(tmp).length === 0) { + await out(null, ['clone', '-q', '--no-hardlinks', '--template=', '--', source.root, tmp], {}, true); + } + await services.barrier?.('clone'); + // A clean clone of exactly the source commit, with at most the remote the clone itself added. + const head = await tryOut(tmp, ['rev-parse', 'HEAD']), remotes = await tryOut(tmp, ['remote']); + const status = await tryOut(tmp, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']); + if (head === null || remotes === null || status !== '' || !['', 'origin'].includes(remotes)) uncertain(); + if (head !== plan.source.commit) refuse('request-plan-stale'); + if (metadataProblem(tmp)) refuse('bootstrap-metadata-refused'); + if (remotes === 'origin') await out(tmp, ['remote', 'remove', 'origin']); + fresh(); + if (exists(here.root) || !confined(product)) uncertain(); renameSync(tmp, here.root); - return seeded(); + await services.barrier?.('cloned'); + } + const seed = async ({ recovering }) => { + if (exists(here.root)) { + const code = exists(here.root); + if (!code.isDirectory() || code.isSymbolicLink() || !markerOwns()) uncertain(); + } else await clone(recovering); + const seededAt = await seeded(); + dropClaim(); + return seededAt; }; // 3. working. The working repository's main follows the checkout's HEAD, which is the seed commit. @@ -190,8 +294,6 @@ export async function executeBootstrap({ binding, owner, journal, services }) { }; // 4. mapping. Only the private exact-add writer can publish it; a revision that moved needs a fresh preview. - const entry = { root: here.root, repo: here.repo }; - const receipt = { request, plan_digest: planDigest, project, data_root: dataRoot }; const mapping = () => { const found = writer.findAddition(business, { request, plan_digest: planDigest, project }); const now = writer.read(business); @@ -248,10 +350,11 @@ export async function executeBootstrap({ binding, owner, journal, services }) { const activate = async () => { await services.barrier?.('activate'); const mapped = done('mapping'); - services.activate({ business, project, receipt, organization_revision: mapped.organization_revision }); - const complete = markerBytes({ state: 'complete', request, plan_digest: planDigest, project }); - writeFileSync(`${marker}.tmp`, complete, { mode: 0o600 }); - renameSync(`${marker}.tmp`, marker); + // The marker is staged before the broker admits anything, so a refusal leaves no half-admitted product. + const place = stage(marker, `${marker}.tmp`, markerBytes({ state: 'complete', request, plan_digest: planDigest, project })); + try { services.activate({ business, project, receipt, organization_revision: mapped.organization_revision }); } + catch (e) { if (e?.code === 'bootstrap-revision-stale') refuse('request-plan-stale'); throw e; } + place(); return { project, organization_revision: mapped.organization_revision }; }; diff --git a/packages/bus/src/request-host.mjs b/packages/bus/src/request-host.mjs new file mode 100644 index 000000000..a4c0eb477 --- /dev/null +++ b/packages/bus/src/request-host.mjs @@ -0,0 +1,66 @@ +// Lifecycle Task 2 (#797 / Gitea #1839, review I3): the host's own preparation and admission of a new-project +// plan, so a normal boot reaches a bootstrap selection without anything injected. +// +// Every input comes from something the host already holds: the validated organization document and its +// revision, the displayed source commit (prepareBootstrap reads it from the declared source checkout), the +// tracker configuration the host booted with, and the staff the organization names. A resource nothing can +// supply is a precise setup wait, never a placeholder: no tracker root, or a staff the organization does not +// name exactly, leaves the request waiting with a named code and creates nothing. +// +// Not verified here, because nothing the host holds can: that the tracker board the owner selected exists. +// That is checked when the first task is created, not before the owner confirms. +import { prepareBootstrap } from './request-bootstrap.mjs'; + +const positive = n => Number.isSafeInteger(n) && n > 0; +const MODES = ['autonomous', 'guided']; + +// The staff a bootstrapped project is built with, from the organization document alone: the request's PM, the +// technical arbiter, the launcher as chief, and the one launchable instance of each of the coder and reviewer +// roles. null unless every name is declared and the PM is not also the technical reviewer. +export function staffFor(document, pm) { + const roles = document?.roles, launch = document?.launch; + if (!roles || typeof roles !== 'object' || !launch || typeof launch.by !== 'string' || !Array.isArray(launch.instances)) return null; + const only = definition => { + const names = launch.instances.filter(i => roles[i]?.definition === definition); + return names.length === 1 ? names[0] : null; + }; + const staff = { pm, technical_reviewer: document.arbiters?.technical, coder: only('coder'), reviewer: only('reviewer'), chief: launch.by }; + const named = Object.values(staff).every(name => typeof name === 'string' && Object.hasOwn(roles, name)); + return named && staff.pm !== staff.technical_reviewer ? staff : null; +} + +// hostPreparation({ dataRoot, organization, trackers }) -> { preparePlans, admitPlan } +// `organization` is the organization writer, `trackers` the boot config's { business: { project, ... } }. +export function hostPreparation({ dataRoot, organization, trackers = null }) { + const read = business => { try { return organization.read(business); } catch { return null; } }; + // The tracker root board and the staff for one business, or the setup wait code that names what is missing. + const resources = (document, pm) => { + const root = trackers?.[document.id]?.project; + if (!positive(root)) return { code: 'request-tracker-unavailable' }; + const staff = staffFor(document, pm); + return staff ? { root, staff } : { code: 'request-staff-unavailable' }; + }; + return { + async preparePlans(state, args, from) { + const now = read(state.business); + if (!now) return { ready: false, code: 'request-organization-unavailable' }; + const found = resources(now.document, state.pm); + if (found.code) return { ready: false, code: found.code }; + return Promise.all(MODES.map(mode => prepareBootstrap({ business: now.document, request: state.request, sourceProject: args.source_project, + project: args.project, name: args.name, mode, dataRoot, organizationRevision: now.revision, tracker: { root: found.root, project: args.tracker_project }, + staff: found.staff, intent: { text: from.text, source_message: from.source_message } }))); + }, + // May this plan be shown and confirmed: the organization is still at the revision it was prepared on, and the + // resources it names are the ones the host holds now. + admitPlan(plan) { + const now = read(plan.business); + if (!now) return { ready: false, code: 'request-organization-unavailable' }; + if (now.revision !== plan.organization_revision) return { ready: false, code: 'request-organization-changed' }; + const found = resources(now.document, plan.staff.pm); + if (found.code) return { ready: false, code: found.code }; + if (plan.tracker.root !== found.root) return { ready: false, code: 'request-tracker-unavailable' }; + if (Object.keys(found.staff).some(k => plan.staff[k] !== found.staff[k])) return { ready: false, code: 'request-staff-unavailable' }; + return { ready: true }; + }, + }; +} diff --git a/packages/bus/src/request-lifecycle.mjs b/packages/bus/src/request-lifecycle.mjs index cc261face..90b6efaef 100644 --- a/packages/bus/src/request-lifecycle.mjs +++ b/packages/bus/src/request-lifecycle.mjs @@ -59,7 +59,8 @@ export function createRequestLifecycle({ journal, services }) { p.intent.text !== source.text || p.intent.source_message !== source.source_message || p.intent.generated !== false) refuse('request-plan-binding-refused'); const mapping = services.project(state.business, p.source.project); if (!mapping || (mapping.remote ?? null) !== p.source.remote) refuse('request-plan-binding-refused'); - if (p.existing && (p.records_root !== mapping.root || p.code_root !== mapping.repo)) refuse('request-plan-binding-refused'); + // An existing project's code checkout is its mapping `root`; its records repository is `repo`, or the data root default. + if (p.existing && (p.code_root !== mapping.root || p.records_root !== (mapping.repo ?? join(services.dataRoot, 'projects', state.business, p.project)))) refuse('request-plan-binding-refused'); // No inferred resource names or filesystem effects: preparation must supply exact roots. if (!p.existing) { if (services.project(state.business, p.project)) refuse('request-project-collision'); @@ -177,8 +178,9 @@ export function createRequestLifecycle({ journal, services }) { }); } // Runs the confirmed bootstrap plan through the host executor. The executor re-verifies the bound - // organization revision and association before any effect (#800); a stale plan reverts to selection so - // the owner gets a fresh preview, and anything else waits as uncertain until recovery drives it again. + // organization revision and association before every effect (#800). A plan found stale before any effect reverts + // to selection so the owner gets a fresh preview; one found stale after an effect waits with its evidence kept. + // Anything else waits as uncertain until recovery drives it again. async function runBootstrap(state) { if (!services.bootstrap || state.phase !== 'bootstrap' || !state.plan_digest) return state; const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest); @@ -186,7 +188,13 @@ export function createRequestLifecycle({ journal, services }) { let done; try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); } catch (e) { - if (e?.code === 'request-plan-stale') return append(state, 'bootstrap.stale', { digest: binding.digest, code: 'request-plan-stale' }); + if (e?.code === 'request-plan-stale') { + // Before any effect the plan is only stale and the owner gets a fresh preview. Once an effect has a committed receipt + // (#800 is checked before every effect, not only the first), what is done stays where it is and the request waits, so no + // later effect runs on a world the owner never confirmed and no fresh preview meets half-built paths. + const begun = journal.effects(state.business, state.request).some(r => r.kind === 'bootstrap' && r.digest === binding.digest && r.stage === 'committed'); + return begun ? wait(state, 'request-plan-stale') : append(state, 'bootstrap.stale', { digest: binding.digest, code: 'request-plan-stale' }); + } return wait(state, 'bootstrap-uncertain'); } return append(state, 'bootstrap.completed', { digest: binding.digest, project: done.project, chat_project: done.chat_project, diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 0440df5cf..cb22adc6b 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -5,6 +5,7 @@ import { Store } from './store.mjs'; import { createRequestStore } from './request-store.mjs'; import { createRequestLifecycle } from './request-lifecycle.mjs'; import { executeBootstrap } from './request-bootstrap.mjs'; +import { hostPreparation } from './request-host.mjs'; import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs'; import * as projectModule from '../../project/src/index.mjs'; import * as libraryModule from '../../library/src/index.mjs'; @@ -258,8 +259,12 @@ export async function startBroker({ consoleOwners = [], // P1 validated sidecar from the trusted host; never a socket request setting. proxy = null, - // Host-only preparation/admission capabilities; missing capabilities leave a setup wait. + // Host-only preparation/admission capabilities. The host builds its own from the organization writer and `trackers` + // (request-host.mjs); anything given here replaces the host's, and a capability nothing supplies leaves a setup wait. requestServices = {}, + // The boot config's plain-data tracker settings, { business: { project, ... } }. The tasks adapter reads its own copy; + // the host's new-project preparation reads each business's tracker root board from this one. + trackers = null, }) { if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) || Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512))) @@ -518,9 +523,11 @@ export async function startBroker({ if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business'); if (Object.hasOwn(businesses[id].projects ?? {}, project)) return; // already admitted: activation is idempotent const refuse = code => { throw new BusError(code); }; - writer.hold(id, () => { + writer.hold(id, ({ revision }) => { const found = writer.findAddition(id, receipt); if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch'); + // Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800). + if (revision !== found.after) refuse('bootstrap-revision-stale'); const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir }); const resolved = {}; for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance }); @@ -557,7 +564,7 @@ export async function startBroker({ return no(declared ? 'project-pending' : 'project-unknown'); } broker = new Broker({ - requestLifecycle: services => (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { ...requestServices, ...services, + requestLifecycle: services => (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers }) : {}), ...requestServices, ...services, // The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait. ...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: { dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, diff --git a/packages/bus/tests/request-bootstrap.test.mjs b/packages/bus/tests/request-bootstrap.test.mjs index 97a46069d..bebbb49a0 100644 --- a/packages/bus/tests/request-bootstrap.test.mjs +++ b/packages/bus/tests/request-bootstrap.test.mjs @@ -8,10 +8,12 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, sy import { join } from 'node:path'; import { startBroker } from '../src/runtime.mjs'; import { prepareBootstrap } from '../src/request-bootstrap.mjs'; +import { hostPreparation, staffFor } from '../src/request-host.mjs'; import { bootConfig, loadSystem } from '../../cli/src/config.mjs'; import { fixture, tmp } from '../../cli/tests/helpers.mjs'; import { organizationWriter } from '../../business/src/organization.mjs'; import { loadBusiness } from '../../business/src/business.mjs'; +import { managedState } from '../../business/src/project-bootstrap.mjs'; import { workingRepo } from '../../seat/src/working.mjs'; const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n'; @@ -30,8 +32,11 @@ function factory(root) { return dir; } -async function setup(t, { barrier } = {}) { +// `defaultServices` boots with nothing injected, so the host builds its own preparation and admission; `withTrackers` forwards the +// boot config's tracker settings as process.mjs does (the adapter itself is not built). +async function setup(t, { barrier, configureFactory = () => {}, beforePropose = () => {}, defaultServices = false, withTrackers = defaultServices } = {}) { const root = tmp(t, 'bootstrap-'), f = fixture(root, 'demo'), source = factory(root); + configureFactory({ root, f, source }); const hooks = { barrier }; const system = loadSystem({ env: f.env }); const writer = organizationWriter({ dir: join(root, 'config'), rolesDir: f.roles }); @@ -47,7 +52,7 @@ async function setup(t, { barrier } = {}) { const boot = async () => { const warnings = []; const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: 'demo', env: f.env, warn: w => warnings.push(w) }))); - const bus = await startBroker({ ...config, requestServices: plans(f.dataRoot) }); + const bus = await startBroker({ ...config, ...(withTrackers ? { trackers } : {}), requestServices: defaultServices ? {} : plans(f.dataRoot) }); t.after(() => bus.close().catch(() => {})); return { bus, config, trackers, warnings }; }; @@ -60,8 +65,13 @@ async function setup(t, { barrier } = {}) { const call = (who, verb, args = {}) => first.bus.broker.request(caps[who], { verb, args }); const sent = call('human', 'message.send', { to: 'pm', body: INTENT }); const opened = await call('pm', 'request.open', { request: sent.request }); - const proposed = await call('pm', 'request.propose', { request: sent.request, revision: opened.revision, op: 'plan-1', source_project: 'stack', - project: 'timer', name: 'Timer', tracker_project: 2 }); + // A source the preparation refuses never reaches a decision; the refusal is the outcome, so setup hands it back. + let proposed = null, proposeError = null; + beforePropose({ root, f, source }); + try { + proposed = await call('pm', 'request.propose', { request: sent.request, revision: opened.revision, op: 'plan-1', source_project: 'stack', + project: 'timer', name: 'Timer', tracker_project: 2 }); + } catch (e) { proposeError = e; } const choose = async (choice = 'guided') => { call('human', 'decision.resolve', { id: proposed.selection.decision, choice }); return first.bus.requestLifecycle.resume('demo', sent.request); @@ -69,7 +79,7 @@ async function setup(t, { barrier } = {}) { const paths = { code: join(f.dataRoot, 'products/demo/timer/code'), records: join(f.dataRoot, 'projects/demo/timer'), product: join(f.dataRoot, 'products/demo/timer') }; const changes = () => { try { return readFileSync(writer.logFile('demo'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); } catch { return []; } }; const additions = () => changes().filter(c => c.state === 'committed' && c.detail?.verb === 'project.add'); - return { root, f, system, source, writer, hooks, boot, bus: first.bus, first, call, caps, request: sent.request, proposed, choose, paths, changes, additions, + return { root, f, system, source, writer, hooks, boot, bus: first.bus, first, call, caps, request: sent.request, proposed, proposeError, choose, paths, changes, additions, rows: () => first.bus.broker.request(caps.human, { verb: 'project.list', args: {} }) }; } @@ -201,3 +211,265 @@ test('ordinary_organization_writers_still_refuse_every_project_change', async t assert.throws(() => f.writer.write('demo', next, { revision: now.revision, actor: 'jason' }), { code: 'organization-projects-protected' }); assert.equal(f.writer.read('demo').revision, now.revision); }); + +// ---- Fix round for review r1 (#797 Task 2): C1, C2, I1 --------------------------------------------------------- + +const external = (root, name = 'external') => { const dir = join(root, name); mkdirSync(dir); return dir; }; +const stateOf = async (f, bus = f.first.bus) => bus.broker.request(bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'request.get', args: { request: f.request } }); + +// C1. A committed `.mosaic` link is refused where the plan is prepared, so the owner never sees a plan that would write through it. +test('c1_a_committed_mosaic_directory_link_is_refused_and_the_external_file_keeps_its_bytes', async t => { + let victim, before; + const f = await setup(t, { configureFactory: ({ root, source }) => { + const outside = external(root); + victim = join(outside, 'project.json'); before = JSON.stringify(PROJECT_FILE, null, 2) + '\n'; + writeFileSync(victim, before); + rmSync(join(source, '.mosaic'), { recursive: true }); + symlinkSync(outside, join(source, '.mosaic')); + git(source, 'add', '-A'); git(source, 'commit', '-qm', 'linked project metadata'); + } }); + assert.equal(f.proposeError?.code, 'request-project-refused'); assert.equal(f.proposed, null); + assert.equal(readFileSync(victim, 'utf8'), before); + assert.equal(existsSync(f.paths.product), false); assert.equal(f.additions().length, 0); +}); + +// The config loader refuses a linked project file at boot, so this link is committed after boot: preparation is the guard. +test('c1_a_committed_project_file_link_is_refused_at_preparation', async t => { + let victim, before; + const f = await setup(t, { beforePropose: ({ root, source }) => { + const outside = external(root); + victim = join(outside, 'project.json'); before = JSON.stringify(PROJECT_FILE, null, 2) + '\n'; + writeFileSync(victim, before); + rmSync(join(source, '.mosaic/project.json')); + symlinkSync(victim, join(source, '.mosaic/project.json')); + git(source, 'add', '-A'); git(source, 'commit', '-qm', 'linked project file'); + } }); + assert.equal(f.proposeError?.code, 'request-project-refused'); assert.equal(f.proposed, null); + assert.equal(readFileSync(victim, 'utf8'), before); + assert.equal(existsSync(f.paths.product), false); +}); + +// The executor does not trust the plan: a link that appears in the checkout is refused before anything is written through it. +for (const [what, link] of [ + ['mosaic_directory', (code, outside) => { rmSync(join(code, '.mosaic'), { recursive: true }); symlinkSync(outside, join(code, '.mosaic')); }], + ['project_file', (code, outside) => { rmSync(join(code, '.mosaic/project.json')); symlinkSync(join(outside, 'project.json'), join(code, '.mosaic/project.json')); }], +]) { + test(`c1_a_${what}_link_in_the_checkout_is_refused_and_the_external_file_keeps_its_bytes`, async t => { + let victim, before, f; + f = await setup(t, { barrier: step => { + if (step !== 'cloned') return; + const outside = external(f.root); + victim = join(outside, 'project.json'); before = JSON.stringify(PROJECT_FILE, null, 2) + '\n'; + writeFileSync(victim, before); + link(f.paths.code, outside); + } }); + const out = await f.choose(); + assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain'); + assert.equal(readFileSync(victim, 'utf8'), before); + assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '1', 'no seed commit was made through the link'); + assert.equal(f.additions().length, 0); + assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false); + }); +} + +test('c1_a_marker_temp_link_is_never_followed_and_the_product_is_not_admitted', async t => { + let victim, f; + f = await setup(t, { barrier: step => { + if (step !== 'activate') return; + victim = join(external(f.root), 'victim.txt'); writeFileSync(victim, 'mine\n'); + symlinkSync(victim, join(f.paths.product, 'bootstrap.json.tmp')); + } }); + const out = await f.choose(); + assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain'); + assert.equal(readFileSync(victim, 'utf8'), 'mine\n'); + assert.equal(JSON.parse(readFileSync(join(f.paths.product, 'bootstrap.json'), 'utf8')).state, 'reserved'); + assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false); +}); + +test('c1_a_seed_temp_link_is_never_followed_and_the_seed_is_not_published', async t => { + let victim, f; + f = await setup(t, { barrier: step => { + if (step !== 'reserve') return; + victim = join(external(f.root), 'victim.txt'); writeFileSync(victim, 'mine\n'); + symlinkSync(victim, join(f.paths.product, 'project.json.seed')); + } }); + const out = await f.choose(); + assert.equal(out.wait.code, 'bootstrap-uncertain'); + assert.equal(readFileSync(victim, 'utf8'), 'mine\n'); + assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '1'); assert.equal(f.additions().length, 0); +}); + +// C2. A path nobody claimed is never removed. +test('c2_unrelated_code_tmp_contents_survive_and_the_step_is_uncertain', async t => { + let f; + f = await setup(t, { barrier: step => { + if (step !== 'reserve') return; + mkdirSync(join(f.paths.product, 'code.tmp')); + writeFileSync(join(f.paths.product, 'code.tmp', 'keep.txt'), 'unrelated\n'); + } }); + const out = await f.choose(); + assert.equal(readFileSync(join(f.paths.product, 'code.tmp', 'keep.txt'), 'utf8'), 'unrelated\n'); + assert.deepEqual(readdirSync(join(f.paths.product, 'code.tmp')), ['keep.txt']); + assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain'); + assert.equal(existsSync(f.paths.code), false); assert.equal(f.additions().length, 0); + const op = `bootstrap:${out.plan_digest}:seed`; + assert.equal(f.first.bus.requestJournal?.effects?.('demo', f.request).filter(r => r.op === op).at(-1)?.stage ?? 'uncertain', 'uncertain'); +}); + +// Ownership comes from the claim this plan wrote, never from how a directory looks. +test('c2_an_unclaimed_empty_code_tmp_is_kept_and_not_adopted', async t => { + let f; + f = await setup(t, { barrier: step => { if (step === 'reserve') mkdirSync(join(f.paths.product, 'code.tmp')); } }); + const out = await f.choose(); + assert.equal(out.wait.code, 'bootstrap-uncertain'); + assert.deepEqual(readdirSync(join(f.paths.product, 'code.tmp')), []); assert.equal(existsSync(f.paths.code), false); + assert.equal(existsSync(join(f.paths.product, 'code.tmp.claim')), false); assert.equal(f.additions().length, 0); +}); + +test('c2_an_unclaimed_clone_of_the_same_commit_is_kept_and_not_adopted', async t => { + let f; + f = await setup(t, { barrier: step => { if (step === 'reserve') git(f.root, 'clone', '-q', f.source, join(f.paths.product, 'code.tmp')); } }); + const out = await f.choose(); + assert.equal(out.wait.code, 'bootstrap-uncertain'); + assert.equal(existsSync(join(f.paths.product, 'code.tmp/.git')), true); assert.equal(existsSync(f.paths.code), false); + assert.equal(f.additions().length, 0); +}); + +test('c2_a_claimed_clone_left_by_a_kill_is_adopted_exactly_once_on_retry', async t => { + let armed = true; + const f = await setup(t, { barrier: step => { if (armed && step === 'clone') { armed = false; throw new Error('killed'); } } }); + const stopped = await f.choose(); + assert.equal(stopped.wait.code, 'bootstrap-uncertain'); + assert.equal(existsSync(join(f.paths.product, 'code.tmp/.git')), true); assert.equal(existsSync(f.paths.code), false); + assert.equal(existsSync(join(f.paths.product, 'code.tmp.claim')), true); + await f.first.bus.close(); + const next = await f.boot(); + const done = await stateOf(f, next.bus); + assert.equal(done.phase, 'discovery'); + assert.equal(existsSync(join(f.paths.product, 'code.tmp')), false); assert.equal(existsSync(join(f.paths.product, 'code.tmp.claim')), false); + assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '2'); assert.equal(f.additions().length, 1); +}); + +test('c2_a_claimed_clone_with_foreign_content_is_kept_and_reported_uncertain', async t => { + let armed = true; + const f = await setup(t, { barrier: step => { if (armed && step === 'clone') { armed = false; throw new Error('killed'); } } }); + await f.choose(); await f.first.bus.close(); + writeFileSync(join(f.paths.product, 'code.tmp/extra.txt'), 'foreign\n'); + const next = await f.boot(); + const out = await stateOf(f, next.bus); + assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain'); + assert.equal(readFileSync(join(f.paths.product, 'code.tmp/extra.txt'), 'utf8'), 'foreign\n'); + assert.equal(existsSync(f.paths.code), false); assert.equal(f.additions().length, 0); +}); + +// I1. The freshness check holds before every effect, not only the first one. +test('i1_an_organization_change_after_reservation_waits_and_keeps_the_reserved_paths', async t => { + let armed = true, f; + f = await setup(t, { barrier: step => { + if (armed && step === 'reserve') { + armed = false; + const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61; + f.writer.write('demo', next, { revision: now.revision, actor: 'jason' }); + } + } }); + const out = await f.choose(); + assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale'); assert.equal(out.project, 'timer'); + assert.equal(existsSync(join(f.paths.product, 'bootstrap.json')), true, 'completed evidence is kept'); + assert.equal(existsSync(f.paths.code), false); assert.equal(existsSync(join(f.paths.product, 'code.tmp')), false); + assert.equal(f.additions().length, 0); assert.deepEqual(readdirSync(f.paths.records), []); + assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false); + assert.equal((await f.first.bus.requestLifecycle.resume('demo', f.request)).wait.code, 'request-plan-stale', 'a re-run performs no effect either'); + assert.equal(existsSync(f.paths.code), false); +}); + +test('i1_an_organization_change_after_the_mapping_stops_the_remaining_effects_across_a_restart', async t => { + let armed = true, f; + f = await setup(t, { barrier: step => { + if (armed && step === 'mapping') { + armed = false; + const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61; + f.writer.write('demo', next, { revision: now.revision, actor: 'jason' }); + } + } }); + const out = await f.choose(); + assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale'); + assert.equal(f.additions().length, 1); + assert.deepEqual(readdirSync(f.paths.records), [], 'no records were written on the moved organization'); + await f.first.bus.close(); + const next = await f.boot(); + const again = await stateOf(f, next.bus); + assert.equal(again.phase, 'bootstrap'); assert.equal(again.wait.code, 'request-plan-stale'); + assert.deepEqual(readdirSync(f.paths.records), []); assert.equal(f.additions().length, 1); + assert.equal(f.writer.read('demo').document.projects.timer !== undefined, true); + assert.equal((await next.bus.admission({ business: 'demo', project: 'timer' })).ready, false); +}); + +// I2. A product is classified by its layout, so deleting the marker cannot turn an unfinished one into an ordinary project. +test('i2_a_missing_marker_does_not_admit_an_unfinished_product_after_reopen', async t => { + let armed = true; + const f = await setup(t, { barrier: step => { if (armed && step === 'mapping') { armed = false; throw new Error('killed'); } } }); + const stopped = await f.choose(); + assert.equal(stopped.wait.code, 'bootstrap-uncertain'); assert.equal(f.additions().length, 1); + await f.first.bus.close(); + rmSync(join(f.paths.product, 'bootstrap.json')); + const entry = f.writer.read('demo').document.projects.timer; + assert.equal(managedState({ dataRoot: f.f.dataRoot, business: 'demo', project: 'timer', entry }), 'reserved'); + const next = await f.boot(); + const admitted = await next.bus.admission({ business: 'demo', project: 'timer' }); + assert.equal(admitted.ready, false); assert.equal(admitted.code, 'project-pending'); + assert.equal(existsSync(join(f.paths.product, 'bootstrap.json')), false, 'recovery never rewrites a marker it did not write'); + assert.equal((await stateOf(f, next.bus)).phase, 'bootstrap'); + assert.equal(next.warnings.length, 0); +}); + +// I3. Nothing is injected: the host builds its own preparation and admission from the organization, the source +// checkout, the tracker settings it booted with and the staff the organization names. +test('i3_a_normal_boot_reaches_a_bootstrap_selection_and_bootstraps_the_project', async t => { + const f = await setup(t, { defaultServices: true }); + assert.equal(f.proposeError, null); + assert.equal(f.proposed.phase, 'selection'); assert.equal(f.proposed.wait.kind, 'decision'); + const [auto, guided] = f.proposed.selection.bindings.map(b => b.plan); + assert.deepEqual([auto.mode, guided.mode], ['autonomous', 'guided']); + assert.equal(auto.source.commit, git(f.source, 'rev-parse', 'HEAD'), 'the displayed commit is the source checkout\'s'); + assert.equal(auto.organization_revision, f.writer.read('demo').revision); + assert.deepEqual(auto.tracker, { root: 1, project: 2 }); + assert.deepEqual(auto.staff, { pm: 'pm', technical_reviewer: 'cto', coder: 'coder', reviewer: 'reviewer', chief: 'chief' }); + assert.deepEqual([auto.code_root, auto.records_root], [f.paths.code, f.paths.records]); + assert.equal(auto.intent.text, INTENT); + const out = await f.choose(); + assert.equal(out.phase, 'discovery'); assert.equal(f.additions().length, 1); + assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, true); +}); + +test('i3_a_missing_tracker_root_is_a_named_setup_wait_and_creates_nothing', async t => { + const f = await setup(t, { defaultServices: true, withTrackers: false }); + assert.equal(f.proposeError, null); + assert.equal(f.proposed.phase, 'selection'); assert.equal(f.proposed.wait.code, 'request-tracker-unavailable'); + assert.equal(f.proposed.selection ?? null, null, 'no plan and no decision were made up'); + assert.equal(existsSync(f.paths.product), false); assert.equal(f.additions().length, 0); +}); + +test('i3_a_staff_the_organization_does_not_name_is_a_named_setup_wait', async t => { + const f = await setup(t, { defaultServices: true, configureFactory: ({ root }) => { + const file = join(root, 'config/businesses/demo.json'), doc = JSON.parse(readFileSync(file, 'utf8')); + doc.launch.instances = ['coder']; writeFileSync(file, JSON.stringify(doc, null, 2) + '\n'); + } }); + assert.equal(f.proposeError, null); + assert.equal(f.proposed.wait.code, 'request-staff-unavailable'); assert.equal(f.proposed.selection ?? null, null); + assert.equal(existsSync(f.paths.product), false); +}); + +test('i3_host_admission_refuses_a_plan_whose_world_has_moved_with_a_named_code', async t => { + const f = await setup(t, { defaultServices: true }), plan = f.proposed.selection.bindings[0].plan; + const host = (over = {}) => hostPreparation({ dataRoot: f.f.dataRoot, organization: f.writer, trackers: { demo: { project: 1 } }, ...over }); + assert.deepEqual(host().admitPlan(plan), { ready: true }); + assert.deepEqual(host({ trackers: null }).admitPlan(plan), { ready: false, code: 'request-tracker-unavailable' }); + assert.deepEqual(host({ trackers: { demo: { project: 7 } } }).admitPlan(plan), { ready: false, code: 'request-tracker-unavailable' }); + assert.deepEqual(host({ organization: { read: () => { throw new Error('gone'); } } }).admitPlan(plan), { ready: false, code: 'request-organization-unavailable' }); + assert.deepEqual(host().admitPlan({ ...plan, staff: { ...plan.staff, coder: 'reviewer' } }), { ready: false, code: 'request-staff-unavailable' }); + const now = f.writer.read('demo'), next = structuredClone(now.document); next.vars['tracker.pollSeconds'] = 61; + f.writer.write('demo', next, { revision: now.revision, actor: 'jason' }); + assert.deepEqual(host().admitPlan(plan), { ready: false, code: 'request-organization-changed' }); + assert.equal(staffFor(now.document, 'cto'), null, 'the PM cannot also be the technical reviewer'); + assert.equal(staffFor({ ...now.document, launch: { ...now.document.launch, instances: ['coder', 'reviewer', 'reviewer'] } }, 'pm'), null); +}); diff --git a/packages/bus/tests/request-intake.test.mjs b/packages/bus/tests/request-intake.test.mjs index 0fc2a0f10..ac7484c37 100644 --- a/packages/bus/tests/request-intake.test.mjs +++ b/packages/bus/tests/request-intake.test.mjs @@ -36,8 +36,8 @@ function setup(t, { prepared = false, authority = true, halted = false, configur version: 1, business: state.business, request: state.request, project: args.project, name: args.name, chat_project: null, mode, organization_revision: revision, source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] }, - code_root: existing ? business.projects[args.project].repo : join(root, 'products/demo', args.project, 'code'), - records_root: existing ? business.projects[args.project].root : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project }, + code_root: existing ? business.projects[args.project].root : join(root, 'products/demo', args.project, 'code'), + records_root: existing ? (business.projects[args.project].repo ?? join(root, 'projects/demo', args.project)) : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project }, staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'cto', chief: 'cto' }, intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false }, project_vars: {}, existing, @@ -167,6 +167,29 @@ test('existing_picker_filters_authorized_software_mappings_then_confirms_exact_a assert.equal(bound.project, 'old'); assert.equal(bound.snapshot, null); assert.deepEqual(bound.tasks, []); assert.equal(bound.wait.code, 'request-attachment-unavailable'); }); +// A mapping with no `repo` keeps its records repository at the data-root default; the guard must compare +// code_root to `root` and records_root to that default, not the other way round. +test('existing_project_without_a_repo_binds_its_root_and_the_default_records_repository', async t => { + const x = setup(t, { prepared: true }); + delete x.business.projects.old.repo; + x.b.reloadBusiness('demo', x.business); + const picker = await reachPicker(x); + const candidate = await x.call('pm', 'request.propose', existingArgs(picker, 'existing-1')); + const [{ plan }] = candidate.selection.bindings; + assert.equal(plan.existing, true); + assert.equal(plan.code_root, x.business.projects.old.root); + assert.equal(plan.records_root, join(x.root, 'projects/demo/old')); +}); +// The Task 1 guard compared the two roots crosswise, so a plan that swaps them was the one it accepted. +for (const repo of [true, false]) test(`existing_project_plan_with_swapped_roots_is_refused_${repo ? 'with' : 'without'}_a_repo`, async t => { + const x = setup(t, { prepared: true, configureHost: host => { + const real = host.prepareExisting; + host.prepareExisting = (...a) => { const p = real(...a); return { ...p, code_root: p.records_root, records_root: p.code_root }; }; + } }); + if (!repo) { delete x.business.projects.old.repo; x.b.reloadBusiness('demo', x.business); } + const picker = await reachPicker(x); + await assert.rejects(async () => x.call('pm', 'request.propose', existingArgs(picker, 'swapped-1')), /request-plan-binding-refused/); +}); test('withdrawal_cancels_without_resources_and_preserves_source', async t => { const x = setup(t, { prepared: true }); const a = await x.call('pm', 'request.open', { request: x.source.request }); const state = await x.call('pm', 'request.propose', x.propose(a)); diff --git a/packages/business/src/project-bootstrap.mjs b/packages/business/src/project-bootstrap.mjs index cdb09b82a..f2e9ba90c 100644 --- a/packages/business/src/project-bootstrap.mjs +++ b/packages/business/src/project-bootstrap.mjs @@ -63,12 +63,15 @@ export function readMarker(file) { export const markerBytes = ({ state, request, plan_digest, project }) => JSON.stringify({ version: 1, state, request, plan_digest, project }, null, 2) + "\n"; -// 'unmanaged' for any entry that is not at the managed layout (or has no marker, so it is checked like any -// declared project), 'complete' for a finished product, 'reserved' for everything else at the layout. +// Whether an entry is a managed product is a fact of its layout, never of the marker: the mapping a bootstrap +// published sits at managedPaths, and the organization writer refuses any other writer that tries to put an entry +// there. So 'unmanaged' only for an entry off that layout; 'complete' only for a readable, matching `complete` +// marker; 'reserved' for everything else at the layout, including a product whose marker is missing or unreadable. +// Neither state means admitted: the bus admits a managed product only on its committed activation receipt, and +// the launcher asks the bus before it clones one. export function managedState({ dataRoot, business, project, entry }) { const here = managedPaths({ dataRoot, business, project }); if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged"; const marker = readMarker(markerFile({ dataRoot, business, project })); - if (marker === null) return "unmanaged"; - return marker.state === "complete" && marker.project === project ? "complete" : "reserved"; + return marker?.state === "complete" && marker.project === project ? "complete" : "reserved"; } diff --git a/packages/business/tests/project-bootstrap.test.mjs b/packages/business/tests/project-bootstrap.test.mjs index 6f364b0d0..dd55fd1d8 100644 --- a/packages/business/tests/project-bootstrap.test.mjs +++ b/packages/business/tests/project-bootstrap.test.mjs @@ -2,9 +2,10 @@ // bootstrapped project's mapping. Every ordinary writer keeps refusing project changes. import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync, rmSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { organizationWriter } from '../src/organization.mjs'; +import { managedState, markerBytes, markerFile } from '../src/project-bootstrap.mjs'; import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs'; import { mappingEntry } from '../../library/tests/helpers.mjs'; @@ -140,3 +141,23 @@ test('a_crash_after_the_rename_still_finds_the_addition_from_the_prepared_receip assert.equal(found.after, f.writer.read('acme').revision); assert.throws(() => f.add(), { code: 'organization-project-add-refused' }); }); + +test('managed_state_follows_the_layout_and_never_depends_on_the_marker', t => { + const f = fixture(t), at = { dataRoot: f.dataRoot, business: 'acme', project: 'timer', entry: f.entry }; + assert.equal(managedState(at), 'reserved', 'no marker, at the managed layout'); + const file = markerFile(at); mkdirSync(join(f.dataRoot, 'products/acme/timer'), { recursive: true }); + writeFileSync(file, markerBytes({ state: 'reserved', request: 'req-1', plan_digest: DIGEST, project: 'timer' })); + assert.equal(managedState(at), 'reserved'); + writeFileSync(file, markerBytes({ state: 'complete', request: 'req-1', plan_digest: DIGEST, project: 'timer' })); + assert.equal(managedState(at), 'complete'); + writeFileSync(file, markerBytes({ state: 'complete', request: 'req-1', plan_digest: DIGEST, project: 'other' })); + assert.equal(managedState(at), 'reserved', 'a marker naming another project does not finish this one'); + writeFileSync(file, '{ not json'); + assert.equal(managedState(at), 'reserved', 'an unreadable marker is not an ordinary project'); + rmSync(file); + assert.equal(managedState(at), 'reserved', 'deleting the marker does not make it ordinary'); + assert.equal(managedState({ ...at, entry: { ...f.entry, root: join(f.root, 'elsewhere') } }), 'unmanaged'); + assert.equal(managedState({ ...at, entry: { ...f.entry, repo: join(f.root, 'elsewhere') } }), 'unmanaged'); + assert.equal(managedState({ ...at, entry: mappingEntry() }), 'unmanaged'); + assert.equal(managedState({ ...at, entry: null }), 'unmanaged'); +});