Build packet for row 153, project init cleanup and the nested records repo refusal. Filbert approved round 1 (comment 28727). Committed by Sage for Rocko, who has no git authority; secrets scan clean, no binaries. Co-Authored-By: Claude Opus 5.5 <[email protected]>
333 lines
18 KiB
Diff
333 lines
18 KiB
Diff
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
|
index 6ac0c7399..045b14eaf 100644
|
|
--- a/docs/TOOLS.md
|
|
+++ b/docs/TOOLS.md
|
|
@@ -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
|
|
diff --git a/packages/business/tests/project-repo.test.mjs b/packages/business/tests/project-repo.test.mjs
|
|
index bfd57004a..e440dfd05 100644
|
|
--- a/packages/business/tests/project-repo.test.mjs
|
|
+++ b/packages/business/tests/project-repo.test.mjs
|
|
@@ -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);
|
|
+});
|
|
diff --git a/packages/project/README.md b/packages/project/README.md
|
|
index 6c3561af7..a23a43c63 100644
|
|
--- a/packages/project/README.md
|
|
+++ b/packages/project/README.md
|
|
@@ -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.
|
|
|
|
diff --git a/packages/project/src/index.mjs b/packages/project/src/index.mjs
|
|
index ea2618688..6523ca8a3 100644
|
|
--- a/packages/project/src/index.mjs
|
|
+++ b/packages/project/src/index.mjs
|
|
@@ -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) {
|
|
diff --git a/packages/project/src/paths.mjs b/packages/project/src/paths.mjs
|
|
index 2b9ec5407..7f0676a59 100644
|
|
--- a/packages/project/src/paths.mjs
|
|
+++ b/packages/project/src/paths.mjs
|
|
@@ -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;
|
|
}
|
|
diff --git a/packages/project/tests/init-failure.test.mjs b/packages/project/tests/init-failure.test.mjs
|
|
new file mode 100644
|
|
index 000000000..978e47d30
|
|
--- /dev/null
|
|
+++ b/packages/project/tests/init-failure.test.mjs
|
|
@@ -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');
|
|
+});
|