fix(bus): confine the working repo, serialize freshness with protected writes, fence readiness on publication (#802)
This commit is contained in:
1 parent
1031686140
commit
7f5bbbf747
9 files changed
+414
-71
No files matched your search
@@ -6,25 +6,28 @@
|
||||
//
|
||||
// reserve re-verify the plan against the world (#800), then reserve the product paths and a marker
|
||||
// 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 confine the working path, then 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
|
||||
// intent save the original displayed intent as the owner's edit of spec/north-star.md
|
||||
// chat associate the chat project
|
||||
// activate admit the project to the running broker, then mark the product complete
|
||||
// activate mark the product complete, then admit the project to the running broker
|
||||
//
|
||||
// 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).
|
||||
// Before every effect the organization revision and the chat association are checked again (#800), and the check
|
||||
// and the start of the effect are one synchronous run under the organization lock (`guarded`). A project is ready
|
||||
// only with both a complete marker and a committed activate receipt, so the marker goes first and nothing is
|
||||
// admitted until it is published.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
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, relative, sep } from 'node:path';
|
||||
import { digest, refuse, validatePlan } from './request-store.mjs';
|
||||
import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
|
||||
import { initializeWorking } from '../../seat/src/working.mjs';
|
||||
import { initializeWorking, workingRepo } from '../../seat/src/working.mjs';
|
||||
import { initProject, writeHumanFile } from '../../project/src/index.mjs';
|
||||
import { SAFE, identity } from '../../project/src/git.mjs';
|
||||
import { loadProject } from '../../business/src/project.mjs';
|
||||
@@ -139,16 +142,23 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
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 }) => {
|
||||
// association is not another's. `fresh` runs under the organization lock, so a bare `fresh()` proves the world
|
||||
// matched at that instant and nothing more: the lock is released when it returns. What keeps a write from landing
|
||||
// under a stale plan is `guarded(effect)`: the caller has finished every asynchronous read the effect depends on,
|
||||
// and the check and the start of the effect run in one synchronous run with the lock held, so no organization
|
||||
// write lands between them. An effect that is a git child is started inside the lock; the child's own running
|
||||
// time is not covered, and the next step's check catches a change made meanwhile. A mismatch keeps what is done
|
||||
// and performs nothing further. `effect` must be synchronous and must not take the organization lock itself.
|
||||
const fresh = effect => 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();
|
||||
effect?.();
|
||||
});
|
||||
const guarded = effect => { let started; fresh(() => { started = effect(); }); return started; };
|
||||
|
||||
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 });
|
||||
@@ -160,6 +170,7 @@ 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');
|
||||
// An early refusal, before anything is recorded. The effects below each check again through `guarded`.
|
||||
if (name !== 'reserve') fresh();
|
||||
const header = { version: 1, business, request, op, kind: 'bootstrap', digest: planDigest };
|
||||
journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null });
|
||||
@@ -226,12 +237,11 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
// 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 === '') 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()));
|
||||
const who = identity(human());
|
||||
if (remotes === 'origin') await guarded(() => out(code, ['remote', 'remove', 'origin']));
|
||||
if (status === '') guarded(() => publish(seedFile, seedTemp, seedBytes));
|
||||
await guarded(() => out(code, ['add', '--', PROJECT_FILE]));
|
||||
await guarded(() => out(code, ['commit', '-q', '-m', `Bootstrap ${project} from ${plan.source.project}`], who));
|
||||
return { commit: await out(code, ['rev-parse', 'HEAD']), base: plan.source.commit };
|
||||
}
|
||||
const names = await tryOut(code, ['diff', '--name-status', `${head}~1`, head]);
|
||||
@@ -256,12 +266,14 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
if (held && (!claim || held.isSymbolicLink() || !held.isDirectory())) uncertain();
|
||||
if (!held) {
|
||||
try {
|
||||
if (!claim) writeFileSync(claimFile, claimBytes, { flag: 'wx', mode: 0o600 });
|
||||
mkdirSync(tmp, { mode: 0o700 });
|
||||
guarded(() => {
|
||||
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 guarded(() => 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.
|
||||
@@ -270,10 +282,11 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
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);
|
||||
if (remotes === 'origin') await guarded(() => out(tmp, ['remote', 'remove', 'origin']));
|
||||
guarded(() => {
|
||||
if (exists(here.root) || !confined(product)) uncertain();
|
||||
renameSync(tmp, here.root);
|
||||
});
|
||||
await services.barrier?.('cloned');
|
||||
}
|
||||
const seed = async ({ recovering }) => {
|
||||
@@ -282,13 +295,37 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
if (!code.isDirectory() || code.isSymbolicLink() || !markerOwns()) uncertain();
|
||||
} else await clone(recovering);
|
||||
const seededAt = await seeded();
|
||||
dropClaim();
|
||||
guarded(dropClaim);
|
||||
return seededAt;
|
||||
};
|
||||
|
||||
// 3. working. The working repository's main follows the checkout's HEAD, which is the seed commit.
|
||||
const working = async () => {
|
||||
const seedCommit = done('seed').commit, w = await initializeWorking({ dataRoot, business, project, root: here.root });
|
||||
// 3. working. The working repository's main follows the checkout's HEAD, which is the seed commit. Nothing is made
|
||||
// while a directory between the data root and the repository is a link or a file (N1: `working` linked to a
|
||||
// sibling directory would take the repository and every file in it out of the data root). A repository that is
|
||||
// already there is this plan's only on a retry, and only if it is a bare repository holding no ref but root and
|
||||
// main, both at the seed commit; on a first attempt it is someone else's. Either way it is kept as it is.
|
||||
const workingPath = workingRepo(dataRoot, business, project);
|
||||
const workingExists = () => {
|
||||
let at = dataRoot;
|
||||
for (const name of relative(dataRoot, workingPath).split(sep)) {
|
||||
at = join(at, name);
|
||||
const st = exists(at);
|
||||
if (!st) return false;
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) uncertain();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const working = async ({ recovering }) => {
|
||||
const seedCommit = done('seed').commit;
|
||||
if (workingExists()) {
|
||||
if (!recovering) uncertain();
|
||||
const at = [`--git-dir=${workingPath}`];
|
||||
const refs = await tryOut(null, [...at, 'for-each-ref', '--format=%(refname) %(objectname)']);
|
||||
const bare = await tryOut(null, [...at, 'rev-parse', '--is-bare-repository']);
|
||||
const expected = new Set(['refs/heads/root', 'refs/heads/main'].map(ref => `${ref} ${seedCommit}`));
|
||||
if (bare !== 'true' || refs === null || refs.split('\n').filter(Boolean).some(line => !expected.has(line))) uncertain();
|
||||
}
|
||||
const w = await initializeWorking({ dataRoot, business, project, root: here.root, around: guarded });
|
||||
if (w.commit !== seedCommit) uncertain();
|
||||
return { commit: w.commit };
|
||||
};
|
||||
@@ -312,7 +349,11 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
const records = async () => {
|
||||
const repo = here.repo, st = exists(repo);
|
||||
if (!st || !st.isDirectory() || st.isSymbolicLink()) uncertain();
|
||||
if (readdirSync(repo).length === 0) { const made = await initProject(context(), { author: human() }); return { commit: made.commit }; }
|
||||
if (readdirSync(repo).length === 0) {
|
||||
const where = context(), author = human();
|
||||
const made = await guarded(() => initProject(where, { author }));
|
||||
return { commit: made.commit };
|
||||
}
|
||||
const count = await tryOut(repo, ['rev-list', '--count', 'HEAD']);
|
||||
const head = await tryOut(repo, ['log', '-1', '--format=%H|%an|%s', 'HEAD']);
|
||||
if (count !== '1' || head === null) uncertain();
|
||||
@@ -326,7 +367,8 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
const repo = here.repo, init = done('records').commit;
|
||||
const count = await tryOut(repo, ['rev-list', '--count', 'HEAD']), head = await tryOut(repo, ['rev-parse', 'HEAD']);
|
||||
if (count === '1' && head === init) {
|
||||
const saved = await writeHumanFile({ path: INTENT_PATH, content: plan.intent.text, base: init, ...context() });
|
||||
// Every read above and inside the writer is finished before `around` checks and publishes the file.
|
||||
const saved = await writeHumanFile({ path: INTENT_PATH, content: plan.intent.text, base: init, around: guarded, ...context() });
|
||||
return { commit: saved.commit };
|
||||
}
|
||||
const line = await tryOut(repo, ['log', '-1', '--format=%P|%an|%s', 'HEAD']);
|
||||
@@ -338,7 +380,8 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
return { commit: head };
|
||||
};
|
||||
|
||||
// 7. chat. The association is one row; a row that is not this plan's is a conflict, never replaced.
|
||||
// 7. chat. The association is one row; a row that is not this plan's is a conflict, never replaced. No await sits
|
||||
// between the step's own check and the association.
|
||||
const chat = () => {
|
||||
const args = { business, project, name: plan.name, id: chatId }, state = services.chat.state(args);
|
||||
if (state === 'conflict') uncertain();
|
||||
@@ -346,15 +389,16 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
||||
return { chat_project: chatId };
|
||||
};
|
||||
|
||||
// 8. activate. Synchronous from the activation to the receipt, so the broker and the marker agree.
|
||||
// 8. activate. The complete marker is published first and the broker admits the project second, so a project is
|
||||
// never admitted without it: a failed marker write ends the step before the broker is touched. A complete marker
|
||||
// with no committed receipt admits nothing, since readiness at runtime and at startup needs both, and a revision
|
||||
// that moved before the admission leaves the marker as it is and the project pending.
|
||||
const activate = async () => {
|
||||
await services.barrier?.('activate');
|
||||
const mapped = done('mapping');
|
||||
// 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 }));
|
||||
guarded(() => publish(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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -283,15 +283,20 @@ export async function startBroker({
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null;
|
||||
try {
|
||||
// Lifecycle Task 2 (#797): a product whose mapping is published but not yet activated is not part of
|
||||
// this process. The request journal says which are activated; every other managed entry waits for the
|
||||
// bootstrap executor's activateManagedProject, so the broker, the Credentials roots and the boot copy
|
||||
// never see a half-built product. `businesses` becomes this process's own copy.
|
||||
// this process. A managed product is part of it only with both a committed activate receipt in the
|
||||
// request journal and a complete marker on disk (#802: a receipt alone, with the marker gone or never
|
||||
// published, is not enough); every other managed entry waits for the bootstrap executor's
|
||||
// activateManagedProject, so the broker, the Credentials roots and the boot copy never see a
|
||||
// half-built product. `businesses` becomes this process's own copy.
|
||||
store = new Store(dataRoot);
|
||||
const requestJournal = createRequestStore(store);
|
||||
const activatedProjects = business => new Set(requestJournal.committed(business, 'bootstrap')
|
||||
.filter(r => r.op.endsWith(':activate') && typeof r.result?.project === 'string').map(r => r.result.project));
|
||||
const pendingProject = (business, project, entry, activated) => entry?.kind !== 'library' && !activated.has(project)
|
||||
&& managedState({ dataRoot, business, project, entry }) !== 'unmanaged';
|
||||
const pendingProject = (business, project, entry, activated) => {
|
||||
if (entry?.kind === 'library') return false;
|
||||
const state = managedState({ dataRoot, business, project, entry });
|
||||
return state !== 'unmanaged' && !(state === 'complete' && activated.has(project));
|
||||
};
|
||||
const withoutPending = (business, b, admit = []) => {
|
||||
const activated = new Set([...activatedProjects(business), ...admit]);
|
||||
const projects = Object.fromEntries(Object.entries(b.projects ?? {}).filter(([project, entry]) => !pendingProject(business, project, entry, activated)));
|
||||
@@ -514,9 +519,11 @@ export async function startBroker({
|
||||
}
|
||||
}
|
||||
// Lifecycle Task 2 (#797): the only way a project joins a running bus. The bootstrap executor calls it
|
||||
// synchronously after every earlier step passed. It admits exactly the receipt-backed addition under the
|
||||
// organization lock: the file must still be this business plus that entry, with the credentials, bots,
|
||||
// libraries, roles and prior mappings the bus booted with. Anything else leaves the product pending.
|
||||
// synchronously after every earlier step passed and the complete marker is published. It admits exactly the
|
||||
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
|
||||
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
|
||||
// the product pending. The executor commits the activate receipt right after this returns; admission() and a
|
||||
// restart treat the product as ready only once that receipt exists.
|
||||
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
|
||||
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
|
||||
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
|
||||
@@ -536,6 +543,7 @@ export async function startBroker({
|
||||
const here = managedPaths({ dataRoot, business: id, project });
|
||||
if (!entry || entry.kind === 'library' || entry.root !== here.root || entry.repo !== here.repo
|
||||
|| Object.keys(entry).some(k => !['root', 'repo', 'remote'].includes(k))) refuse('bootstrap-mapping-mismatch');
|
||||
if (managedState({ dataRoot, business: id, project, entry }) !== 'complete') refuse('bootstrap-marker-incomplete');
|
||||
const next = withoutPending(id, full, [project]);
|
||||
const { [project]: _added, ...before } = next.projects;
|
||||
if (canonicalJson(before) !== known.get(id)) refuse('bootstrap-mapping-mismatch');
|
||||
@@ -549,7 +557,10 @@ export async function startBroker({
|
||||
});
|
||||
}
|
||||
// Host IPC request.admission: may a project's code checkout be cloned and its tools used. A project the
|
||||
// broker does not hold is never ready, and a declared one that is not yet held says it is pending.
|
||||
// broker does not hold is never ready, and a declared one that is not yet held says it is pending. A managed
|
||||
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
|
||||
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
|
||||
// broker's admission, which comes before the receipt commits, is not yet readiness (#802).
|
||||
function admission({ business: id, project }) {
|
||||
const no = code => ({ ready: false, revision: null, code });
|
||||
if (typeof id !== 'string' || typeof project !== 'string' || !Object.hasOwn(businesses, id)) return no('project-unknown');
|
||||
@@ -560,7 +571,11 @@ export async function startBroker({
|
||||
revision = now.revision; declared = Object.hasOwn(now.document.projects ?? {}, project);
|
||||
}
|
||||
} catch { return no('organization-unreadable'); }
|
||||
if (Object.hasOwn(businesses[id].projects ?? {}, project)) return { ready: true, revision, code: null };
|
||||
if (Object.hasOwn(businesses[id].projects ?? {}, project)) {
|
||||
try { if (pendingProject(id, project, businesses[id].projects[project], activatedProjects(id))) return no('project-pending'); }
|
||||
catch { return no('project-pending'); }
|
||||
return { ready: true, revision, code: null };
|
||||
}
|
||||
return no(declared ? 'project-pending' : 'project-unknown');
|
||||
}
|
||||
broker = new Broker({
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// The test barrier stands in for a kill: it throws after a step's effect and before its receipt.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import cp, { execFileSync } from 'node:child_process';
|
||||
import fs, { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { prepareBootstrap } from '../src/request-bootstrap.mjs';
|
||||
@@ -473,3 +475,190 @@ test('i3_host_admission_refuses_a_plan_whose_world_has_moved_with_a_named_code',
|
||||
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);
|
||||
});
|
||||
|
||||
// ---- Fix round for review r2 (#802): N1, I1, I2 -------------------------------------------------------------------
|
||||
|
||||
// N1. The working repository is confined to the data root like every other bootstrap path.
|
||||
for (const [what, plant, kind] of [
|
||||
['the_working_directory_is_a_link', (f, outside) => { symlinkSync(outside, join(f.dataRoot, 'working')); return join(f.dataRoot, 'working'); }, 'link'],
|
||||
['the_business_directory_under_working_is_a_link', (f, outside) => {
|
||||
mkdirSync(join(f.dataRoot, 'working')); symlinkSync(outside, join(f.dataRoot, 'working', 'demo')); return join(f.dataRoot, 'working', 'demo');
|
||||
}, 'link'],
|
||||
['the_working_directory_is_a_file', f => { writeFileSync(join(f.dataRoot, 'working'), 'not a directory\n'); return join(f.dataRoot, 'working'); }, 'file'],
|
||||
]) {
|
||||
test(`n1_${what}_so_nothing_is_written_through_it_and_the_product_is_not_admitted`, async t => {
|
||||
let outside, planted;
|
||||
const f = await setup(t, { configureFactory: ({ root, f }) => { outside = external(root, 'external-working'); planted = plant(f, outside); } });
|
||||
const out = await f.choose();
|
||||
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
|
||||
assert.deepEqual(readdirSync(outside), [], 'nothing was made behind the link');
|
||||
assert.equal(kind === 'link' ? lstatSync(planted).isSymbolicLink() : lstatSync(planted).isFile(), true, 'what was planted is left as it was');
|
||||
assert.equal(f.additions().length, 0);
|
||||
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('n1_a_working_repository_already_there_on_the_first_attempt_is_kept_and_not_adopted', async t => {
|
||||
const f = await setup(t, { configureFactory: ({ f }) => {
|
||||
const repo = workingRepo(f.dataRoot, 'demo', 'timer'); mkdirSync(repo, { recursive: true }); writeFileSync(join(repo, 'keep.txt'), 'mine\n');
|
||||
} });
|
||||
const out = await f.choose();
|
||||
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
|
||||
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer');
|
||||
assert.deepEqual(readdirSync(repo), ['keep.txt']); assert.equal(readFileSync(join(repo, 'keep.txt'), 'utf8'), 'mine\n');
|
||||
assert.equal(f.additions().length, 0);
|
||||
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
|
||||
});
|
||||
|
||||
// The plan's own repository, left by a kill after the step's effect, is adopted on retry and the project completes.
|
||||
test('n1_a_working_repository_left_by_a_kill_is_adopted_on_retry_and_the_project_completes', async t => {
|
||||
let armed = true;
|
||||
const f = await setup(t, { barrier: step => { if (armed && step === 'working') { armed = false; throw new Error('killed'); } } });
|
||||
const stopped = await f.choose();
|
||||
assert.equal(stopped.wait.code, 'bootstrap-uncertain');
|
||||
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer');
|
||||
assert.equal(git(repo, 'rev-parse', 'refs/heads/main'), git(f.paths.code, 'rev-parse', 'HEAD'));
|
||||
await f.first.bus.close();
|
||||
const next = await f.boot();
|
||||
const done = await stateOf(f, next.bus);
|
||||
assert.equal(done.phase, 'discovery'); assert.equal(f.additions().length, 1);
|
||||
assert.equal(git(repo, 'rev-parse', 'refs/heads/main'), git(f.paths.code, 'rev-parse', 'HEAD'));
|
||||
assert.equal((await next.bus.admission({ business: 'demo', project: 'timer' })).ready, true);
|
||||
});
|
||||
|
||||
test('n1_a_working_repository_with_a_foreign_ref_is_kept_and_not_adopted_on_retry', async t => {
|
||||
let armed = true;
|
||||
const f = await setup(t, { barrier: step => { if (armed && step === 'working') { armed = false; throw new Error('killed'); } } });
|
||||
await f.choose(); await f.first.bus.close();
|
||||
const repo = workingRepo(f.f.dataRoot, 'demo', 'timer'), head = git(repo, 'rev-parse', 'refs/heads/main');
|
||||
git(repo, 'update-ref', 'refs/heads/foreign', head);
|
||||
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(git(repo, 'rev-parse', 'refs/heads/foreign'), head, 'the foreign ref is kept');
|
||||
assert.equal(f.additions().length, 0);
|
||||
assert.equal((await next.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
|
||||
});
|
||||
|
||||
// I1. A protected write happens only while the plan is still fresh, with the check and the start of the write in one
|
||||
// synchronous run. These interleavings land an ordinary organization write after the step's last asynchronous read and
|
||||
// before its write, which is exactly where a bare check-then-await let a stale plan write.
|
||||
const moveOrganization = f => {
|
||||
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' });
|
||||
};
|
||||
// `before(binary, args)` runs just ahead of every child the code spawns while `during` runs.
|
||||
async function withSpawnHook(before, during) {
|
||||
const original = cp.spawn;
|
||||
cp.spawn = function (binary, args, ...rest) { before(binary, args); return original.call(this, binary, args, ...rest); };
|
||||
syncBuiltinESMExports();
|
||||
try { return await during(); } finally { cp.spawn = original; syncBuiltinESMExports(); }
|
||||
}
|
||||
|
||||
test('i1_an_organization_write_during_the_intent_reads_leaves_no_intent_commit_under_the_stale_plan', async t => {
|
||||
let ready = false, changed = false, f;
|
||||
f = await setup(t, { barrier: step => { if (step === 'records') ready = true; } });
|
||||
const out = await withSpawnHook((binary, args) => {
|
||||
if (ready && !changed && binary === 'git' && args.includes(f.paths.records) && args.includes('rev-list') && args.includes('--count')) { changed = true; moveOrganization(f); }
|
||||
}, () => f.choose());
|
||||
assert.equal(changed, true, 'the organization write landed between the reads and the write');
|
||||
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale');
|
||||
const star = join(f.paths.records, 'spec/north-star.md');
|
||||
assert.notEqual(existsSync(star) ? readFileSync(star, 'utf8') : null, INTENT, 'the intent was not written');
|
||||
assert.equal(git(f.paths.records, 'rev-list', '--count', 'HEAD'), '1');
|
||||
assert.equal(git(f.paths.records, 'log', '-1', '--format=%s'), 'Initialize project layout v1');
|
||||
assert.equal(git(f.paths.records, 'status', '--porcelain'), '');
|
||||
assert.equal((await f.first.bus.admission({ business: 'demo', project: 'timer' })).ready, false);
|
||||
});
|
||||
|
||||
test('i1_an_organization_write_during_the_seed_reads_leaves_the_checkout_unseeded', async t => {
|
||||
let changed = false, f;
|
||||
f = await setup(t);
|
||||
const out = await withSpawnHook((binary, args) => {
|
||||
if (!changed && binary === 'git' && args.includes(f.paths.code) && args.includes('status')) { changed = true; moveOrganization(f); }
|
||||
}, () => f.choose());
|
||||
assert.equal(changed, true);
|
||||
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'request-plan-stale');
|
||||
assert.equal(git(f.paths.code, 'rev-list', '--count', 'HEAD'), '1', 'no seed commit was made');
|
||||
assert.equal(readFileSync(join(f.paths.code, '.mosaic/project.json'), 'utf8'), JSON.stringify(PROJECT_FILE, null, 2) + '\n');
|
||||
assert.equal(git(f.paths.code, 'status', '--porcelain'), '');
|
||||
assert.equal(f.additions().length, 0);
|
||||
});
|
||||
|
||||
// I2. The complete marker is published before the broker admits the project, and admission needs both the marker and the
|
||||
// committed activation receipt, live and after a restart.
|
||||
const activations = f => {
|
||||
const database = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
|
||||
try { return database.prepare('SELECT op, stage FROM request_effects WHERE business = ? AND request = ?').all('demo', f.request).filter(r => r.op.endsWith(':activate') && r.stage === 'committed'); }
|
||||
finally { database.close(); }
|
||||
};
|
||||
const marked = f => JSON.parse(readFileSync(join(f.paths.product, 'bootstrap.json'), 'utf8')).state;
|
||||
const admission = f => f.first.bus.admission({ business: 'demo', project: 'timer' });
|
||||
|
||||
test('i2_a_failed_marker_publication_ends_the_step_before_the_broker_admits_the_project', async t => {
|
||||
const f = await setup(t), marker = join(f.paths.product, 'bootstrap.json');
|
||||
const original = fs.renameSync; let faults = 1;
|
||||
fs.renameSync = function (from, to, ...rest) {
|
||||
if (faults && from === `${marker}.tmp` && to === marker) { faults--; throw Object.assign(new Error('injected final publication I/O failure'), { code: 'EIO' }); }
|
||||
return original.call(this, from, to, ...rest);
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
let out;
|
||||
try { out = await f.choose(); } finally { fs.renameSync = original; syncBuiltinESMExports(); }
|
||||
assert.equal(faults, 0, 'the fault fired');
|
||||
assert.equal(out.phase, 'bootstrap'); assert.equal(out.wait.code, 'bootstrap-uncertain');
|
||||
assert.equal(marked(f), 'reserved');
|
||||
const pending = await admission(f);
|
||||
assert.equal(pending.ready, false); assert.equal(pending.code, 'project-pending');
|
||||
assert.deepEqual(activations(f), [], 'no activation receipt was committed');
|
||||
assert.throws(() => f.call('human', 'project.create', { name: 'Side', repo: 'timer' }), 'the broker does not serve the project');
|
||||
// The fault is gone: a retry publishes the marker, admits the project and commits the receipt.
|
||||
const again = await f.first.bus.requestLifecycle.resume('demo', f.request);
|
||||
assert.equal(again.phase, 'discovery');
|
||||
assert.equal(marked(f), 'complete'); assert.equal((await admission(f)).ready, true); assert.equal(activations(f).length, 1);
|
||||
assert.ok(f.call('human', 'project.create', { name: 'Side', repo: 'timer' }).id);
|
||||
});
|
||||
|
||||
// Defence in depth: even if the executor's marker write did nothing, the runtime refuses to admit a product whose marker
|
||||
// is not complete.
|
||||
test('i2_the_runtime_refuses_to_admit_a_product_whose_marker_is_not_complete', async t => {
|
||||
const f = await setup(t), marker = join(f.paths.product, 'bootstrap.json');
|
||||
const original = fs.renameSync; let skipped = 0;
|
||||
fs.renameSync = function (from, to, ...rest) {
|
||||
if (from === `${marker}.tmp` && to === marker) { skipped++; return undefined; }
|
||||
return original.call(this, from, to, ...rest);
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
let out;
|
||||
try { out = await f.choose(); } finally { fs.renameSync = original; syncBuiltinESMExports(); }
|
||||
assert.ok(skipped >= 1, 'the marker rename was skipped');
|
||||
assert.equal(out.phase, 'bootstrap');
|
||||
assert.equal(marked(f), 'reserved');
|
||||
assert.equal((await admission(f)).ready, false); assert.deepEqual(activations(f), []);
|
||||
assert.throws(() => f.call('human', 'project.create', { name: 'Side', repo: 'timer' }));
|
||||
});
|
||||
|
||||
test('i2_a_completed_product_whose_marker_is_removed_is_not_ready_live_or_after_reopen', async t => {
|
||||
const f = await setup(t), marker = join(f.paths.product, 'bootstrap.json');
|
||||
assert.equal((await f.choose()).phase, 'discovery');
|
||||
assert.equal((await admission(f)).ready, true);
|
||||
const bytes = readFileSync(marker);
|
||||
// Live: the broker is already serving the project, and admission still needs the marker.
|
||||
rmSync(marker);
|
||||
const live = await admission(f);
|
||||
assert.equal(live.ready, false); assert.equal(live.code, 'project-pending');
|
||||
writeFileSync(marker, bytes, { mode: 0o600 });
|
||||
assert.equal((await admission(f)).ready, true, 'the marker back makes it ready again');
|
||||
// After a restart: the committed receipt alone admits nothing, and the marker is not rewritten.
|
||||
await f.first.bus.close();
|
||||
rmSync(marker);
|
||||
const next = await f.boot();
|
||||
const reopened = await next.bus.admission({ business: 'demo', project: 'timer' });
|
||||
assert.equal(reopened.ready, false); assert.equal(reopened.code, 'project-pending');
|
||||
assert.equal(existsSync(marker), false, 'recovery never rewrites a marker it did not write');
|
||||
assert.equal(next.warnings.length, 0);
|
||||
await next.bus.close();
|
||||
writeFileSync(marker, bytes, { mode: 0o600 });
|
||||
const last = await f.boot();
|
||||
assert.equal((await last.bus.admission({ business: 'demo', project: 'timer' })).ready, true);
|
||||
});
|
||||
@@ -39,8 +39,10 @@ export function additionProblem({ old, next, project, entry, receipt }) {
|
||||
}
|
||||
|
||||
// The product marker, <dataRoot>/products/<business>/<project>/bootstrap.json. The bootstrap executor writes it
|
||||
// `reserved` before the mapping exists and `complete` once the product is activated, so a process that cannot
|
||||
// read the request journal (the launcher and the boot config) can tell a half-built product from a finished one.
|
||||
// `reserved` before the mapping exists and `complete` once every step before the activation passed, just before the
|
||||
// bus admits the project, so a process that cannot read the request journal (the launcher and the boot config) can
|
||||
// tell a half-built product from a finished one. `complete` alone is not admission: the bus also needs the
|
||||
// committed activate receipt (#802).
|
||||
const MARKER_STATES = ["reserved", "complete"];
|
||||
export const markerFile = ({ dataRoot, business, project }) => join(dataRoot, "products", business, project, "bootstrap.json");
|
||||
export function readMarker(file) {
|
||||
|
||||
@@ -192,7 +192,9 @@ function publish(file, bytes) {
|
||||
// One file, one local commit: the lock, the clean-tree and layout checks, publish, commit and the
|
||||
// rollback. The agent writer and the human writer (row 148) differ only in who authors the commit
|
||||
// and in `guard`, which runs under the lock with the file's current bytes (null when absent).
|
||||
async function commitFile({ context, repo, path, bytes, name, subject, guard }) {
|
||||
// `around(effect)` (the bootstrap executor's, #802) runs `effect`, the one write that starts the commit, after every
|
||||
// read above it has finished; it checks its caller's precondition and starts the effect in one synchronous run.
|
||||
async function commitFile({ context, repo, path, bytes, name, subject, guard, around }) {
|
||||
const metadata = lstatSync(join(repo, '.git'));
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
||||
throw new ProjectError('project-repository', 'expected a local .git directory');
|
||||
@@ -214,7 +216,8 @@ async function commitFile({ context, repo, path, bytes, name, subject, guard })
|
||||
if (guard) await guard({ before });
|
||||
if (before?.equals(bytes)) throw new ProjectError('project-record-unchanged', 'record already has these bytes');
|
||||
head = await git(repo, ['rev-parse', 'HEAD']);
|
||||
publish(file, bytes); published = true;
|
||||
if (around) around(() => publish(file, bytes)); else publish(file, bytes);
|
||||
published = true;
|
||||
await git(repo, ['add', '--', path]);
|
||||
await git(repo, ['commit', '--only', '-m', subject, '--', path], identity(name));
|
||||
const commit = await git(repo, ['rev-parse', 'HEAD']);
|
||||
@@ -245,7 +248,7 @@ export async function writeRecord({ path, record, author, ...context }) {
|
||||
// spec/north-star.md or docs/*.md and makes one commit authored by the business's human. `base` is
|
||||
// the file's last commit as the editor opened it; a newer last commit refuses `stale-base` and
|
||||
// writes nothing. Agents never reach this: only the human bus verb calls it.
|
||||
export async function writeHumanFile({ path, content, base, ...context }) {
|
||||
export async function writeHumanFile({ path, content, base, around, ...context }) {
|
||||
const repo = projectRepo(context);
|
||||
if (!documentPath(path)) throw new ProjectError('project-record-path', 'expected spec/north-star.md or a named Markdown doc');
|
||||
if (typeof content !== 'string' || !content.length || !content.isWellFormed() || content.includes('\0') || Buffer.byteLength(content) > MAX_BYTES)
|
||||
@@ -257,7 +260,7 @@ export async function writeHumanFile({ path, content, base, ...context }) {
|
||||
if (await git(repo, ['log', '-1', '--format=%H', 'HEAD', '--', path]) !== base)
|
||||
throw new ProjectError('stale-base', 'the file changed since the editor opened it');
|
||||
};
|
||||
return commitFile({ context, repo, path, bytes: Buffer.from(content, 'utf8'), name, subject: `Edit ${path}`, guard });
|
||||
return commitFile({ context, repo, path, bytes: Buffer.from(content, 'utf8'), name, subject: `Edit ${path}`, guard, around });
|
||||
}
|
||||
|
||||
export { readProjectFile, readSpec, listProjectFiles } from './read.mjs';
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname, join, relative, sep } from "node:path";
|
||||
import { gitEnv, GIT_LIMITS, runGit } from "./checkout.mjs";
|
||||
import { withoutProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
@@ -153,22 +153,64 @@ function locked(repo, fn) {
|
||||
return run;
|
||||
}
|
||||
|
||||
// The bare repository, made on first use in a temp directory beside it and
|
||||
// renamed into place. One that exists must be a real directory.
|
||||
async function ensure(repo, g) {
|
||||
const look = (path) => {
|
||||
try {
|
||||
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid", `${repo} isn't a directory`);
|
||||
return;
|
||||
return lstatSync(path);
|
||||
} catch (e) {
|
||||
if (e instanceof WorkingError) throw e;
|
||||
if (e.code !== "ENOENT") refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
|
||||
if (e.code === "ENOENT") return null;
|
||||
refuse("working-repo-invalid", `${path} can't be read: ${e.code}`);
|
||||
}
|
||||
mkdirSync(dirname(repo), { recursive: true, mode: 0o700 });
|
||||
};
|
||||
|
||||
// Every directory between the data root and the repository (`working`, then the business) must be a real
|
||||
// directory, never a link or a file, so no later mkdir, init or rename can leave the data root. With `make`
|
||||
// a missing one is created, one level at a time and not recursively, and checked again once it is there.
|
||||
// The data root itself is the operator's; only a missing one is made. Returns whether all of them exist.
|
||||
function ancestors(dataRoot, repo, make = false) {
|
||||
const names = relative(dataRoot, repo).split(sep);
|
||||
if (names.length < 2 || names[0] === "..") refuse("working-repo-invalid", `${repo} isn't under ${dataRoot}`);
|
||||
if (make && look(dataRoot) === null) mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||
let at = dataRoot;
|
||||
for (const name of names.slice(0, -1)) {
|
||||
at = join(at, name);
|
||||
let st = look(at);
|
||||
if (st === null && make) {
|
||||
try {
|
||||
mkdirSync(at, { mode: 0o700 });
|
||||
} catch (e) {
|
||||
if (e.code !== "EEXIST") throw e;
|
||||
}
|
||||
st = look(at);
|
||||
}
|
||||
if (st === null) return false;
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) refuse("working-repo-invalid", `${at} isn't a real directory`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The effect runs as it is unless the caller gives a wrapper. initializeWorking's caller (the bootstrap
|
||||
// executor) passes one that checks, under the organization lock, that its plan is still fresh and starts the
|
||||
// effect in the same synchronous run, so a write here never begins under a plan that went stale while this
|
||||
// code waited on the lock or on git.
|
||||
const direct = (effect) => effect();
|
||||
|
||||
// The bare repository, made on first use in a temp directory beside it and
|
||||
// renamed into place. One that exists must be a real directory, and so must every
|
||||
// directory above it up to the data root.
|
||||
async function ensure(dataRoot, repo, g, around = direct) {
|
||||
if (ancestors(dataRoot, repo)) {
|
||||
const st = look(repo);
|
||||
if (st !== null) {
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) refuse("working-repo-invalid", `${repo} isn't a directory`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
around(() => ancestors(dataRoot, repo, true));
|
||||
const tmp = join(dirname(repo), `.init-${randomBytes(4).toString("hex")}`);
|
||||
try {
|
||||
const r = await g(["init", "-q", "--bare", "--template=", tmp]);
|
||||
const r = await around(() => g(["init", "-q", "--bare", "--template=", tmp]));
|
||||
if (!r.ok) refuse("working-repo-invalid", `git init failed: ${r.why}`);
|
||||
renameSync(tmp, repo);
|
||||
around(() => renameSync(tmp, repo));
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
@@ -178,9 +220,9 @@ async function ensure(repo, g) {
|
||||
// owner's, not a session's, and may move any way. main starts at root and
|
||||
// follows it only by fast-forward, since a landing (row 137) moves main past
|
||||
// root and a reseed must not throw the landing away. main is never forced.
|
||||
async function seed(repo, root, g) {
|
||||
async function seed(repo, root, g, around = direct) {
|
||||
const at = `--git-dir=${repo}`;
|
||||
const r = await g([at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/root"]);
|
||||
const r = await around(() => g([at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/root"]));
|
||||
if (!r.ok) refuse("root-unreadable", `the root couldn't be fetched from ${root}: ${r.why}`);
|
||||
const top = await tip(repo, "refs/heads/root", g);
|
||||
if (!top) refuse("root-unreadable", `${root} has no commit checked out`);
|
||||
@@ -189,7 +231,7 @@ async function seed(repo, root, g) {
|
||||
if (main !== null && !(await g([at, "merge-base", "--is-ancestor", main, top])).ok) return;
|
||||
// An empty old value creates main only if it doesn't exist; otherwise the
|
||||
// swap moves it only from where it was read.
|
||||
const moved = await g([at, "update-ref", "refs/heads/main", top, main ?? ""]);
|
||||
const moved = await around(() => g([at, "update-ref", "refs/heads/main", top, main ?? ""]));
|
||||
if (!moved.ok) refuse("working-repo-invalid", `main couldn't follow root to ${top}: ${moved.why}`);
|
||||
}
|
||||
|
||||
@@ -220,7 +262,7 @@ export function push({ dataRoot, business, project, root, taskRef, branch, bundl
|
||||
const g = bound(env, limits);
|
||||
return locked(repo, async () => {
|
||||
if (!header(bundle)) refuse("invalid-bundle", "not a v2 git bundle");
|
||||
await ensure(repo, g);
|
||||
await ensure(dataRoot, repo, g);
|
||||
await seed(repo, root, g);
|
||||
const at = [`--git-dir=${repo}`];
|
||||
// Not -q: with it, git 2.55 drops the list of missing prerequisites.
|
||||
@@ -261,18 +303,20 @@ export function push({ dataRoot, business, project, root, taskRef, branch, bundl
|
||||
});
|
||||
}
|
||||
|
||||
// await initializeWorking({ dataRoot, business, project, root, env, limits })
|
||||
// await initializeWorking({ dataRoot, business, project, root, env, limits, around })
|
||||
// Host-only (lifecycle Task 2, #797): makes the project's working repository and seeds main from
|
||||
// the root's checked-out commit, under the same lock and through the same private seed a push
|
||||
// uses. It makes no work ref, takes no bundle and has no remote to push to. A second call is the
|
||||
// same seed: it moves nothing the first call left. Resolves { repo, commit } with main's commit.
|
||||
export function initializeWorking({ dataRoot, business, project, root, env = process.env, limits = GIT_LIMITS }) {
|
||||
// `around(effect)` wraps every write (the directories, the init and its rename, the seed's fetch and
|
||||
// ref update); it must run `effect` synchronously and return what it returns.
|
||||
export function initializeWorking({ dataRoot, business, project, root, env = process.env, limits = GIT_LIMITS, around = direct }) {
|
||||
if (!PROJECT.test(project ?? "") || !PROJECT.test(business ?? "")) refuse("invalid-request", "not an id");
|
||||
const repo = workingRepo(dataRoot, business, project);
|
||||
const g = bound(env, limits);
|
||||
return locked(repo, async () => {
|
||||
await ensure(repo, g);
|
||||
await seed(repo, root, g);
|
||||
await ensure(dataRoot, repo, g, around);
|
||||
await seed(repo, root, g, around);
|
||||
const commit = await tip(repo, "refs/heads/main", g);
|
||||
if (!commit) refuse("working-repo-invalid", "main has no commit after the seed");
|
||||
return { repo, commit };
|
||||
|
||||
@@ -8,11 +8,11 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { gitEnv } from "../src/checkout.mjs";
|
||||
import { dropLand, finishLand, prepareLand, push, review, validBranch, workingRepo, workRef, WorkingError } from "../src/working.mjs";
|
||||
import { dropLand, finishLand, initializeWorking, prepareLand, push, review, validBranch, workingRepo, workRef, WorkingError } from "../src/working.mjs";
|
||||
import { rootHits } from "../src/landing.mjs";
|
||||
|
||||
const TASK = "vikunja:1/2";
|
||||
@@ -124,6 +124,50 @@ async function lands(w, fn) {
|
||||
return r;
|
||||
}
|
||||
|
||||
// #802 N1: the helper makes its directories one at a time and never follows a link or a file among them.
|
||||
for (const [what, plant] of [
|
||||
["the working directory is a link", (w, outside) => symlinkSync(outside, join(w.dataRoot, "working"))],
|
||||
["the business directory is a link", (w, outside) => { mkdirSync(join(w.dataRoot, "working")); symlinkSync(outside, join(w.dataRoot, "working", "acme")); }],
|
||||
["the working directory is a file", (w) => writeFileSync(join(w.dataRoot, "working"), "not a directory\n")],
|
||||
]) {
|
||||
test(`push and initializeWorking refuse when ${what}, and nothing lands behind it`, async (t) => {
|
||||
const w = world(t);
|
||||
const outside = join(w.dir, "outside");
|
||||
mkdirSync(outside);
|
||||
plant(w, outside);
|
||||
git(w.clone, "checkout", "-q", "-b", "feat");
|
||||
commit(w.clone, "two");
|
||||
const file = w.bundle();
|
||||
await assert.rejects(w.go(file), (e) => e instanceof WorkingError && e.code === "working-repo-invalid");
|
||||
await assert.rejects(initializeWorking({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root }), (e) => e instanceof WorkingError && e.code === "working-repo-invalid");
|
||||
assert.deepEqual(readdirSync(outside), [], "nothing was made behind the link");
|
||||
assert.equal(existsSync(join(w.dataRoot, "working", "acme", "stack.git")), false);
|
||||
});
|
||||
}
|
||||
|
||||
// #802 I1: every write initializeWorking makes goes through `around`, so a caller that checks there never has a
|
||||
// write begin after its check went stale. A refusal from `around` stops that write and every later one.
|
||||
test("initializeWorking runs each of its five writes through around, and a refusal stops it there", async (t) => {
|
||||
const run = (w, around) => initializeWorking({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, around });
|
||||
let calls = 0;
|
||||
const w = world(t);
|
||||
const made = await run(w, (effect) => { calls++; return effect(); });
|
||||
assert.equal(calls, 5, "the directories, the init, its rename, the root fetch and the main ref");
|
||||
assert.equal(made.commit, w.first);
|
||||
const at = [
|
||||
{ repo: false, refs: "" }, { repo: false, refs: "" }, { repo: false, refs: "" },
|
||||
{ repo: true, refs: "" }, { repo: true, refs: `refs/heads/root ${w.first}` },
|
||||
];
|
||||
for (let stop = 0; stop < 5; stop++) {
|
||||
const v = world(t), refusal = new Error(`stale at ${stop}`);
|
||||
let n = 0;
|
||||
await assert.rejects(run(v, (effect) => { if (n++ === stop) throw refusal; return effect(); }), (e) => e === refusal);
|
||||
assert.equal(existsSync(v.repo), at[stop].repo, `repository after a refusal at write ${stop}`);
|
||||
assert.equal(v.refs(), at[stop].refs.replace(w.first, v.first), `refs after a refusal at write ${stop}`);
|
||||
assert.deepEqual(existsSync(join(v.dataRoot, "working", "acme")) ? readdirSync(join(v.dataRoot, "working", "acme")).filter((x) => x.startsWith(".init-")) : [], [], "no init temp is left");
|
||||
}
|
||||
});
|
||||
|
||||
test("workRef and validBranch take only what a ref name can hold", () => {
|
||||
assert.equal(workRef("vikunja:12/345"), "work/vikunja-12-345");
|
||||
for (const bad of ["vikunja:0/1", "vikunja:1", "gitea:1/2", "vikunja:1/2\n", null]) assert.throws(() => workRef(bad), WorkingError);
|
||||
|
||||
Reference in new issue
Block a user