fix(bus): confine bootstrap writes, keep unproven partials, recheck freshness per effect, wire host preparation (#1839)
This commit is contained in:
1 parent
c558498062
commit
8830030412
11 files changed
+555
-47
No files matched your search
@@ -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-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:<plan_digest>:<step>; 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 <dataRoot>/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-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:<plan_digest>:<step>; 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 <dataRoot>/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.
|
||||||
@@ -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 | 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/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.
|
||||||
@@ -148,7 +148,8 @@ if (!process.send) {
|
|||||||
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
|
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
|
||||||
config.tasks = tasksAdapter({ trackers });
|
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) {
|
if (closing) {
|
||||||
await runtime.close();
|
await runtime.close();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// so a restart re-drives it from the receipts and the on-disk evidence without doing any step twice:
|
// 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
|
// 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
|
// working seed the working repository from that commit
|
||||||
// mapping publish the one organization mapping through the private exact-add writer
|
// mapping publish the one organization mapping through the private exact-add writer
|
||||||
// records initialize the records repository under that mapping
|
// 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
|
// 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.
|
// 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 { createHash } from 'node:crypto';
|
||||||
import { execFileSync } from 'node:child_process';
|
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 { dirname, join, sep } from 'node:path';
|
||||||
import { digest, refuse, validatePlan } from './request-store.mjs';
|
import { digest, refuse, validatePlan } from './request-store.mjs';
|
||||||
import { gitEnv, runGit } from '../../seat/src/checkout.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 overlaps = (a, b) => within(a, b) || within(b, a);
|
||||||
const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
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.
|
// One synchronous git read, so the stale check has no await in it. null when git refuses.
|
||||||
function headOf(root) {
|
function gitSync(root, args) {
|
||||||
try {
|
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();
|
{ env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(root) }, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
} catch { return null; }
|
} 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.
|
// The chat project id is a function of the plan, so a retry computes the same one.
|
||||||
export function chatProjectId(request, planDigest) {
|
export function chatProjectId(request, planDigest) {
|
||||||
@@ -56,7 +79,7 @@ export async function prepareBootstrap({ business, request, sourceProject, proje
|
|||||||
const source = business?.projects?.[sourceProject];
|
const source = business?.projects?.[sourceProject];
|
||||||
if (!plain(source) || source.kind === 'library' || typeof source.root !== 'string') refuse('request-project-refused');
|
if (!plain(source) || source.kind === 'library' || typeof source.root !== 'string') refuse('request-project-refused');
|
||||||
const commit = headOf(source.root);
|
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);
|
const factory = loadProject(source.root);
|
||||||
// The plan carries variables only, so a project file with role overrides cannot be reproduced from it.
|
// 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');
|
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);
|
const m = readMarker(marker);
|
||||||
return m !== null && m.version === 1 && m.project === project && m.request === request && m.plan_digest === planDigest;
|
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],
|
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 });
|
{ ...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);
|
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 === 'committed') return last.result;
|
||||||
if (last?.stage === 'refused') refuse(last.code ?? 'bootstrap-refused');
|
if (last?.stage === 'refused') refuse(last.code ?? 'bootstrap-refused');
|
||||||
|
if (name !== 'reserve') fresh();
|
||||||
const header = { version: 1, business, request, op, kind: 'bootstrap', digest: planDigest };
|
const header = { version: 1, business, request, op, kind: 'bootstrap', digest: planDigest };
|
||||||
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
|
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
|
||||||
let result;
|
let result;
|
||||||
try { result = await run({ recovering: last !== undefined }); }
|
try {
|
||||||
catch (e) {
|
// 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;
|
if (!(e instanceof Uncertain)) throw e;
|
||||||
journal.finishEffect({ ...header, stage: 'uncertain', result: null, code: 'bootstrap-uncertain' });
|
journal.finishEffect({ ...header, stage: 'uncertain', result: null, code: 'bootstrap-uncertain' });
|
||||||
refuse('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.
|
// 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 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() {
|
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']);
|
const status = await out(code, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']);
|
||||||
if (!['', 'origin'].includes(remotes)) uncertain();
|
if (!['', 'origin'].includes(remotes)) uncertain();
|
||||||
if (head === plan.source.commit) {
|
if (head === plan.source.commit) {
|
||||||
// Cloned, or seeded but not yet committed. The only change allowed is the exact project file.
|
// 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);
|
const onlyFile = status === '' || (/^( M|M ) \.mosaic\/project\.json$/.test(status) && readFileSync(seedFile, 'utf8') === seedBytes);
|
||||||
if (!onlyFile) uncertain();
|
if (!onlyFile) uncertain();
|
||||||
|
fresh();
|
||||||
if (remotes === 'origin') await out(code, ['remote', 'remove', 'origin']);
|
if (remotes === 'origin') await out(code, ['remote', 'remove', 'origin']);
|
||||||
if (status === '') writeFileSync(seedFile, seedBytes);
|
if (status === '') publish(seedFile, seedTemp, seedBytes);
|
||||||
await out(code, ['add', '--', '.mosaic/project.json']);
|
await out(code, ['add', '--', PROJECT_FILE]);
|
||||||
|
fresh();
|
||||||
await out(code, ['commit', '-q', '-m', `Bootstrap ${project} from ${plan.source.project}`], identity(human()));
|
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 };
|
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 };
|
status === '' && remotes === '' && readFileSync(seedFile, 'utf8') === seedBytes) return { commit: head, base: plan.source.commit };
|
||||||
return uncertain();
|
return uncertain();
|
||||||
}
|
}
|
||||||
const seed = async () => {
|
// The temporary checkout is this plan's only through a claim file made exclusively beside it, and only while the
|
||||||
const code = exists(here.root), tmp = join(product, 'code.tmp');
|
// plan's own seed receipt says an earlier attempt began. A path that is already there on the first attempt, or
|
||||||
if (code) { if (!code.isDirectory() || code.isSymbolicLink() || !markerOwns()) uncertain(); return seeded(); }
|
// 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];
|
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 (!markerOwns() || !confined(product) || !plain(source) || source.kind === 'library' || (source.remote ?? null) !== plan.source.remote) uncertain();
|
||||||
if (exists(tmp)) rmSync(tmp, { recursive: true, force: true });
|
const held = exists(tmp), claim = exists(claimFile);
|
||||||
await out(null, ['clone', '-q', '--no-hardlinks', '--template=', '--', source.root, tmp], {}, true);
|
if (!recovering && (held || claim)) uncertain();
|
||||||
if (await out(tmp, ['rev-parse', 'HEAD']) !== plan.source.commit) refuse('request-plan-stale');
|
if (claim && !claimed()) uncertain();
|
||||||
await out(tmp, ['remote', 'remove', 'origin']);
|
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);
|
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.
|
// 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.
|
// 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 mapping = () => {
|
||||||
const found = writer.findAddition(business, { request, plan_digest: planDigest, project });
|
const found = writer.findAddition(business, { request, plan_digest: planDigest, project });
|
||||||
const now = writer.read(business);
|
const now = writer.read(business);
|
||||||
@@ -248,10 +350,11 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
|||||||
const activate = async () => {
|
const activate = async () => {
|
||||||
await services.barrier?.('activate');
|
await services.barrier?.('activate');
|
||||||
const mapped = done('mapping');
|
const mapped = done('mapping');
|
||||||
services.activate({ business, project, receipt, organization_revision: mapped.organization_revision });
|
// The marker is staged before the broker admits anything, so a refusal leaves no half-admitted product.
|
||||||
const complete = markerBytes({ state: 'complete', request, plan_digest: planDigest, project });
|
const place = stage(marker, `${marker}.tmp`, markerBytes({ state: 'complete', request, plan_digest: planDigest, project }));
|
||||||
writeFileSync(`${marker}.tmp`, complete, { mode: 0o600 });
|
try { services.activate({ business, project, receipt, organization_revision: mapped.organization_revision }); }
|
||||||
renameSync(`${marker}.tmp`, marker);
|
catch (e) { if (e?.code === 'bootstrap-revision-stale') refuse('request-plan-stale'); throw e; }
|
||||||
|
place();
|
||||||
return { project, organization_revision: mapped.organization_revision };
|
return { project, organization_revision: mapped.organization_revision };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
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);
|
const mapping = services.project(state.business, p.source.project);
|
||||||
if (!mapping || (mapping.remote ?? null) !== p.source.remote) refuse('request-plan-binding-refused');
|
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.
|
// No inferred resource names or filesystem effects: preparation must supply exact roots.
|
||||||
if (!p.existing) {
|
if (!p.existing) {
|
||||||
if (services.project(state.business, p.project)) refuse('request-project-collision');
|
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
|
// 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
|
// organization revision and association before every effect (#800). A plan found stale before any effect reverts
|
||||||
// the owner gets a fresh preview, and anything else waits as uncertain until recovery drives it again.
|
// 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) {
|
async function runBootstrap(state) {
|
||||||
if (!services.bootstrap || state.phase !== 'bootstrap' || !state.plan_digest) return state;
|
if (!services.bootstrap || state.phase !== 'bootstrap' || !state.plan_digest) return state;
|
||||||
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
|
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
|
||||||
@@ -186,7 +188,13 @@ export function createRequestLifecycle({ journal, services }) {
|
|||||||
let done;
|
let done;
|
||||||
try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); }
|
try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); }
|
||||||
catch (e) {
|
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 wait(state, 'bootstrap-uncertain');
|
||||||
}
|
}
|
||||||
return append(state, 'bootstrap.completed', { digest: binding.digest, project: done.project, chat_project: done.chat_project,
|
return append(state, 'bootstrap.completed', { digest: binding.digest, project: done.project, chat_project: done.chat_project,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Store } from './store.mjs';
|
|||||||
import { createRequestStore } from './request-store.mjs';
|
import { createRequestStore } from './request-store.mjs';
|
||||||
import { createRequestLifecycle } from './request-lifecycle.mjs';
|
import { createRequestLifecycle } from './request-lifecycle.mjs';
|
||||||
import { executeBootstrap } from './request-bootstrap.mjs';
|
import { executeBootstrap } from './request-bootstrap.mjs';
|
||||||
|
import { hostPreparation } from './request-host.mjs';
|
||||||
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
|
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
|
||||||
import * as projectModule from '../../project/src/index.mjs';
|
import * as projectModule from '../../project/src/index.mjs';
|
||||||
import * as libraryModule from '../../library/src/index.mjs';
|
import * as libraryModule from '../../library/src/index.mjs';
|
||||||
@@ -258,8 +259,12 @@ export async function startBroker({
|
|||||||
consoleOwners = [],
|
consoleOwners = [],
|
||||||
// P1 validated sidecar from the trusted host; never a socket request setting.
|
// P1 validated sidecar from the trusted host; never a socket request setting.
|
||||||
proxy = null,
|
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 = {},
|
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) ||
|
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)))
|
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 (!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
|
if (Object.hasOwn(businesses[id].projects ?? {}, project)) return; // already admitted: activation is idempotent
|
||||||
const refuse = code => { throw new BusError(code); };
|
const refuse = code => { throw new BusError(code); };
|
||||||
writer.hold(id, () => {
|
writer.hold(id, ({ revision }) => {
|
||||||
const found = writer.findAddition(id, receipt);
|
const found = writer.findAddition(id, receipt);
|
||||||
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
|
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 b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||||
const resolved = {};
|
const resolved = {};
|
||||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
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');
|
return no(declared ? 'project-pending' : 'project-unknown');
|
||||||
}
|
}
|
||||||
broker = new Broker({
|
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.
|
// 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: {
|
...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: {
|
||||||
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier,
|
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, sy
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { startBroker } from '../src/runtime.mjs';
|
import { startBroker } from '../src/runtime.mjs';
|
||||||
import { prepareBootstrap } from '../src/request-bootstrap.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 { bootConfig, loadSystem } from '../../cli/src/config.mjs';
|
||||||
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
||||||
import { organizationWriter } from '../../business/src/organization.mjs';
|
import { organizationWriter } from '../../business/src/organization.mjs';
|
||||||
import { loadBusiness } from '../../business/src/business.mjs';
|
import { loadBusiness } from '../../business/src/business.mjs';
|
||||||
|
import { managedState } from '../../business/src/project-bootstrap.mjs';
|
||||||
import { workingRepo } from '../../seat/src/working.mjs';
|
import { workingRepo } from '../../seat/src/working.mjs';
|
||||||
|
|
||||||
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
|
const INTENT = 'Build a single-file timer.\nKeep these exact bytes.\n';
|
||||||
@@ -30,8 +32,11 @@ function factory(root) {
|
|||||||
return dir;
|
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);
|
const root = tmp(t, 'bootstrap-'), f = fixture(root, 'demo'), source = factory(root);
|
||||||
|
configureFactory({ root, f, source });
|
||||||
const hooks = { barrier };
|
const hooks = { barrier };
|
||||||
const system = loadSystem({ env: f.env });
|
const system = loadSystem({ env: f.env });
|
||||||
const writer = organizationWriter({ dir: join(root, 'config'), rolesDir: f.roles });
|
const writer = organizationWriter({ dir: join(root, 'config'), rolesDir: f.roles });
|
||||||
@@ -47,7 +52,7 @@ async function setup(t, { barrier } = {}) {
|
|||||||
const boot = async () => {
|
const boot = async () => {
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: 'demo', env: f.env, warn: w => warnings.push(w) })));
|
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(() => {}));
|
t.after(() => bus.close().catch(() => {}));
|
||||||
return { bus, config, trackers, warnings };
|
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 call = (who, verb, args = {}) => first.bus.broker.request(caps[who], { verb, args });
|
||||||
const sent = call('human', 'message.send', { to: 'pm', body: INTENT });
|
const sent = call('human', 'message.send', { to: 'pm', body: INTENT });
|
||||||
const opened = await call('pm', 'request.open', { request: sent.request });
|
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',
|
// A source the preparation refuses never reaches a decision; the refusal is the outcome, so setup hands it back.
|
||||||
project: 'timer', name: 'Timer', tracker_project: 2 });
|
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') => {
|
const choose = async (choice = 'guided') => {
|
||||||
call('human', 'decision.resolve', { id: proposed.selection.decision, choice });
|
call('human', 'decision.resolve', { id: proposed.selection.decision, choice });
|
||||||
return first.bus.requestLifecycle.resume('demo', sent.request);
|
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 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 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');
|
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: {} }) };
|
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.throws(() => f.writer.write('demo', next, { revision: now.revision, actor: 'jason' }), { code: 'organization-projects-protected' });
|
||||||
assert.equal(f.writer.read('demo').revision, now.revision);
|
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);
|
||||||
|
});
|
||||||
@@ -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,
|
version: 1, business: state.business, request: state.request, project: args.project,
|
||||||
name: args.name, chat_project: null, mode, organization_revision: revision,
|
name: args.name, chat_project: null, mode, organization_revision: revision,
|
||||||
source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] },
|
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'),
|
code_root: existing ? business.projects[args.project].root : 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 },
|
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' },
|
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 },
|
intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false },
|
||||||
project_vars: {}, existing,
|
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.project, 'old'); assert.equal(bound.snapshot, null); assert.deepEqual(bound.tasks, []);
|
||||||
assert.equal(bound.wait.code, 'request-attachment-unavailable');
|
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 => {
|
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 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));
|
const state = await x.call('pm', 'request.propose', x.propose(a));
|
||||||
|
|||||||
@@ -63,12 +63,15 @@ export function readMarker(file) {
|
|||||||
export const markerBytes = ({ state, request, plan_digest, project }) =>
|
export const markerBytes = ({ state, request, plan_digest, project }) =>
|
||||||
JSON.stringify({ version: 1, state, request, plan_digest, project }, null, 2) + "\n";
|
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
|
// Whether an entry is a managed product is a fact of its layout, never of the marker: the mapping a bootstrap
|
||||||
// declared project), 'complete' for a finished product, 'reserved' for everything else at the layout.
|
// 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 }) {
|
export function managedState({ dataRoot, business, project, entry }) {
|
||||||
const here = managedPaths({ dataRoot, business, project });
|
const here = managedPaths({ dataRoot, business, project });
|
||||||
if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged";
|
if (!plain(entry) || entry.root !== here.root || entry.repo !== here.repo) return "unmanaged";
|
||||||
const marker = readMarker(markerFile({ dataRoot, business, project }));
|
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";
|
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
// bootstrapped project's mapping. Every ordinary writer keeps refusing project changes.
|
// bootstrapped project's mapping. Every ordinary writer keeps refusing project changes.
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { join } from 'node:path';
|
||||||
import { organizationWriter } from '../src/organization.mjs';
|
import { organizationWriter } from '../src/organization.mjs';
|
||||||
|
import { managedState, markerBytes, markerFile } from '../src/project-bootstrap.mjs';
|
||||||
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
|
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
|
||||||
import { mappingEntry } from '../../library/tests/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.equal(found.after, f.writer.read('acme').revision);
|
||||||
assert.throws(() => f.add(), { code: 'organization-project-add-refused' });
|
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');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user