Row 286 C2-03b: fold dewey-0864 (T7/T9 through grants.revoke and instance.fire, race fetch takes the sync guard, two-chief code, id -u shim note)

This commit is contained in:
jason.woltje committed 2026-10-24 06:22:44 -05:00
1 parent 4488f8ab08
commit 6b731a3052
5 files changed
+242 -73

No files matched your search

+28 -14
View File
@@ -172,7 +172,7 @@ N3: `fetchCredential` reads the store, so it is the bus's choice function, not a
through C2-21".
- N1: as above.
## Build notes (code written; nothing run yet)
## Build notes (code written; nothing run yet; dewey-0864 folded)
Files changed:
- `packages/library/src/sync.mjs`: `syncCommand` takes `push` with a full-sha `commit`; `push()`;
@@ -193,26 +193,40 @@ Choices made without a ruling. Each is small and reversible:
source runtime's `slotDir` uses.
- `BusError` carries only a code, so the sage-2928 fix text can't ride on the verb's refusal. It
goes in the timer's log line (`fix`) and in the README. The verb answers `library-fetch-no-grant`.
- T7 stages revoke and fire as the state those verbs leave: row deleted plus `grant.revoked`; for a
fire, the name delisted with `roles.chief` kept and the row revoked (row 281's fire revokes grants).
A delisted chief with its row still there, and a `fire.stopped` with no `fire.done`, also refuse. The chief has no Gitea
grant path before C2-21, so the evidence and the row go in by SQL, as gitea-grant.test.mjs does for
decisions. The verbs' own effects are pinned in gitea-grant.test.mjs and fire-revokes-grants.test.mjs.
This departs from the brief's "through grants.revoke" and "through instance.fire". For Dewey.
- T7 and T9 run the real verbs (dewey-0864, replacing the staged state in cefebf65b). The chief's
grant goes through decision.raise and the human's yes against fake-gitea-api.mjs. The test's own
definitions give chief-of-staff a gitea credential, as gitea-grant.test.mjs gives coder one. The
real roles/chief-of-staff.json has `credentials: []`, so a live chief can't get one before C2-21.
- Revoked: `grants.revoke`, then the row is gone and the fetch refuses.
- Fired: `instance.fire` can't fire the launch.by chief. Only the launch.by role calls it, and it
refuses its own name (`organization-instance-referenced`, broker.mjs `#chiefLifecycle`). So the
test's launch.by is `boss` (definition pm, the chief's authority), and boss fires the one listed
chief-of-staff instance. Step G deletes the row (row 281), `roles.chief` stays, the fetch refuses.
- T9's revoked case runs `grants.revoke` on the runtime's own data root and store, between starts.
- Delisted with the row still there, a `fire.stopped` with no `fire.done`, held, two chiefs and
another project stay unit checks on the choice function with SQL fixtures.
- "Another uid" in T5: the suite can't chown, so a PATH `stat` shim reports uid+1 for the open file.
- The race fetch inside `push()` isn't serialized with the timer's sync. Two fetches of the same
ref race on git's ref lock, and the loser fails (`library-push-failed` or `library-fetch-failed`),
never a wrong ref. C2-02 owns serializing a write with sync. This is a bounded follow-up, not this row.
The real `id -u` compare is covered only through that shim (dewey-0864).
- Two listed chiefs, or none listed, refuse `library-fetch-no-grant` (T8).
- The race fetch inside `push()` takes sync's `running` guard (dewey-0864 R1). While a sync of the
mirror runs it refuses `library-sync-busy`, fetches nothing, and the caller writes again. Test:
"race fetch: while a sync of the mirror runs".
- The slot at 0000: T9 covers the revoked case at runtime. Fired and held refuse inside the choice
function, which takes no path and opens no file. `sync` with any `{ refused }` runs no git (library
test, valid slot present).
- First-run risks: T9's runtime rebuilds definitions from rolesDir, where chief-of-staff has no gitea
credential. If giteaStart or reconcile then holds or drops the chief's grant, T9 part 1 fails. The
fix would be in the test's setup, not in the product.
- The chief's evidence is any gitea grant event naming the chief, of any action (grant.applied,
grant.revoked and the rest), per the plan. The spend event names no agent, so it doesn't count.
Test map:
- library sync.test.mjs: argv (push), T1, T2+T3, T4 (dead remote, hook, wrong token), refused or
unsafe credential runs no git, T10, fetch credential and state with N1/T12, T5+T5b.
- bus library-credential.test.mjs: T6, T7, T8, departure, T9 runtime (chief file and user via timer
and verb; revoked with slot 0000: no git, log `fix`, state `credential: null`).
- T9 at runtime covers the revoked case with the slot at 0000. Fired and held are unit-level in the
broker; `sync` with any `{ refused }` opens no file and runs no git (library test, valid slot present).
- library sync.test.mjs also: race fetch busy (R1).
- bus library-credential.test.mjs: T6, T7 (real revoke and fire), T7 choice checks, T8, departure,
T9 runtime (real grant, chief file and user via timer and verb; real revoke, slot 0000: no git,
log `fix`, state `credential: null`).
- webby-0151 R3: T9 reads `library.status` for `credential` and `lastError`.
- T11 token search: T1 scans the data root and every file but the seat's; T9 scans the log lines.
The gate's output search uses the fixture tokens `fixture-token-*-made-up-00{42..49}`.
+176 -51
View File
@@ -1,29 +1,48 @@
// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise. The
// chief has no Gitea grant path before C2-21, so its evidence and gitea_grants row go in by SQL as
// fixtures, as gitea-grant.test.mjs does for decisions. A revoke and a fire are staged as the state
// those verbs leave: gitea-grant.test.mjs and fire-revokes-grants.test.mjs pin that a revoke deletes
// the row and writes grant.revoked, and that a fire delists the name and keeps roles[name] (dewey-0860).
// Tokens are made up and only ever compared.
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise.
// T7's revoked and fired cases and T9's revoked case run the broker's real paths (dewey-0864): the
// chief's grant through decision.raise and the human's yes against the fake Gitea in fake-gitea-api.mjs
// on 127.0.0.1, then grants.revoke or instance.fire. roles/chief-of-staff.json names no gitea credential,
// so these tests give chief-of-staff one in their own definitions, as gitea-grant.test.mjs gives coder
// one. The choice function's other checks (listed, a fire under way, held, two chiefs, another project)
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
// decisions. Tokens are made up or the fake's, and only ever compared.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
import { Store } from '../src/store.mjs';
import { Broker, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
import { Broker, GRANT_EVIDENCE, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { giteaGrant, giteaKeys, giteaStart } from '../src/gitea-grant.mjs';
import { startBroker } from '../src/runtime.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs';
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
import * as library from '../../library/src/index.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { grantDecision } from './host-fixtures.mjs';
import { rolesCopy, systemFor } from '../../business/tests/helpers.mjs';
const AT = '2026-10-22T00:00:00.000Z';
const REV = 'b'.repeat(64);
const SLOT_TOKEN = 'fixture-token-2c90-made-up-0048';
const CHIEF_TOKEN = 'fixture-token-e413-made-up-0049';
const role = { authority: { withinRole: [], crossRole: [] } };
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
const SWITCH = { version: 1, halted: false, generation: '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69' };
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error('timed out');
await sleep(10);
}
}
const makeBusiness = ({ chief = true, instances = null } = {}) => ({
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
@@ -44,12 +63,6 @@ function grant(store, agent, { user = agent + '-bot', project = 'books', row = t
'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision);
return decision;
}
// The state a revoke leaves: no gitea_grants row and grant.revoked.
function revoke(store, agent) {
store.run('DELETE FROM gitea_grants WHERE business=? AND agent=?', 'acme', agent);
writeGrantEvent(store, AT, 'acme', { action: 'grant.revoked', method: 'gitea', agent });
assert.equal(store.get('SELECT 1 FROM gitea_grants WHERE business=? AND agent=?', 'acme', agent), undefined);
}
function world(t, options) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root);
@@ -60,6 +73,108 @@ function world(t, options) {
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
}
// The business for the real paths. `boss` is launch.by: instance.fire is the launch.by role's verb and
// refuses its own name (organization-instance-referenced), so the fired chief is the one listed
// chief-of-staff instance, fired by boss. The grant targets project app; books is the library.
function verbDoc(url) {
return {
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
roles: {
boss: { definition: 'pm', authority: chiefAuthority },
chief: { definition: 'chief-of-staff', ...none, identity: { name: 'Chief', email: '[email protected]', gitea: { url, user: 'acme-chief' } }, publish: ['app'] },
coder: { definition: 'coder', ...none },
},
definitions: {
'chief-of-staff': { roleVersion: 2, credentials: [{ service: 'gitea' }] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [] },
},
projects: { app: { remote: `${url}/acme/app` }, books: { ...mappingEntry(), user: 'books-bot' } },
launch: { by: 'boss', instances: ['chief', 'coder'] },
};
}
// A bare broker on the real grant paths, as gitea-grant.test.mjs and fire-revokes-grants.test.mjs run
// it. A second one on the same data root and store picks the grant up as runtime.mjs does: giteaStart,
// then Credentials with its references and held list. The admin slot sits outside storeRoot's config
// dir, so a runtime started on that dir finds no admin slot.
async function verbWorld(t, opts = {}) {
const dir = mkdtempSync(join(tmpdir(), 'bus-library-verbs-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
let gitea = opts.gitea;
if (!gitea) {
gitea = await fakeGiteaApi(t, join(dir, 'tls'));
gitea.addUser('acme');
gitea.addRepo('acme', 'app');
}
const adminDir = join(dir, 'admin');
mkdirSync(adminDir, { mode: 0o700 });
const dataRoot = opts.dataRoot ?? join(dir, 'data');
mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
const storeRoot = opts.storeRoot ?? join(dir, 'secrets', 'agents');
const doc = opts.doc ?? verbDoc(gitea.url);
const store = new Store(dataRoot);
const references = {};
const { held } = giteaStart(store, storeRoot, { acme: doc }, references);
const credentials = new Credentials({ references, storeRoot, dataRoot, held });
let open = true;
const close = () => {
if (!open) return;
open = false;
credentials.close();
store.close();
};
t.after(close);
const grantApi = giteaGrant({ configDir: adminDir, ca: gitea.ca, timeoutMs: 5000 });
grantApi.writeAdmin('acme', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT });
let broker;
const organization = (business, op, args) => {
if (op === 'instance.fire') {
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
broker.reloadBusiness('acme', doc);
return { revision: REV, transaction: 't-fire' };
}
return { revision: REV };
};
broker = new Broker({
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
});
const boss = broker.bindLaunch({ business: 'acme', role: 'boss', run: 'boss-run', harness: 'pi', address: 'boss' });
broker.request(boss, { verb: 'role.claim' });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const events = () => store.all(`SELECT body FROM events WHERE business='acme' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = (id) => events().findLast((e) => e.grantDecision === id && ['grant.applied', 'grant.failed'].includes(e.action));
const row = () => store.get("SELECT 1 FROM gitea_grants WHERE business='acme' AND agent='chief'");
const approveChief = async () => {
const d = await broker.request(boss, {
verb: 'decision.raise',
args: {
action: 'credential.mint', question: 'Grant chief Gitea?', options, recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent: 'chief', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
},
});
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
await until(() => ended(d.id));
await broker.giteaIdle();
assert.equal(ended(d.id).action, 'grant.applied', 'the chief\'s grant landed');
assert.ok(row(), 'with its gitea_grants row');
};
const revoke = async () => {
await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'chief', reason: 'test' } });
await broker.giteaIdle();
assert.equal(row(), undefined, 'grants.revoke deleted the row');
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
};
const fire = async () => {
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
await until(() => !doc.launch.instances.includes('chief'));
await broker.grantsIdle();
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
};
return { gitea, doc, store, broker, storeRoot, row, approveChief, revoke, fire, close, choose: () => broker.libraryFetchCredential('acme') };
}
const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => {
@@ -75,23 +190,27 @@ test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s li
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
});
test('C2-03b T7: a revoked, fired or held chief each refuse library-fetch-no-grant, and the fallback stays ended', t => {
const revoked = world(t);
grant(revoked.store, 'chief');
revoke(revoked.store, 'chief');
test('C2-03b T7: a chief revoked through grants.revoke or fired through instance.fire refuses, and the fallback stays ended', { timeout: 60000 }, async t => {
const revoked = await verbWorld(t);
assert.deepEqual(revoked.choose(), { kind: 'slot' });
await revoked.approveChief();
assert.deepEqual(revoked.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
await revoked.revoke();
assert.deepEqual(revoked.choose(), REFUSED, 'revoked');
// A fire delists the name and, since row 281, revokes its grants; roles[chief] stays, so its
// evidence still ends the fallback.
const fired = world(t, { instances: ['coder'] });
grant(fired.store, 'chief');
revoke(fired.store, 'chief');
assert.ok(fired.business.roles.chief, 'roles[chief] remains after a fire');
const fired = await verbWorld(t);
await fired.approveChief();
assert.deepEqual(fired.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
await fired.fire();
assert.equal(fired.doc.launch.instances.includes('chief'), false, 'delisted');
assert.ok(fired.doc.roles.chief, 'roles[chief] remains after a fire, so its evidence still ends the fallback');
assert.deepEqual(fired.choose(), REFUSED, 'fired');
// The listed check alone: delisted with the row still there, then a fire under way.
});
test('C2-03b T7 choice checks: delisted, a fire under way and held each refuse; a rehire lists the chief again', t => {
const delisted = world(t, { instances: ['coder'] });
grant(delisted.store, 'chief');
assert.deepEqual(delisted.choose(), REFUSED, 'delisted');
assert.deepEqual(delisted.choose(), REFUSED, 'delisted with the row still there');
delisted.business.launch.instances.push('chief');
assert.deepEqual(delisted.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again');
delisted.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)",
@@ -104,14 +223,17 @@ test('C2-03b T7: a revoked, fired or held chief each refuse library-fetch-no-gra
assert.deepEqual(held.choose(), REFUSED, 'held');
});
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse', t => {
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse library-fetch-no-grant', t => {
const w = world(t);
grant(w.store, 'chief', { project: 'other-project', row: false });
assert.deepEqual(w.choose(), REFUSED);
const two = world(t);
two.business.roles.second = { definition: 'chief-of-staff', ...role };
grant(two.store, 'chief');
assert.deepEqual(two.choose(), REFUSED, 'one chief only');
assert.deepEqual(two.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
two.business.roles.second = { definition: 'chief-of-staff', ...role };
assert.deepEqual(two.choose(), REFUSED, 'two chief-of-staff entries, both listed: one chief only');
two.business.launch = { instances: ['coder'] };
assert.deepEqual(two.choose(), REFUSED, 'neither listed');
});
test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => {
@@ -122,20 +244,23 @@ test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidenc
});
// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's
// store file, the slot, or a refusal that runs no git even with the slot at 0000.
test('C2-03b T9 runtime: the chief\'s store file and user, and a refusal that runs no git with the slot at 0000', { timeout: 30000 }, async t => {
// store file, the slot, or a refusal that runs no git even with the slot at 0000. The grant and its
// revoke run through the real paths on the runtime's own data root and store.
test('C2-03b T9 runtime: the chief\'s store file and user, then a real revoke that runs no git with the slot at 0000', { timeout: 60000 }, async t => {
const f = repo(t), root = f.root, dataRoot = join(root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const storeRoot = join(root, 'secrets', 'agents');
const rolesDir = rolesCopy(root);
const slot = join(root, 'secrets', 'acme', library.SLOT);
mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 });
const chiefFile = join(root, 'secrets', 'agents', 'acme', 'chief', 'gitea');
mkdirSync(join(root, 'secrets', 'agents', 'acme', 'chief'), { recursive: true, mode: 0o700 });
writeFileSync(chiefFile, CHIEF_TOKEN + '\n', { mode: 0o600 });
const seed = new Store(dataRoot);
grant(seed, 'chief', { user: 'chief-bot' });
seed.close();
const granted = await verbWorld(t, { dataRoot, storeRoot });
await granted.approveChief();
granted.close();
const gitea = granted.gitea;
const chiefFile = join(storeRoot, 'acme', 'chief', 'gitea');
assert.ok(existsSync(chiefFile), 'the grant wrote the chief\'s store file');
const tokens = () => [SLOT_TOKEN, ...gitea.issued];
const calls = [], lines = [], write = process.stderr.write;
const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; };
process.stderr.write = function (chunk, ...rest) {
@@ -143,30 +268,30 @@ test('C2-03b T9 runtime: the chief\'s store file and user, and a refusal that ru
return write.call(this, chunk, ...rest);
};
t.after(() => { process.stderr.write = write; });
const start = () => startBroker({ dataRoot, businesses: { acme: makeBusiness() }, organization: { dir: root, rolesDir, system: systemFor(root) },
const start = () => startBroker({ dataRoot, businesses: { acme: verbDoc(gitea.url) }, organization: { dir: root, rolesDir, system: systemFor(root) },
librarySync: { command, intervalMs: 50 } });
const until = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await new Promise(r => setTimeout(r, 10)); };
const untilAsync = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await sleep(10); };
let runtime = await start();
await until(() => calls.length >= 1);
await untilAsync(() => calls.length >= 1);
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e);
// webby-0151 R3: library.status names the kind and user of the last fetch, never the file or value.
const shown = await runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
assert.deepEqual(shown.credential, { kind: 'chief', user: 'chief-bot' });
assert.equal(JSON.stringify(shown).includes(chiefFile) || JSON.stringify(shown).includes(CHIEF_TOKEN), false);
assert.deepEqual(shown.credential, { kind: 'chief', user: 'acme-chief' });
assert.equal(JSON.stringify(shown).includes(chiefFile) || tokens().some(tok => JSON.stringify(shown).includes(tok)), false);
await runtime.close();
assert.ok(calls.length >= 2, 'the timer and the verb');
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'chief-bot'), 'the chief\'s file and user, never the slot');
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'acme-chief'), 'the chief\'s file and user, never the slot');
// Revoked: the next start refuses before git, and the log names the fix.
const again = new Store(dataRoot);
revoke(again, 'chief');
again.close();
// Revoked through grants.revoke: the next start refuses before git, and the log names the fix.
const revoking = await verbWorld(t, { dataRoot, storeRoot, gitea });
await revoking.revoke();
revoking.close();
chmodSync(slot, 0o000);
calls.length = 0; lines.length = 0;
runtime = await start();
await until(() => lines.length >= 1);
await untilAsync(() => lines.length >= 1);
const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' });
const after = await runtime.broker.request(human2, { verb: 'library.status', args: { project: 'books' } });
@@ -177,8 +302,8 @@ test('C2-03b T9 runtime: the chief\'s store file and user, and a refusal that ru
assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up');
assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)),
{ event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX });
for (const line of lines) assert.equal(line.includes(SLOT_TOKEN) || line.includes(CHIEF_TOKEN), false);
const mirror = library.mirrorPath({ business: makeBusiness(), project: 'books', dataRoot });
for (const line of lines) assert.equal(tokens().some(tok => line.includes(tok)), false);
const mirror = library.mirrorPath({ business: verbDoc(gitea.url), project: 'books', dataRoot });
const state = await library.readSyncState(mirror, { branch: 'main' });
assert.equal(state.lastError.code, 'library-fetch-no-grant');
assert.equal(state.credential, null);
+1
View File
@@ -231,6 +231,7 @@ entry's `branch` as `push --porcelain --no-verify --no-follow-tags --end-of-opti
| the push landed | `{ pushed: true }` |
| `[rejected] (fetch first)` or `(non-fast-forward)` | one fetch with the same credential, then `{ pushed: false, raced: true, tip }` |
| the same, with `retried: true` | `stale-base` |
| the same, while a sync of the mirror runs | `library-sync-busy`, with no fetch |
| anything else, `[remote rejected]` (a hook) included | `library-push-failed` |
Push moves no local ref; C2-02 re-applies and moves `served`. git's output is never kept.
+15 -8
View File
@@ -206,7 +206,8 @@ export async function sync(context, { slot, credential = null, accept = null, co
// C2-03b: push `commit`, already in the mirror, to her branch with the writer's own credential. It
// resolves { pushed: true } or, when the remote tip moved, { pushed: false, raced: true, tip } after
// one fetch with the same credential; the caller re-applies once and calls again with retried: true,
// and a second race refuses stale-base. A hook refusal ([remote rejected]) or any other failure
// and a second race refuses stale-base. The fetch refuses library-sync-busy while a sync of the same
// mirror runs. A hook refusal ([remote rejected]) or any other failure
// refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the
// remote-tracking and served refs after a push.
export async function push(context, { commit, credential, retried = false, command = syncCommand, limits = GIT_LIMITS } = {}) {
@@ -230,11 +231,17 @@ export async function push(context, { commit, credential, retried = false, comma
if (!line?.startsWith('!\t') || !/^\[rejected\] \((fetch first|non-fast-forward)\)$/.test(summary))
fail('library-push-failed', 'push refused');
if (retried) fail('stale-base', 'her branch moved again during the write');
const fetch = command('fetch', mapping.remote, opts);
if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed');
let tip;
try { tip = await git(mirror, ['rev-parse', '--verify', '--end-of-options', `refs/remotes/library/${branch}^{commit}`]); }
catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); }
if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
return { pushed: false, raced: true, tip };
// The race fetch writes the mirror, so it takes sync's guard (dewey-0864 R1). A held guard refuses
// library-sync-busy and the caller tries the write again.
if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running');
running.add(mirror);
try {
const fetch = command('fetch', mapping.remote, opts);
if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed');
let tip;
try { tip = await git(mirror, ['rev-parse', '--verify', '--end-of-options', `refs/remotes/library/${branch}^{commit}`]); }
catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); }
if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
return { pushed: false, raced: true, tip };
} finally { running.delete(mirror); }
}
+22
View File
@@ -370,6 +370,28 @@ test('C2-03b T2 and T3: a moved tip reports the race after one fetch, and a seco
assert.equal(w.remoteTip(), again);
});
// dewey-0864 R1: the race fetch takes sync's guard. A sync holds it (its fetch is a 3 s sleep, started
// only once the guard is taken), so the push's fetch refuses busy and the mirror's ref stays put.
test('C2-03b race fetch: while a sync of the mirror runs, the race refuses library-sync-busy', async t => {
const w = await writeWorld(t);
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
const before = git(w.mirror(), 'rev-parse', 'refs/remotes/library/main');
let inside;
const held = new Promise(resolve => { inside = resolve; });
const slow = (kind, url, opts) => {
const real = w.command(kind, url, opts);
inside();
return { argv: ['-c', 'alias.hold=!sleep 3', 'hold'], env: real.env };
};
const syncing = sync(w.context, { slot: w.slot, command: slow });
await held;
await assert.rejects(w.write({ commit: w.child(w.commit) }), { code: 'library-sync-busy' });
assert.equal(w.remoteTip(), moved, 'her commit stands');
await syncing.catch(() => {});
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), before, 'the refused race fetched nothing');
assert.deepEqual(await w.write({ commit: w.child(w.commit) }), { pushed: false, raced: true, tip: moved }, 'free again afterwards');
});
test('C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed', async t => {
const w = await writeWorld(t);
// N2: [remote rejected] is a hook, not a race.