fix(upgrade): error path completeness + observability

5 issues raised by code-quality reviewer on c12013a:

A. exec() wrapper now captures stderr from execSync failures and
   re-throws with `phase X failed: <stderr>` instead of the terse
   "Command failed: ..." default. Operators see the actual git/npm
   error.
B. runFullUpgrade body wrapped in try/catch; any error after phase 2
   (snapshot written) carries snapshotPath + phases + hint pointing
   at `ocp update --rollback`. Aligns with the post-flight failure
   pattern.
C. CLI entrypoint now prints snapshotPath + hint on error.

Plus minor:
- snapshot.mjs tryCopy logs a [snapshot] warn line instead of silently
  swallowing copy errors (e.g. permission-denied admin-key)
- heads-up window 1s → 3s, more operable per the policy intent
- opts.yes intent comment added (Bundle 3 will use)

One regression test added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 03:58:32 +10:00
co-authored by Claude Sonnet 4.6
parent c12013a094
commit 7959f25801
3 changed files with 93 additions and 52 deletions
+3 -1
View File
@@ -15,7 +15,9 @@ export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, ext
const tryCopy = (src, dst) => {
try {
if (existsSync(src)) copyFileSync(src, dst);
} catch { /* swallow */ }
} catch (err) {
console.error(`[snapshot] warn: could not copy ${src} (${err.code || err.message})`);
}
};
tryCopy(join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"), join(root, "plist"));
tryCopy(join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"), join(root, "service"));
+28 -4
View File
@@ -19,6 +19,7 @@ import { writeSnapshot } from "./lib/snapshot.mjs";
export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun;
const yes = !!opts.yes;
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
const plan = [];
// --- doctor pre-flight ---
@@ -69,17 +70,28 @@ export async function runUpgrade(opts = {}) {
async function runFullUpgrade({ doctor, opts }) {
const phases = [];
let snapshotPath = null;
const exec = (cmd, label) => {
if (opts.mockExec) {
phases.push({ name: label, cmd, status: "skipped-mock" });
return "";
}
try {
const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString();
phases.push({ name: label, cmd, status: "ok" });
return out;
} catch (err) {
const detail = err.stderr?.toString().trim();
phases.push({ name: label, cmd, status: "fail", stderr: detail });
throw Object.assign(
new Error(`phase ${label} failed: ${detail || err.message}`),
{ phases, cmd }
);
}
};
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
try {
// phase 1: pre-flight (doctor already passed; just record)
phases.push({ name: "pre-flight", status: "ok", note: `kind=upgrade from=${doctor.current_version} to=${doctor.latest_version}` });
@@ -87,7 +99,7 @@ async function runFullUpgrade({ doctor, opts }) {
const fromCommit = opts.mockExec
? "mock-commit"
: execSync(`git -C ${ocpDir} rev-parse HEAD`).toString().trim();
const snapshotPath = opts.mockExec
snapshotPath = opts.mockExec
? "/tmp/mock-snapshot"
: writeSnapshot({ homeDir: homedir(), fromCommit, fromVersion: doctor.current_version, toVersion: doctor.latest_version });
phases.push({ name: "snapshot", path: snapshotPath, status: "ok" });
@@ -102,8 +114,8 @@ async function runFullUpgrade({ doctor, opts }) {
// phase 5: restart (heads-up note printed before invoking)
if (!opts.mockExec) {
console.error(`[heads-up] restarting OCP service in 1s — expect ~510s blip on requests in flight.`);
await new Promise(r => setTimeout(r, 1000));
console.error(`[heads-up] restarting OCP service in 3s — expect ~510s blip on requests in flight.`);
await new Promise(r => setTimeout(r, 3000));
}
if (process.platform === "darwin") {
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
@@ -126,7 +138,7 @@ async function runFullUpgrade({ doctor, opts }) {
}
if (!ok) {
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
throw Object.assign(new Error("post-flight failed; run `ocp update --rollback`"), { phases, snapshotPath });
throw new Error("post-flight failed");
}
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
phases.push({ name: "post-flight", status: "ok" });
@@ -135,6 +147,16 @@ async function runFullUpgrade({ doctor, opts }) {
}
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
} catch (err) {
if (snapshotPath && !err.snapshotPath) {
Object.assign(err, {
snapshotPath,
phases,
hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot."
});
}
throw err;
}
}
// CLI entrypoint
@@ -148,6 +170,8 @@ if (import.meta.url === `file://${process.argv[1]}`) {
process.exit(0);
} catch (e) {
console.error(`${e.message}`);
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
if (e.hint) console.error(` hint: ${e.hint}`);
process.exit(1);
}
}
+15
View File
@@ -747,6 +747,21 @@ test("listSnapshots returns sorted by ISO timestamp", () => {
rmSync(root, { recursive: true, force: true });
});
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
// Use mockExec=true so no real commands are run.
// Verify the success path returns a snapshotPath (Fix B regression guard).
const result = await runUpgrade({
yes: true,
dryRun: false,
mockExec: true,
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" },
current_version: "v3.10.0", latest_version: "v3.14.0" }
});
assert.ok(result.snapshotPath, "successful upgrade returns snapshotPath");
assert.equal(result.path, "upgrade");
assert.equal(result.executed, true);
});
// ── Cleanup ──
closeDb();