mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
fix: recover failed ledger batches and preserve project state
This commit is contained in:
7 files changed
+304
-56
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"id": "hand-seeded",
|
||||
"source": "manual-curation-pending-regeneration"
|
||||
},
|
||||
"registryDigest": "988dbc0f5771edc78c56a3fa2206f5b63b7b5fcb22713f08349d3c469e9f26cf",
|
||||
"registryDigest": "8f496cbd3561621f4727117c3358eddf3e350bcc545e0defd3dd529c14c6990f",
|
||||
"schemaVersion": 1,
|
||||
"triggers": {
|
||||
"skill:agent-runtime-gateway-smoke-test": [
|
||||
|
||||
@@ -147,7 +147,7 @@ function buildUsageLines(data, bridge, task) {
|
||||
task,
|
||||
});
|
||||
|
||||
const plugins = render.readInstalledPlugins();
|
||||
const plugins = render.readInstalledPlugins(undefined, data.workspace?.current_dir || process.cwd());
|
||||
const eccLine = render.buildEccLine({
|
||||
eccVersion: getEccVersion(plugins),
|
||||
hooks: render.getHooksSummary(),
|
||||
|
||||
@@ -217,30 +217,55 @@ function buildEccLine(info) {
|
||||
return parts.join(SEP);
|
||||
}
|
||||
|
||||
function pluginProjectRoot(workspace) {
|
||||
const cwd = path.resolve(workspace || process.cwd());
|
||||
for (let candidate = cwd; ; candidate = path.dirname(candidate)) {
|
||||
if (fs.existsSync(path.join(candidate, '.git'))) return candidate;
|
||||
if (candidate === path.dirname(candidate)) return cwd;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginEnablement(files) {
|
||||
return files.reduce((enabled, file) => {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(file, 'utf8')).enabledPlugins;
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { ...enabled, ...value } : enabled;
|
||||
} catch { return enabled; /* Missing or malformed optional display settings. */ }
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled plugins with versions, ecc first.
|
||||
* Reads installed_plugins.json (v2) and settings.json enabledPlugins.
|
||||
* @param {string} [claudeDir] - defaults to CLAUDE_CONFIG_DIR or ~/.claude
|
||||
* User and applicable project/local plugin metadata for the current Git root.
|
||||
* Project-local enablement overrides shared-project and selected user settings.
|
||||
* This display helper does not reproduce managed or per-session host policy.
|
||||
* @param {string} [claudeDir] selected profile root
|
||||
* @param {string} [workspace] current working directory
|
||||
* @returns {Array<{name: string, version: string}>}
|
||||
*/
|
||||
function readInstalledPlugins(claudeDir) {
|
||||
function readInstalledPlugins(claudeDir, workspace) {
|
||||
try {
|
||||
const dir = claudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const installed = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, 'plugins', 'installed_plugins.json'), 'utf8')
|
||||
);
|
||||
let enabled = null;
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));
|
||||
if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') {
|
||||
enabled = settings.enabledPlugins;
|
||||
}
|
||||
} catch { /* no settings \u2014 show all installed */ }
|
||||
const project = pluginProjectRoot(workspace);
|
||||
const normalized = value => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value);
|
||||
const enabled = pluginEnablement([path.join(dir, 'settings.json'),
|
||||
path.join(project, '.claude', 'settings.json'), path.join(project, '.claude', 'settings.local.json')]);
|
||||
const ranks = { user: 0, project: 1, local: 2 };
|
||||
const applicable = entry => entry && typeof entry === 'object' &&
|
||||
((!entry.scope || entry.scope === 'user') ||
|
||||
(['project', 'local'].includes(entry.scope) && typeof entry.projectPath === 'string' &&
|
||||
normalized(entry.projectPath) === normalized(project)));
|
||||
|
||||
const plugins = [];
|
||||
for (const [id, entries] of Object.entries(installed?.plugins || {})) {
|
||||
if (enabled && enabled[id] === false) continue;
|
||||
const entry = Array.isArray(entries) ? entries[0] : entries;
|
||||
if (enabled[id] === false) continue;
|
||||
const entry = (Array.isArray(entries) ? entries : [entries]).filter(applicable).sort((a, b) =>
|
||||
(ranks[b.scope || 'user'] - ranks[a.scope || 'user']) ||
|
||||
String(b.lastUpdated || b.installedAt || '').localeCompare(String(a.lastUpdated || a.installedAt || '')) ||
|
||||
String(b.version || '').localeCompare(String(a.version || '')))[0];
|
||||
if (!entry) continue;
|
||||
plugins.push({ name: id.split('@')[0], version: entry?.version || '' });
|
||||
}
|
||||
plugins.sort((a, b) => (a.name === 'ecc' ? -1 : b.name === 'ecc' ? 1 : a.name.localeCompare(b.name)));
|
||||
|
||||
@@ -261,6 +261,9 @@ function recover(dir) {
|
||||
}
|
||||
rolledBack++;
|
||||
}
|
||||
// Restoration is complete. Cleanup failure must never repeat a completed
|
||||
// rollback after another owner has changed a restored file.
|
||||
fs.rmSync(journalPath);
|
||||
}
|
||||
fs.rmSync(txn, { recursive: true, force: true });
|
||||
return rolledBack;
|
||||
@@ -293,17 +296,22 @@ function replaceTable(md, rel, header, rows) {
|
||||
const skipBlanks = (at) => at + (md.slice(at).match(/^(?:[ \t]*\r?\n)+/) || [''])[0].length;
|
||||
const hasDerivedHeader = (at) => md.slice(at).split(/\r?\n/, 2).join('\n').replace(/[ \t]/g, '') === header.replace(/[ \t]/g, '');
|
||||
const first = skipBlanks(afterMarker);
|
||||
const start = md[first] === '|' ? first : afterMarker;
|
||||
const start = hasDerivedHeader(first) ? first : afterMarker;
|
||||
let end = start;
|
||||
while (md[end] === '|') {
|
||||
while (md[end] === '|') { const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1; }
|
||||
while (hasDerivedHeader(end)) {
|
||||
while (md[end] === '|') {
|
||||
const nextLine = md.slice(end).split(/\r?\n/, 2)[1] || '';
|
||||
if (end !== start && /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(nextLine) && !hasDerivedHeader(end)) break;
|
||||
const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1;
|
||||
}
|
||||
// Older projections could insert a second table before a blank-separated
|
||||
// original. Only adjacent copies of this derived header belong to us.
|
||||
const next = skipBlanks(end);
|
||||
if (!hasDerivedHeader(next)) break;
|
||||
end = next;
|
||||
}
|
||||
return md.slice(0, start) + (newline < 0 ? '\n' : '') + [header, ...rows].join('\n') + '\n' + md.slice(end);
|
||||
const separator = start === end && md[start] === '|' ? '\n' : '';
|
||||
return md.slice(0, start) + (newline < 0 ? '\n' : '') + [header, ...rows].join('\n') + '\n' + separator + md.slice(end);
|
||||
}
|
||||
|
||||
// Read the ledger with pending changes overlaid; return regenerated epic/sprint files that differ.
|
||||
@@ -377,38 +385,7 @@ function allocate(root, dir, epic, count) {
|
||||
return { ids };
|
||||
}
|
||||
|
||||
// changes: { create: { rel: content }, update: { rel: content } }
|
||||
function apply(root, dir, changes) {
|
||||
const keys = new Set();
|
||||
const normalize = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([rel, content]) => {
|
||||
const key = rel.replace(/\\/g, '/');
|
||||
if (keys.has(key)) throw new LedgerError(`duplicate path in batch: ${key}`);
|
||||
keys.add(key);
|
||||
return [key, content];
|
||||
}));
|
||||
const create = normalize(changes.create);
|
||||
const update = normalize(changes.update);
|
||||
const reg = registryDir(root, dir);
|
||||
for (const rel of Object.keys(create)) {
|
||||
if (fs.existsSync(resolveLedgerPath(dir, rel))) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4);
|
||||
const m = rel.match(STORY_FILE);
|
||||
if (m && !fs.existsSync(path.join(reg, `${m[1]}-${m[2]}`))) throw new LedgerError(`${rel}: story ID was not allocated`);
|
||||
if (m && parseStory(rel, create[rel]).status !== 'todo') throw new LedgerError(`${rel}: new stories start as todo`);
|
||||
}
|
||||
for (const rel of Object.keys(update)) {
|
||||
const abs = resolveLedgerPath(dir, rel);
|
||||
if (!fs.existsSync(abs)) throw new LedgerError(`cannot update missing ${rel}`);
|
||||
if (!STORY_FILE.test(rel)) continue;
|
||||
const from = parseStory(rel, readRegular(abs, 'utf8')).status;
|
||||
const to = parseStory(rel, update[rel]).status;
|
||||
if (to !== from && STATUSES.indexOf(to) !== STATUSES.indexOf(from) + 1) {
|
||||
throw new LedgerError(`${rel}: status ${from} -> ${to} is not allowed; status moves forward one step at a time`);
|
||||
}
|
||||
}
|
||||
const pending = { ...create, ...update };
|
||||
const all = { ...pending, ...project(dir, pending).derived };
|
||||
const txn = path.join(dir, '.txn');
|
||||
fs.mkdirSync(txn, { mode: 0o700 });
|
||||
function publishTransaction(dir, txn, all, create) {
|
||||
const entries = Object.keys(all).map((rel, i) => {
|
||||
const existed = fs.existsSync(resolveLedgerPath(dir, rel));
|
||||
if (rel in create && existed) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4);
|
||||
@@ -440,6 +417,70 @@ function apply(root, dir, changes) {
|
||||
return { ok: true, written: entries.map((e) => e.path) };
|
||||
}
|
||||
|
||||
// changes: { create: { rel: content }, update: { rel: content } }
|
||||
function apply(root, dir, changes) {
|
||||
const keys = new Set();
|
||||
const normalize = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([rel, content]) => {
|
||||
const key = rel.replace(/\\/g, '/');
|
||||
if (keys.has(key)) throw new LedgerError(`duplicate path in batch: ${key}`);
|
||||
keys.add(key);
|
||||
return [key, content];
|
||||
}));
|
||||
const create = normalize(changes.create);
|
||||
const update = normalize(changes.update);
|
||||
const reg = registryDir(root, dir);
|
||||
for (const rel of Object.keys(create)) {
|
||||
if (fs.existsSync(resolveLedgerPath(dir, rel))) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4);
|
||||
const m = rel.match(STORY_FILE);
|
||||
if (m && !fs.existsSync(path.join(reg, `${m[1]}-${m[2]}`))) throw new LedgerError(`${rel}: story ID was not allocated`);
|
||||
if (m && parseStory(rel, create[rel]).status !== 'todo') throw new LedgerError(`${rel}: new stories start as todo`);
|
||||
}
|
||||
for (const rel of Object.keys(update)) {
|
||||
const abs = resolveLedgerPath(dir, rel);
|
||||
if (!fs.existsSync(abs)) throw new LedgerError(`cannot update missing ${rel}`);
|
||||
if (!STORY_FILE.test(rel)) continue;
|
||||
const from = parseStory(rel, readRegular(abs, 'utf8')).status;
|
||||
const to = parseStory(rel, update[rel]).status;
|
||||
if (to !== from && STATUSES.indexOf(to) !== STATUSES.indexOf(from) + 1) {
|
||||
throw new LedgerError(`${rel}: status ${from} -> ${to} is not allowed; status moves forward one step at a time`);
|
||||
}
|
||||
}
|
||||
const pending = { ...create, ...update };
|
||||
const all = { ...pending, ...project(dir, pending).derived };
|
||||
return withTransaction(dir, txn => publishTransaction(dir, txn, all, create));
|
||||
}
|
||||
|
||||
function describeError(error) { return `${error.code ? `${error.code}: ` : ''}${error.message}`; }
|
||||
|
||||
function withOwnerLock(dir, waitMs, operation) {
|
||||
const release = acquireLock(dir, waitMs);
|
||||
let result, primaryError;
|
||||
try { result = operation(); } catch (error) { primaryError = error; }
|
||||
try { release(); } catch (releaseError) {
|
||||
if (!primaryError) throw releaseError;
|
||||
throw new LedgerError(`${describeError(primaryError)}; lock release failed: ${describeError(releaseError)}`, primaryError.exitCode || 1);
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result;
|
||||
}
|
||||
|
||||
function withTransaction(dir, operation) {
|
||||
const txn = path.join(dir, '.txn');
|
||||
fs.mkdirSync(txn, { mode: 0o700 });
|
||||
try {
|
||||
return operation(txn);
|
||||
} catch (error) {
|
||||
// Caller retains the live owner lock throughout restoration. A removed
|
||||
// journal marks a committed batch, so recover then only cleans debris.
|
||||
try { recover(dir); } catch (recoveryError) {
|
||||
const state = fs.existsSync(path.join(dir, '.txn', 'journal.json')) ? 'recovery pending' : 'transaction cleanup pending';
|
||||
throw new LedgerError(`${describeError(error)}; ${state}: ${describeError(recoveryError)}; ` +
|
||||
'resolve the cause, then run recover to retry without overwriting foreign-owned files.', error.exitCode || 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const cmd = opts._[0];
|
||||
@@ -462,17 +503,14 @@ function main() {
|
||||
throw new LedgerError('--wait-ms must be an integer in 0..60000');
|
||||
}
|
||||
requireHardlinks(dir);
|
||||
const release = acquireLock(dir, waitMs);
|
||||
try {
|
||||
return withOwnerLock(dir, waitMs, () => {
|
||||
const rolledBack = recover(dir);
|
||||
if (cmd === 'recover') return { ok: true, rolledBack };
|
||||
if (cmd === 'allocate') return allocate(root, dir, opts.epic, Number(opts.count || 1));
|
||||
if (cmd === 'reconcile') return apply(root, dir, {});
|
||||
if (cmd === 'apply') return apply(root, dir, JSON.parse(fs.readFileSync(opts.input, 'utf8')));
|
||||
throw new LedgerError(`unknown command: ${cmd}`);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const LEDGER = path.join(__dirname, '../../skills/story-lifecycle/scripts/ledger.js');
|
||||
const temps = new Set();
|
||||
const marker = '<!-- Derived from story files -->';
|
||||
const epic = `# Epic: Auth\n\n${marker}\n| ID | Title | Status |\n| --- | --- | --- |\n`;
|
||||
const story = (id, status = 'todo') => `# Story: ${id}\n\n**ID:** ${id}\n**Epic:** auth\n**Sprint:** unassigned\n**Status:** ${status}\n**Points:** 3\n`;
|
||||
function run(root, args, preload) {
|
||||
return spawnSync(process.execPath, [...(preload ? ['-r', preload] : []), LEDGER, ...args, '--root', root],
|
||||
{ encoding: 'utf8', timeout: 15000 });
|
||||
}
|
||||
function ok(result) { assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); }
|
||||
function input(root, changes) { const file = path.join(root, 'input.json'); fs.writeFileSync(file, JSON.stringify(changes)); return file; }
|
||||
function snapshot(dir) {
|
||||
return Object.fromEntries(fs.readdirSync(dir).sort().map(name => {
|
||||
const file = path.join(dir, name);return [name, fs.lstatSync(file).isDirectory() ? snapshot(file) : fs.readFileSync(file).toString('base64')];
|
||||
}));
|
||||
}
|
||||
function fixture(update = false) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'story-ordinary-error-'));temps.add(root);
|
||||
execFileSync('git', ['init', '-q', root]);ok(run(root, ['init']));
|
||||
fs.writeFileSync(path.join(root, '.stories/epics/auth.md'), epic);
|
||||
const ids = ok(run(root, ['allocate', '--epic', 'auth', '--count', '2'])).ids;
|
||||
if (update) ok(run(root, ['apply', '--input', input(root, { create: Object.fromEntries(ids.map(id => [`${id}.md`, story(id)])) })]));
|
||||
const before = snapshot(path.join(root, '.stories'));
|
||||
const changes = { [update ? 'update' : 'create']: Object.fromEntries(ids.map(id => [`${id}.md`, story(id, update ? 'in-progress' : 'todo')])) };
|
||||
return { root, ids, before, registryBefore: snapshot(path.join(root, '.git/story-lifecycle')), file: input(root, changes) };
|
||||
}
|
||||
function fault(root, { _update = false, recoveryFailure = false, cleanupFailure = false, journalRemovalFailure = false, releaseFailure = false } = {}) {
|
||||
const file = path.join(root, 'bounded-fault.cjs');
|
||||
fs.writeFileSync(file, `const fs=require('node:fs');const path=require('node:path');
|
||||
const dir=${JSON.stringify(path.join(root, '.stories'))};const trace=${JSON.stringify(path.join(root, 'trace.json'))};
|
||||
const state={published:0,recoveryLocks:[]};const save=()=>fs.writeFileSync(trace,JSON.stringify(state));
|
||||
const failure=(text,code)=>Object.assign(new Error(text),{code});
|
||||
for(const method of ['linkSync','renameSync']){const original=fs[method];fs[method]=function(source,target,...args){
|
||||
const publication=path.dirname(target)===dir && /^auth-[12]\\.md$/.test(path.basename(target)) && /^new-\\d+$/.test(path.basename(source));
|
||||
if(publication && state.published===1 && ${!cleanupFailure}){state.primary=true;state.firstBytes=fs.readFileSync(path.join(dir,'auth-1.md')).toString('base64');state.lock=JSON.parse(fs.readFileSync(path.join(dir,'.lock'),'utf8'));save();throw failure('PRIMARY ordinary second publication','EIO');}
|
||||
if(path.basename(source).startsWith('backup-')){const holder=JSON.parse(fs.readFileSync(path.join(dir,'.lock'),'utf8'));state.recoveryLocks.push(holder.pid===process.pid);save();
|
||||
if(${recoveryFailure} && path.basename(target)==='restore.tmp' && path.basename(source)==='backup-1')throw failure('RECOVERY backup restore refused','EACCES');}
|
||||
const result=original.call(fs,source,target,...args);if(publication){state.published++;save();}return result;
|
||||
};}
|
||||
${journalRemovalFailure ? "const rmJournal=fs.rmSync;fs.rmSync=function(target,...args){if(target===path.join(dir,'.txn','journal.json') && state.primary && state.recoveryLocks.length){throw failure('RECOVERY journal removal refused','EACCES');}return rmJournal.call(fs,target,...args);};" : ''}
|
||||
${releaseFailure ? "const lstat=fs.lstatSync;fs.lstatSync=function(target,...args){if(target===path.join(dir,'.lock') && state.primary && state.recoveryLocks.length && !fs.existsSync(path.join(dir,'.txn'))){throw failure('RELEASE ownership read refused','EACCES');}return lstat.call(fs,target,...args);};" : ''}
|
||||
${cleanupFailure ? "const rm=fs.rmSync;fs.rmSync=function(target,...args){if(target===path.join(dir,'.txn') && state.published===2 && fs.existsSync(target) && !fs.existsSync(path.join(dir,'.txn','journal.json'))){state.cleanup=true;save();throw failure('CLEANUP committed directory refused','EACCES');}return rm.call(fs,target,...args);};" : ''}
|
||||
`);
|
||||
return file;
|
||||
}
|
||||
function restored(f) {
|
||||
assert.deepEqual(snapshot(path.join(f.root, '.stories')), f.before, 'every prior byte and new-file absence must match');
|
||||
assert.deepEqual(snapshot(path.join(f.root, '.git/story-lifecycle')), f.registryBefore);
|
||||
const state = ok(run(f.root, ['status']));assert.equal(state.recoveryPending, false);assert.equal(state.projectionAvailable, undefined);
|
||||
assert.deepEqual(state.drift, []);
|
||||
}
|
||||
const tests = [
|
||||
['journal removal failure leaves restored bytes and a retryable journal', () => {
|
||||
const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { journalRemovalFailure: true }));
|
||||
assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY journal removal refused/);assert.match(result.stderr, /recovery pending/i);
|
||||
const current = snapshot(path.join(f.root, '.stories'));const { '.txn': transaction, ...data } = current;assert.ok(transaction['journal.json']);assert.deepEqual(data, f.before);
|
||||
assert.equal(ok(run(f.root, ['status'])).recoveryPending, true);ok(run(f.root, ['recover']));restored(f);
|
||||
}],
|
||||
['release failure preserves the primary error after successful rollback and permits stale-lock recovery', () => {
|
||||
const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { releaseFailure: true }));
|
||||
assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RELEASE ownership read refused/);
|
||||
const { '.lock': lock, ...data } = snapshot(path.join(f.root, '.stories'));assert.ok(lock);assert.deepEqual(data, f.before);
|
||||
ok(run(f.root, ['recover']));restored(f);
|
||||
}],
|
||||
['failure after the committed journal marker does not undo a completed update', () => {
|
||||
const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, cleanupFailure: true }));
|
||||
assert.equal(result.status, 1);assert.match(result.stderr, /CLEANUP committed directory refused/);
|
||||
for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress'));
|
||||
assert.ok(!fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));
|
||||
assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock')));
|
||||
const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.equal(trace.published, 2);assert.deepEqual(trace.recoveryLocks, []);
|
||||
ok(run(f.root, ['recover']));assert.deepEqual(ok(run(f.root, ['status'])).drift, []);
|
||||
for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress'));
|
||||
}],
|
||||
['crash70 preserves the pending journal for explicit recovery', () => {
|
||||
const f = fixture();const preload = path.join(f.root, 'crash.cjs');fs.writeFileSync(preload, "process.env.STORY_LEDGER_CRASH_AFTER='1';");
|
||||
assert.equal(run(f.root, ['apply', '--input', f.file], preload).status, 70);
|
||||
assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));
|
||||
ok(run(f.root, ['recover']));restored(f);
|
||||
}],
|
||||
...[false, true].map(update => [`ordinary second ${update ? 'rename/update' : 'link/create'} failure restores the entire batch before returning`, () => {
|
||||
const f = fixture(update);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update }));
|
||||
assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);
|
||||
const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.equal(trace.published, 1);assert.equal(trace.lock.pid > 0, true);
|
||||
if (update) { assert.notEqual(trace.firstBytes, f.before['auth-1.md']);assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); }
|
||||
else assert.ok(trace.firstBytes);
|
||||
assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean));
|
||||
restored(f);
|
||||
}]),
|
||||
['rollback failure reports both errors, retains journal, and explicit retry restores all bytes', () => {
|
||||
const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, recoveryFailure: true }));
|
||||
assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY backup restore refused/);assert.match(result.stderr, /recovery pending/i);
|
||||
assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock')));
|
||||
const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean));
|
||||
assert.equal(ok(run(f.root, ['status'])).recoveryPending, true);ok(run(f.root, ['recover']));restored(f);
|
||||
}],
|
||||
];
|
||||
let passed = 0;let failed = 0;
|
||||
for (const [name, test] of tests) {
|
||||
try { test();console.log(`PASS ${name}`);passed++; } catch (error) { console.log(`FAIL ${name}: ${error.stack}`);failed++; }
|
||||
finally { for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true });temps.clear(); }
|
||||
}
|
||||
console.log(`Passed: ${passed}\nFailed: ${failed}`);process.exitCode = failed ? 1 : 0;
|
||||
@@ -57,6 +57,28 @@ function assertStable(root, rel, expected) {
|
||||
}
|
||||
|
||||
const tests = [
|
||||
...['epic', 'sprint'].flatMap(kind => ['apply', 'reconcile'].flatMap(command => ['', '\n'].map(gap => [
|
||||
`${command} preserves a handwritten ${kind} table below an empty marker with gap ${JSON.stringify(gap)}`, () => {
|
||||
const root = newRepo();const rel = kind === 'epic' ? 'epics/auth-flow.md' : 'sprints/sprint-1.md';
|
||||
const prefix = kind === 'epic' ? PREFIX : '# Sprint 1\n\n';
|
||||
const handwritten = '| Owner | Note |\n| --- | --- |\n| User | Keep every byte |\n';
|
||||
const suffix = `${gap}${handwritten}${SUFFIX}${kind === 'sprint' ? '\n## Total Points: 0\n' : ''}`;
|
||||
write(root, rel, `${prefix}${MARKER}\n${suffix}`);
|
||||
if (command === 'apply') {
|
||||
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids;
|
||||
apply(root, { create: { [`${id}.md`]: story(id) } });
|
||||
} else run(root, ['reconcile']);
|
||||
const actual = read(root, rel);assert.ok(actual.endsWith(suffix), 'handwritten table and prose must remain byte-identical');
|
||||
assert.equal(actual.split(handwritten).length, 2);assertStable(root, rel, actual);
|
||||
},
|
||||
]))),
|
||||
['an unrelated table immediately after derived rows survives repeat transitions', () => {
|
||||
const root = newRepo();const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids;
|
||||
const user = '| Owner | Note |\n| --- | --- |\n| User | Keep |\n';
|
||||
write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n${user}${SUFFIX}`);
|
||||
apply(root, { create: { [`${id}.md`]: story(id) } });
|
||||
assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n| ${id} | Sign in | todo |\n${user}${SUFFIX}`);
|
||||
}],
|
||||
['an EOF marker without a newline appends its table after the marker', () => {
|
||||
const root = newRepo();
|
||||
write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}`);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { readInstalledPlugins } = require('../../scripts/lib/statusline-render');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plugin-project-'));
|
||||
const config = path.join(root, 'profile');const a = path.join(root, 'A');const b = path.join(root, 'B');
|
||||
for (const dir of [config, a, b]) fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
|
||||
fs.mkdirSync(path.join(config, 'plugins'));
|
||||
const records = { 'a-only@fixture': [{ scope: 'local', projectPath: a, version: '9.9.9' }],
|
||||
'shared@fixture': [{ scope: 'project', projectPath: a, version: '1.1.0' }, { scope: 'project', projectPath: b, version: '2.2.0' }, { scope: 'user', version: '0.0.1' }],
|
||||
'global@fixture': [{ scope: 'user', version: '3.3.3' }] };
|
||||
const write = value => fs.writeFileSync(path.join(config, 'plugins/installed_plugins.json'), JSON.stringify({ version: 2, plugins: value }));
|
||||
const settings = (dir, name, value) => fs.writeFileSync(path.join(dir, '.claude', name), JSON.stringify({ enabledPlugins: value }));
|
||||
function cli(project) {
|
||||
const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../scripts/hooks/ecc-statusline.js')], {
|
||||
input: JSON.stringify({ model: { display_name: 'Fixture' }, workspace: { current_dir: project } }), encoding: 'utf8', timeout: 5000,
|
||||
env: { ...process.env, CLAUDE_CONFIG_DIR: config, ECC_STATUSLINE_USAGE: '1', ECC_STATUSLINE_COMPACT: '0' },
|
||||
});assert.equal(result.status, 0, result.stderr);return result.stdout;
|
||||
}
|
||||
const tests = [
|
||||
['actual B statusline excludes A-only plugins and selects B shared version independent of record order', () => {
|
||||
write(records);const first = cli(b);assert.doesNotMatch(first, /a-only|1\.1\.0/);assert.match(first, /shared.*2\.2\.0/);
|
||||
write({ ...records, 'shared@fixture': [...records['shared@fixture']].reverse() });assert.equal(cli(b), first);
|
||||
}],
|
||||
['project A gets its applicable local and shared records', () => { write(records);const output = cli(a);assert.match(output, /a-only.*9\.9\.9/);assert.match(output, /shared.*1\.1\.0/); }],
|
||||
['project settings disable a user-enabled plugin in the real statusline', () => {
|
||||
write(records);fs.writeFileSync(path.join(config, 'settings.json'), JSON.stringify({ enabledPlugins: { 'global@fixture': true } }));
|
||||
settings(b, 'settings.json', { 'global@fixture': false });assert.doesNotMatch(cli(b), /global/);
|
||||
}],
|
||||
['local settings override shared project settings without changing their bytes', () => {
|
||||
settings(b, 'settings.local.json', { 'global@fixture': true });const file = path.join(b, '.claude/settings.json');const before = fs.readFileSync(file);assert.match(cli(b), /global/);assert.deepEqual(fs.readFileSync(file), before);
|
||||
}],
|
||||
['applicable local version overrides project and user records', () => {
|
||||
write({ ...records, 'shared@fixture': [...records['shared@fixture'], { scope: 'local', projectPath: b, version: '4.4.4' }] });
|
||||
assert.equal(readInstalledPlugins(config, b).find(x => x.name === 'shared').version, '4.4.4');
|
||||
}],
|
||||
['nested Git workspaces use the project inventory and local settings', () => {
|
||||
require('node:child_process').execFileSync('git', ['init', '-q', b]);
|
||||
const nested = path.join(b, 'src');fs.mkdirSync(nested);
|
||||
write(records);settings(b, 'settings.local.json', { 'global@fixture': false });
|
||||
const output = cli(nested);assert.match(output, /shared.*2\.2\.0/);assert.doesNotMatch(output, /global|a-only/);
|
||||
}],
|
||||
['a similarly named sibling path does not inherit B-scoped installations', () => {
|
||||
const sibling = path.join(root, 'B-other');fs.mkdirSync(sibling);write(records);
|
||||
const plugins = readInstalledPlugins(config, sibling);assert.equal(plugins.find(x => x.name === 'shared').version, '0.0.1');assert.ok(!plugins.some(x => x.name === 'a-only'));
|
||||
}],
|
||||
];
|
||||
let passed = 0;let failed = 0;
|
||||
try {
|
||||
for (const [name, test] of tests) { try { test();console.log(`PASS ${name}`);passed++; } catch (error) { console.log(`FAIL ${name}: ${error.stack}`);failed++; } }
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
console.log(`Passed: ${passed}\nFailed: ${failed}`);process.exitCode = failed ? 1 : 0;
|
||||
Reference in new issue
Block a user