project: init cleans up after a failure and refuses a nested records repo (row 153, #1647)

Row 153 round 1, Rocko's candidate on 686fc1cb6. Filbert approved
(comment 28727). projectRepo lives in packages/project/src/paths.mjs, so
business.mjs is unchanged (sage-0469). Sage's landing gate ran on this
commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-15 19:32:58 -05:00
1 parent 810186578a
commit 56639c99b0
6 files changed
+197 -29

No files matched your search

+5 -1
View File
@@ -602,10 +602,14 @@ Never migrate the live file from an agent session.
separate local record repository, with layout version 1, package-validated JSON
templates, Markdown spec/docs and its first commit authored as the business's
human. Init refuses an agent environment, an undeclared project or a non-empty
directory. No bus or remote is involved.
directory. Failed init cleans up its own layout and allows retry, preserving a
pre-existing empty target and unrelated files. A changed owned path refuses
`project-init-cleanup` for inspection. Records may not overlap any declared
project's code root. No bus or remote is involved.
`mosaic project check <business> <project>` validates project metadata, every
record, filename/ID agreement and the fixed layout, naming each problem. It
refuses `project-no-commit` if the layout has no readable HEAD commit. It
exits 0 when valid, 2 for project/record problems (including an undeclared
project), 3 for configuration loading problems and 4 for usage. The optional business project `repo` is a normalized absolute
record-repository path; the default is
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { join } from 'node:path';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
import { validateBusinessDocument } from '../src/business.mjs';
import { projectRepo } from '../../project/src/index.mjs';
test('optional project repo is preserved and must be a normalized absolute path', () => {
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
const check = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
@@ -14,3 +15,15 @@ test('optional project repo is preserved and must be a normalized absolute path'
assert.throws(check, /repo.*normalized absolute path/);
}
});
test('loaded business project repo cannot be nested in any declared code checkout', () => {
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
doc.projects.other = { root: join(root, 'other-code') };
doc.projects.stack.repo = join(doc.projects.other.root, 'records');
const business = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
const context = { business, project: 'stack', dataRoot: join(root, 'data') };
assert.throws(() => projectRepo(context), { code: 'project-path' });
doc.projects.stack.repo = join(root, 'safe-records');
const safe = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
assert.equal(projectRepo({ ...context, business: safe }), doc.projects.stack.repo);
});
+15 -4
View File
@@ -11,19 +11,29 @@ Set an optional normalized absolute `repo` in the business project entry:
```
Without `repo`, records live at
`<dataRoot>/projects/<business>/<project>/`. The code and records paths must not
overlap. Record paths and their existing ancestors must not be symlinks.
`<dataRoot>/projects/<business>/<project>/`. The records path must not overlap any declared project's code root,
including another project's root (in either direction). Record paths and their existing ancestors must not be symlinks.
```sh
mosaic project init acme stack
mosaic project check acme stack
```
Init failure removes its own files and Git directory, allowing retry. A target
that existed empty is preserved; newly created empty parent directories are
removed. Unrelated files at the root or inside a layout directory are preserved,
so a concurrent human save can leave a non-empty target that needs inspection
before retry. Changed ownership of an initializer path refuses
`project-init-cleanup` and preserves that path for reconciliation. The private
`.git` created by this initializer is removed as a unit; external tools must not
modify Git metadata during initialization.
Both commands use the existing system and business config loaders. Init requires
an outside-agent CLI environment, a declared project and an empty directory. It
creates `main` and the first commit as the business's human, with a local
`<human>@mosaic.invalid` email. Check is read-only; it exits 2 and lists all
problems found. Usage exits 4; configuration problems exit 3. No bus is needed.
problems found. An otherwise valid layout with no readable HEAD commit refuses
`project-no-commit` (CLI exit 2), rather than reporting success. Usage exits 4; configuration problems exit 3. No bus is needed.
## Layout
@@ -84,7 +94,8 @@ local commit authored and committed as `<definition>.<instance>` with a local
validated here; row 138's broker must derive it from the bound role. This
package neither authenticates an agent nor grants it authority.
Every agent path under `spec/` refuses with `project-spec-human-only`. Agents
An author with definition `human` refuses `project-author`; this agent writer
provides no human record write path. Every agent path under `spec/` refuses with `project-spec-human-only`. Agents
cannot write project.json, templates or git metadata through this API. A valid
but unchanged record refuses `project-record-unchanged` without an empty commit.
+67 -21
View File
@@ -1,6 +1,6 @@
import {
closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync,
renameSync, unlinkSync, writeFileSync, fsyncSync,
closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync,
renameSync, unlinkSync, writeFileSync, fsyncSync, rmSync, rmdirSync,
} from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { randomUUID } from 'node:crypto';
@@ -28,7 +28,7 @@ function jsonProblems(kind, text, relative, problems) {
return value;
} catch (e) { problems.push(`${relative}: invalid JSON (${e.message})`); return null; }
}
export async function checkProject(context) {
async function checkLayout(context) {
const repo = projectRepo(context), problems = [];
const problem = (path, message) => problems.push(`${path}: ${message}`);
try {
@@ -83,28 +83,74 @@ export async function checkProject(context) {
return { ok: problems.length === 0, repo, problems };
}
export async function checkProject(context) {
const result = await checkLayout(context);
if (result.ok) {
try { await git(result.repo, ['rev-parse', '--verify', 'HEAD^{commit}']); }
catch { throw new ProjectError('project-no-commit', 'project repository has no readable commit'); }
}
return result;
}
export async function initProject(context) {
const repo = projectRepo(context), human = checkedId(context.business.human);
if (existsSync(repo) && readdirSync(repo).length) throw new ProjectError('project-not-empty', 'refusing existing non-empty directory');
const parents = [];
for (let path = repo; !existsSync(path); path = dirname(path)) parents.unshift(path);
mkdirSync(repo, { recursive: true, mode: 0o700 });
// Exclusive directory creation prevents two initializers from racing past the empty check.
try { mkdirSync(join(repo, '.git'), { mode: 0o700 }); }
catch { throw new ProjectError('project-not-empty', 'another initializer already owns this directory'); }
await git(repo, ['init', '--initial-branch=main', '--template=']);
for (const directory of DIRECTORIES) mkdirSync(join(repo, directory), { mode: 0o700 });
for (const name of TEMPLATES)
copyFileSync(new URL(`../templates/${name}`, import.meta.url), join(repo, 'templates', name));
copyFileSync(new URL('../templates/north-star.md', import.meta.url), join(repo, 'spec/north-star.md'));
writeFileSync(join(repo, 'docs/README.md'), '# Project documentation\n\nKeep project Markdown documents here.\n');
for (const directory of ['requirements', 'decisions']) writeFileSync(join(repo, directory, '.gitkeep'), '');
const metadata = { layoutVersion: 1, business: context.business.id, project: context.project,
name: context.project, createdAt: new Date().toISOString() };
writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n');
const result = await checkProject(context);
if (!result.ok) throw new ProjectError('project-invalid', result.problems.join('\n'));
await git(repo, ['add', '--', 'project.json', ...DIRECTORIES]);
await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(human));
return { repo, commit: await git(repo, ['rev-parse', 'HEAD']) };
const created = [];
const remember = (path, recursive = false) => {
const stat = lstatSync(path);
created.push({ path, ino: stat.ino, dev: stat.dev, recursive });
};
const createFile = (path, bytes) => {
const fd = openSync(path, 'wx', 0o600);
try { remember(path); writeFileSync(fd, bytes); }
finally { closeSync(fd); }
};
try {
// Only the initializer that owns this exclusive directory may clean it up.
try { mkdirSync(join(repo, '.git'), { mode: 0o700 }); }
catch { throw new ProjectError('project-not-empty', 'another initializer already owns this directory'); }
remember(join(repo, '.git'), true);
await git(repo, ['init', '--initial-branch=main', '--template=']);
for (const directory of DIRECTORIES) {
const path = join(repo, directory);
mkdirSync(path, { mode: 0o700 });
remember(path);
}
for (const name of TEMPLATES)
createFile(join(repo, 'templates', name), readFileSync(new URL(`../templates/${name}`, import.meta.url)));
createFile(join(repo, 'spec/north-star.md'), readFileSync(new URL('../templates/north-star.md', import.meta.url)));
createFile(join(repo, 'docs/README.md'), '# Project documentation\n\nKeep project Markdown documents here.\n');
for (const directory of ['requirements', 'decisions']) createFile(join(repo, directory, '.gitkeep'), '');
const metadata = { layoutVersion: 1, business: context.business.id, project: context.project,
name: context.project, createdAt: new Date().toISOString() };
createFile(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n');
const result = await checkLayout(context);
if (!result.ok) throw new ProjectError('project-invalid', result.problems.join('\n'));
await git(repo, ['add', '--', 'project.json', ...DIRECTORIES]);
await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(human));
return { repo, commit: await git(repo, ['rev-parse', 'HEAD']) };
} catch (error) {
for (const entry of created.reverse()) {
let stat;
try { stat = lstatSync(entry.path); }
catch (e) { if (e.code === 'ENOENT') continue; throw e; }
if (stat.ino !== entry.ino || stat.dev !== entry.dev)
throw new ProjectError('project-init-cleanup', 'owned path changed; preserve it for reconciliation');
if (stat.isDirectory() && !entry.recursive) {
try { rmdirSync(entry.path); }
catch (e) { if (!['ENOTEMPTY', 'EEXIST'].includes(e.code)) throw e; }
} else rmSync(entry.path, { recursive: entry.recursive, force: true });
}
// rmdir never removes unrelated files or a pre-existing empty directory.
for (const path of parents.reverse()) {
try { rmdirSync(path); }
catch (e) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(e.code)) throw e; }
}
throw error;
}
}
function recordBytes(path, record) {
+6 -3
View File
@@ -28,14 +28,17 @@ export function projectRepo({ business, project, dataRoot }) {
if (!Object.hasOwn(business?.projects ?? {}, project))
throw new ProjectError('project-not-declared', `business does not declare ${project}`);
absolute(dataRoot);
const entry = business.projects[project], root = absolute(entry.root);
const entry = business.projects[project];
const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project));
const contains = (base, child) => {
const rel = relative(base, child);
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
};
if (contains(root, repo) || contains(repo, root))
throw new ProjectError('project-path', 'record repository and code checkout overlap');
for (const project of Object.values(business.projects)) {
const code = absolute(project.root);
if (contains(code, repo) || contains(repo, code))
throw new ProjectError('project-path', 'record repository and code checkout overlap');
}
noLinks(repo);
return repo;
}
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { checkProject, initProject, projectRepo, writeRecord } from '../src/index.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'project-init-failure-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return { root, dataRoot: join(root, 'data'), project: 'stack', business: {
id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } },
} };
}
async function failingCommit(c, operation, sideEffect = ':') {
const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
const bin = join(c.root, 'bin');
mkdirSync(bin);
writeFileSync(join(bin, 'git'), `#!/bin/sh\nfor arg do if [ "$arg" = commit ]; then ${sideEffect}; exit 71; fi; done\nexec '${realGit}' "$@"\n`);
chmodSync(join(bin, 'git'), 0o755);
const saved = process.env.PATH;
try { process.env.PATH = bin + ':' + saved; await operation(); }
finally { process.env.PATH = saved; }
}
for (const existing of [false, true]) test(`failed init cleans its layout and permits retry (pre-existing=${existing})`, async t => {
const c = fixture(t), repo = projectRepo(c);
if (existing) mkdirSync(repo);
const inode = existing ? lstatSync(repo).ino : null;
await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-git' }));
if (existing) {
assert.equal(lstatSync(repo).ino, inode);
assert.deepEqual(readdirSync(repo), []);
} else assert.equal(existsSync(repo), false);
const result = await initProject(c);
assert.match(result.commit, /^[0-9a-f]{40}$/);
assert.equal((await checkProject(c)).ok, true);
});
test('project check distinguishes a complete but uncommitted layout', async t => {
const c = fixture(t), { repo } = await initProject(c);
execFileSync('git', ['-C', repo, 'update-ref', '-d', 'refs/heads/main']);
await assert.rejects(checkProject(c), { code: 'project-no-commit' });
});
test('record repo refuses every declared code root including a sibling project', async t => {
const c = fixture(t);
c.business.projects.other = { root: join(c.root, 'other-code') };
c.business.projects.stack.repo = join(c.business.projects.other.root, 'records');
assert.throws(() => projectRepo(c), { code: 'project-path' });
await assert.rejects(initProject(c), { code: 'project-path' });
assert.equal(existsSync(c.business.projects.other.root), false);
c.business.projects.stack.repo = c.business.projects.other.root;
assert.throws(() => projectRepo(c), { code: 'project-path' });
c.business.projects.stack.repo = c.root;
assert.throws(() => projectRepo(c), { code: 'project-path' });
});
test('agent record writer refuses a human definition without changing history or files', async t => {
const c = fixture(t), { repo, commit } = await initProject(c);
await assert.rejects(writeRecord({ ...c, path: 'docs/human.md', record: '# Human',
author: { definition: 'human', instance: c.business.human } }), { code: 'project-author' });
assert.equal(existsSync(join(repo, 'docs/human.md')), false);
assert.equal(execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), commit);
assert.equal(execFileSync('git', ['-C', repo, 'status', '--porcelain'], { encoding: 'utf8' }), '');
});
test('failed init preserves an unrelated file created while its commit runs', async t => {
const c = fixture(t), repo = projectRepo(c), keep = join(repo, 'human-note.txt');
await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-git' }),
`printf keep > '${keep}'`);
assert.deepEqual(readdirSync(repo), ['human-note.txt']);
assert.equal(readFileSync(keep, 'utf8'), 'keep');
});
test('failed init preserves an unrelated file inside a newly created layout directory', async t => {
const c = fixture(t), repo = projectRepo(c), keep = join(repo, 'docs/human-note.md');
await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-git' }),
`printf keep > '${keep}'`);
assert.deepEqual(readdirSync(repo), ['docs']);
assert.deepEqual(readdirSync(join(repo, 'docs')), ['human-note.md']);
assert.equal(readFileSync(keep, 'utf8'), 'keep');
});
test('failed init refuses to delete a replacement of its owned metadata file', async t => {
const c = fixture(t), repo = projectRepo(c), file = join(repo, 'project.json');
await failingCommit(c, () => assert.rejects(initProject(c), { code: 'project-init-cleanup' }),
`mv '${file}' '${join(c.root, 'original.json')}' && printf human > '${file}'`);
assert.equal(readFileSync(file, 'utf8'), 'human');
});