knodin 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +72 -4
- package/dist/src/backup-retention.js +345 -12
- package/dist/src/cli-model.js +12 -0
- package/dist/src/engine/candidate-database.js +624 -66
- package/dist/src/engine/index.js +1405 -242
- package/dist/src/engine/state-paths.js +192 -31
- package/dist/src/manager-update.js +9 -10
- package/dist/src/mirror.js +2 -0
- package/dist/src/shared-index/restore.js +16 -6
- package/dist/src/storage-budget.js +146 -0
- package/dist/src/storage-inventory.js +263 -0
- package/dist/src/storage-management.js +204 -0
- package/dist/src/storage-policy-contract.js +121 -0
- package/dist/src/worktree-seed.js +7 -1
- package/docs/BACKUP-RETENTION.md +46 -2
- package/docs/CLI.md +6 -2
- package/docs/MCP.md +8 -0
- package/docs/releases/0.13.1.md +216 -0
- package/package.json +4 -2
package/dist/bin/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../src/co
|
|
|
31
31
|
import { clearDiagnostics, collectDiagnostics, diagnosticsStatus, disableDiagnostics, enableDiagnostics, inspectDiagnosticsBundle, persistDiagnosticsPreview, recordDiagnosticFailure, } from "../src/diagnostics.js";
|
|
32
32
|
import { getDocSection, listDocTopics } from "../src/docs-sections.js";
|
|
33
33
|
import { diagnoseInstallation } from "../src/doctor.js";
|
|
34
|
+
import { sweepAbandonedCandidates } from "../src/engine/candidate-database.js";
|
|
34
35
|
import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
|
|
35
36
|
import { runSeal } from "../src/engine/seal-command.js";
|
|
36
37
|
import { runSealedQuery } from "../src/engine/sealed-query.js";
|
|
@@ -60,6 +61,8 @@ import { detectRepositorySignals, discoverRepositories, formatRepositoryHuman, i
|
|
|
60
61
|
import { applyResponseBudget } from "../src/response-budget.js";
|
|
61
62
|
import { appendSessionEvent, clearSessionTelemetry, disableSessionTelemetry, enableSessionTelemetry, readSessionEvents, sessionTelemetryStatus, } from "../src/session-telemetry.js";
|
|
62
63
|
import { inspectKnodinSkills, installKnodinSkills, KNODIN_SKILLS, removeKnodinSkills, } from "../src/skill-management.js";
|
|
64
|
+
import { inventoryStorage } from "../src/storage-inventory.js";
|
|
65
|
+
import { configureStoragePolicy, postOperationStorageMaintenance, resolveStoragePolicy, } from "../src/storage-management.js";
|
|
63
66
|
import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, systemMembershipsForPath, validateSystemHealth, } from "../src/system-config.js";
|
|
64
67
|
import { applySystemPlan, planSystemImport, planSystemLink, planSystemRelate, planSystemUnlink, planSystemUnrelate, } from "../src/system-management.js";
|
|
65
68
|
import { coordinationStatus } from "../src/update-coordination.js";
|
|
@@ -1289,6 +1292,8 @@ async function main() {
|
|
|
1289
1292
|
const depth = invocation.options.depth;
|
|
1290
1293
|
const retentionDays = invocation.options.retentionDays;
|
|
1291
1294
|
const keepNewest = invocation.options.keepNewest;
|
|
1295
|
+
const maxCount = invocation.options.maxCount;
|
|
1296
|
+
const maxAllocatedBytes = invocation.options.maxBytes;
|
|
1292
1297
|
let output;
|
|
1293
1298
|
if (action === "list")
|
|
1294
1299
|
output = listBackups(defaultRoots(), { depth });
|
|
@@ -1297,16 +1302,70 @@ async function main() {
|
|
|
1297
1302
|
depth,
|
|
1298
1303
|
retentionDays,
|
|
1299
1304
|
keepNewest,
|
|
1305
|
+
maxCount,
|
|
1306
|
+
maxAllocatedBytes,
|
|
1300
1307
|
apply: invocation.options.apply === true,
|
|
1301
1308
|
});
|
|
1302
1309
|
}
|
|
1310
|
+
else if (action === "policy") {
|
|
1311
|
+
output = defaultRoots().map((repository) => {
|
|
1312
|
+
if (retentionAction === "status")
|
|
1313
|
+
return {
|
|
1314
|
+
repository,
|
|
1315
|
+
policy: resolveStoragePolicy(repository),
|
|
1316
|
+
inventory: inventoryStorage(repository),
|
|
1317
|
+
};
|
|
1318
|
+
if (retentionAction !== "preview" && retentionAction !== "apply")
|
|
1319
|
+
throw new Error("knodin backups policy requires preview, apply, or status");
|
|
1320
|
+
const changes = {
|
|
1321
|
+
apply: retentionAction === "apply",
|
|
1322
|
+
};
|
|
1323
|
+
if (maxCount !== undefined)
|
|
1324
|
+
changes.maxCount = maxCount;
|
|
1325
|
+
if (maxAllocatedBytes !== undefined)
|
|
1326
|
+
changes.maxAllocatedBytes = maxAllocatedBytes;
|
|
1327
|
+
if (invocation.options.reserveBytes !== undefined)
|
|
1328
|
+
changes.minFreeBytes = invocation.options.reserveBytes;
|
|
1329
|
+
const mode = invocation.options.mode;
|
|
1330
|
+
if (mode !== undefined) {
|
|
1331
|
+
changes.enabled = mode !== "disabled";
|
|
1332
|
+
changes.dryRun = mode === "preview";
|
|
1333
|
+
}
|
|
1334
|
+
const configured = configureStoragePolicy(repository, changes);
|
|
1335
|
+
const maintenance = retentionAction === "apply" ? postOperationStorageMaintenance(repository) : null;
|
|
1336
|
+
const candidateWarnings = [];
|
|
1337
|
+
const candidatesRemoved = maintenance?.policy?.enabled && !maintenance.policy.dryRun
|
|
1338
|
+
? sweepAbandonedCandidates(repository, {
|
|
1339
|
+
onDiagnostic: (warning) => {
|
|
1340
|
+
if (candidateWarnings.length < 8)
|
|
1341
|
+
candidateWarnings.push(warning.reason.slice(0, 512));
|
|
1342
|
+
},
|
|
1343
|
+
})
|
|
1344
|
+
: 0;
|
|
1345
|
+
return {
|
|
1346
|
+
repository,
|
|
1347
|
+
...configured,
|
|
1348
|
+
inventory: inventoryStorage(repository),
|
|
1349
|
+
cleanup: retentionAction === "apply"
|
|
1350
|
+
? { ...maintenance, candidatesRemoved, candidateWarnings }
|
|
1351
|
+
: pruneBackups([repository], {
|
|
1352
|
+
...configured.policy,
|
|
1353
|
+
depth: 0,
|
|
1354
|
+
includeRegistry: false,
|
|
1355
|
+
apply: false,
|
|
1356
|
+
}),
|
|
1357
|
+
};
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1303
1360
|
else if (action === "retention" && retentionAction === "install") {
|
|
1304
1361
|
output = installBackupRetention(roots, {
|
|
1305
1362
|
depth,
|
|
1306
1363
|
retentionDays,
|
|
1307
1364
|
keepNewest,
|
|
1365
|
+
maxCount,
|
|
1366
|
+
maxAllocatedBytes,
|
|
1308
1367
|
schedule: invocation.options.schedule,
|
|
1309
|
-
dryRun: invocation.options.dryRun
|
|
1368
|
+
dryRun: invocation.options.dryRun,
|
|
1310
1369
|
launcher: runtimeCommand,
|
|
1311
1370
|
});
|
|
1312
1371
|
}
|
|
@@ -1389,10 +1448,19 @@ async function main() {
|
|
|
1389
1448
|
const repo = resolved.repo;
|
|
1390
1449
|
if (cmd !== "doctor") {
|
|
1391
1450
|
try {
|
|
1392
|
-
maybeRunOpportunisticRetention(repo);
|
|
1451
|
+
const maintenance = maybeRunOpportunisticRetention(repo);
|
|
1452
|
+
const issues = "issues" in maintenance
|
|
1453
|
+
? maintenance.issues
|
|
1454
|
+
: "result" in maintenance && maintenance.result
|
|
1455
|
+
? maintenance.result.skipped.map((entry) => entry.reason)
|
|
1456
|
+
: [];
|
|
1457
|
+
if (issues?.length)
|
|
1458
|
+
process.stderr.write(`knodin storage: ${issues.slice(0, 8).join("; ").slice(0, 4096)}\n`);
|
|
1393
1459
|
}
|
|
1394
|
-
catch {
|
|
1395
|
-
//
|
|
1460
|
+
catch (error) {
|
|
1461
|
+
// Cleanup remains nonfatal, but failures are not hidden or reported as
|
|
1462
|
+
// reclaimed storage. Keep stdout/JSON framing untouched.
|
|
1463
|
+
process.stderr.write(`knodin storage: ${String(error).slice(0, 512)}\n`);
|
|
1396
1464
|
}
|
|
1397
1465
|
}
|
|
1398
1466
|
if (cmd === "agent-event" && !fs.existsSync(resolveDbPath(repo)))
|
|
@@ -4,7 +4,11 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import YAML from "yaml";
|
|
7
|
+
import { Database } from "./engine/sqlite.js";
|
|
8
|
+
import { resolveStateDir } from "./engine/state-paths.js";
|
|
7
9
|
import { acquireRepairLease } from "./repair-lease.js";
|
|
10
|
+
import { assertStoragePolicyWritable, MANAGED_BACKUP_DEFAULTS, readLocalStoragePolicy, } from "./storage-policy-contract.js";
|
|
11
|
+
export { MANAGED_BACKUP_DEFAULTS } from "./storage-policy-contract.js";
|
|
8
12
|
const BACKUP = /^db\.sqlite\.backup\.(\d{13})\.([0-9a-f]{8})$/;
|
|
9
13
|
const PRUNED_DIRECTORIES = new Set([
|
|
10
14
|
".git",
|
|
@@ -49,9 +53,30 @@ function registryPaths(configHome) {
|
|
|
49
53
|
return [];
|
|
50
54
|
}
|
|
51
55
|
}
|
|
56
|
+
/** Shared bounded consent scope; does not scan sibling repositories. */
|
|
57
|
+
export function retentionAppliesToRepository(repository, policy) {
|
|
58
|
+
let repo;
|
|
59
|
+
try {
|
|
60
|
+
repo = fs.realpathSync(repository);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return policy.roots.some((configuredRoot) => {
|
|
66
|
+
const root = canonicalDirectory(configuredRoot);
|
|
67
|
+
if (!root)
|
|
68
|
+
return false;
|
|
69
|
+
const relative = path.relative(root, repo);
|
|
70
|
+
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`))
|
|
71
|
+
return false;
|
|
72
|
+
const segments = relative ? relative.split(path.sep) : [];
|
|
73
|
+
return (segments.length <= policy.depth &&
|
|
74
|
+
!segments.some((segment) => PRUNED_DIRECTORIES.has(segment)));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
52
77
|
function isRepository(candidate) {
|
|
53
78
|
try {
|
|
54
|
-
const state = fs.lstatSync(
|
|
79
|
+
const state = fs.lstatSync(resolveStateDir(candidate));
|
|
55
80
|
return state.isDirectory() && !state.isSymbolicLink();
|
|
56
81
|
}
|
|
57
82
|
catch {
|
|
@@ -106,12 +131,26 @@ function message(error) {
|
|
|
106
131
|
return error instanceof Error ? error.message : String(error);
|
|
107
132
|
}
|
|
108
133
|
function inventory(repository) {
|
|
109
|
-
const stateRoot =
|
|
134
|
+
const stateRoot = resolveStateDir(repository);
|
|
110
135
|
const backups = [];
|
|
111
136
|
const skipped = [];
|
|
112
137
|
let entries;
|
|
113
138
|
try {
|
|
114
|
-
entries =
|
|
139
|
+
entries = [];
|
|
140
|
+
const directory = fs.opendirSync(stateRoot);
|
|
141
|
+
try {
|
|
142
|
+
for (let inspected = 0; inspected <= 1024; inspected++) {
|
|
143
|
+
const entry = directory.readSync();
|
|
144
|
+
if (!entry)
|
|
145
|
+
break;
|
|
146
|
+
if (inspected === 1024)
|
|
147
|
+
throw new Error("backup-inventory-limit-exceeded");
|
|
148
|
+
entries.push(entry);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
directory.closeSync();
|
|
153
|
+
}
|
|
115
154
|
}
|
|
116
155
|
catch (error) {
|
|
117
156
|
return { backups, skipped: [{ repository, reason: `inventory-failed: ${message(error)}` }] };
|
|
@@ -134,6 +173,11 @@ function inventory(repository) {
|
|
|
134
173
|
createdAt: new Date(timestamp).toISOString(),
|
|
135
174
|
timestamp,
|
|
136
175
|
bytes: stat.size,
|
|
176
|
+
allocatedBytes: Number.isSafeInteger(stat.blocks) &&
|
|
177
|
+
stat.blocks >= 0 &&
|
|
178
|
+
Number.isSafeInteger(stat.blocks * 512)
|
|
179
|
+
? stat.blocks * 512
|
|
180
|
+
: null,
|
|
137
181
|
device: stat.dev,
|
|
138
182
|
inode: stat.ino,
|
|
139
183
|
modifiedAtMs: stat.mtimeMs,
|
|
@@ -162,11 +206,14 @@ export function listBackups(roots, options = {}) {
|
|
|
162
206
|
backups,
|
|
163
207
|
count: backups.length,
|
|
164
208
|
totalBytes: backups.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
209
|
+
totalAllocatedBytes: backups.every((entry) => entry.allocatedBytes !== null)
|
|
210
|
+
? backups.reduce((sum, entry) => sum + (entry.allocatedBytes ?? 0), 0)
|
|
211
|
+
: null,
|
|
165
212
|
skipped,
|
|
166
213
|
};
|
|
167
214
|
}
|
|
168
215
|
function safeLiveDatabase(repository) {
|
|
169
|
-
const statePath =
|
|
216
|
+
const statePath = resolveStateDir(repository);
|
|
170
217
|
const stateStat = fs.lstatSync(statePath);
|
|
171
218
|
if (!stateStat.isDirectory() || stateStat.isSymbolicLink())
|
|
172
219
|
return null;
|
|
@@ -199,6 +246,20 @@ function markerExists(target) {
|
|
|
199
246
|
throw error;
|
|
200
247
|
}
|
|
201
248
|
}
|
|
249
|
+
function recoverableBackup(entry) {
|
|
250
|
+
let database;
|
|
251
|
+
try {
|
|
252
|
+
database = new Database(entry.path, { readonly: true });
|
|
253
|
+
const result = database.query("PRAGMA quick_check").all();
|
|
254
|
+
return result.length === 1 && Object.values(result[0] ?? {})[0] === "ok";
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
database?.close();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
202
263
|
export function pruneBackups(roots, options = {}) {
|
|
203
264
|
const now = options.now ?? new Date();
|
|
204
265
|
const retentionDays = options.retentionDays ?? 3;
|
|
@@ -207,26 +268,147 @@ export function pruneBackups(roots, options = {}) {
|
|
|
207
268
|
throw new Error("knodin backups: --retention-days must be an integer from 0 to 3650");
|
|
208
269
|
if (!Number.isInteger(keepNewest) || keepNewest < 0 || keepNewest > 1000)
|
|
209
270
|
throw new Error("knodin backups: --keep-newest must be an integer from 0 to 1000");
|
|
271
|
+
for (const [name, value] of Object.entries({
|
|
272
|
+
maxCount: options.maxCount,
|
|
273
|
+
maxAllocatedBytes: options.maxAllocatedBytes,
|
|
274
|
+
})) {
|
|
275
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
|
|
276
|
+
throw new Error(`knodin backups: ${name} must be a non-negative safe integer`);
|
|
277
|
+
}
|
|
210
278
|
const listed = listBackups(roots, options);
|
|
211
|
-
const
|
|
279
|
+
const globalOptions = options;
|
|
280
|
+
const repositoryModes = [];
|
|
281
|
+
const quotaLimits = new Map();
|
|
212
282
|
const candidates = [];
|
|
213
283
|
const deleted = [];
|
|
214
284
|
const skipped = [...listed.skipped];
|
|
285
|
+
const quotas = [];
|
|
215
286
|
for (const repository of listed.repositories) {
|
|
287
|
+
let local;
|
|
288
|
+
try {
|
|
289
|
+
local = globalOptions.repositoryPolicy?.(repository) ?? null;
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
skipped.push({ repository, reason: `local-storage-policy-invalid: ${message(error)}` });
|
|
293
|
+
repositoryModes.push({ repository, mode: "invalid" });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (local && !local.enabled) {
|
|
297
|
+
skipped.push({ repository, reason: "local-storage-policy-disabled" });
|
|
298
|
+
repositoryModes.push({ repository, mode: "disabled" });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const options = local
|
|
302
|
+
? {
|
|
303
|
+
...globalOptions,
|
|
304
|
+
retentionDays: local.retentionDays ?? globalOptions.retentionDays,
|
|
305
|
+
keepNewest: local.keepNewest ?? globalOptions.keepNewest,
|
|
306
|
+
maxCount: local.maxCount ?? globalOptions.maxCount,
|
|
307
|
+
maxAllocatedBytes: local.maxAllocatedBytes ?? globalOptions.maxAllocatedBytes,
|
|
308
|
+
apply: Boolean(globalOptions.apply && !local.dryRun),
|
|
309
|
+
}
|
|
310
|
+
: globalOptions;
|
|
311
|
+
quotaLimits.set(repository, options);
|
|
312
|
+
repositoryModes.push({ repository, mode: options.apply ? "apply" : "preview" });
|
|
313
|
+
const keepNewest = options.keepNewest ?? 1;
|
|
314
|
+
const cutoff = now.getTime() - (options.retentionDays ?? 3) * DAY_MS;
|
|
216
315
|
const repositoryBackups = listed.backups.filter((entry) => entry.repository === repository);
|
|
217
|
-
const
|
|
316
|
+
const budgeted = options.maxCount !== undefined || options.maxAllocatedBytes !== undefined;
|
|
317
|
+
const protectedPaths = new Set(repositoryBackups
|
|
318
|
+
.slice(0, budgeted ? Math.max(1, keepNewest) : keepNewest)
|
|
319
|
+
.map((entry) => entry.path));
|
|
320
|
+
if ((options.protectRecoverable || budgeted) && repositoryBackups.length > 0) {
|
|
321
|
+
// Bounded integrity probes. If no rollback is proved, fail closed instead
|
|
322
|
+
// of deleting older backups that might be the only recoverable copy.
|
|
323
|
+
const rollback = repositoryBackups.slice(0, 32).find(recoverableBackup);
|
|
324
|
+
if (!rollback) {
|
|
325
|
+
skipped.push({ repository, reason: "no-proven-recoverable-backup" });
|
|
326
|
+
const allocated = repositoryBackups.every((entry) => entry.allocatedBytes !== null)
|
|
327
|
+
? repositoryBackups.reduce((sum, entry) => sum + (entry.allocatedBytes ?? 0), 0)
|
|
328
|
+
: null;
|
|
329
|
+
quotas.push({
|
|
330
|
+
repository,
|
|
331
|
+
retainedCount: repositoryBackups.length,
|
|
332
|
+
retainedAllocatedBytes: allocated,
|
|
333
|
+
overQuota: (options.maxCount !== undefined && repositoryBackups.length > options.maxCount) ||
|
|
334
|
+
(options.maxAllocatedBytes !== undefined &&
|
|
335
|
+
allocated !== null &&
|
|
336
|
+
allocated > options.maxAllocatedBytes)
|
|
337
|
+
? true
|
|
338
|
+
: options.maxAllocatedBytes !== undefined && allocated === null
|
|
339
|
+
? null
|
|
340
|
+
: false,
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
protectedPaths.add(rollback.path);
|
|
345
|
+
}
|
|
346
|
+
let retainedCount = repositoryBackups.length;
|
|
347
|
+
let retainedAllocatedBytes = repositoryBackups.every((entry) => entry.allocatedBytes !== null)
|
|
348
|
+
? repositoryBackups.reduce((sum, entry) => sum + (entry.allocatedBytes ?? 0), 0)
|
|
349
|
+
: null;
|
|
350
|
+
const planned = [];
|
|
351
|
+
for (const entry of [...repositoryBackups].reverse()) {
|
|
352
|
+
if (protectedPaths.has(entry.path))
|
|
353
|
+
continue;
|
|
354
|
+
const countExceeded = options.maxCount !== undefined && retainedCount > options.maxCount;
|
|
355
|
+
const bytesExceeded = options.maxAllocatedBytes !== undefined &&
|
|
356
|
+
retainedAllocatedBytes !== null &&
|
|
357
|
+
retainedAllocatedBytes > options.maxAllocatedBytes;
|
|
358
|
+
if (entry.timestamp >= cutoff && !countExceeded && !bytesExceeded)
|
|
359
|
+
continue;
|
|
360
|
+
planned.push(entry);
|
|
361
|
+
retainedCount--;
|
|
362
|
+
if (retainedAllocatedBytes !== null)
|
|
363
|
+
retainedAllocatedBytes -= entry.allocatedBytes ?? 0;
|
|
364
|
+
}
|
|
365
|
+
// Preserve the established deterministic newest-first preview ordering.
|
|
366
|
+
planned.sort((a, b) => b.timestamp - a.timestamp || a.path.localeCompare(b.path));
|
|
367
|
+
if (options.maxAllocatedBytes !== undefined && retainedAllocatedBytes === null)
|
|
368
|
+
skipped.push({ repository, reason: "allocated-byte-measurement-unavailable" });
|
|
369
|
+
quotas.push({
|
|
370
|
+
repository,
|
|
371
|
+
retainedCount,
|
|
372
|
+
retainedAllocatedBytes,
|
|
373
|
+
overQuota: (options.maxCount !== undefined && retainedCount > options.maxCount) ||
|
|
374
|
+
(options.maxAllocatedBytes !== undefined &&
|
|
375
|
+
retainedAllocatedBytes !== null &&
|
|
376
|
+
retainedAllocatedBytes > options.maxAllocatedBytes)
|
|
377
|
+
? true
|
|
378
|
+
: options.maxAllocatedBytes !== undefined && retainedAllocatedBytes === null
|
|
379
|
+
? null
|
|
380
|
+
: false,
|
|
381
|
+
});
|
|
218
382
|
candidates.push(...planned);
|
|
219
383
|
if (!options.apply || planned.length === 0)
|
|
220
384
|
continue;
|
|
221
385
|
let lease;
|
|
222
386
|
try {
|
|
387
|
+
assertStoragePolicyWritable(repository);
|
|
223
388
|
lease = acquireRepairLease(repository, "backups prune");
|
|
389
|
+
assertStoragePolicyWritable(repository);
|
|
390
|
+
if (globalOptions.repositoryPolicy &&
|
|
391
|
+
JSON.stringify(globalOptions.repositoryPolicy(repository)) !== JSON.stringify(local))
|
|
392
|
+
throw new Error("local-storage-policy-changed-after-plan");
|
|
224
393
|
const marker = path.join(repository, ".knodin", "promotion.json");
|
|
225
394
|
if (markerExists(marker))
|
|
226
395
|
throw new Error("promotion-in-progress-or-malformed-marker");
|
|
227
396
|
if (!safeLiveDatabase(repository))
|
|
228
397
|
throw new Error("missing-or-unsafe-live-database");
|
|
229
398
|
const stateRoot = fs.realpathSync(path.join(repository, ".knodin"));
|
|
399
|
+
const protectedEntries = repositoryBackups.filter((entry) => protectedPaths.has(entry.path));
|
|
400
|
+
for (const entry of protectedEntries) {
|
|
401
|
+
const stat = fs.lstatSync(entry.path);
|
|
402
|
+
if (!stat.isFile() ||
|
|
403
|
+
stat.isSymbolicLink() ||
|
|
404
|
+
stat.dev !== entry.device ||
|
|
405
|
+
stat.ino !== entry.inode ||
|
|
406
|
+
stat.size !== entry.bytes ||
|
|
407
|
+
stat.mtimeMs !== entry.modifiedAtMs)
|
|
408
|
+
throw new Error("protected-backup-changed-after-plan");
|
|
409
|
+
}
|
|
410
|
+
if ((options.protectRecoverable || budgeted) && !protectedEntries.some(recoverableBackup))
|
|
411
|
+
throw new Error("protected-backup-no-longer-recoverable");
|
|
230
412
|
for (const candidate of planned) {
|
|
231
413
|
try {
|
|
232
414
|
const name = path.basename(candidate.path);
|
|
@@ -238,8 +420,10 @@ export function pruneBackups(roots, options = {}) {
|
|
|
238
420
|
stat.size !== candidate.bytes ||
|
|
239
421
|
stat.dev !== candidate.device ||
|
|
240
422
|
stat.ino !== candidate.inode ||
|
|
241
|
-
stat.mtimeMs !== candidate.modifiedAtMs
|
|
423
|
+
stat.mtimeMs !== candidate.modifiedAtMs ||
|
|
424
|
+
(candidate.allocatedBytes !== null && stat.blocks * 512 !== candidate.allocatedBytes))
|
|
242
425
|
throw new Error("candidate-changed-after-plan");
|
|
426
|
+
assertStoragePolicyWritable(repository);
|
|
243
427
|
fs.unlinkSync(candidate.path);
|
|
244
428
|
deleted.push(candidate);
|
|
245
429
|
}
|
|
@@ -255,6 +439,31 @@ export function pruneBackups(roots, options = {}) {
|
|
|
255
439
|
lease?.release();
|
|
256
440
|
}
|
|
257
441
|
}
|
|
442
|
+
const observed = options.apply ? listBackups(roots, options) : null;
|
|
443
|
+
if (observed)
|
|
444
|
+
skipped.push(...observed.skipped);
|
|
445
|
+
const actualQuotas = observed?.repositories.map((repository) => {
|
|
446
|
+
const options = quotaLimits.get(repository) ?? globalOptions;
|
|
447
|
+
const entries = observed.backups.filter((entry) => entry.repository === repository);
|
|
448
|
+
const unknown = observed.skipped.some((entry) => entry.repository === repository) ||
|
|
449
|
+
entries.some((entry) => entry.allocatedBytes === null);
|
|
450
|
+
const retainedAllocatedBytes = unknown
|
|
451
|
+
? null
|
|
452
|
+
: entries.reduce((sum, entry) => sum + (entry.allocatedBytes ?? 0), 0);
|
|
453
|
+
return {
|
|
454
|
+
repository,
|
|
455
|
+
retainedCount: entries.length,
|
|
456
|
+
retainedAllocatedBytes,
|
|
457
|
+
overQuota: (options.maxCount !== undefined && entries.length > options.maxCount) ||
|
|
458
|
+
(options.maxAllocatedBytes !== undefined &&
|
|
459
|
+
retainedAllocatedBytes !== null &&
|
|
460
|
+
retainedAllocatedBytes > options.maxAllocatedBytes)
|
|
461
|
+
? true
|
|
462
|
+
: unknown && options.maxAllocatedBytes !== undefined
|
|
463
|
+
? null
|
|
464
|
+
: false,
|
|
465
|
+
};
|
|
466
|
+
});
|
|
258
467
|
return {
|
|
259
468
|
schemaVersion: 1,
|
|
260
469
|
mode: options.apply ? "apply" : "preview",
|
|
@@ -265,9 +474,60 @@ export function pruneBackups(roots, options = {}) {
|
|
|
265
474
|
reclaimableBytes: candidates.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
266
475
|
deleted,
|
|
267
476
|
deletedBytes: deleted.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
477
|
+
deletedAllocatedBytes: deleted.every((entry) => entry.allocatedBytes !== null)
|
|
478
|
+
? deleted.reduce((sum, entry) => sum + (entry.allocatedBytes ?? 0), 0)
|
|
479
|
+
: null,
|
|
480
|
+
quotas: actualQuotas ?? quotas,
|
|
481
|
+
quotaBasis: options.apply ? "observed-after-cleanup" : "planned-after-cleanup",
|
|
482
|
+
repositoryModes,
|
|
268
483
|
skipped,
|
|
269
484
|
};
|
|
270
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* Post-promotion maintenance for explicitly managed stores only. Call AFTER
|
|
488
|
+
* releasing the lifecycle lease; this takes the normal lease and never bypasses
|
|
489
|
+
* another holder. Legacy stores must obtain preview consent before supplying
|
|
490
|
+
* an enabled policy. This function never installs or overwrites any policy.
|
|
491
|
+
*/
|
|
492
|
+
export function maintainPromotionBackups(repository, policy, resolvePolicy) {
|
|
493
|
+
if (!policy?.enabled)
|
|
494
|
+
return { status: "disabled" };
|
|
495
|
+
try {
|
|
496
|
+
const state = path.join(path.resolve(repository), ".knodin");
|
|
497
|
+
const stateStat = fs.lstatSync(state);
|
|
498
|
+
if (!stateStat.isDirectory() || stateStat.isSymbolicLink())
|
|
499
|
+
return { status: "skipped", reason: "unsafe-state-directory" };
|
|
500
|
+
const result = pruneBackups([repository], {
|
|
501
|
+
repositoryPolicy: resolvePolicy
|
|
502
|
+
? (repo) => {
|
|
503
|
+
const current = resolvePolicy(repo);
|
|
504
|
+
if (JSON.stringify(current) !== JSON.stringify(policy))
|
|
505
|
+
throw new Error("local-storage-policy-changed-after-plan");
|
|
506
|
+
return current;
|
|
507
|
+
}
|
|
508
|
+
: undefined,
|
|
509
|
+
depth: 0,
|
|
510
|
+
includeRegistry: false,
|
|
511
|
+
retentionDays: policy.retentionDays ?? MANAGED_BACKUP_DEFAULTS.retentionDays,
|
|
512
|
+
keepNewest: Math.max(1, policy.keepNewest ?? MANAGED_BACKUP_DEFAULTS.keepNewest),
|
|
513
|
+
maxCount: policy.maxCount ?? MANAGED_BACKUP_DEFAULTS.maxCount,
|
|
514
|
+
maxAllocatedBytes: policy.maxAllocatedBytes ?? MANAGED_BACKUP_DEFAULTS.maxAllocatedBytes,
|
|
515
|
+
protectRecoverable: true,
|
|
516
|
+
apply: !policy.dryRun,
|
|
517
|
+
});
|
|
518
|
+
return {
|
|
519
|
+
status: policy.dryRun
|
|
520
|
+
? "preview"
|
|
521
|
+
: result.skipped.length
|
|
522
|
+
? "skipped"
|
|
523
|
+
: "applied",
|
|
524
|
+
result,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
return { status: "failed", reason: message(error) };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
271
531
|
function digest(content) {
|
|
272
532
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
273
533
|
}
|
|
@@ -293,7 +553,7 @@ function parseRetentionPolicy(value) {
|
|
|
293
553
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
294
554
|
return null;
|
|
295
555
|
const record = value;
|
|
296
|
-
if (!exactKeys(record, [
|
|
556
|
+
if (!exactKeys(Object.fromEntries(Object.entries(record).filter(([key]) => !["maxCount", "maxAllocatedBytes"].includes(key))), [
|
|
297
557
|
"schemaVersion",
|
|
298
558
|
"installedAt",
|
|
299
559
|
"roots",
|
|
@@ -325,7 +585,8 @@ function parseRetentionPolicy(value) {
|
|
|
325
585
|
record.launcher.length === 0 ||
|
|
326
586
|
!record.launcher.every((part) => typeof part === "string") ||
|
|
327
587
|
!path.isAbsolute(record.launcher[0]) ||
|
|
328
|
-
typeof record.dryRun !== "boolean"
|
|
588
|
+
typeof record.dryRun !== "boolean" ||
|
|
589
|
+
[record.maxCount, record.maxAllocatedBytes].some((value) => value !== undefined && (!Number.isSafeInteger(value) || value < 0)))
|
|
329
590
|
return null;
|
|
330
591
|
return record;
|
|
331
592
|
}
|
|
@@ -468,6 +729,13 @@ function schedulerRun(platform, action, run, artifacts) {
|
|
|
468
729
|
}
|
|
469
730
|
}
|
|
470
731
|
export function installBackupRetention(roots, options = {}) {
|
|
732
|
+
for (const [name, value] of Object.entries({
|
|
733
|
+
maxCount: options.maxCount,
|
|
734
|
+
maxAllocatedBytes: options.maxAllocatedBytes,
|
|
735
|
+
})) {
|
|
736
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
|
|
737
|
+
throw new Error(`knodin backups retention: ${name} must be a non-negative safe integer`);
|
|
738
|
+
}
|
|
471
739
|
const discovered = discoverBackupRepositories(roots, { ...options, includeRegistry: true });
|
|
472
740
|
if (discovered.repositories.length === 0)
|
|
473
741
|
throw new Error("knodin backups retention install: no repositories found in roots or registry");
|
|
@@ -480,6 +748,7 @@ export function installBackupRetention(roots, options = {}) {
|
|
|
480
748
|
(!Number.isInteger(options.keepNewest) || options.keepNewest < 0 || options.keepNewest > 1000))
|
|
481
749
|
throw new Error("knodin backups retention: --keep-newest must be an integer from 0 to 1000");
|
|
482
750
|
const { policy: policyPath, stateRoot } = homes(options);
|
|
751
|
+
const previousInstalled = readInstalledRetention(options);
|
|
483
752
|
const now = new Date();
|
|
484
753
|
const launcher = (options.launcher ?? [process.execPath]).map((entry, index) => index === 0 ? path.resolve(entry) : entry);
|
|
485
754
|
const policy = {
|
|
@@ -496,15 +765,18 @@ export function installBackupRetention(roots, options = {}) {
|
|
|
496
765
|
].sort((a, b) => a.localeCompare(b)),
|
|
497
766
|
retentionDays: options.retentionDays ?? 3,
|
|
498
767
|
keepNewest: options.keepNewest ?? 1,
|
|
768
|
+
maxCount: options.maxCount ?? previousInstalled.policy?.maxCount ?? MANAGED_BACKUP_DEFAULTS.maxCount,
|
|
769
|
+
maxAllocatedBytes: options.maxAllocatedBytes ??
|
|
770
|
+
previousInstalled.policy?.maxAllocatedBytes ??
|
|
771
|
+
MANAGED_BACKUP_DEFAULTS.maxAllocatedBytes,
|
|
499
772
|
depth: options.depth ?? 5,
|
|
500
773
|
schedule: validateSchedule(options.schedule ?? "04:17"),
|
|
501
774
|
launcher,
|
|
502
|
-
dryRun: options.dryRun ?? false,
|
|
775
|
+
dryRun: options.dryRun ?? previousInstalled.policy?.dryRun ?? false,
|
|
503
776
|
};
|
|
504
777
|
const platform = options.platform ?? process.platform;
|
|
505
778
|
if (!["darwin", "linux", "win32"].includes(platform))
|
|
506
779
|
throw new Error(`knodin backups retention: scheduler unsupported on ${platform}`);
|
|
507
|
-
const previousInstalled = readInstalledRetention(options);
|
|
508
780
|
const previous = previousInstalled.issues.length === 0 ? previousInstalled.receipt : null;
|
|
509
781
|
const artifacts = schedulerArtifacts(policy, options);
|
|
510
782
|
const run = options.run ?? commandRunner;
|
|
@@ -746,6 +1018,18 @@ export function removeBackupRetention(options = {}) {
|
|
|
746
1018
|
}
|
|
747
1019
|
return { schemaVersion: 1, removed: skipped.length === 0, skipped };
|
|
748
1020
|
}
|
|
1021
|
+
function installedConsentReader(options, installed) {
|
|
1022
|
+
const snapshot = JSON.stringify({ policy: installed.policy, receipt: installed.receipt });
|
|
1023
|
+
return (repository) => {
|
|
1024
|
+
const current = readInstalledRetention(options);
|
|
1025
|
+
if (!current.policy ||
|
|
1026
|
+
!current.receipt ||
|
|
1027
|
+
current.issues.length > 0 ||
|
|
1028
|
+
JSON.stringify({ policy: current.policy, receipt: current.receipt }) !== snapshot)
|
|
1029
|
+
throw new Error("installed retention consent changed; backups preserved");
|
|
1030
|
+
return readLocalStoragePolicy(repository);
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
749
1033
|
export function runInstalledRetention(options = {}) {
|
|
750
1034
|
const locations = homes(options);
|
|
751
1035
|
const installed = readInstalledRetention(options);
|
|
@@ -760,9 +1044,22 @@ export function runInstalledRetention(options = {}) {
|
|
|
760
1044
|
includeRegistry: true,
|
|
761
1045
|
retentionDays: policy.retentionDays,
|
|
762
1046
|
keepNewest: policy.keepNewest,
|
|
1047
|
+
maxCount: policy.maxCount,
|
|
1048
|
+
maxAllocatedBytes: policy.maxAllocatedBytes,
|
|
763
1049
|
apply: !policy.dryRun,
|
|
1050
|
+
repositoryPolicy: installedConsentReader(options, installed),
|
|
764
1051
|
});
|
|
765
|
-
const record = {
|
|
1052
|
+
const record = {
|
|
1053
|
+
...result,
|
|
1054
|
+
status: result.skipped.length
|
|
1055
|
+
? result.deleted.length
|
|
1056
|
+
? "partial"
|
|
1057
|
+
: "skipped"
|
|
1058
|
+
: result.repositoryModes.every((entry) => entry.mode === "preview")
|
|
1059
|
+
? "preview"
|
|
1060
|
+
: "completed",
|
|
1061
|
+
ranAt: (options.now ?? new Date()).toISOString(),
|
|
1062
|
+
};
|
|
766
1063
|
atomicWrite(path.join(locations.stateRoot, "last-run.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
767
1064
|
appendRunLog(locations.stateRoot, record);
|
|
768
1065
|
return record;
|
|
@@ -803,7 +1100,32 @@ export function maybeRunOpportunisticRetention(repository, options = {}) {
|
|
|
803
1100
|
return { status: "disabled" };
|
|
804
1101
|
if (installed.issues.length > 0)
|
|
805
1102
|
return { status: "attention-required", issues: installed.issues };
|
|
1103
|
+
try {
|
|
1104
|
+
assertStoragePolicyWritable(repository);
|
|
1105
|
+
}
|
|
1106
|
+
catch (error) {
|
|
1107
|
+
return { status: "skipped", reason: message(error) };
|
|
1108
|
+
}
|
|
806
1109
|
const policy = installed.policy;
|
|
1110
|
+
const currentRepository = canonicalDirectory(repository);
|
|
1111
|
+
const isApplicable = (candidatePolicy) => currentRepository &&
|
|
1112
|
+
canonicalDirectory(repository) === currentRepository &&
|
|
1113
|
+
retentionAppliesToRepository(repository, candidatePolicy);
|
|
1114
|
+
if (!isApplicable(policy))
|
|
1115
|
+
return { status: "skipped", reason: "outside-retention-roots" };
|
|
1116
|
+
const consentReader = installedConsentReader(options, installed);
|
|
1117
|
+
let local;
|
|
1118
|
+
try {
|
|
1119
|
+
local = readLocalStoragePolicy(repository);
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
return {
|
|
1123
|
+
status: "attention-required",
|
|
1124
|
+
issues: [`local-storage-policy-invalid: ${message(error)}`],
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
if (local && !local.enabled)
|
|
1128
|
+
return { status: "disabled", reason: "local-storage-policy-disabled" };
|
|
807
1129
|
const duePath = path.join(locations.stateRoot, "due", `${digest(path.resolve(repository))}.json`);
|
|
808
1130
|
const due = readJson(duePath);
|
|
809
1131
|
const now = options.now ?? new Date();
|
|
@@ -815,8 +1137,19 @@ export function maybeRunOpportunisticRetention(repository, options = {}) {
|
|
|
815
1137
|
includeRegistry: false,
|
|
816
1138
|
retentionDays: policy.retentionDays,
|
|
817
1139
|
keepNewest: policy.keepNewest,
|
|
1140
|
+
maxCount: policy.maxCount,
|
|
1141
|
+
maxAllocatedBytes: policy.maxAllocatedBytes,
|
|
818
1142
|
apply: !policy.dryRun,
|
|
1143
|
+
repositoryPolicy: (target) => {
|
|
1144
|
+
if (!isApplicable(policy))
|
|
1145
|
+
throw new Error("installed retention consent changed; backups preserved");
|
|
1146
|
+
return consentReader(target);
|
|
1147
|
+
},
|
|
819
1148
|
});
|
|
1149
|
+
if (result.skipped.length > 0)
|
|
1150
|
+
return { status: "skipped", result };
|
|
1151
|
+
if (policy.dryRun || local?.dryRun)
|
|
1152
|
+
return { status: "preview", result };
|
|
820
1153
|
atomicWrite(duePath, `${JSON.stringify({ schemaVersion: 1, ranAt: now.toISOString() })}\n`);
|
|
821
1154
|
return { status: "ran", result };
|
|
822
1155
|
}
|
package/dist/src/cli-model.js
CHANGED
|
@@ -188,12 +188,24 @@ function addBackupCommands(program, capture) {
|
|
|
188
188
|
.option("--apply", "delete exact preview candidates after revalidation")
|
|
189
189
|
.addOption(option("--retention-days <count>", "retention window in days", "integer"))
|
|
190
190
|
.addOption(option("--keep-newest <count>", "newest backups retained per repository", "integer"))
|
|
191
|
+
.addOption(option("--max-count <count>", "hard backup count budget", "integer"))
|
|
192
|
+
.addOption(option("--max-bytes <count>", "allocated backup byte budget", "integer"))
|
|
191
193
|
.addOption(option("--depth <count>", "maximum discovery depth", "integer"))
|
|
192
194
|
.option("--jsonl", "stream one record per line");
|
|
195
|
+
const policy = backups.command("policy").description("manage repository storage budgets");
|
|
196
|
+
for (const action of ["preview", "apply"])
|
|
197
|
+
leaf(policy, `${action} [roots...]`, `${action} repository storage policy`, capture)
|
|
198
|
+
.addOption(option("--max-count <count>", "hard backup count budget", "integer"))
|
|
199
|
+
.addOption(option("--max-bytes <count>", "allocated backup byte budget", "integer"))
|
|
200
|
+
.addOption(option("--reserve-bytes <count>", "minimum free-space reserve", "integer"))
|
|
201
|
+
.addOption(new Option("--mode <mode>", "maintenance mode").choices(["enabled", "disabled", "preview"]));
|
|
202
|
+
leaf(policy, "status [roots...]", "inspect repository storage policy", capture);
|
|
193
203
|
const retention = backups.command("retention").description("manage opt-in ongoing retention");
|
|
194
204
|
leaf(retention, "install [roots...]", "install daily and opportunistic retention", capture)
|
|
195
205
|
.addOption(option("--retention-days <count>", "retention window in days", "integer"))
|
|
196
206
|
.addOption(option("--keep-newest <count>", "newest backups retained per repository", "integer"))
|
|
207
|
+
.addOption(option("--max-count <count>", "hard backup count budget", "integer"))
|
|
208
|
+
.addOption(option("--max-bytes <count>", "allocated backup byte budget", "integer"))
|
|
197
209
|
.addOption(option("--depth <count>", "maximum discovery depth", "integer"))
|
|
198
210
|
.option("--schedule <time>", "daily local HH:MM schedule")
|
|
199
211
|
.option("--dry-run", "install a policy that logs without deleting")
|