mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Plan §3.A5 (snapshot GC) + §3.B3 (od plugin run shorthand).
- New apps/daemon/src/plugins/gc.ts wraps pruneExpiredSnapshots() in a
periodic worker driven by OD_SNAPSHOT_GC_INTERVAL_MS. Daemon boot
starts the worker and runs an immediate sweep so a fresh daemon does
not wait the full interval before reaping pre-existing expired rows.
Setting the interval to 0 disables the worker entirely.
- New HTTP endpoints:
* GET /api/applied-plugins → list all snapshots
* GET /api/projects/:projectId/applied-plugins → list snapshots for
one project
* POST /api/applied-plugins/prune { before? } → operator escape hatch
that calls pruneExpiredSnapshots() with the optional cutoff so a
hosted operator can force-delete unreferenced rows older than ts.
- New CLI verbs:
* od plugin snapshots list [--project <id>]
* od plugin snapshots prune [--before <unix-ms>]
* od plugin run <id> --project <projectId> [--inputs <json>]
[--grant-caps a,b] (apply + run start shorthand). Capability gate
failures map to exit 66; missing-input to exit 67.
- ConnectorApiErrorCode gains CONNECTOR_NOT_GRANTED so the §3.A3 gate
rejection can use the canonical sender.
Daemon tests: 1425 → 1429 (added plugins-snapshot-gc with 4 cases).
Co-authored-by: Tom Huang <1043269994@qq.com>
87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
// Snapshot GC worker (plan §3.A5 / spec §16 Phase 5 / PB2).
|
|
//
|
|
// The unreferenced-snapshot TTL is enforced by `pruneExpiredSnapshots()`
|
|
// (defined in `snapshots.ts`); this module owns the periodic schedule
|
|
// and the audit log. The worker is started from `server.ts` boot and
|
|
// disabled when `OD_SNAPSHOT_GC_INTERVAL_MS` is set to `0`.
|
|
//
|
|
// Operator escape hatch: `od plugin snapshots prune --before <ts>` calls
|
|
// `pruneExpiredSnapshots(db, { before: cutoff })` synchronously without
|
|
// touching the periodic timer.
|
|
|
|
import type Database from 'better-sqlite3';
|
|
import { pruneExpiredSnapshots, type PruneExpiredResult } from './snapshots.js';
|
|
import { readPluginEnvKnobs } from '../app-config.js';
|
|
|
|
type SqliteDb = Database.Database;
|
|
|
|
export interface SnapshotGcOptions {
|
|
db: SqliteDb;
|
|
// Override the timer interval. When undefined we read
|
|
// `readPluginEnvKnobs().snapshotGcIntervalMs`. Setting `0` disables.
|
|
intervalMs?: number;
|
|
// Sink for audit messages. Defaults to console.log so packaged
|
|
// deployments capture it on stdout.
|
|
logger?: (msg: string, meta?: Record<string, unknown>) => void;
|
|
// Optional hook invoked after each tick so tests can synchronize on
|
|
// sweep completion.
|
|
onTick?: (result: PruneExpiredResult) => void;
|
|
}
|
|
|
|
export interface SnapshotGcHandle {
|
|
stop(): void;
|
|
// Force-runs a sweep synchronously. Useful for the CLI escape hatch
|
|
// and for tests that don't want to sleep through the timer.
|
|
sweep(now?: number): PruneExpiredResult;
|
|
}
|
|
|
|
const NOOP_HANDLE: SnapshotGcHandle = {
|
|
stop: () => undefined,
|
|
sweep: () => ({ removed: 0, ids: [] }),
|
|
};
|
|
|
|
export function startSnapshotGc(opts: SnapshotGcOptions): SnapshotGcHandle {
|
|
const knobs = readPluginEnvKnobs();
|
|
const intervalMs = opts.intervalMs ?? knobs.snapshotGcIntervalMs;
|
|
const log = opts.logger ?? defaultLogger;
|
|
|
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
log('[plugins] snapshot GC disabled (interval ms <= 0)');
|
|
return NOOP_HANDLE;
|
|
}
|
|
|
|
const tick = () => {
|
|
try {
|
|
const result = pruneExpiredSnapshots(opts.db);
|
|
if (result.removed > 0) {
|
|
log(`[plugins] snapshot GC removed ${result.removed} expired snapshot(s)`, {
|
|
ids: result.ids,
|
|
});
|
|
}
|
|
opts.onTick?.(result);
|
|
} catch (err) {
|
|
log(`[plugins] snapshot GC tick failed: ${(err as Error).message ?? String(err)}`);
|
|
}
|
|
};
|
|
|
|
const timer = setInterval(tick, intervalMs);
|
|
timer.unref?.();
|
|
|
|
return {
|
|
stop: () => {
|
|
clearInterval(timer);
|
|
},
|
|
sweep: (now?: number) => pruneExpiredSnapshots(opts.db, now ? { now } : {}),
|
|
};
|
|
}
|
|
|
|
function defaultLogger(msg: string, meta?: Record<string, unknown>): void {
|
|
if (meta) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(msg, meta);
|
|
} else {
|
|
// eslint-disable-next-line no-console
|
|
console.log(msg);
|
|
}
|
|
}
|