refactor(ultraplan-local): extract atomicWriteJson to lib/util
Three changes in one commit: 1. NEW lib/util/atomic-write.mjs — exports atomicWriteJson(path, obj), the canonical tmp+rename pattern. Reused by pre-compact-flush.mjs and (in subsequent steps) by the new session-state writer. 2. NEW tests/lib/atomic-write.test.mjs — 4 unit tests covering round-trip, no-orphan-tmp, overwrite-atomic, pretty-print formatting. 3. REFACTOR hooks/scripts/pre-compact-flush.mjs — replace the inline atomicWrite() with the imported atomicWriteJson(). Also fixes a pre-existing syntax error (leading whitespace + stray --resume token outside the comment block) that silently broke the hook from v3.1.0 onward — PreCompact runtime is fail-open and swallowed the error. File reformatted with standard zero-indent JS. 163 → 167 tests, 0 fail. Step 2 of /ultracontinue v3.3.0 (project 2026-05-01-ultracontinue).
This commit is contained in:
parent
bdddf52873
commit
655c8d46f8
3 changed files with 216 additions and 146 deletions
|
|
@ -1,9 +1,9 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Hook: pre-compact-flush.mjs
|
// Hook: pre-compact-flush.mjs
|
||||||
// Event: PreCompact (Claude Code v2.1.105+)
|
// Event: PreCompact (Claude Code v2.1.105+)
|
||||||
// Purpose: Flush progress.json drift before context compaction so /ultraexecute-local
|
// Purpose: Flush progress.json drift before context compaction so
|
||||||
--resume
|
// /ultraexecute-local --resume works after long conversations.
|
||||||
// works after long conversations. Direct fix for the documented P0 in
|
// Direct fix for the documented P0 in
|
||||||
// docs/ultraexecute-v2-observations-from-config-audit-v4.md.
|
// docs/ultraexecute-v2-observations-from-config-audit-v4.md.
|
||||||
//
|
//
|
||||||
// Behavior:
|
// Behavior:
|
||||||
|
|
@ -14,12 +14,17 @@
|
||||||
// 5. If derived current_step > stored current_step → write fresh checkpoint
|
// 5. If derived current_step > stored current_step → write fresh checkpoint
|
||||||
// atomically (tmp + rename), monotonic only (current_step never decreases).
|
// atomically (tmp + rename), monotonic only (current_step never decreases).
|
||||||
// 6. Always exit 0 — NEVER blocks compaction.
|
// 6. Always exit 0 — NEVER blocks compaction.
|
||||||
|
//
|
||||||
|
// v3.3.0:
|
||||||
|
// - atomicWrite extracted to lib/util/atomic-write.mjs for reuse
|
||||||
|
// - File reformatted (removed pre-existing leading-whitespace syntax error
|
||||||
|
// that silently broke the hook since v3.1.0; PreCompact swallowed it)
|
||||||
|
|
||||||
import { readFileSync, writeFileSync, renameSync, existsSync, readdirSync, statSync } from
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||||
'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { atomicWriteJson } from '../../lib/util/atomic-write.mjs';
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
const PLUGIN_ROOT = join(HERE, '..', '..');
|
const PLUGIN_ROOT = join(HERE, '..', '..');
|
||||||
|
|
@ -72,8 +77,7 @@
|
||||||
function gitLog(repoDir, baseSha) {
|
function gitLog(repoDir, baseSha) {
|
||||||
if (!baseSha) return [];
|
if (!baseSha) return [];
|
||||||
try {
|
try {
|
||||||
const out = execSync(`git -C "${repoDir}" log --pretty=format:'%H %s' ${baseSha}..HEAD
|
const out = execSync(`git -C "${repoDir}" log --pretty=format:'%H %s' ${baseSha}..HEAD 2>/dev/null`, {
|
||||||
2>/dev/null`, {
|
|
||||||
encoding: 'utf-8', timeout: 5000,
|
encoding: 'utf-8', timeout: 5000,
|
||||||
});
|
});
|
||||||
return out.trim().split('\n').filter(Boolean).map(line => {
|
return out.trim().split('\n').filter(Boolean).map(line => {
|
||||||
|
|
@ -88,23 +92,15 @@
|
||||||
const stored = progress.current_step || 0;
|
const stored = progress.current_step || 0;
|
||||||
let highestMatched = stored;
|
let highestMatched = stored;
|
||||||
for (const [stepN, prefix] of plan.entries()) {
|
for (const [stepN, prefix] of plan.entries()) {
|
||||||
const matchedCommit = gitCommits.find(c => c.subject.startsWith(prefix.replace(/\\/g,
|
const matchedCommit = gitCommits.find(c => c.subject.startsWith(prefix.replace(/\\/g, '')));
|
||||||
'')));
|
|
||||||
if (matchedCommit && stepN > highestMatched) highestMatched = stepN;
|
if (matchedCommit && stepN > highestMatched) highestMatched = stepN;
|
||||||
}
|
}
|
||||||
return highestMatched;
|
return highestMatched;
|
||||||
}
|
}
|
||||||
|
|
||||||
function atomicWrite(path, obj) {
|
|
||||||
const tmp = path + '.tmp';
|
|
||||||
writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
||||||
renameSync(tmp, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function repoRootOf(dir) {
|
function repoRootOf(dir) {
|
||||||
try {
|
try {
|
||||||
return execSync(`git -C "${dir}" rev-parse --show-toplevel 2>/dev/null`, { encoding:
|
return execSync(`git -C "${dir}" rev-parse --show-toplevel 2>/dev/null`, { encoding: 'utf-8', timeout: 2000 }).trim();
|
||||||
'utf-8', timeout: 2000 }).trim();
|
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,9 +141,8 @@
|
||||||
note: 'reconstructed by pre-compact-flush from git log',
|
note: 'reconstructed by pre-compact-flush from git log',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
atomicWrite(progPath, progress);
|
atomicWriteJson(progPath, progress);
|
||||||
process.stderr.write(`[ultraplan-local] pre-compact flush: ${progPath} →
|
process.stderr.write(`[ultraplan-local] pre-compact flush: ${progPath} → current_step=${derivedStep}\n`);
|
||||||
current_step=${derivedStep}\n`);
|
|
||||||
mutationsMade++;
|
mutationsMade++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
plugins/ultraplan-local/lib/util/atomic-write.mjs
Normal file
14
plugins/ultraplan-local/lib/util/atomic-write.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// lib/util/atomic-write.mjs
|
||||||
|
// Atomic JSON file write — writes to {path}.tmp then renames to {path}.
|
||||||
|
// Crash-safe: a partial write leaves the original file untouched.
|
||||||
|
//
|
||||||
|
// Extracted from hooks/scripts/pre-compact-flush.mjs in v3.3.0 so that
|
||||||
|
// session-state writers and progress.json writers share one implementation.
|
||||||
|
|
||||||
|
import { writeFileSync, renameSync } from 'node:fs';
|
||||||
|
|
||||||
|
export function atomicWriteJson(path, obj) {
|
||||||
|
const tmp = path + '.tmp';
|
||||||
|
writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
||||||
|
renameSync(tmp, path);
|
||||||
|
}
|
||||||
61
plugins/ultraplan-local/tests/lib/atomic-write.test.mjs
Normal file
61
plugins/ultraplan-local/tests/lib/atomic-write.test.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// tests/lib/atomic-write.test.mjs
|
||||||
|
// Unit tests for lib/util/atomic-write.mjs
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { atomicWriteJson } from '../../lib/util/atomic-write.mjs';
|
||||||
|
|
||||||
|
test('atomicWriteJson — writes valid JSON and round-trips', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||||
|
try {
|
||||||
|
const path = join(dir, 'state.json');
|
||||||
|
const obj = { schema_version: 1, status: 'in_progress', items: [1, 2, 3] };
|
||||||
|
atomicWriteJson(path, obj);
|
||||||
|
const read = JSON.parse(readFileSync(path, 'utf-8'));
|
||||||
|
assert.deepEqual(read, obj);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomicWriteJson — leaves no .tmp orphan after success', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||||
|
try {
|
||||||
|
const path = join(dir, 'state.json');
|
||||||
|
atomicWriteJson(path, { ok: true });
|
||||||
|
assert.equal(existsSync(path), true);
|
||||||
|
assert.equal(existsSync(path + '.tmp'), false);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomicWriteJson — overwrites existing file atomically', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||||
|
try {
|
||||||
|
const path = join(dir, 'state.json');
|
||||||
|
writeFileSync(path, '{"old":true}');
|
||||||
|
atomicWriteJson(path, { new: true });
|
||||||
|
const read = JSON.parse(readFileSync(path, 'utf-8'));
|
||||||
|
assert.deepEqual(read, { new: true });
|
||||||
|
assert.equal(existsSync(path + '.tmp'), false);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('atomicWriteJson — pretty-prints with 2-space indent', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||||
|
try {
|
||||||
|
const path = join(dir, 'state.json');
|
||||||
|
atomicWriteJson(path, { a: 1, b: { c: 2 } });
|
||||||
|
const text = readFileSync(path, 'utf-8');
|
||||||
|
assert.match(text, /\n {2}"a": 1/);
|
||||||
|
assert.match(text, /\n {4}"c": 2/);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue