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.
191 lines
11 KiB
JavaScript
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);
|