mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
feat(snapshot): gcSnapshots + ocp update --rollback --gc + auto-GC; v3.16.0 (#94)
Adds snapshot garbage collection with retention policy: keep last 5, keep snapshots within 30 days, always keep the most recent. Configurable via keepCount / keepDays opts. Wire-up: - ocp update --rollback --gc (manual trigger; --dry-run supported) - runFullUpgrade auto-GC after successful upgrade (best-effort, swallows errors) 4 unit tests: keepCount enforcement, keepDays override, never-delete- most-recent safety, dry-run mode. Bumps to v3.16.0 (bundles PR #93 --check oauth + this GC feature). No cli.js citation needed: this is OCP-internal snapshot lifecycle with no corresponding cli.js operation. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v3.16.0 — 2026-XX-XX (release date filled at tag time)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping
|
||||||
|
version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's
|
||||||
|
~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health
|
||||||
|
gates. Help text in `cmd_doctor_help` now reflects working behaviour.
|
||||||
|
- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots.
|
||||||
|
Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most
|
||||||
|
recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC
|
||||||
|
at the end of the full path; light path does not (no snapshot created there).
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots`
|
||||||
|
to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature.
|
||||||
|
|
||||||
## v3.15.1 — 2026-05-10
|
## v3.15.1 — 2026-05-10
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -709,6 +709,8 @@ Usage:
|
|||||||
ocp update --rollback --list List available snapshots
|
ocp update --rollback --list List available snapshots
|
||||||
ocp update --rollback <path> Restore a specific snapshot
|
ocp update --rollback <path> Restore a specific snapshot
|
||||||
ocp update --rollback --dry-run Preview rollback plan
|
ocp update --rollback --dry-run Preview rollback plan
|
||||||
|
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
|
||||||
|
ocp update --rollback --gc --dry-run Preview what would be deleted
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.15.1",
|
"version": "3.16.0",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||||
@@ -50,3 +50,66 @@ export function listSnapshots(homeDir) {
|
|||||||
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garbage-collect old upgrade snapshots.
|
||||||
|
*
|
||||||
|
* Retention rule (a snapshot is KEPT if any of these is true):
|
||||||
|
* - It is among the last `keepCount` snapshots (sorted oldest→newest)
|
||||||
|
* - Its timestamp is within `keepDays` of `now`
|
||||||
|
* - It is the single most-recent snapshot (always-keep safety net)
|
||||||
|
*
|
||||||
|
* @param {string} homeDir - Root containing ~/.ocp/
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} [opts.keepCount=5] - Minimum count to keep
|
||||||
|
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
|
||||||
|
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
|
||||||
|
* @param {Date} [opts.now=new Date()] - Override clock for testing
|
||||||
|
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
|
||||||
|
*/
|
||||||
|
export function gcSnapshots(homeDir, opts = {}) {
|
||||||
|
const keepCount = opts.keepCount ?? 5;
|
||||||
|
const keepDays = opts.keepDays ?? 30;
|
||||||
|
const dryRun = !!opts.dryRun;
|
||||||
|
const now = opts.now || new Date();
|
||||||
|
|
||||||
|
const all = listSnapshots(homeDir); // sorted oldest→newest
|
||||||
|
if (all.length === 0) return { kept: [], removed: [], dryRun };
|
||||||
|
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
|
||||||
|
|
||||||
|
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
|
||||||
|
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
|
||||||
|
|
||||||
|
const kept = [], removed = [];
|
||||||
|
for (let i = 0; i < all.length; i++) {
|
||||||
|
const s = all[i];
|
||||||
|
const isMostRecent = i === all.length - 1;
|
||||||
|
const isInLastN = lastN.has(s.path);
|
||||||
|
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
|
||||||
|
if (isMostRecent || isInLastN || isWithinDays) {
|
||||||
|
kept.push(s);
|
||||||
|
} else {
|
||||||
|
removed.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
for (const s of removed) {
|
||||||
|
try {
|
||||||
|
rmSync(s.path, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kept, removed, dryRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshotTimestamp(name) {
|
||||||
|
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
||||||
|
const m = name.match(/upgrade-snapshot-(.+)$/);
|
||||||
|
if (!m) return 0;
|
||||||
|
const t = Date.parse(m[1]);
|
||||||
|
return Number.isFinite(t) ? t : 0;
|
||||||
|
}
|
||||||
|
|||||||
+22
-2
@@ -14,7 +14,7 @@ import { execSync } from "node:child_process";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync, copyFileSync } from "node:fs";
|
import { existsSync, copyFileSync } from "node:fs";
|
||||||
import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs";
|
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||||
|
|
||||||
export async function runUpgrade(opts = {}) {
|
export async function runUpgrade(opts = {}) {
|
||||||
const dryRun = !!opts.dryRun;
|
const dryRun = !!opts.dryRun;
|
||||||
@@ -154,6 +154,16 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
phases.push({ name: "post-flight", status: "skipped-mock" });
|
phases.push({ name: "post-flight", status: "skipped-mock" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-GC old snapshots after successful upgrade (best-effort, never throws).
|
||||||
|
try {
|
||||||
|
const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 });
|
||||||
|
if (gc.removed.length > 0) {
|
||||||
|
console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[gc] warn: snapshot GC failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (snapshotPath && !err.snapshotPath) {
|
if (snapshotPath && !err.snapshotPath) {
|
||||||
@@ -193,6 +203,11 @@ async function runRollback(opts) {
|
|||||||
const homeDir = opts.homeDir || homedir();
|
const homeDir = opts.homeDir || homedir();
|
||||||
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
|
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
|
||||||
|
|
||||||
|
if (opts.gc) {
|
||||||
|
const result = gcSnapshots(homeDir, { dryRun: opts.dryRun });
|
||||||
|
return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result };
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.list) {
|
if (opts.list) {
|
||||||
return { path: "rollback-list", snapshots };
|
return { path: "rollback-list", snapshots };
|
||||||
}
|
}
|
||||||
@@ -295,6 +310,7 @@ if (_isMain()) {
|
|||||||
const yes = args.includes("--yes");
|
const yes = args.includes("--yes");
|
||||||
const rollback = args.includes("--rollback");
|
const rollback = args.includes("--rollback");
|
||||||
const list = args.includes("--list");
|
const list = args.includes("--list");
|
||||||
|
const gc = args.includes("--gc");
|
||||||
const targetIdx = args.indexOf("--target");
|
const targetIdx = args.indexOf("--target");
|
||||||
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
||||||
// First non-flag positional after --rollback is the snapshot path
|
// First non-flag positional after --rollback is the snapshot path
|
||||||
@@ -305,7 +321,7 @@ if (_isMain()) {
|
|||||||
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await runUpgrade({ dryRun, yes, rollback, list, snapshotPath, target });
|
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
|
||||||
if (result.plan) for (const line of result.plan) console.log(line);
|
if (result.plan) for (const line of result.plan) console.log(line);
|
||||||
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
|
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
|
||||||
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
||||||
@@ -313,6 +329,10 @@ if (_isMain()) {
|
|||||||
console.log(`Found ${result.snapshots.length} snapshots:`);
|
console.log(`Found ${result.snapshots.length} snapshots:`);
|
||||||
for (const s of result.snapshots) console.log(` ${s.name}`);
|
for (const s of result.snapshots) console.log(` ${s.name}`);
|
||||||
}
|
}
|
||||||
|
if (result.removed && result.kept) {
|
||||||
|
console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`);
|
||||||
|
for (const s of result.removed) console.log(` - ${s.name}`);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`✗ ${e.message}`);
|
console.error(`✗ ${e.message}`);
|
||||||
|
|||||||
+59
-2
@@ -719,8 +719,8 @@ test("upgrade full path executes 5 phases", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Snapshot Tests ──
|
// ── Snapshot Tests ──
|
||||||
import { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs";
|
import { writeSnapshot, readSnapshot, listSnapshots, gcSnapshots } from "./scripts/lib/snapshot.mjs";
|
||||||
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs";
|
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile, existsSync as testExistsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join as testJoin } from "node:path";
|
import { join as testJoin } from "node:path";
|
||||||
|
|
||||||
@@ -843,6 +843,63 @@ test("rollback latest snapshot restores files (mockExec)", async () => {
|
|||||||
assert.ok(result.phases.some(p => p.name === "git-checkout"));
|
assert.ok(result.phases.some(p => p.name === "git-checkout"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots keeps last N regardless of age", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-test-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
||||||
|
}
|
||||||
|
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
assert.equal(result.kept.length, 3);
|
||||||
|
assert.equal(result.removed.length, 2);
|
||||||
|
assert.ok(result.kept[0].name.includes("2026-04-30"));
|
||||||
|
assert.ok(result.kept[2].name.includes("2026-05-10"));
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
||||||
|
}
|
||||||
|
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
|
||||||
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
// Kept: 2026-04-30 (within 15 days), 2026-05-01 (within 15 days), 2026-05-10 (within 15 days)
|
||||||
|
assert.ok(result.kept.length >= 3);
|
||||||
|
// Removed: 2026-04-01, 2026-04-15
|
||||||
|
assert.ok(result.removed.some(s => s.name.includes("2026-04-01")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots never deletes the most recent snapshot", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
tMkdirSync(testJoin(dotOcp, "upgrade-snapshot-2026-01-01T10:00:00Z"));
|
||||||
|
// Even with keepCount=0 and keepDays=0, the most recent must survive
|
||||||
|
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
assert.equal(result.kept.length, 1);
|
||||||
|
assert.equal(result.removed.length, 0);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots --dry-run reports plan without deleting", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-dryrun-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
||||||
|
}
|
||||||
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
assert.equal(result.dryRun, true);
|
||||||
|
assert.equal(result.removed.length, 2);
|
||||||
|
// Files still exist
|
||||||
|
assert.ok(testExistsSync(testJoin(dotOcp, "upgrade-snapshot-2026-04-01T10:00:00Z")));
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Doctor --check oauth fast path tests ──
|
// ── Doctor --check oauth fast path tests ──
|
||||||
console.log("\nDoctor --check oauth:");
|
console.log("\nDoctor --check oauth:");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user