Files
stack/packages/library/src/write.mjs
T
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

191 lines
11 KiB
JavaScript

// C2-02: one-path plumbing commits on the private bare mirror. No checkout and no pending push.
import { lstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SAFE, git, gitBytes } from '../../project/src/git.mjs';
import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { checkMapping } from './mapping.mjs';
import { classify } from './scope.mjs';
import { supportingPath } from './supporting.mjs';
import { LibraryError } from './problems.mjs';
import { listTree, MAX_BLOB } from './reader.mjs';
import { mirrorPath, ensureMirror, withLibraryLock } from './mirror.mjs';
import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs';
const SHA = /^[a-f0-9]{40}$/;
const fail = code => { throw new LibraryError(code, code); };
const ident = identity => ({ GIT_AUTHOR_NAME: identity.name, GIT_AUTHOR_EMAIL: identity.email,
GIT_COMMITTER_NAME: identity.name, GIT_COMMITTER_EMAIL: identity.email });
function mappingOf(context) {
if (!Object.hasOwn(context.business?.projects ?? {}, context.project)) fail('project-not-configured');
return checkMapping(context.business.projects[context.project]);
}
function contentOf(value) {
if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value, 'utf8') > MAX_BLOB
|| Buffer.from(value, 'utf8').toString('utf8') !== value) fail('invalid-request');
return value;
}
function credentialOf(credential, human) {
const code = human ? 'library-human-missing' : 'library-write-no-grant';
if (!credential || credential.kind !== (human ? 'human' : 'seat')
|| typeof credential.user !== 'string' || !/^[A-Za-z0-9_.-]{1,100}$/.test(credential.user)
|| typeof credential.file !== 'string') fail(code);
try {
const st = lstatSync(credential.file);
if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600 || st.size > 16384) fail(code);
} catch { fail(code); }
return credential;
}
// Her save uses the same structural and folded-tree checks; only the supporting rule changes.
function pathOf(mapping, universe, path, tree, human) {
if (!human) return supportingPath(mapping, universe, path, tree);
if (typeof path !== 'string' || classify(mapping, path).scope === 'unmapped') fail('library-write-story-path');
const place = classify(mapping, path);
const universes = Object.fromEntries(Object.keys(mapping.universes).map(id => [id,
{ ...mapping.universes[id], supporting: id === place.universe ? [path.slice(0, path.lastIndexOf('/') + 1)] : [] }]));
return supportingPath({ ...mapping, universes }, place.universe, path, tree);
}
async function ref(mirror, name) {
const rows = await git(mirror, ['for-each-ref', '--format=%(refname) %(objectname)', name]);
const line = rows.split('\n').find(row => row.startsWith(name + ' '));
return line ? line.slice(name.length + 1) : null;
}
async function ancestor(mirror, older, newer) {
const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer], gitEnv());
if (result.ok) return true;
if (result.why === 'exit 1') return false;
fail('library-git');
}
async function fetchTip(mirror, mapping, credential, command) {
const c = command('fetch', mapping.remote, { mirror, branch: mapping.branch, user: credential.user, slot: credential.file });
if (!(await runGit(c.argv, c.env)).ok) fail('library-push-failed');
const tip = await ref(mirror, `refs/remotes/library/${mapping.branch}`);
if (!tip || !SHA.test(tip)) fail('library-push-failed');
return tip;
}
async function plumbing(mirror, tip, path, content, identity, message, stage) {
const env = { ...gitEnv(), ...ident(identity), GIT_INDEX_FILE: join(stage, 'index'), GIT_CEILING_DIRECTORIES: dirname(mirror) };
const run = async (args, input = null) => {
const result = await runGit([...SAFE, '-c', 'core.createObject=rename', '-C', mirror, ...args], env, GIT_LIMITS, { stdin: input });
if (!result.ok) fail('library-git');
return result.out.trimEnd();
};
rmSync(env.GIT_INDEX_FILE, { force: true });
await run(['read-tree', tip]);
// --force-remove requires a worktree. Feed a NUL-delimited removal to the bare index.
if (content === null) {
const removal = join(stage, 'removal');
writeFileSync(removal, `0 ${'0'.repeat(40)}\t${path}\0`, { mode: 0o600 });
await run(['update-index', '-z', '--index-info'], removal);
}
else {
const file = join(stage, 'content'); writeFileSync(file, content, { mode: 0o600 });
const blob = await run(['hash-object', '-w', '--no-filters', file]);
// --cacheinfo keeps the path a separate argv item; no pathspec expansion or filters.
await run(['update-index', '--add', '--cacheinfo', '100644', blob, path]);
}
const tree = await run(['write-tree']);
return run(['commit-tree', tree, '-p', tip, '-m', message]);
}
async function revertChange(mirror, served, context, mapping) {
const commit = context.commit;
const refuse = () => fail('library-revert-not-agent');
if (!served || !SHA.test(commit)) refuse();
let text, parents;
try {
if (!await ancestor(mirror, commit, served)) refuse();
text = await git(mirror, ['show', '-s', '--format=%(trailers:key=Mosaic-Agent,valueonly)', commit]);
parents = (await git(mirror, ['show', '-s', '--format=%P', commit])).split(' ');
} catch { refuse(); }
if (parents.length !== 1 || !SHA.test(parents[0])
|| !text.startsWith(context.business.id + '/')
|| !ID_PATTERN.test(text.slice(context.business.id.length + 1))) refuse();
const before = await listTree(mirror, parents[0]), after = await listTree(mirror, commit);
const old = new Map(before.map(e => [e.path, e])), current = new Map(after.map(e => [e.path, e]));
const changed = [...new Set([...old.keys(), ...current.keys()])].filter(path =>
old.get(path)?.oid !== current.get(path)?.oid || old.get(path)?.mode !== current.get(path)?.mode);
if (changed.length !== 1) refuse();
const path = changed[0], was = old.get(path), made = current.get(path);
if ((was && (was.mode !== '100644' || was.type !== 'blob' || was.size > MAX_BLOB))
|| (made && (made.mode !== '100644' || made.type !== 'blob'))) refuse();
pathOf(mapping, null, path, after, true);
const bytes = was ? await gitBytes(mirror, ['cat-file', 'blob', was.oid], MAX_BLOB) : null;
let content = null;
if (bytes !== null) {
try { content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { refuse(); }
contentOf(content);
}
return { path, base: made?.oid ?? null, content, reverted: commit };
}
async function write(context, options, human, revert = false) {
const mapping = mappingOf(context);
if (human && !mapping.editor) fail('library-mapping-invalid');
const credential = credentialOf(options.credential, human);
const identity = human ? mapping.editor : options.identity;
if (!identity || typeof identity.name !== 'string' || typeof identity.email !== 'string') fail('library-write-refused');
if (!human && (typeof options.agent !== 'string' || !options.agent.startsWith(context.business.id + '/'))) fail('library-write-refused');
const op = options.op ?? 'write';
if (!['write', 'delete'].includes(op)) fail('invalid-request');
let change = revert ? null : { path: context.path, base: context.base, content: op === 'delete' ? null : contentOf(context.content) };
if (change) {
if (change.base !== null && (typeof change.base !== 'string' || !SHA.test(change.base))) fail('invalid-request');
pathOf(mapping, context.universe, change.path, [], human);
}
const mirror = mirrorPath(context);
return withLibraryWriteGuard(mirror, writeToken => withLibraryLock(mirror, async lock => {
await ensureMirror(context);
const state = await lock.readSyncState({ branch: mapping.branch });
if (state.diverged !== null) fail('library-diverged');
const served = state.served;
if (!served) fail('library-commit');
if (revert) change = await revertChange(mirror, served, context, mapping);
// Check the served tree before any remote effect, then recheck against every fetched tip.
pathOf(mapping, context.universe, change.path, await listTree(mirror, served), human);
const tracking = `refs/remotes/library/${mapping.branch}`, previous = await ref(mirror, tracking);
let pushed = false;
const stage = mkdtempSync(join(dirname(mirror), '.library-write-'));
try {
let tip = await fetchTip(mirror, mapping, credential, options.command ?? syncCommand);
for (let attempt = 0; attempt < 2; attempt++) {
if (!await ancestor(mirror, served, tip)) fail('library-diverged');
const tree = await listTree(mirror, tip);
pathOf(mapping, context.universe, change.path, tree, human);
const entry = tree.find(e => e.path === change.path);
if ((!human && op === 'delete' && (!entry || entry.mode !== '100644' || entry.type !== 'blob'))
|| (entry && (entry.mode !== '100644' || entry.type !== 'blob'))
|| tree.some(e => e.path.startsWith(change.path + '/') || change.path.startsWith(e.path + '/')))
fail('library-write-story-path');
if ((entry?.oid ?? null) !== change.base) fail('stale-base');
const action = change.content === null ? 'delete' : 'write';
const message = `Mosaic: ${action} ${change.path}` + (human ? '' : `\n\nMosaic-Agent: ${options.agent}`);
const commit = await plumbing(mirror, tip, change.path, change.content, identity, message, stage);
// Broker rechecks live grants/holder immediately before each push.
options.beforePush?.();
const result = await push(context, { commit, credential, retried: attempt === 1, writeToken, command: options.command ?? syncCommand });
if (!result.pushed) { tip = result.tip; continue; }
pushed = true;
try {
// The broker records the pushed commit even if publication fails next (mos-1520).
options.onPushed?.({ project: context.project, path: change.path, commit });
await publishLibraryWrite(mirror, { mapping, commit, previous: served, readSyncState: lock.readSyncState, now: options.now });
} catch { fail('outcome-unknown'); }
return { project: context.project, path: change.path, ...(revert ? { reverted: change.reverted } : {}), commit };
}
fail('stale-base');
} finally {
rmSync(stage, { recursive: true, force: true });
if (!pushed) {
if (previous === null) await git(mirror, ['update-ref', '-d', tracking]);
else await git(mirror, ['update-ref', tracking, previous]);
}
}
}));
}
export const writeLibraryDoc = (context, options = {}) => write(context, options, false);
export const saveLibraryFile = (context, options = {}) => write(context, options, true);
export const revertLibraryCommit = (context, options = {}) => write(context, options, true, true);