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:
1 parent
810186578a
commit
56639c99b0
6 files changed
+197
-29
No files matched your search
+5
-1
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user