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 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 === true,
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
- // Best-effort cleanup must never make an ordinary command fail.
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(path.join(candidate, ".knodin"));
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 = path.join(repository, ".knodin");
134
+ const stateRoot = resolveStateDir(repository);
110
135
  const backups = [];
111
136
  const skipped = [];
112
137
  let entries;
113
138
  try {
114
- entries = fs.readdirSync(stateRoot, { withFileTypes: true });
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 = path.join(repository, ".knodin");
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 cutoff = now.getTime() - retentionDays * DAY_MS;
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 planned = repositoryBackups.slice(keepNewest).filter((entry) => entry.timestamp < cutoff);
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 = { ...result, ranAt: (options.now ?? new Date()).toISOString() };
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
  }
@@ -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")