@1aboveio/skills 1.0.18 → 1.0.20
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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +151 -145
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/maestro/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +5 -4
- package/skills/engineering/better-review/references/passes.md +15 -0
- package/skills/engineering/better-review/references/result.md +14 -0
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/SKILL.md +14 -9
- package/skills/engineering/resolve-issues/references/ci.md +3 -1
- package/skills/engineering/resolve-issues/references/delivery-progress.md +16 -0
- package/skills/engineering/resolve-issues/references/done.md +7 -0
- package/skills/engineering/resolve-issues/references/dropbox.md +31 -6
- package/skills/engineering/resolve-issues/references/expire.md +6 -3
- package/skills/engineering/resolve-issues/references/implementation.md +67 -6
- package/skills/engineering/resolve-issues/references/preflight.md +17 -3
- package/skills/engineering/resolve-issues/references/retention.md +162 -0
- package/skills/engineering/resolve-issues/references/review.md +42 -9
- package/skills/engineering/resolve-issues/references/task-plan.md +17 -2
- package/skills/engineering/resolve-issues/references/workspaces.md +40 -10
- package/skills/engineering/resolve-issues/scripts/daily-cleanup.mjs +169 -0
- package/skills/engineering/resolve-issues/scripts/dropbox.mjs +2 -1
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +79 -4
- package/skills/engineering/resolve-issues/scripts/resource-formats.mjs +625 -0
- package/skills/engineering/resolve-issues/scripts/retention-lock.mjs +548 -0
- package/skills/engineering/resolve-issues/scripts/retention-plans.mjs +57 -0
- package/skills/engineering/resolve-issues/scripts/retention.mjs +2314 -0
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +63 -10
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -0,0 +1,2314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bounded, explicit-ownership retention for resolve-issues run resources.
|
|
3
|
+
//
|
|
4
|
+
// retention.mjs plan --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
|
|
5
|
+
// retention.mjs apply --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
|
|
6
|
+
// retention.mjs register --repo <repo root> --run <run dir> --unit <id> --path <abs path>
|
|
7
|
+
// --kind generated|artifact --last-used-at <ISO> --recipe <text>
|
|
8
|
+
// [--format generic|pnpm-install|uv-venv|next-build|pnpm-store]
|
|
9
|
+
// [--input <abs canonical rebuild input> ...] [--release-owner] [--json]
|
|
10
|
+
// retention.mjs register-owned --repo <repo root> --run <run dir> --unit <id>
|
|
11
|
+
// --last-used-at <ISO> [--apply] [--json]
|
|
12
|
+
// retention.mjs status --repo <repo root> [--run <run dir> --unit <id>] [--json]
|
|
13
|
+
// retention.mjs recover --repo <repo root> [--apply] [--json]
|
|
14
|
+
//
|
|
15
|
+
// `register-owned` is the bounded batch form: it reads only the resources the unit's plan
|
|
16
|
+
// declares, needs each one to carry explicit retention metadata (kind, format, recipe,
|
|
17
|
+
// inputs), never discovers or adopts an unrecorded path, and never releases an owner. It
|
|
18
|
+
// previews by default and registers through the same register API with --apply.
|
|
19
|
+
//
|
|
20
|
+
// `status` is read-only and reports, per registered resource, whether it is ready to reuse
|
|
21
|
+
// or why a rebuild is required. `recover` is read-only by default and clears a retention
|
|
22
|
+
// lock only when its owner record proves that process dead on this host.
|
|
23
|
+
//
|
|
24
|
+
// This is not a whole-run purge and not a second task-plan lifecycle. The canonical
|
|
25
|
+
// manifest <repo>/.resolve-issues/retention.json is the only source of deletion
|
|
26
|
+
// candidates: a resource must have been registered explicitly, by path, against the
|
|
27
|
+
// unit that owns it. Existing run metadata is never a candidate, and no code here
|
|
28
|
+
// infers disposability from a name, an extension, or an age.
|
|
29
|
+
//
|
|
30
|
+
// A registered resource is removable only when every check holds: the owner unit is
|
|
31
|
+
// succeeded+merged (or the manifest carries an explicit ownerReleased approval), the
|
|
32
|
+
// path is inside that unit's workspace or a declared resource, no other unit or run claims
|
|
33
|
+
// the same tree as its workspace or resource, the tree contains only untracked/ignored
|
|
34
|
+
// content in its containing Git workspace, no metadata-named files, no credential- or
|
|
35
|
+
// secret-bearing path (environment file, registry or cloud credential, SSH identity,
|
|
36
|
+
// private key, keystore), no nested Git, no external symlinks, no special files, no
|
|
37
|
+
// required evidence, pin or checkpoint overlaps anywhere in the repository's plans, the
|
|
38
|
+
// recorded lastUsedAt and the newest write are old enough, and no process (or an
|
|
39
|
+
// unreadable process probe) uses the path. Two exceptions are narrow and typed: a
|
|
40
|
+
// resource registered with a proven format (pnpm-install, uv-venv, next-build,
|
|
41
|
+
// pnpm-store) may embed a vendored `source`/`sources`/`results`/`raw` name and may hold
|
|
42
|
+
// external symbolic links, because those are references that a removal unlinks without
|
|
43
|
+
// following. A credential, nested Git, special file, blocking run metadata name, or a
|
|
44
|
+
// changed rebuild input still retains the tree, and a generic resource keeps every
|
|
45
|
+
// strict check. A plan declaration that cannot be read is
|
|
46
|
+
// uncertainty about a path that must survive, so only a complete evidence scan can
|
|
47
|
+
// authorize a deletion. Content is hashed by streaming
|
|
48
|
+
// it through a fixed buffer under a finite total and per-file bound, so a real multi-GiB
|
|
49
|
+
// cache is snapshotted without allocating it, and a file that grows while it is read is
|
|
50
|
+
// refused instead of silently truncated.
|
|
51
|
+
//
|
|
52
|
+
// --apply takes <repo>/.resolve-issues/.retention-lock (never reclaimed by age; the lock
|
|
53
|
+
// schema and its liveness proof live in retention-lock.mjs), snapshots identity and
|
|
54
|
+
// content, rechecks it immediately before and after an atomic same-parent quarantine
|
|
55
|
+
// rename, writes receipts under <repo>/.resolve-issues/retention-receipts/, and only then
|
|
56
|
+
// deletes the quarantined tree. A retained row is not an I/O failure; a failure to finish
|
|
57
|
+
// an apply is. Before taking the lock, apply and register clear one only when its owner is
|
|
58
|
+
// provably dead on this host, so an interrupted apply cannot block the next one forever.
|
|
59
|
+
// Every other lock, receipt, and quarantined tree is left exactly as it was.
|
|
60
|
+
//
|
|
61
|
+
// Exit 0: plan/status/recover ran; apply had no I/O failure (retained rows are allowed);
|
|
62
|
+
// register saved; register-owned reported.
|
|
63
|
+
// Exit 1: apply started but could not finish a removal (read the failed receipt).
|
|
64
|
+
// Exit 2: usage, unreadable/contradictory manifest, held lock, or register refusal.
|
|
65
|
+
|
|
66
|
+
import { createHash } from 'node:crypto'
|
|
67
|
+
import { execFileSync } from 'node:child_process'
|
|
68
|
+
import {
|
|
69
|
+
closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync,
|
|
70
|
+
readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync,
|
|
71
|
+
rmSync, unlinkSync, writeFileSync,
|
|
72
|
+
} from 'node:fs'
|
|
73
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
74
|
+
import { isMainModule } from '../../engineering-runtime/scripts/main-module.mjs'
|
|
75
|
+
import { containsPath, liveProcessesUsing, normPath } from '../../harness-runtime/workspace-lifecycle.mjs'
|
|
76
|
+
import {
|
|
77
|
+
MAX_REBUILD_INPUTS, TYPED_FORMATS, classifyEmbeddedNames, classifyRebuildState,
|
|
78
|
+
inspectResourceFormat,
|
|
79
|
+
} from './resource-formats.mjs'
|
|
80
|
+
import {
|
|
81
|
+
RetentionLockError, acquireRetentionLock, inspectRetentionLock, recoverRetentionLock,
|
|
82
|
+
} from './retention-lock.mjs'
|
|
83
|
+
import { retentionPlanFiles } from './retention-plans.mjs'
|
|
84
|
+
|
|
85
|
+
const MANIFEST_FILE = 'retention.json'
|
|
86
|
+
const MANIFEST_VERSION = 1
|
|
87
|
+
const RECEIPT_VERSION = 1
|
|
88
|
+
const LOCK_NAME = '.retention-lock'
|
|
89
|
+
const RECEIPT_DIR = 'retention-receipts'
|
|
90
|
+
const QUARANTINE_PREFIX = '.retention-quarantine-'
|
|
91
|
+
const KINDS = new Set(['generated', 'artifact'])
|
|
92
|
+
// `generic` is the strict default: it carries no typed proof, so every metadata-name and
|
|
93
|
+
// external-link check stays in force. The rest are proven formats from resource-formats.mjs.
|
|
94
|
+
const FORMATS = new Set(['generic', ...TYPED_FORMATS])
|
|
95
|
+
const MAX_BATCH_RESOURCES = 200
|
|
96
|
+
const MAX_STATUS_RECEIPTS = 50
|
|
97
|
+
// The slots a unit or the plan root may declare a path that must survive removal in.
|
|
98
|
+
const EVIDENCE_KEYS = ['evidence', 'requiredEvidence', 'pins', 'checkpoints']
|
|
99
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
100
|
+
// Finite snapshot bounds. The total is a ceiling for one tree snapshot, not a size policy:
|
|
101
|
+
// a real package or build cache comfortably fits, and a bound is still enforced.
|
|
102
|
+
const MAX_TOTAL_HASH_BYTES = 16 * 1024 * 1024 * 1024
|
|
103
|
+
const MAX_FILE_HASH_BYTES = 2 * 1024 * 1024 * 1024
|
|
104
|
+
const HASH_CHUNK_BYTES = 2 * 1024 * 1024
|
|
105
|
+
|
|
106
|
+
// Metadata that belongs to a run record, never to a disposable resource. These are
|
|
107
|
+
// exact base names: a package.json or other harmless JSON is not on the list.
|
|
108
|
+
const METADATA_NAMES = new Set([
|
|
109
|
+
'task-plan.json', 'issue-tree.json', 'taskplan.json', 'dropbox.json', 'findings.json',
|
|
110
|
+
'next-actions-seen.json', 'source', 'sources', 'decisions', 'findings', 'results',
|
|
111
|
+
'reviewverdicts', 'review-verdicts', 'briefs', 'profiling', 'checkpoints', 'raw', '.git',
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
// Credential and secret-bearing configuration, matched by base name only. The list is
|
|
115
|
+
// deliberately finite: a guard that withheld every .json, .yml or source file would
|
|
116
|
+
// classify ordinary cache configuration, lockfiles and vendored sources as secrets, and a
|
|
117
|
+
// guard that is wrong most of the time is a guard that gets switched off. A single match
|
|
118
|
+
// retains the whole candidate rather than trying to lift the one entry out, because
|
|
119
|
+
// deciding whether a vendor's test fixture private key is synthetic means reading and
|
|
120
|
+
// interpreting secret content — and this module never does that. It inspects names only.
|
|
121
|
+
const CREDENTIAL_FILE_NAMES = new Set([
|
|
122
|
+
'.npmrc', '.netrc', '.yarnrc', '.yarnrc.yml', '.pypirc', '.git-credentials', '.htpasswd',
|
|
123
|
+
'.pgpass', '.my.cnf', 'credentials', 'credentials.db', 'keystore', 'truststore',
|
|
124
|
+
'application_default_credentials.json', 'service-account.json',
|
|
125
|
+
])
|
|
126
|
+
const CREDENTIAL_FILE_PREFIXES = ['.env', 'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519']
|
|
127
|
+
const CREDENTIAL_FILE_SUFFIXES = ['.pem', '.key', '.p8', '.p12', '.pfx', '.keystore', '.jks', '.ppk']
|
|
128
|
+
const CREDENTIAL_DIR_NAMES = new Set(['.ssh', '.aws', '.azure', '.gnupg', '.kube', '.docker', 'gcloud'])
|
|
129
|
+
// Bounded reporting: the tree is retained either way, so only a readable sample is kept.
|
|
130
|
+
const MAX_REPORTED_CREDENTIALS = 50
|
|
131
|
+
|
|
132
|
+
function credentialName(name, isDirectory) {
|
|
133
|
+
const lower = String(name).toLowerCase()
|
|
134
|
+
if (isDirectory) {
|
|
135
|
+
if (CREDENTIAL_DIR_NAMES.has(lower)) return name
|
|
136
|
+
return lower.startsWith('.env') ? name : null
|
|
137
|
+
}
|
|
138
|
+
if (CREDENTIAL_FILE_NAMES.has(lower)) return name
|
|
139
|
+
if (CREDENTIAL_FILE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return name
|
|
140
|
+
return CREDENTIAL_FILE_SUFFIXES.some((suffix) => lower.endsWith(suffix)) ? name : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const USAGE = `usage: retention.mjs plan --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
|
|
144
|
+
retention.mjs apply --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
|
|
145
|
+
retention.mjs register --repo <repo root> --run <run dir> --unit <id> --path <abs path>
|
|
146
|
+
--kind generated|artifact --last-used-at <ISO> --recipe <text>
|
|
147
|
+
[--format generic|pnpm-install|uv-venv|next-build|pnpm-store]
|
|
148
|
+
[--input <abs path> ...] [--release-owner] [--json]
|
|
149
|
+
retention.mjs register-owned --repo <repo root> --run <run dir> --unit <id>
|
|
150
|
+
--last-used-at <ISO> [--apply] [--json]
|
|
151
|
+
retention.mjs status --repo <repo root> [--run <run dir> --unit <id>] [--json]
|
|
152
|
+
retention.mjs recover --repo <repo root> [--apply] [--json]
|
|
153
|
+
|
|
154
|
+
plan, status, and recover never write (recover writes only with --apply, and only to a
|
|
155
|
+
lock whose owner is provably dead on this host). apply writes the lock, receipts,
|
|
156
|
+
quarantine, and the manifest. register and register-owned are explicit owner mapping;
|
|
157
|
+
they never write a task-plan.`
|
|
158
|
+
|
|
159
|
+
export class RetentionError extends Error {
|
|
160
|
+
constructor(code, message) {
|
|
161
|
+
super(message)
|
|
162
|
+
this.code = code
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class UsageError extends RetentionError {
|
|
167
|
+
constructor(message) { super('usage', message) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v)
|
|
171
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0
|
|
172
|
+
const real = (p) => realpathSync.native ? realpathSync.native(p) : realpathSync(p)
|
|
173
|
+
const lstatOrNull = (p) => { try { return lstatSync(p) } catch (error) { if (error?.code === 'ENOENT') return null; throw error } }
|
|
174
|
+
const safeInt = (v) => Number.isSafeInteger(v)
|
|
175
|
+
const normalized = (v) => (nonEmpty(v) ? normPath(v) : v)
|
|
176
|
+
|
|
177
|
+
function nowValue(value) {
|
|
178
|
+
if (value === undefined) return Date.now()
|
|
179
|
+
if (value instanceof Date) return value.getTime()
|
|
180
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
181
|
+
const parsed = Date.parse(String(value))
|
|
182
|
+
if (!Number.isFinite(parsed)) throw new UsageError('the clock value is not a valid time')
|
|
183
|
+
return parsed
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function timestamp(value) {
|
|
187
|
+
if (!nonEmpty(value)) return null
|
|
188
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value)) return null
|
|
189
|
+
const parsed = Date.parse(value)
|
|
190
|
+
if (!Number.isFinite(parsed)) return null
|
|
191
|
+
return parsed
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isoTimestamp(ms) {
|
|
195
|
+
return new Date(ms).toISOString()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function safeId(value) {
|
|
199
|
+
return String(value).replace(/[^A-Za-z0-9._-]/g, '_')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- paths ----
|
|
203
|
+
|
|
204
|
+
function canonicalRepo(repo) {
|
|
205
|
+
if (!nonEmpty(repo) || !isAbsolute(repo)) throw new UsageError('--repo must be an absolute repository path')
|
|
206
|
+
let canonical
|
|
207
|
+
try { canonical = real(repo) } catch (error) { throw new RetentionError('repo', `cannot resolve ${repo}: ${error?.code ?? error?.message}`) }
|
|
208
|
+
if (!existsSync(join(canonical, '.git'))) throw new RetentionError('repo', `${canonical} is not a Git repository root`)
|
|
209
|
+
return canonical
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function canonicalRoot(repo, { mustExist = false } = {}) {
|
|
213
|
+
const canonical = canonicalRepo(repo)
|
|
214
|
+
const root = join(canonical, '.resolve-issues')
|
|
215
|
+
const st = lstatOrNull(root)
|
|
216
|
+
if (!st) {
|
|
217
|
+
if (mustExist) throw new RetentionError('root', `canonical root ${root} does not exist`)
|
|
218
|
+
return { repo: canonical, root }
|
|
219
|
+
}
|
|
220
|
+
if (st.isSymbolicLink()) throw new RetentionError('symlink', `${root} is a symbolic link; the canonical root must be a real directory`)
|
|
221
|
+
if (!st.isDirectory()) throw new RetentionError('root', `${root} is not a directory`)
|
|
222
|
+
return { repo: canonical, root }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A path segment from a resolved base down to a target may not be a symlink. Missing
|
|
226
|
+
// trailing segments are fine (the tree is created later), but an unreadable segment is
|
|
227
|
+
// uncertainty and is refused.
|
|
228
|
+
function symlinkOnPath(base, target) {
|
|
229
|
+
const rel = relative(base, target)
|
|
230
|
+
if (rel === '' ) return false
|
|
231
|
+
if (rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) return true
|
|
232
|
+
let cur = base
|
|
233
|
+
for (const part of rel.split(sep)) {
|
|
234
|
+
cur = join(cur, part)
|
|
235
|
+
let st
|
|
236
|
+
try { st = lstatSync(cur) } catch (error) {
|
|
237
|
+
if (error?.code === 'ENOENT') return false
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
if (st.isSymbolicLink()) return true
|
|
241
|
+
}
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function strictInside(parent, child) {
|
|
246
|
+
const p = normPath(parent)
|
|
247
|
+
const c = normPath(child)
|
|
248
|
+
return Boolean(p && c && p !== c && containsPath(p, c))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function canonicalExistingDir(path, label) {
|
|
252
|
+
const abs = resolve(path)
|
|
253
|
+
const st = lstatOrNull(abs)
|
|
254
|
+
if (!st) throw new RetentionError('missing', `${label} ${abs} does not exist`)
|
|
255
|
+
if (st.isSymbolicLink()) throw new RetentionError('symlink', `${label} ${abs} is a symbolic link`)
|
|
256
|
+
if (!st.isDirectory()) throw new RetentionError('not-directory', `${label} ${abs} is not a directory`)
|
|
257
|
+
return real(abs)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- bounded tree inspection ----
|
|
261
|
+
|
|
262
|
+
function allocation(st) {
|
|
263
|
+
if (Number.isFinite(st.blocks) && st.blocks > 0) return st.blocks * 512
|
|
264
|
+
return Number.isFinite(st.size) ? st.size : 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Streams one file through a fixed buffer, so nothing scales with the file size. The size
|
|
268
|
+
// seen by lstat is the exact expected length: a short read, one readable byte past that
|
|
269
|
+
// length (a growth probe), or a differing final stat (identity, size, mtime, ctime) is a
|
|
270
|
+
// change, and a changed file is never removed.
|
|
271
|
+
function hashFile(path, st, { remaining, maxFileBytes }) {
|
|
272
|
+
if (st.size > maxFileBytes) {
|
|
273
|
+
return { error: `${path} is ${st.size} bytes, beyond the ${maxFileBytes}-byte per-file snapshot bound` }
|
|
274
|
+
}
|
|
275
|
+
if (st.size > remaining) return { hash: null, read: 0, truncated: true }
|
|
276
|
+
let fd
|
|
277
|
+
try {
|
|
278
|
+
fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0))
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return { error: `cannot open ${path}: ${error?.code ?? error?.message}` }
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const opened = fstatSync(fd)
|
|
284
|
+
if (!opened.isFile() || opened.dev !== st.dev || opened.ino !== st.ino || opened.size !== st.size) {
|
|
285
|
+
return { error: `${path} changed while it was being snapshotted` }
|
|
286
|
+
}
|
|
287
|
+
const digest = createHash('sha256')
|
|
288
|
+
const buffer = Buffer.allocUnsafe(Math.min(HASH_CHUNK_BYTES, Math.max(1, st.size)))
|
|
289
|
+
let read = 0
|
|
290
|
+
while (read < st.size) {
|
|
291
|
+
const want = Math.min(buffer.length, st.size - read)
|
|
292
|
+
let got
|
|
293
|
+
try { got = readSync(fd, buffer, 0, want, read) } catch (error) {
|
|
294
|
+
return { error: `cannot read ${path}: ${error?.code ?? error?.message}` }
|
|
295
|
+
}
|
|
296
|
+
if (got === 0) return { error: `${path} shrank while it was being snapshotted` }
|
|
297
|
+
digest.update(buffer.subarray(0, got))
|
|
298
|
+
read += got
|
|
299
|
+
}
|
|
300
|
+
let growth
|
|
301
|
+
try { growth = readSync(fd, buffer, 0, 1, st.size) } catch (error) {
|
|
302
|
+
return { error: `cannot probe ${path}: ${error?.code ?? error?.message}` }
|
|
303
|
+
}
|
|
304
|
+
if (growth !== 0) return { error: `${path} grew while it was being snapshotted` }
|
|
305
|
+
const final = fstatSync(fd)
|
|
306
|
+
if (!final.isFile() || final.dev !== st.dev || final.ino !== st.ino || final.size !== st.size
|
|
307
|
+
|| final.mtimeMs !== st.mtimeMs || final.ctimeMs !== st.ctimeMs) {
|
|
308
|
+
return { error: `${path} changed while it was being snapshotted` }
|
|
309
|
+
}
|
|
310
|
+
return { hash: digest.digest('hex'), read, truncated: false }
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return { error: `cannot snapshot ${path}: ${error?.code ?? error?.message}` }
|
|
313
|
+
} finally {
|
|
314
|
+
try { closeSync(fd) } catch { /* already closed */ }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Reads with lstat only (never stat/follow), sorts names for a deterministic digest,
|
|
319
|
+
// and stops on any bound. Any truncation or unreadable entry is a refusal, not a guess.
|
|
320
|
+
export function inspectTree(rootInput, options = {}) {
|
|
321
|
+
const maxEntries = options.maxEntries ?? 100000
|
|
322
|
+
const maxDepth = options.maxDepth ?? 64
|
|
323
|
+
const maxHashBytes = options.maxHashBytes ?? MAX_TOTAL_HASH_BYTES
|
|
324
|
+
const maxFileHashBytes = options.maxFileHashBytes ?? MAX_FILE_HASH_BYTES
|
|
325
|
+
const hashContent = options.hashContent !== false
|
|
326
|
+
const result = {
|
|
327
|
+
ok: false, reason: null, truncated: false, entries: 0, files: 0, dirs: 0, symlinks: 0,
|
|
328
|
+
allocatedBytes: 0, newestMtimeMs: null, rootMtimeMs: null, rootIdentity: null,
|
|
329
|
+
digest: null, metadataNames: [], credentialPaths: [], nestedGit: [], externalSymlinks: [],
|
|
330
|
+
special: [], containingWorkspace: null,
|
|
331
|
+
}
|
|
332
|
+
const input = resolve(rootInput)
|
|
333
|
+
let rootStat
|
|
334
|
+
try { rootStat = lstatSync(input) } catch (error) {
|
|
335
|
+
result.reason = `cannot read ${input}: ${error?.code ?? error?.message}`
|
|
336
|
+
return result
|
|
337
|
+
}
|
|
338
|
+
if (rootStat.isSymbolicLink()) { result.reason = `${input} is a symbolic link`; return result }
|
|
339
|
+
let root
|
|
340
|
+
try { root = real(input) } catch (error) {
|
|
341
|
+
result.reason = `cannot resolve ${input}: ${error?.code ?? error?.message}`
|
|
342
|
+
return result
|
|
343
|
+
}
|
|
344
|
+
if (!rootStat.isDirectory()) { result.reason = `${root} is not a directory`; return result }
|
|
345
|
+
result.rootMtimeMs = rootStat.mtimeMs
|
|
346
|
+
result.rootIdentity = { dev: rootStat.dev, ino: rootStat.ino }
|
|
347
|
+
const digest = createHash('sha256')
|
|
348
|
+
digest.update(`root\0${rootStat.dev}\0${rootStat.ino}\0${rootStat.mode}\0${rootStat.mtimeMs}\n`)
|
|
349
|
+
let hashedBytes = 0
|
|
350
|
+
let newest = null
|
|
351
|
+
|
|
352
|
+
const stack = [{ abs: root, rel: '', depth: 0 }]
|
|
353
|
+
while (stack.length) {
|
|
354
|
+
const item = stack.pop()
|
|
355
|
+
let entries
|
|
356
|
+
try {
|
|
357
|
+
entries = readdirSync(item.abs, { withFileTypes: true })
|
|
358
|
+
.slice()
|
|
359
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
360
|
+
} catch (error) {
|
|
361
|
+
result.reason = `cannot read ${item.abs}: ${error?.code ?? error?.message}`
|
|
362
|
+
return result
|
|
363
|
+
}
|
|
364
|
+
for (const entry of entries) {
|
|
365
|
+
if (result.entries++ >= maxEntries) { result.truncated = true; result.reason = `tree exceeds the ${maxEntries}-entry bound`; return result }
|
|
366
|
+
const abs = join(item.abs, entry.name)
|
|
367
|
+
const rel = item.rel ? `${item.rel}/${entry.name}` : entry.name
|
|
368
|
+
let st
|
|
369
|
+
try { st = lstatSync(abs) } catch (error) {
|
|
370
|
+
result.reason = `cannot inspect ${abs}: ${error?.code ?? error?.message}`
|
|
371
|
+
return result
|
|
372
|
+
}
|
|
373
|
+
result.allocatedBytes += allocation(st)
|
|
374
|
+
digest.update(`${rel}\0${st.dev}\0${st.ino}\0${st.mode}\0${st.size}\0${st.mtimeMs}\n`)
|
|
375
|
+
if (result.credentialPaths.length < MAX_REPORTED_CREDENTIALS && credentialName(entry.name, st.isDirectory())) {
|
|
376
|
+
result.credentialPaths.push(rel)
|
|
377
|
+
}
|
|
378
|
+
if (METADATA_NAMES.has(entry.name)) result.metadataNames.push(rel)
|
|
379
|
+
if (st.isSymbolicLink()) {
|
|
380
|
+
result.symlinks += 1
|
|
381
|
+
if (st.mtimeMs > (newest ?? 0)) newest = st.mtimeMs
|
|
382
|
+
let link = null
|
|
383
|
+
try { link = readlinkSync(abs) } catch (error) {
|
|
384
|
+
result.reason = `cannot read symbolic link ${abs}: ${error?.code ?? error?.message}`
|
|
385
|
+
return result
|
|
386
|
+
}
|
|
387
|
+
digest.update(`link\0${rel}\0${link}\n`)
|
|
388
|
+
const candidate = isAbsolute(link) ? resolve(link) : resolve(dirname(abs), link)
|
|
389
|
+
let outside = !(containsPath(root, candidate) || normPath(candidate) === root)
|
|
390
|
+
if (!outside) {
|
|
391
|
+
try {
|
|
392
|
+
const target = real(abs)
|
|
393
|
+
outside = !(containsPath(root, target) || normPath(target) === root)
|
|
394
|
+
} catch { /* a dangling link is unlinked, never followed */ }
|
|
395
|
+
}
|
|
396
|
+
if (outside) result.externalSymlinks.push({ path: abs, target: link })
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
if (st.isDirectory()) {
|
|
400
|
+
result.dirs += 1
|
|
401
|
+
if (item.depth + 1 > maxDepth) { result.truncated = true; result.reason = `tree exceeds the ${maxDepth}-level depth bound`; return result }
|
|
402
|
+
if (entry.name === '.git') result.nestedGit.push(rel)
|
|
403
|
+
const bare = existsSync(join(abs, 'HEAD')) && existsSync(join(abs, 'objects')) && existsSync(join(abs, 'refs'))
|
|
404
|
+
if (bare) result.nestedGit.push(rel)
|
|
405
|
+
stack.push({ abs, rel, depth: item.depth + 1 })
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
if (entry.name === '.git') result.nestedGit.push(rel)
|
|
409
|
+
if (!st.isFile()) { result.special.push(rel); continue }
|
|
410
|
+
result.files += 1
|
|
411
|
+
if (st.mtimeMs > (newest ?? 0)) newest = st.mtimeMs
|
|
412
|
+
if (hashContent) {
|
|
413
|
+
const hashed = hashFile(abs, st, { remaining: maxHashBytes - hashedBytes, maxFileBytes: maxFileHashBytes })
|
|
414
|
+
if (hashed.error) { result.reason = hashed.error; return result }
|
|
415
|
+
if (hashed.truncated) {
|
|
416
|
+
result.truncated = true
|
|
417
|
+
result.reason = `content snapshot exceeds the ${maxHashBytes}-byte bound`
|
|
418
|
+
return result
|
|
419
|
+
}
|
|
420
|
+
hashedBytes += hashed.read
|
|
421
|
+
digest.update(`content\0${rel}\0${hashed.hash}\n`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
result.digest = digest.digest('hex')
|
|
426
|
+
result.newestMtimeMs = newest ?? rootStat.mtimeMs
|
|
427
|
+
result.ok = true
|
|
428
|
+
return result
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function snapshotOf(inspection) {
|
|
432
|
+
return {
|
|
433
|
+
digest: inspection.digest,
|
|
434
|
+
entries: inspection.entries,
|
|
435
|
+
files: inspection.files,
|
|
436
|
+
allocatedBytes: inspection.allocatedBytes,
|
|
437
|
+
newestMtimeMs: inspection.newestMtimeMs,
|
|
438
|
+
root: inspection.rootIdentity,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function sameSnapshot(a, b) {
|
|
443
|
+
if (!isObject(a) || !isObject(b)) return false
|
|
444
|
+
return a.digest === b.digest && a.entries === b.entries && a.files === b.files
|
|
445
|
+
&& a.allocatedBytes === b.allocatedBytes && a.newestMtimeMs === b.newestMtimeMs
|
|
446
|
+
&& a.root?.dev === b.root?.dev && a.root?.ino === b.root?.ino
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---- Git proof ----
|
|
450
|
+
|
|
451
|
+
function git(dir, args) {
|
|
452
|
+
try {
|
|
453
|
+
const out = execFileSync('git', ['-C', dir, ...args], {
|
|
454
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000, maxBuffer: 8 * 1024 * 1024,
|
|
455
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' },
|
|
456
|
+
})
|
|
457
|
+
return { ok: true, out: String(out) }
|
|
458
|
+
} catch (error) {
|
|
459
|
+
return { ok: false, err: String(error?.stderr ?? error?.message ?? '') }
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The resource must live inside a real Git workspace and contain no tracked path.
|
|
464
|
+
// If no containing workspace can be found, trackedness cannot be proved, so the
|
|
465
|
+
// resource is refused rather than assumed disposable.
|
|
466
|
+
function trackedProof(target) {
|
|
467
|
+
const top = git(target, ['rev-parse', '--show-toplevel'])
|
|
468
|
+
if (!top.ok || !nonEmpty(top.out.trim())) {
|
|
469
|
+
return { ok: false, reason: `${target} is not inside a readable Git workspace, so untracked content cannot be proved` }
|
|
470
|
+
}
|
|
471
|
+
let toplevel
|
|
472
|
+
try { toplevel = real(top.out.trim()) } catch (error) {
|
|
473
|
+
return { ok: false, reason: `cannot resolve the containing Git workspace of ${target}: ${error?.code ?? error?.message}` }
|
|
474
|
+
}
|
|
475
|
+
let targetReal
|
|
476
|
+
try { targetReal = real(target) } catch (error) {
|
|
477
|
+
return { ok: false, reason: `cannot resolve ${target}: ${error?.code ?? error?.message}` }
|
|
478
|
+
}
|
|
479
|
+
if (!containsPath(toplevel, targetReal)) return { ok: false, reason: `${target} is outside its containing Git workspace ${toplevel}` }
|
|
480
|
+
const rel = relative(toplevel, targetReal)
|
|
481
|
+
const tracked = git(toplevel, ['ls-files', '-z', '--', rel])
|
|
482
|
+
if (!tracked.ok) return { ok: false, reason: `cannot list tracked paths under ${target}` }
|
|
483
|
+
const count = tracked.out.split('\0').filter(Boolean).length
|
|
484
|
+
if (count) return { ok: false, reason: `${count} tracked path(s) live inside ${target}; tracked source is never disposable` }
|
|
485
|
+
return { ok: true, toplevel, relative: rel }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- manifest ----
|
|
489
|
+
|
|
490
|
+
function manifestPath(root) {
|
|
491
|
+
return join(root, MANIFEST_FILE)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function readManifest(root) {
|
|
495
|
+
const path = manifestPath(root)
|
|
496
|
+
const st = lstatOrNull(path)
|
|
497
|
+
if (!st) return { version: MANIFEST_VERSION, entries: [] }
|
|
498
|
+
if (st.isSymbolicLink()) throw new RetentionError('symlink', `${path} is a symbolic link`)
|
|
499
|
+
if (!st.isFile()) throw new RetentionError('manifest', `${path} is not a regular file`)
|
|
500
|
+
if (st.size > 8 * 1024 * 1024) throw new RetentionError('manifest', `${path} exceeds the 8 MiB manifest bound`)
|
|
501
|
+
let parsed
|
|
502
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')) } catch (error) {
|
|
503
|
+
throw new RetentionError('manifest', `${path} is not readable JSON: ${error.message}`)
|
|
504
|
+
}
|
|
505
|
+
if (!isObject(parsed) || parsed.version !== MANIFEST_VERSION || !Array.isArray(parsed.entries)) {
|
|
506
|
+
throw new RetentionError('manifest', `${path} must be {"version": 1, "entries": [...]}`)
|
|
507
|
+
}
|
|
508
|
+
return parsed
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function writeManifest(root, manifest) {
|
|
512
|
+
const path = manifestPath(root)
|
|
513
|
+
const st = lstatOrNull(path)
|
|
514
|
+
if (st?.isSymbolicLink()) throw new RetentionError('symlink', `${path} is a symbolic link`)
|
|
515
|
+
const temporary = join(root, `.${MANIFEST_FILE}.${process.pid}.${Date.now()}.tmp`)
|
|
516
|
+
try {
|
|
517
|
+
writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
|
|
518
|
+
renameSync(temporary, path)
|
|
519
|
+
} catch (error) {
|
|
520
|
+
try { unlinkSync(temporary) } catch { /* absent */ }
|
|
521
|
+
throw new RetentionError('io', `cannot write ${path}: ${error?.code ?? error?.message}`)
|
|
522
|
+
}
|
|
523
|
+
return path
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---- locking ----
|
|
527
|
+
|
|
528
|
+
// The lock contract (strong owner record, liveness proof, atomic same-parent quarantine)
|
|
529
|
+
// lives in retention-lock.mjs. What retention adds is one policy: before taking the lock it
|
|
530
|
+
// may clear a lock whose recorded owner is provably dead on this host, because an
|
|
531
|
+
// interrupted apply leaves that lock behind and the lock is never reclaimed by age. Live,
|
|
532
|
+
// foreign, legacy, unreadable and ambiguous locks are kept, as is every receipt and
|
|
533
|
+
// quarantined tree the interrupted apply wrote: releasing a dead lock never claims that
|
|
534
|
+
// the apply finished.
|
|
535
|
+
function recoverDeadLock(root) {
|
|
536
|
+
let inspection
|
|
537
|
+
try {
|
|
538
|
+
inspection = inspectRetentionLock(root)
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (error instanceof RetentionLockError) throw new RetentionError(error.code === 'usage' ? 'usage' : 'lock', error.message)
|
|
541
|
+
throw error
|
|
542
|
+
}
|
|
543
|
+
if (!inspection.present || !inspection.recoverable) return null
|
|
544
|
+
let recovery
|
|
545
|
+
try {
|
|
546
|
+
recovery = recoverRetentionLock(root, { apply: true })
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error instanceof RetentionLockError) throw new RetentionError('lock', error.message)
|
|
549
|
+
throw error
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
status: recovery.status,
|
|
553
|
+
action: recovery.action,
|
|
554
|
+
reason: recovery.reason,
|
|
555
|
+
cleared: recovery.cleared,
|
|
556
|
+
owner: inspection.owner,
|
|
557
|
+
pendingQuarantines: recovery.quarantines,
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function acquireLock(root) {
|
|
562
|
+
const dir = join(root, LOCK_NAME)
|
|
563
|
+
if (symlinkOnPath(root, dir)) throw new RetentionError('symlink', `${dir} passes through a symbolic link`)
|
|
564
|
+
const recovery = recoverDeadLock(root)
|
|
565
|
+
let lock
|
|
566
|
+
try {
|
|
567
|
+
lock = acquireRetentionLock(root)
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (error instanceof RetentionLockError) {
|
|
570
|
+
const code = error.code === 'locked' ? 'locked' : error.code === 'usage' ? 'usage' : 'io'
|
|
571
|
+
throw new RetentionError(code, error.message)
|
|
572
|
+
}
|
|
573
|
+
throw error
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
path: lock.path,
|
|
577
|
+
owner: lock.owner,
|
|
578
|
+
recovery,
|
|
579
|
+
release() {
|
|
580
|
+
try { return lock.release() } catch { return { released: false, reason: 'release-failed' } }
|
|
581
|
+
},
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---- plans, owners, evidence ----
|
|
586
|
+
|
|
587
|
+
function readPlanFile(path) {
|
|
588
|
+
const st = lstatOrNull(path)
|
|
589
|
+
if (!st || st.isSymbolicLink() || !st.isFile() || st.size > 4 * 1024 * 1024) return null
|
|
590
|
+
try {
|
|
591
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
592
|
+
return isObject(parsed) ? parsed : null
|
|
593
|
+
} catch { return null }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function unitOfPlan(plan, unitId) {
|
|
597
|
+
const rows = [...(Array.isArray(plan?.units) ? plan.units : []), ...(Array.isArray(plan?.issues) ? plan.issues : [])]
|
|
598
|
+
return rows.find((row) => isObject(row) && String(row.id) === String(unitId)) ?? null
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function deliveryMerged(unit) {
|
|
602
|
+
const delivery = unit?.delivery
|
|
603
|
+
if (delivery === 'merged') return true
|
|
604
|
+
if (isObject(delivery)) return delivery.outcome === 'merged' || delivery.state === 'merged' || delivery.status === 'merged'
|
|
605
|
+
return false
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function unitCompleted(unit) {
|
|
609
|
+
return unit?.status === 'succeeded' && deliveryMerged(unit)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function declaredResources(unit, run) {
|
|
613
|
+
const raw = unit?.lifecycle?.resources
|
|
614
|
+
if (!Array.isArray(raw)) return []
|
|
615
|
+
return raw.filter((r) => isObject(r) && nonEmpty(r.path))
|
|
616
|
+
.map((r) => (isAbsolute(r.path) ? resolve(r.path) : resolve(run, r.path)))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Resolve as far as the filesystem allows. A required-evidence path that has not been
|
|
620
|
+
// written yet still names a tree: its deepest existing ancestor is canonicalized and the
|
|
621
|
+
// missing tail is appended lexically, so a link spelling cannot hide an overlap with the
|
|
622
|
+
// target it really points into. Non-absence failures are uncertainty (null).
|
|
623
|
+
function canonicalResolve(p) {
|
|
624
|
+
let current = normPath(resolve(p))
|
|
625
|
+
const missing = []
|
|
626
|
+
for (;;) {
|
|
627
|
+
try {
|
|
628
|
+
const base = normPath(real(current))
|
|
629
|
+
return missing.length ? normPath(join(base, ...missing.slice().reverse())) : base
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') return null
|
|
632
|
+
}
|
|
633
|
+
const parent = dirname(current)
|
|
634
|
+
if (parent === current) return normPath(resolve(p))
|
|
635
|
+
missing.push(basename(current))
|
|
636
|
+
current = parent
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// A path may be spelled lexically or reached through a link. Both forms are compared, so
|
|
641
|
+
// no claim can be dodged by an alternate spelling. A path that cannot be resolved for a
|
|
642
|
+
// reason other than absence is uncertainty, and uncertainty never authorizes a deletion.
|
|
643
|
+
function pathForms(p) {
|
|
644
|
+
const lexical = normPath(resolve(p))
|
|
645
|
+
if (!lexical) return null
|
|
646
|
+
const forms = [lexical]
|
|
647
|
+
const canonical = canonicalResolve(lexical)
|
|
648
|
+
if (canonical === null) return null
|
|
649
|
+
if (canonical && canonical !== lexical) forms.push(canonical)
|
|
650
|
+
return forms
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function formsOverlap(left, right) {
|
|
654
|
+
return left.some((a) => right.some((b) => containsPath(a, b) || containsPath(b, a)))
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Another unit or run claiming the same tree as its own workspace or resource makes this
|
|
658
|
+
// target ambiguous ownership, not a candidate. Only real claims count: a run directory or a
|
|
659
|
+
// shared resource container is not a claim unless a plan declares it as one.
|
|
660
|
+
function claimConflict(target, claims, owner) {
|
|
661
|
+
if (!isObject(claims) || !claims.complete) {
|
|
662
|
+
return { status: 'retained', reason: 'the bounded workspace/resource claim scan did not finish, so overlapping ownership cannot be ruled out' }
|
|
663
|
+
}
|
|
664
|
+
const forms = pathForms(target)
|
|
665
|
+
if (!forms) return { status: 'retained', reason: `the ownership forms of ${target} cannot be resolved` }
|
|
666
|
+
for (const claim of claims.list) {
|
|
667
|
+
if (claim.run === owner.run && claim.unit === String(owner.unit)) continue
|
|
668
|
+
if (formsOverlap(forms, claim.forms)) {
|
|
669
|
+
return {
|
|
670
|
+
status: 'refused',
|
|
671
|
+
reason: `${claim.label} declares ${claim.kind} ${claim.path}, which overlaps ${target}; a tree another unit or run claims is never removed`,
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return null
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Every path a plan declares as required evidence (or a required checkpoint or pin), plus
|
|
679
|
+
// every workspace and declared resource that names an owner. One bounded walk of the
|
|
680
|
+
// canonical root produces both, and each proof keeps its own completeness: an unreadable
|
|
681
|
+
// evidence declaration says nothing about who owns a tree, and claiming otherwise would
|
|
682
|
+
// report the wrong uncertainty. Either one incomplete never authorizes a deletion.
|
|
683
|
+
function planIndex(root, bounds = {}) {
|
|
684
|
+
const locations = retentionPlanFiles(root, { maxPlans: bounds.maxPlans ?? 200, maxEntries: bounds.maxEvidenceEntries ?? 20000 })
|
|
685
|
+
const maxClaims = bounds.maxClaims ?? 20000
|
|
686
|
+
const evidence = []
|
|
687
|
+
const claims = []
|
|
688
|
+
let evidenceComplete = locations.complete
|
|
689
|
+
let claimsComplete = locations.complete
|
|
690
|
+
const collectEvidence = (raw, run) => {
|
|
691
|
+
if (raw === undefined) return
|
|
692
|
+
if (!Array.isArray(raw)) { evidenceComplete = false; return }
|
|
693
|
+
for (const entry of raw) {
|
|
694
|
+
if (typeof entry === 'string') {
|
|
695
|
+
if (!nonEmpty(entry)) { evidenceComplete = false; continue }
|
|
696
|
+
evidence.push({ path: resolve(run, entry), run })
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
// An entry that names no readable path, or whose `required` is not a boolean, is a
|
|
700
|
+
// declaration nobody can resolve. Dropping it silently is how a required path became
|
|
701
|
+
// an authorized deletion, so a malformed entry makes the whole scan incomplete.
|
|
702
|
+
// Only an entry that says `required: false` is knowingly optional, and it protects
|
|
703
|
+
// nothing: the caller chose that.
|
|
704
|
+
if (!isObject(entry) || !nonEmpty(entry.path)
|
|
705
|
+
|| (entry.required !== undefined && typeof entry.required !== 'boolean')) {
|
|
706
|
+
evidenceComplete = false
|
|
707
|
+
continue
|
|
708
|
+
}
|
|
709
|
+
if (entry.required === false) continue
|
|
710
|
+
evidence.push({ path: resolve(run, entry.path), run })
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// A unit and the plan itself declare evidence, pins and checkpoints in the same four
|
|
714
|
+
// slots, plus lifecycle.cleanup.evidence. Every slot is read the same way, so a path is
|
|
715
|
+
// protected wherever the run recorded it, including at the root of the plan.
|
|
716
|
+
const collectDeclarations = (scope, run) => {
|
|
717
|
+
if (!isObject(scope)) return
|
|
718
|
+
for (const key of EVIDENCE_KEYS) {
|
|
719
|
+
collectEvidence(scope[key], run)
|
|
720
|
+
collectEvidence(scope.lifecycle?.[key], run)
|
|
721
|
+
}
|
|
722
|
+
collectEvidence(scope.lifecycle?.cleanup?.evidence, run)
|
|
723
|
+
}
|
|
724
|
+
const collectClaim = (run, unitId, raw, kind) => {
|
|
725
|
+
if (raw === undefined) return
|
|
726
|
+
if (!nonEmpty(raw)) { claimsComplete = false; return }
|
|
727
|
+
const lexical = isAbsolute(raw) ? resolve(raw) : resolve(run, raw)
|
|
728
|
+
const forms = pathForms(lexical)
|
|
729
|
+
if (!forms || claims.length >= maxClaims) { claimsComplete = false; return }
|
|
730
|
+
claims.push({ run, unit: String(unitId), kind, path: lexical, forms, label: `unit ${unitId} of ${run}` })
|
|
731
|
+
}
|
|
732
|
+
const readPlan = (dir) => {
|
|
733
|
+
const abs = join(dir, 'task-plan.json')
|
|
734
|
+
if (!existsSync(abs)) return
|
|
735
|
+
const plan = readPlanFile(abs)
|
|
736
|
+
if (!plan) { evidenceComplete = false; claimsComplete = false; return }
|
|
737
|
+
if (plan.units !== undefined && !Array.isArray(plan.units)) { evidenceComplete = false; claimsComplete = false; return }
|
|
738
|
+
if (plan.issues !== undefined && !Array.isArray(plan.issues)) { evidenceComplete = false; claimsComplete = false; return }
|
|
739
|
+
collectDeclarations(plan, dir)
|
|
740
|
+
for (const unit of [...(Array.isArray(plan.units) ? plan.units : []), ...(Array.isArray(plan.issues) ? plan.issues : [])]) {
|
|
741
|
+
if (!isObject(unit)) { evidenceComplete = false; claimsComplete = false; continue }
|
|
742
|
+
collectDeclarations(unit, dir)
|
|
743
|
+
if (!nonEmpty(unit.id) && !Number.isSafeInteger(unit.id)) { claimsComplete = false; continue }
|
|
744
|
+
// A null workspace explicitly means no allocation in legacy pending units.
|
|
745
|
+
// A non-null malformed path remains uncertainty and blocks the scan.
|
|
746
|
+
if (unit.workspace !== null) collectClaim(dir, unit.id, unit.workspace, 'workspace')
|
|
747
|
+
const resources = unit.lifecycle?.resources === undefined ? unit.resources : unit.lifecycle.resources
|
|
748
|
+
if (resources !== undefined && !Array.isArray(resources)) { claimsComplete = false; continue }
|
|
749
|
+
for (const resource of Array.isArray(resources) ? resources : []) {
|
|
750
|
+
if (!isObject(resource)) { claimsComplete = false; continue }
|
|
751
|
+
collectClaim(dir, unit.id, resource.path, 'resource')
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const path of locations.paths) readPlan(dirname(path))
|
|
756
|
+
return { evidence: { paths: evidence, complete: evidenceComplete }, claims: { list: claims, complete: claimsComplete } }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function requiredEvidenceOverlap(target, index) {
|
|
760
|
+
if (!index.complete) return `the bounded evidence scan did not finish, so required-evidence overlap cannot be ruled out`
|
|
761
|
+
const targetForms = pathForms(target)
|
|
762
|
+
if (!targetForms) return `the canonical forms of ${target} cannot be resolved, so required-evidence overlap cannot be ruled out`
|
|
763
|
+
for (const evidence of index.paths) {
|
|
764
|
+
const evidenceForms = pathForms(evidence.path)
|
|
765
|
+
if (!evidenceForms) return `required evidence ${evidence.path} (declared by ${evidence.run}) cannot be resolved, so overlap with ${target} cannot be ruled out`
|
|
766
|
+
if (formsOverlap(targetForms, evidenceForms)) {
|
|
767
|
+
return `required evidence ${evidence.path} (declared by ${evidence.run}) overlaps ${target}`
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return null
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function runDirs(root, bounds = {}) {
|
|
774
|
+
const limit = bounds.maxRuns ?? 100
|
|
775
|
+
const runs = []
|
|
776
|
+
const add = (path) => {
|
|
777
|
+
if (runs.length >= limit) return
|
|
778
|
+
const st = lstatOrNull(path)
|
|
779
|
+
if (!st || st.isSymbolicLink() || !st.isDirectory()) return
|
|
780
|
+
runs.push(path)
|
|
781
|
+
}
|
|
782
|
+
if (!existsSync(root)) return runs
|
|
783
|
+
const rush = join(root, 'rush')
|
|
784
|
+
if (existsSync(rush) && !lstatSync(rush).isSymbolicLink()) {
|
|
785
|
+
for (const entry of readdirSync(rush, { withFileTypes: true })) {
|
|
786
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) add(join(rush, entry.name))
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
790
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
|
791
|
+
if (!entry.name.endsWith('-workspaces')) continue
|
|
792
|
+
const legacy = join(root, entry.name)
|
|
793
|
+
let children = []
|
|
794
|
+
try {
|
|
795
|
+
children = readdirSync(legacy, { withFileTypes: true })
|
|
796
|
+
.filter((child) => child.isDirectory() && !child.isSymbolicLink())
|
|
797
|
+
.map((child) => join(legacy, child.name))
|
|
798
|
+
} catch { /* an unreadable legacy container is inventory-only */ }
|
|
799
|
+
if (children.length) for (const child of children) add(child)
|
|
800
|
+
else add(legacy)
|
|
801
|
+
}
|
|
802
|
+
return runs
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function inventory(root, bounds) {
|
|
806
|
+
const runs = []
|
|
807
|
+
let complete = true
|
|
808
|
+
if (!existsSync(root)) return { runs, complete, totalBytes: 0 }
|
|
809
|
+
for (const run of runDirs(root, bounds)) {
|
|
810
|
+
const scan = inspectTree(run, {
|
|
811
|
+
maxEntries: bounds.maxInventoryEntries ?? 20000,
|
|
812
|
+
maxDepth: bounds.maxDepth ?? 64,
|
|
813
|
+
hashContent: false,
|
|
814
|
+
})
|
|
815
|
+
if (!scan.ok) complete = false
|
|
816
|
+
runs.push({
|
|
817
|
+
run, bytes: scan.allocatedBytes, entries: scan.entries,
|
|
818
|
+
truncated: scan.truncated, readable: scan.ok, reason: scan.reason,
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
runs.sort((a, b) => b.bytes - a.bytes)
|
|
822
|
+
const resources = []
|
|
823
|
+
const maxResources = bounds.maxInventoryResources ?? 100
|
|
824
|
+
const addResource = (path, ownerRun) => {
|
|
825
|
+
if (resources.length >= maxResources) return
|
|
826
|
+
const st = lstatOrNull(path)
|
|
827
|
+
if (!st || st.isSymbolicLink() || !st.isDirectory()) return
|
|
828
|
+
const scan = inspectTree(path, {
|
|
829
|
+
maxEntries: bounds.maxInventoryEntries ?? 20000,
|
|
830
|
+
maxDepth: bounds.maxDepth ?? 64,
|
|
831
|
+
hashContent: false,
|
|
832
|
+
})
|
|
833
|
+
if (!scan.ok) complete = false
|
|
834
|
+
resources.push({ path, run: ownerRun, bytes: scan.allocatedBytes, entries: scan.entries, truncated: scan.truncated })
|
|
835
|
+
}
|
|
836
|
+
const addTwoLevels = (base, depth, ownerRun) => {
|
|
837
|
+
let entries
|
|
838
|
+
try { entries = readdirSync(base, { withFileTypes: true }) } catch { return }
|
|
839
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
840
|
+
if (resources.length >= maxResources) return
|
|
841
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
|
842
|
+
const abs = join(base, entry.name)
|
|
843
|
+
if (depth > 1) addTwoLevels(abs, depth - 1, ownerRun)
|
|
844
|
+
else addResource(abs, ownerRun)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
for (const run of runs) {
|
|
848
|
+
if (resources.length >= maxResources) break
|
|
849
|
+
addTwoLevels(join(run.run, 'resources'), 2, run.run)
|
|
850
|
+
let reviewUnits = []
|
|
851
|
+
try { reviewUnits = readdirSync(join(run.run, 'reviews'), { withFileTypes: true }) } catch { /* none */ }
|
|
852
|
+
for (const unit of reviewUnits) {
|
|
853
|
+
if (!unit.isDirectory() || unit.isSymbolicLink()) continue
|
|
854
|
+
addResource(join(run.run, 'reviews', unit.name, 'clone'), run.run)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
resources.sort((a, b) => b.bytes - a.bytes)
|
|
858
|
+
return {
|
|
859
|
+
runs, resources, complete,
|
|
860
|
+
totalBytes: runs.reduce((sum, r) => sum + r.bytes, 0),
|
|
861
|
+
totalResourceBytes: resources.reduce((sum, r) => sum + r.bytes, 0),
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ---- entry validation and ownership ----
|
|
866
|
+
|
|
867
|
+
// A recorded rebuild input proof is a small, bounded record: the canonical file's name and
|
|
868
|
+
// absolute path, the content hash that was proven, and the filesystem identity it was
|
|
869
|
+
// bound to. Anything else is a claim no apply pass can recheck, so it is refused by shape
|
|
870
|
+
// rather than trusted.
|
|
871
|
+
function inputProofProblem(proof, index) {
|
|
872
|
+
const where = `inputProofs[${index}]`
|
|
873
|
+
if (!isObject(proof)) return `${where} is not an object`
|
|
874
|
+
if (!nonEmpty(proof.name)) return `${where}.name is missing`
|
|
875
|
+
if (!nonEmpty(proof.path) || !isAbsolute(proof.path)) return `${where}.path must be absolute`
|
|
876
|
+
if (!nonEmpty(proof.hash) || !/^[0-9a-f]{64}$/.test(proof.hash)) return `${where}.hash must be a sha256 hex digest`
|
|
877
|
+
const identity = proof.identity
|
|
878
|
+
if (!isObject(identity) || !safeInt(identity.dev) || !safeInt(identity.ino) || !safeInt(identity.size)) {
|
|
879
|
+
return `${where}.identity must carry integer dev, ino and size`
|
|
880
|
+
}
|
|
881
|
+
return null
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function parseEntry(entry, { forStatus = false } = {}) {
|
|
885
|
+
const problems = []
|
|
886
|
+
if (!isObject(entry)) return { problems: ['entry is not an object'] }
|
|
887
|
+
if (!nonEmpty(entry.id)) problems.push('id is missing')
|
|
888
|
+
if (!nonEmpty(entry.path) || !isAbsolute(entry.path)) problems.push('path must be absolute')
|
|
889
|
+
if (!KINDS.has(entry.kind)) problems.push(`kind ${String(entry.kind)} is not generated|artifact`)
|
|
890
|
+
if (!isObject(entry.owner) || !nonEmpty(entry.owner.run) || !isAbsolute(entry.owner.run) || !nonEmpty(entry.owner.unit)) problems.push('owner must be {run: absolute path, unit: id}')
|
|
891
|
+
if (timestamp(entry.lastUsedAt) === null) problems.push('lastUsedAt must be an ISO timestamp')
|
|
892
|
+
if (typeof entry.disposable !== 'boolean' || (!forStatus && entry.disposable !== true)) problems.push('disposable must be true; the manifest cannot be edited into disposability')
|
|
893
|
+
if (typeof entry.pinned !== 'boolean' || (!forStatus && entry.pinned !== false)) problems.push('pinned must be false; pinned resources are never candidates')
|
|
894
|
+
if (entry.kind === 'generated' && !nonEmpty(entry.recipe)) problems.push('generated resources need a nonempty reconstruction recipe')
|
|
895
|
+
if (entry.kind === 'artifact' && typeof entry.recipe !== 'string') problems.push('artifact recipe must be a string (it may be empty)')
|
|
896
|
+
if (!isObject(entry.identity) || !safeInt(entry.identity.dev) || !safeInt(entry.identity.ino)) problems.push('identity must carry integer dev and ino')
|
|
897
|
+
if (!isObject(entry.authorization) || entry.authorization.source !== 'explicit-registration' || timestamp(entry.authorization.at) === null) problems.push('authorization must be an explicit-registration record with an ISO at')
|
|
898
|
+
if (entry.ownerReleased !== undefined && typeof entry.ownerReleased !== 'boolean') problems.push('ownerReleased must be a boolean when present')
|
|
899
|
+
if (entry.cleanupState !== undefined && !['removed', 'failed'].includes(entry.cleanupState)) problems.push(`cleanupState ${String(entry.cleanupState)} is not removed|failed`)
|
|
900
|
+
// A format is a claim about proof, so an unreadable or unsupported name is refused
|
|
901
|
+
// rather than treated as `generic`: the two carry different checks.
|
|
902
|
+
if (entry.format !== undefined && (!nonEmpty(entry.format) || !FORMATS.has(entry.format))) {
|
|
903
|
+
problems.push(`format ${String(entry.format)} is not ${[...FORMATS].join('|')}`)
|
|
904
|
+
}
|
|
905
|
+
const format = entry.format ?? 'generic'
|
|
906
|
+
if (entry.inputProofs !== undefined) {
|
|
907
|
+
if (!Array.isArray(entry.inputProofs)) problems.push('inputProofs must be an array')
|
|
908
|
+
else if (entry.inputProofs.length === 0) problems.push('inputProofs must record at least one proven rebuild input')
|
|
909
|
+
else if (entry.inputProofs.length > MAX_REBUILD_INPUTS) problems.push(`inputProofs carries more than ${MAX_REBUILD_INPUTS} proofs`)
|
|
910
|
+
else {
|
|
911
|
+
for (const [index, proof] of entry.inputProofs.entries()) {
|
|
912
|
+
const problem = inputProofProblem(proof, index)
|
|
913
|
+
if (problem) { problems.push(problem); break }
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// The stored format and the stored proof must agree. A typed format without retained
|
|
918
|
+
// inputs cannot be rebuilt, and a generic resource carrying them is a proof nobody made.
|
|
919
|
+
if (format !== 'generic' && !Array.isArray(entry.inputProofs)) problems.push(`${format} needs recorded rebuild input proofs`)
|
|
920
|
+
if (format === 'generic' && entry.inputProofs !== undefined) problems.push('a generic resource carries no typed rebuild input proofs')
|
|
921
|
+
return { problems, format }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function ownershipProblem({ root, run, unit, unitId, target, plan }) {
|
|
925
|
+
let runReal, targetReal, rootReal
|
|
926
|
+
try { rootReal = real(root) } catch { return `cannot resolve ${root}` }
|
|
927
|
+
try { runReal = real(run) } catch { return `run ${run} does not exist or cannot be resolved` }
|
|
928
|
+
try { targetReal = real(target) } catch { return `target ${target} does not exist or cannot be resolved` }
|
|
929
|
+
if (!strictInside(rootReal, runReal)) return `run ${run} is not inside the canonical root ${root}`
|
|
930
|
+
if (!strictInside(rootReal, targetReal)) return `target ${target} is not inside the canonical root ${root}`
|
|
931
|
+
if (symlinkOnPath(rootReal, resolve(run))) return `run ${run} passes through a symbolic link`
|
|
932
|
+
if (symlinkOnPath(rootReal, resolve(target))) return `target ${target} passes through a symbolic link`
|
|
933
|
+
if (targetReal === rootReal || targetReal === runReal) return `target ${target} is the root or the run itself`
|
|
934
|
+
const repoRoot = plan?.repoRoot
|
|
935
|
+
if (nonEmpty(repoRoot)) {
|
|
936
|
+
let recorded
|
|
937
|
+
try { recorded = real(repoRoot) } catch { return `the plan's repoRoot ${repoRoot} cannot be resolved` }
|
|
938
|
+
if (recorded !== real(dirname(rootReal))) return `the plan's repoRoot ${repoRoot} is not this repository`
|
|
939
|
+
}
|
|
940
|
+
const expectedWorkspace = join(runReal, 'units', String(unitId))
|
|
941
|
+
let expectedReal
|
|
942
|
+
try { expectedReal = real(expectedWorkspace) } catch { return `the unit workspace ${expectedWorkspace} does not exist or cannot be resolved` }
|
|
943
|
+
if (!nonEmpty(unit.workspace)) return `unit ${unitId} records no workspace`
|
|
944
|
+
let recordedWorkspace
|
|
945
|
+
try { recordedWorkspace = real(unit.workspace) } catch { return `unit ${unitId} workspace ${unit.workspace} cannot be resolved` }
|
|
946
|
+
if (recordedWorkspace !== expectedReal) return `unit ${unitId} workspace ${unit.workspace} is not ${expectedWorkspace}; the plan and the disk disagree`
|
|
947
|
+
if (targetReal === recordedWorkspace) return `target ${target} is the unit workspace itself`
|
|
948
|
+
const resourcesRoot = join(runReal, 'resources', String(unitId))
|
|
949
|
+
const reviewRoot = join(runReal, 'reviews', String(unitId))
|
|
950
|
+
if (targetReal === resourcesRoot || targetReal === reviewRoot) return `target ${target} is a resource/review container, not a leaf resource`
|
|
951
|
+
const declared = declaredResources(unit, runReal).map((p) => {
|
|
952
|
+
try { return real(p) } catch { return resolve(p) }
|
|
953
|
+
})
|
|
954
|
+
if (METADATA_NAMES.has(basename(targetReal))) return `target ${target} is named like run metadata, which is never disposable`
|
|
955
|
+
if (containsPath(recordedWorkspace, targetReal) && targetReal !== recordedWorkspace) return null
|
|
956
|
+
const owners = declared.filter((p) => containsPath(p, targetReal))
|
|
957
|
+
if (owners.length) {
|
|
958
|
+
// A declared resource may be a leaf or a parent of the registered path. Refuse a
|
|
959
|
+
// declared path that itself escapes the run's resource roots.
|
|
960
|
+
if (owners.some((p) => containsPath(resourcesRoot, p) || containsPath(reviewRoot, p) || containsPath(recordedWorkspace, p))) return null
|
|
961
|
+
return `declared resource(s) ${owners.join(', ')} for unit ${unitId} are outside the run's resource roots`
|
|
962
|
+
}
|
|
963
|
+
return `target ${target} is neither inside unit ${unitId}'s workspace nor a declared resource in its task-plan`
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// A typed format that was proven from the same tree inspection is the only authority that
|
|
967
|
+
// can relax two generic guards. It never relaxes the rest: credentials, nested Git,
|
|
968
|
+
// special files and blocking run metadata are engine policy, not format policy, and a
|
|
969
|
+
// resource whose rebuild inputs changed keeps every guard.
|
|
970
|
+
function formatVerdict(entry, target, inspection) {
|
|
971
|
+
const format = nonEmpty(entry.format) ? entry.format : 'generic'
|
|
972
|
+
if (format === 'generic') {
|
|
973
|
+
return {
|
|
974
|
+
format, typed: false, ok: true, state: 'generic', reason: null,
|
|
975
|
+
blockingMetadata: [], allowReferenceSymlinks: false,
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
// The recorded format and its rebuild inputs are re-proved here: the markers must still
|
|
979
|
+
// describe that format, and every recorded input must still be the proven file. A changed
|
|
980
|
+
// input means the artifact is no longer reproducible, so it is retained for rebuild.
|
|
981
|
+
const state = classifyRebuildState({ format, path: target, inputProofs: entry.inputProofs, inspection })
|
|
982
|
+
const names = classifyEmbeddedNames({ format, names: inspection.metadataNames })
|
|
983
|
+
return {
|
|
984
|
+
format,
|
|
985
|
+
typed: true,
|
|
986
|
+
ok: state.ok === true,
|
|
987
|
+
state: state.state,
|
|
988
|
+
reason: state.reason,
|
|
989
|
+
blockingMetadata: names.blocking,
|
|
990
|
+
allowReferenceSymlinks: state.ok === true,
|
|
991
|
+
markers: state.markers,
|
|
992
|
+
inputCheck: state.inputCheck,
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function preservationProblem(inspection, typed = null) {
|
|
997
|
+
const proven = typed?.ok === true && typed?.format !== undefined && typed.format !== 'generic'
|
|
998
|
+
const referenceLinks = proven && typed.allowReferenceSymlinks
|
|
999
|
+
if (!referenceLinks && inspection.externalSymlinks.length) {
|
|
1000
|
+
return `symbolic link(s) point outside the target: ${inspection.externalSymlinks.slice(0, 3).map((s) => `${s.path} -> ${s.target}`).join(', ')}`
|
|
1001
|
+
}
|
|
1002
|
+
if (inspection.special.length) return `special file(s) cannot be snapshotted or removed safely: ${inspection.special.slice(0, 3).join(', ')}`
|
|
1003
|
+
if (inspection.nestedGit.length) return `nested Git repository inside the target: ${inspection.nestedGit.slice(0, 3).join(', ')}`
|
|
1004
|
+
if (inspection.credentialPaths.length) {
|
|
1005
|
+
return `credential- or secret-bearing path(s) inside the target are never disposable, so the whole candidate is retained: ${inspection.credentialPaths.slice(0, 3).join(', ')}`
|
|
1006
|
+
}
|
|
1007
|
+
// A proven format may embed the vendored names installed dependencies really contain;
|
|
1008
|
+
// every other metadata name still blocks, because a generated tree never owns a plan, a
|
|
1009
|
+
// finding record, a checkpoint or a Git repository.
|
|
1010
|
+
const blocking = proven ? typed.blockingMetadata : inspection.metadataNames
|
|
1011
|
+
if (blocking.length) return `metadata-named path(s) inside the target are never disposable: ${blocking.slice(0, 3).join(', ')}`
|
|
1012
|
+
return null
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function treeSafety(target, inspection, typed = null) {
|
|
1016
|
+
if (!inspection.ok) return inspection.reason
|
|
1017
|
+
if (inspection.truncated) return inspection.reason ?? 'tree inspection hit a bound'
|
|
1018
|
+
const problem = preservationProblem(inspection, typed)
|
|
1019
|
+
if (problem) return problem
|
|
1020
|
+
const proof = trackedProof(target)
|
|
1021
|
+
if (!proof.ok) return proof.reason
|
|
1022
|
+
inspection.containingWorkspace = proof.toplevel
|
|
1023
|
+
return null
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function duplicateIds(entries) {
|
|
1027
|
+
const counts = new Map()
|
|
1028
|
+
for (const entry of entries) {
|
|
1029
|
+
if (!nonEmpty(entry?.id)) continue
|
|
1030
|
+
counts.set(entry.id, (counts.get(entry.id) ?? 0) + 1)
|
|
1031
|
+
}
|
|
1032
|
+
return new Set([...counts].filter(([, count]) => count > 1).map(([id]) => id))
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function overlappingPairs(entries) {
|
|
1036
|
+
const realPaths = entries.map((entry) => {
|
|
1037
|
+
try { return { id: entry?.id, path: normPath(real(entry.path)) } } catch { return { id: entry?.id, path: normPath(resolve(String(entry?.path ?? ''))) } }
|
|
1038
|
+
})
|
|
1039
|
+
const overlaps = new Map()
|
|
1040
|
+
for (let i = 0; i < realPaths.length; i++) {
|
|
1041
|
+
for (let j = i + 1; j < realPaths.length; j++) {
|
|
1042
|
+
const a = realPaths[i]
|
|
1043
|
+
const b = realPaths[j]
|
|
1044
|
+
if (!a.path || !b.path) continue
|
|
1045
|
+
if (containsPath(a.path, b.path) || containsPath(b.path, a.path)) {
|
|
1046
|
+
if (!overlaps.has(i)) overlaps.set(i, [])
|
|
1047
|
+
if (!overlaps.has(j)) overlaps.set(j, [])
|
|
1048
|
+
overlaps.get(i).push(b.id ?? b.path)
|
|
1049
|
+
overlaps.get(j).push(a.id ?? a.path)
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return overlaps
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function receiptPathFor(root, id, atMs) {
|
|
1057
|
+
return join(root, RECEIPT_DIR, `${safeId(id)}-${isoTimestamp(atMs).replace(/[:.]/g, '-')}.json`)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function receiptCandidates(root, id) {
|
|
1061
|
+
const dir = join(root, RECEIPT_DIR)
|
|
1062
|
+
let names
|
|
1063
|
+
try { names = readdirSync(dir) } catch { return [] }
|
|
1064
|
+
return names.filter((name) => name.startsWith(`${safeId(id)}-`) && name.endsWith('.json'))
|
|
1065
|
+
.sort().reverse().map((name) => join(dir, name))
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function verifiedCompleteReceipt(root, entry) {
|
|
1069
|
+
for (const path of receiptCandidates(root, entry.id)) {
|
|
1070
|
+
const st = lstatOrNull(path)
|
|
1071
|
+
if (!st || st.isSymbolicLink() || !st.isFile() || st.size > 4 * 1024 * 1024) continue
|
|
1072
|
+
let saved
|
|
1073
|
+
try { saved = JSON.parse(readFileSync(path, 'utf8')) } catch { continue }
|
|
1074
|
+
if (saved?.version !== RECEIPT_VERSION || saved.kind !== 'retention-receipt') continue
|
|
1075
|
+
if (saved.id !== entry.id || normPath(saved.path) !== normPath(entry.path)) continue
|
|
1076
|
+
if (normPath(saved.root) !== normPath(root)) continue
|
|
1077
|
+
if (saved.resourceKind !== entry.kind || normalized(saved.owner?.run) !== normalized(entry.owner?.run) || saved.unit !== String(entry.owner?.unit)) continue
|
|
1078
|
+
if (saved.status !== 'complete' || saved.removed !== true) continue
|
|
1079
|
+
if (saved.identity?.dev !== entry.identity?.dev || saved.identity?.ino !== entry.identity?.ino) continue
|
|
1080
|
+
const now = lstatOrNull(entry.path)
|
|
1081
|
+
if (now) continue
|
|
1082
|
+
return { ok: true, path }
|
|
1083
|
+
}
|
|
1084
|
+
return { ok: false, reason: 'no complete receipt binds this entry and its absence' }
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function writeReceipt(root, payload, targetPath = null) {
|
|
1088
|
+
const dir = join(root, RECEIPT_DIR)
|
|
1089
|
+
if (symlinkOnPath(root, dir)) throw new RetentionError('symlink', `${dir} passes through a symbolic link`)
|
|
1090
|
+
const path = targetPath ?? receiptPathFor(root, payload.id, payload.atMs)
|
|
1091
|
+
const unique = targetPath ? path : (() => {
|
|
1092
|
+
let candidate = path
|
|
1093
|
+
for (let i = 1; existsSync(candidate) && i < 100; i++) candidate = path.replace(/\.json$/, `-${i}.json`)
|
|
1094
|
+
return candidate
|
|
1095
|
+
})()
|
|
1096
|
+
try {
|
|
1097
|
+
mkdirSync(dir, { recursive: true })
|
|
1098
|
+
const temporary = join(dir, `.${safeId(payload.id)}.${process.pid}.${Date.now()}.tmp`)
|
|
1099
|
+
writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
|
|
1100
|
+
renameSync(temporary, unique)
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
throw new RetentionError('io', `cannot write receipt ${unique}: ${error?.code ?? error?.message}`)
|
|
1103
|
+
}
|
|
1104
|
+
return unique
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ---- evaluation ----
|
|
1108
|
+
|
|
1109
|
+
// A plan revision binds one apply step to the plan that evaluation read, so a concurrent
|
|
1110
|
+
// admission cannot be smuggled past the snapshot.
|
|
1111
|
+
function planFingerprint(path, plan) {
|
|
1112
|
+
const st = lstatOrNull(path)
|
|
1113
|
+
const identity = st ? `${st.dev}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}` : 'absent'
|
|
1114
|
+
return `${identity}:${createHash('sha256').update(JSON.stringify(plan)).digest('hex')}`
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function loadOwner(ctx, entry) {
|
|
1118
|
+
const runReal = real(entry.owner.run)
|
|
1119
|
+
if (!strictInside(ctx.rootReal, runReal)) {
|
|
1120
|
+
return { ok: false, status: 'refused', reason: `owner run ${entry.owner.run} is not inside ${ctx.root}` }
|
|
1121
|
+
}
|
|
1122
|
+
const planPath = join(runReal, 'task-plan.json')
|
|
1123
|
+
const plan = readPlanFile(planPath)
|
|
1124
|
+
if (!plan) {
|
|
1125
|
+
return { ok: false, status: 'retained', reason: `owner task-plan ${planPath} is missing or unreadable; ownership cannot be proved` }
|
|
1126
|
+
}
|
|
1127
|
+
const unit = unitOfPlan(plan, entry.owner.unit)
|
|
1128
|
+
if (!unit) {
|
|
1129
|
+
return { ok: false, status: 'retained', reason: `unit ${entry.owner.unit} is not in ${planPath}; ownership cannot be proved` }
|
|
1130
|
+
}
|
|
1131
|
+
return { ok: true, runReal, planPath, plan, unit, revision: planFingerprint(planPath, plan) }
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// One authority gate, used by evaluation and re-run from a fresh plan read immediately
|
|
1135
|
+
// before the quarantine rename. A reused gate is the only way the two can agree.
|
|
1136
|
+
function ownerGate({ ctx, entry, target, loaded, claims, evidence }) {
|
|
1137
|
+
const ownership = ownershipProblem({ root: ctx.root, run: loaded.runReal, unit: loaded.unit, unitId: entry.owner.unit, target, plan: loaded.plan })
|
|
1138
|
+
if (ownership) return { status: 'refused', reason: ownership }
|
|
1139
|
+
if (loaded.unit.worker != null && loaded.unit.worker !== '') {
|
|
1140
|
+
return { status: 'retained', reason: `unit ${entry.owner.unit} records active worker ${loaded.unit.worker}; an old resource is still in use` }
|
|
1141
|
+
}
|
|
1142
|
+
const completed = unitCompleted(loaded.unit)
|
|
1143
|
+
if (!completed && entry.ownerReleased !== true) {
|
|
1144
|
+
return { status: 'retained', reason: `unit ${entry.owner.unit} is not succeeded+merged and the manifest has no explicit ownerReleased approval; age never infers a release` }
|
|
1145
|
+
}
|
|
1146
|
+
const conflict = claimConflict(target, claims, { run: loaded.runReal, unit: entry.owner.unit })
|
|
1147
|
+
if (conflict) return conflict
|
|
1148
|
+
const overlap = requiredEvidenceOverlap(target, evidence)
|
|
1149
|
+
if (overlap) return { status: 'retained', reason: overlap }
|
|
1150
|
+
return null
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function evaluateEntry(context) {
|
|
1154
|
+
const { entry, index, manifest, ctx, mode } = context
|
|
1155
|
+
const checks = []
|
|
1156
|
+
const add = (name, ok, detail) => { checks.push({ name, ok, detail }); return ok }
|
|
1157
|
+
const row = {
|
|
1158
|
+
id: entry?.id ?? null,
|
|
1159
|
+
path: entry?.path ?? null,
|
|
1160
|
+
kind: entry?.kind ?? null,
|
|
1161
|
+
owner: entry?.owner ?? null,
|
|
1162
|
+
lastUsedAt: entry?.lastUsedAt ?? null,
|
|
1163
|
+
bytes: 0,
|
|
1164
|
+
newestMtime: null,
|
|
1165
|
+
status: 'refused',
|
|
1166
|
+
reason: null,
|
|
1167
|
+
checks,
|
|
1168
|
+
}
|
|
1169
|
+
const parsed = parseEntry(entry)
|
|
1170
|
+
if (!parsed.problems.length) add('shape', true, 'manifest entry is complete')
|
|
1171
|
+
else {
|
|
1172
|
+
row.reason = parsed.problems.join('; ')
|
|
1173
|
+
row.status = 'refused'
|
|
1174
|
+
return row
|
|
1175
|
+
}
|
|
1176
|
+
row.format = parsed.format
|
|
1177
|
+
if (entry.cleanupState === 'removed') {
|
|
1178
|
+
const receipt = verifiedCompleteReceipt(ctx.root, entry)
|
|
1179
|
+
const present = lstatOrNull(entry.path)
|
|
1180
|
+
if (present) {
|
|
1181
|
+
row.status = 'refused'
|
|
1182
|
+
row.reason = `manifest says removed, but ${entry.path} exists; the manifest and the disk disagree`
|
|
1183
|
+
return row
|
|
1184
|
+
}
|
|
1185
|
+
if (!receipt.ok) {
|
|
1186
|
+
row.status = 'retained'
|
|
1187
|
+
row.reason = `manifest says removed and the path is absent, but ${receipt.reason}; rerun cannot claim success`
|
|
1188
|
+
return row
|
|
1189
|
+
}
|
|
1190
|
+
row.status = 'already-removed'
|
|
1191
|
+
row.reason = 'path absent and a complete receipt binds the removal'
|
|
1192
|
+
row.receipt = receipt.path
|
|
1193
|
+
return row
|
|
1194
|
+
}
|
|
1195
|
+
if (!lstatOrNull(entry.path)) {
|
|
1196
|
+
const receipt = verifiedCompleteReceipt(ctx.root, entry)
|
|
1197
|
+
if (receipt.ok) {
|
|
1198
|
+
row.status = 'already-removed'
|
|
1199
|
+
row.reason = 'path absent and a complete receipt binds the removal; the manifest marker is missing'
|
|
1200
|
+
row.receipt = receipt.path
|
|
1201
|
+
return row
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (ctx.duplicateIds.has(entry.id)) {
|
|
1205
|
+
row.status = 'refused'
|
|
1206
|
+
row.reason = `id ${entry.id} appears more than once in the manifest; duplicate ownership is never resolved by guessing`
|
|
1207
|
+
return row
|
|
1208
|
+
}
|
|
1209
|
+
const overlap = ctx.overlaps.get(index)
|
|
1210
|
+
if (overlap?.length) {
|
|
1211
|
+
row.status = 'refused'
|
|
1212
|
+
row.reason = `overlaps another registered resource (${overlap.join(', ')}); overlapping registrations never count or delete twice`
|
|
1213
|
+
return row
|
|
1214
|
+
}
|
|
1215
|
+
add('overlap', true, 'no registered path contains or is contained by another')
|
|
1216
|
+
|
|
1217
|
+
const target = resolve(entry.path)
|
|
1218
|
+
let loaded
|
|
1219
|
+
try { loaded = loadOwner(ctx, entry) } catch (error) {
|
|
1220
|
+
row.status = 'retained'
|
|
1221
|
+
row.reason = `owner run cannot be resolved: ${error?.message ?? error}`
|
|
1222
|
+
return row
|
|
1223
|
+
}
|
|
1224
|
+
if (!loaded.ok) { row.status = loaded.status; row.reason = loaded.reason; return row }
|
|
1225
|
+
const gate = ownerGate({ ctx, entry, target, loaded, claims: ctx.claims, evidence: ctx.evidence })
|
|
1226
|
+
if (gate) { row.status = gate.status; row.reason = gate.reason; return row }
|
|
1227
|
+
add('ownership', true, `${entry.owner.unit} owns ${target}`)
|
|
1228
|
+
add('owner', true, unitCompleted(loaded.unit) ? 'succeeded and delivered' : 'owner released explicitly in the manifest')
|
|
1229
|
+
add('claims', true, 'no other unit or run claims the target as its workspace or resource')
|
|
1230
|
+
add('evidence', true, 'no declared required evidence overlaps the target')
|
|
1231
|
+
row.ownerRevision = loaded.revision
|
|
1232
|
+
|
|
1233
|
+
const inspection = inspectTree(target, { ...ctx.bounds, hashContent: mode === 'apply' })
|
|
1234
|
+
row.bytes = inspection.allocatedBytes
|
|
1235
|
+
row.newestMtime = inspection.newestMtimeMs === null ? null : isoTimestamp(inspection.newestMtimeMs)
|
|
1236
|
+
// A typed resource is re-proved before any policy question is asked about deletion: a
|
|
1237
|
+
// format whose markers or rebuild inputs no longer match is not a deletion candidate, it
|
|
1238
|
+
// is work to rebuild.
|
|
1239
|
+
const typed = formatVerdict(entry, target, inspection)
|
|
1240
|
+
if (typed.typed && !typed.ok) {
|
|
1241
|
+
row.status = 'retained'
|
|
1242
|
+
row.rebuildRequired = true
|
|
1243
|
+
row.formatState = typed.state
|
|
1244
|
+
row.reason = `${typed.format} resource at ${target} is not proven reusable: ${typed.reason}`
|
|
1245
|
+
return row
|
|
1246
|
+
}
|
|
1247
|
+
if (typed.typed) add('format', true, `${typed.format} markers and rebuild inputs still match the registration`)
|
|
1248
|
+
const unsafe = treeSafety(target, inspection, typed)
|
|
1249
|
+
if (unsafe) { row.status = 'retained'; row.reason = unsafe; return row }
|
|
1250
|
+
add('tree', true, `${inspection.files} file(s), ${inspection.allocatedBytes} allocated byte(s), only untracked content`)
|
|
1251
|
+
|
|
1252
|
+
const lastUsedMs = timestamp(entry.lastUsedAt)
|
|
1253
|
+
if (lastUsedMs === null) { row.status = 'refused'; row.reason = 'lastUsedAt is not a valid ISO timestamp'; return row }
|
|
1254
|
+
if (lastUsedMs > ctx.nowMs) { row.status = 'refused'; row.reason = `lastUsedAt ${entry.lastUsedAt} is in the future`; return row }
|
|
1255
|
+
if (inspection.newestMtimeMs === null) { row.status = 'retained'; row.reason = 'the newest write cannot be read'; return row }
|
|
1256
|
+
if (inspection.newestMtimeMs > ctx.nowMs + 1000) { row.status = 'refused'; row.reason = `the newest write inside ${target} is in the future`; return row }
|
|
1257
|
+
const days = entry.kind === 'generated' ? ctx.generatedDays : ctx.artifactDays
|
|
1258
|
+
const threshold = days * DAY_MS
|
|
1259
|
+
const age = ctx.nowMs - lastUsedMs
|
|
1260
|
+
const writeAge = ctx.nowMs - inspection.newestMtimeMs
|
|
1261
|
+
row.ageDays = age / DAY_MS
|
|
1262
|
+
if (age < threshold) {
|
|
1263
|
+
row.status = 'retained'
|
|
1264
|
+
row.reason = `lastUsedAt is ${(age / DAY_MS).toFixed(3)} day(s) old; ${entry.kind} retention is ${days} day(s)`
|
|
1265
|
+
return row
|
|
1266
|
+
}
|
|
1267
|
+
if (writeAge < threshold) {
|
|
1268
|
+
row.status = 'retained'
|
|
1269
|
+
row.reason = `a file inside ${target} was written ${(writeAge / DAY_MS).toFixed(3)} day(s) ago; the newest write can only delay deletion`
|
|
1270
|
+
return row
|
|
1271
|
+
}
|
|
1272
|
+
add('age', true, `lastUsedAt ${(age / DAY_MS).toFixed(3)} day(s), newest write ${(writeAge / DAY_MS).toFixed(3)} day(s), threshold ${days}`)
|
|
1273
|
+
|
|
1274
|
+
const recorded = lstatOrNull(target)
|
|
1275
|
+
if (!recorded) { row.status = 'retained'; row.reason = `${target} disappeared during evaluation`; return row }
|
|
1276
|
+
if (recorded.dev !== entry.identity.dev || recorded.ino !== entry.identity.ino) {
|
|
1277
|
+
row.status = 'refused'
|
|
1278
|
+
row.reason = `${target} dev/ino ${recorded.dev}/${recorded.ino} is not the registered ${entry.identity.dev}/${entry.identity.ino}; a replacement tree is never removed`
|
|
1279
|
+
return row
|
|
1280
|
+
}
|
|
1281
|
+
add('identity', true, `${entry.identity.dev}/${entry.identity.ino}`)
|
|
1282
|
+
|
|
1283
|
+
const uncertain = []
|
|
1284
|
+
const live = ctx.probe([target], { onUncertain: (detail) => uncertain.push(detail) })
|
|
1285
|
+
if (live === 'unreadable') {
|
|
1286
|
+
row.status = 'retained'
|
|
1287
|
+
row.reason = `process use of ${target} cannot be determined; uncertainty never authorizes deletion`
|
|
1288
|
+
return row
|
|
1289
|
+
}
|
|
1290
|
+
if (live === 'unavailable') {
|
|
1291
|
+
row.status = 'retained'
|
|
1292
|
+
row.reason = `no process-use probe is available, so ${target} cannot be shown idle`
|
|
1293
|
+
return row
|
|
1294
|
+
}
|
|
1295
|
+
if (!Array.isArray(live)) {
|
|
1296
|
+
row.status = 'retained'
|
|
1297
|
+
row.reason = `process use of ${target} returned ${String(live)}; only a proven empty scan authorizes deletion`
|
|
1298
|
+
return row
|
|
1299
|
+
}
|
|
1300
|
+
if (live.length) {
|
|
1301
|
+
row.status = 'retained'
|
|
1302
|
+
row.reason = `${live.length} process(es) are using ${target} (${live.slice(0, 3).join(', ')})`
|
|
1303
|
+
return row
|
|
1304
|
+
}
|
|
1305
|
+
add('process', true, 'no process was found using the target')
|
|
1306
|
+
|
|
1307
|
+
row.status = 'ready'
|
|
1308
|
+
row.reason = `ready: ${entry.kind} past its ${days}-day threshold; every safety check passed`
|
|
1309
|
+
row.snapshot = snapshotOf(inspection)
|
|
1310
|
+
row.inspection = inspection
|
|
1311
|
+
return row
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function baseContext({ repo, generatedDays, artifactDays, now, probe, bounds }, manifest) {
|
|
1315
|
+
const { root } = canonicalRoot(repo)
|
|
1316
|
+
const rootReal = existsSync(root) ? real(root) : root
|
|
1317
|
+
const index = planIndex(rootReal, bounds)
|
|
1318
|
+
return {
|
|
1319
|
+
repo: canonicalRepo(repo),
|
|
1320
|
+
root,
|
|
1321
|
+
rootReal,
|
|
1322
|
+
generatedDays,
|
|
1323
|
+
artifactDays,
|
|
1324
|
+
nowMs: now,
|
|
1325
|
+
probe,
|
|
1326
|
+
bounds,
|
|
1327
|
+
manifest,
|
|
1328
|
+
evidence: index.evidence,
|
|
1329
|
+
claims: index.claims,
|
|
1330
|
+
overlaps: overlappingPairs(manifest.entries),
|
|
1331
|
+
duplicateIds: duplicateIds(manifest.entries),
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function assertDays(value, flag) {
|
|
1336
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new UsageError(`${flag} must be a nonnegative integer`)
|
|
1337
|
+
return value
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function normalizeBounds(bounds = {}) {
|
|
1341
|
+
return {
|
|
1342
|
+
maxEntries: bounds.maxEntries ?? 100000,
|
|
1343
|
+
maxDepth: bounds.maxDepth ?? 64,
|
|
1344
|
+
maxHashBytes: bounds.maxHashBytes ?? MAX_TOTAL_HASH_BYTES,
|
|
1345
|
+
maxFileHashBytes: bounds.maxFileHashBytes ?? MAX_FILE_HASH_BYTES,
|
|
1346
|
+
maxClaims: bounds.maxClaims ?? 20000,
|
|
1347
|
+
maxPlanDepth: bounds.maxPlanDepth ?? 6,
|
|
1348
|
+
maxPlans: bounds.maxPlans ?? 200,
|
|
1349
|
+
maxEvidenceEntries: bounds.maxEvidenceEntries ?? 20000,
|
|
1350
|
+
maxRuns: bounds.maxRuns ?? 100,
|
|
1351
|
+
maxInventoryEntries: bounds.maxInventoryEntries ?? 20000,
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function summarize(rows) {
|
|
1356
|
+
return {
|
|
1357
|
+
total: rows.length,
|
|
1358
|
+
ready: rows.filter((r) => r.status === 'ready').length,
|
|
1359
|
+
retained: rows.filter((r) => r.status === 'retained').length,
|
|
1360
|
+
refused: rows.filter((r) => r.status === 'refused').length,
|
|
1361
|
+
alreadyRemoved: rows.filter((r) => r.status === 'already-removed').length,
|
|
1362
|
+
candidatesBytes: rows.filter((r) => r.status === 'ready').reduce((sum, r) => sum + (r.bytes ?? 0), 0),
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function publicRow(row) {
|
|
1367
|
+
const { inspection, snapshot, ownerRevision, ...rest } = row
|
|
1368
|
+
return rest
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// ---- plan ----
|
|
1372
|
+
|
|
1373
|
+
export function planRetention({
|
|
1374
|
+
repo, generatedDays = 7, artifactDays = 30, now, probe = liveProcessesUsing, bounds = {},
|
|
1375
|
+
} = {}) {
|
|
1376
|
+
assertDays(generatedDays, '--generated-days')
|
|
1377
|
+
assertDays(artifactDays, '--artifact-days')
|
|
1378
|
+
const nowMs = nowValue(now)
|
|
1379
|
+
const { root } = canonicalRoot(repo)
|
|
1380
|
+
const manifest = existsSync(root) ? readManifest(root) : { version: MANIFEST_VERSION, entries: [] }
|
|
1381
|
+
const ctx = baseContext({ repo, generatedDays, artifactDays, now: nowMs, probe, bounds: normalizeBounds(bounds) }, manifest)
|
|
1382
|
+
const rows = manifest.entries.map((entry, index) => {
|
|
1383
|
+
try { return evaluateEntry({ entry, index, manifest, ctx, mode: 'plan' }) } catch (error) {
|
|
1384
|
+
return { id: entry?.id ?? null, path: entry?.path ?? null, status: 'refused', reason: `entry evaluation failed: ${error?.message ?? error}`, checks: [] }
|
|
1385
|
+
}
|
|
1386
|
+
})
|
|
1387
|
+
rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0))
|
|
1388
|
+
const view = rows.map(publicRow)
|
|
1389
|
+
return {
|
|
1390
|
+
version: 1, command: 'plan', ok: true, applied: false,
|
|
1391
|
+
repo: ctx.repo, root: ctx.root,
|
|
1392
|
+
generatedDays, artifactDays, now: isoTimestamp(nowMs),
|
|
1393
|
+
entries: view,
|
|
1394
|
+
targets: view.filter((r) => r.status === 'ready'),
|
|
1395
|
+
summary: summarize(rows),
|
|
1396
|
+
inventory: inventory(ctx.rootReal, ctx.bounds),
|
|
1397
|
+
evidenceScanComplete: ctx.evidence.complete,
|
|
1398
|
+
errors: [],
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ---- apply ----
|
|
1403
|
+
|
|
1404
|
+
function quarantineDestination(target, id, atMs) {
|
|
1405
|
+
return join(dirname(target), `${QUARANTINE_PREFIX}${safeId(id)}-${isoTimestamp(atMs).replace(/[:.]/g, '-')}`)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function applyOne(context, row) {
|
|
1409
|
+
const { entry, ctx } = context
|
|
1410
|
+
const target = resolve(entry.path)
|
|
1411
|
+
const atMs = ctx.nowMs
|
|
1412
|
+
const receiptBase = {
|
|
1413
|
+
version: RECEIPT_VERSION, kind: 'retention-receipt', id: entry.id, path: normPath(target),
|
|
1414
|
+
root: normPath(ctx.root), repo: normPath(ctx.repo), run: normPath(entry.owner.run),
|
|
1415
|
+
unit: entry.owner.unit, owner: entry.owner, resourceKind: entry.kind, recipe: entry.recipe ?? '',
|
|
1416
|
+
format: entry.format ?? 'generic', inputProofs: entry.inputProofs ?? null,
|
|
1417
|
+
identity: entry.identity, atMs, at: isoTimestamp(atMs),
|
|
1418
|
+
status: 'applying', removed: false, quarantine: null,
|
|
1419
|
+
}
|
|
1420
|
+
// A fresh inode check immediately before anything else.
|
|
1421
|
+
const before = lstatOrNull(target)
|
|
1422
|
+
if (!before) return { action: 'retained', reason: `${target} disappeared before apply` }
|
|
1423
|
+
if (before.dev !== entry.identity.dev || before.ino !== entry.identity.ino) {
|
|
1424
|
+
return { action: 'retained', reason: `${target} dev/ino changed before apply; a replacement tree is never removed` }
|
|
1425
|
+
}
|
|
1426
|
+
const first = inspectTree(target, { ...ctx.bounds, hashContent: true })
|
|
1427
|
+
const firstTyped = formatVerdict(entry, target, first)
|
|
1428
|
+
if (firstTyped.typed && !firstTyped.ok) {
|
|
1429
|
+
return { action: 'retained', reason: `${firstTyped.format} resource is not proven reusable immediately before apply: ${firstTyped.reason}`, rebuildRequired: true }
|
|
1430
|
+
}
|
|
1431
|
+
const unsafe = treeSafety(target, first, firstTyped)
|
|
1432
|
+
if (unsafe) return { action: 'retained', reason: unsafe }
|
|
1433
|
+
if (!sameSnapshot(row.snapshot, snapshotOf(first))) {
|
|
1434
|
+
return { action: 'retained', reason: `${target} changed between plan evaluation and apply` }
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Test seam only: a real filesystem writer outside this function is the production
|
|
1438
|
+
// equivalent. It runs before the final snapshot recheck, never after it.
|
|
1439
|
+
ctx.hooks.beforeQuarantine?.({ entry: publicRow(row), target })
|
|
1440
|
+
|
|
1441
|
+
const second = inspectTree(target, { ...ctx.bounds, hashContent: true })
|
|
1442
|
+
const secondTyped = formatVerdict(entry, target, second)
|
|
1443
|
+
if (secondTyped.typed && !secondTyped.ok) {
|
|
1444
|
+
return { action: 'retained', reason: `${secondTyped.format} resource is not proven reusable immediately before quarantine: ${secondTyped.reason}`, rebuildRequired: true }
|
|
1445
|
+
}
|
|
1446
|
+
const secondUnsafe = treeSafety(target, second, secondTyped)
|
|
1447
|
+
if (secondUnsafe) return { action: 'retained', reason: secondUnsafe }
|
|
1448
|
+
if (!sameSnapshot(snapshotOf(first), snapshotOf(second))) {
|
|
1449
|
+
return { action: 'retained', reason: `${target} changed immediately before quarantine; nothing was moved` }
|
|
1450
|
+
}
|
|
1451
|
+
// Owner authority is re-read here, not trusted from evaluation: a worker can be admitted,
|
|
1452
|
+
// a delivery withdrawn, or another unit or run can declare this tree between the first
|
|
1453
|
+
// snapshot and the rename. The plan revision binds this step to the plan evaluation read.
|
|
1454
|
+
let fresh
|
|
1455
|
+
try { fresh = loadOwner(ctx, entry) } catch (error) {
|
|
1456
|
+
return { action: 'retained', reason: `owner run cannot be resolved immediately before quarantine: ${error?.message ?? error}` }
|
|
1457
|
+
}
|
|
1458
|
+
if (!fresh.ok) return { action: fresh.status, reason: fresh.reason }
|
|
1459
|
+
const index = planIndex(ctx.rootReal, ctx.bounds)
|
|
1460
|
+
const gate = ownerGate({ ctx, entry, target, loaded: fresh, claims: index.claims, evidence: index.evidence })
|
|
1461
|
+
if (gate) return { action: gate.status, reason: gate.reason }
|
|
1462
|
+
if (row.ownerRevision === undefined || fresh.revision !== row.ownerRevision) {
|
|
1463
|
+
return { action: 'retained', reason: `owner task-plan ${fresh.planPath} changed during apply; ${target} was retained, restart with a fresh plan` }
|
|
1464
|
+
}
|
|
1465
|
+
const stillThere = lstatOrNull(target)
|
|
1466
|
+
if (!stillThere || stillThere.dev !== entry.identity.dev || stillThere.ino !== entry.identity.ino) {
|
|
1467
|
+
return { action: 'retained', reason: `${target} identity changed immediately before quarantine` }
|
|
1468
|
+
}
|
|
1469
|
+
const freshManifest = readManifest(ctx.root)
|
|
1470
|
+
const freshEntry = freshManifest.entries.find((item) => item?.id === entry.id)
|
|
1471
|
+
if (!freshEntry || JSON.stringify(freshEntry) !== JSON.stringify(entry)) {
|
|
1472
|
+
return { action: 'retained', reason: `the manifest entry for ${entry.id} changed during apply; restart with a fresh plan` }
|
|
1473
|
+
}
|
|
1474
|
+
const live = ctx.probe([target], { onUncertain: () => {} })
|
|
1475
|
+
if (live === 'unreadable' || live === 'unavailable' || !Array.isArray(live)) {
|
|
1476
|
+
return { action: 'retained', reason: `process use of ${target} became uncertain immediately before apply` }
|
|
1477
|
+
}
|
|
1478
|
+
if (live.length) return { action: 'retained', reason: `${live.length} process(es) are using ${target} immediately before apply` }
|
|
1479
|
+
|
|
1480
|
+
let receipt
|
|
1481
|
+
try {
|
|
1482
|
+
receipt = writeReceipt(ctx.root, receiptBase)
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
return { action: 'failed', reason: error.message, ioFailure: true }
|
|
1485
|
+
}
|
|
1486
|
+
const quarantine = quarantineDestination(target, entry.id, atMs)
|
|
1487
|
+
if (existsSync(quarantine)) {
|
|
1488
|
+
const failed = { ...receiptBase, receipt, status: 'failed', reason: `quarantine destination ${quarantine} already exists` }
|
|
1489
|
+
try { writeReceipt(ctx.root, failed, receipt) } catch { /* already an I/O failure */ }
|
|
1490
|
+
return { action: 'failed', reason: failed.reason, receipt, ioFailure: true }
|
|
1491
|
+
}
|
|
1492
|
+
try {
|
|
1493
|
+
renameSync(target, quarantine)
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
const failed = { ...receiptBase, receipt, status: 'failed', reason: `quarantine rename failed: ${error?.code ?? error?.message}` }
|
|
1496
|
+
try { writeReceipt(ctx.root, failed, receipt) } catch { /* receipt failure is reported below */ }
|
|
1497
|
+
return { action: 'failed', reason: failed.reason, receipt, ioFailure: true }
|
|
1498
|
+
}
|
|
1499
|
+
const moved = inspectTree(quarantine, { ...ctx.bounds, hashContent: true })
|
|
1500
|
+
if (!moved.ok || !sameSnapshot(snapshotOf(second), snapshotOf(moved)) || moved.rootIdentity?.dev !== entry.identity.dev || moved.rootIdentity?.ino !== entry.identity.ino) {
|
|
1501
|
+
let restored = true
|
|
1502
|
+
try { renameSync(quarantine, target) } catch { restored = false }
|
|
1503
|
+
const failed = {
|
|
1504
|
+
...receiptBase, receipt, status: 'failed', quarantine,
|
|
1505
|
+
reason: restored
|
|
1506
|
+
? 'content changed at the quarantine destination; the tree was restored and retained'
|
|
1507
|
+
: 'content changed at the quarantine destination and restoring the original name failed; inspect the quarantine path',
|
|
1508
|
+
}
|
|
1509
|
+
try { writeReceipt(ctx.root, failed, receipt) } catch { /* reported as IO failure */ }
|
|
1510
|
+
return { action: restored ? 'retained' : 'failed', reason: failed.reason, receipt, quarantine, ioFailure: !restored }
|
|
1511
|
+
}
|
|
1512
|
+
try {
|
|
1513
|
+
ctx.hooks.beforeDelete?.({ entry: publicRow(row), quarantine })
|
|
1514
|
+
// fs.rm removes a symbolic link itself and never walks through it, so an external
|
|
1515
|
+
// reference inside a proven format is unlinked while its destination is untouched.
|
|
1516
|
+
rmSync(quarantine, { recursive: true, force: true })
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
const failed = {
|
|
1519
|
+
...receiptBase, receipt, status: 'failed', quarantine,
|
|
1520
|
+
reason: `removal of the quarantined tree failed: ${error?.code ?? error?.message}; the original path is gone`,
|
|
1521
|
+
}
|
|
1522
|
+
try { writeReceipt(ctx.root, failed, receipt) } catch { /* reported as IO failure */ }
|
|
1523
|
+
return { action: 'failed', reason: failed.reason, receipt, quarantine, ioFailure: true }
|
|
1524
|
+
}
|
|
1525
|
+
if (existsSync(target) || existsSync(quarantine)) {
|
|
1526
|
+
const failed = { ...receiptBase, receipt, status: 'failed', quarantine, reason: 'a removal postcondition is not established; the original path or quarantine still exists' }
|
|
1527
|
+
try { writeReceipt(ctx.root, failed, receipt) } catch { /* reported as IO failure */ }
|
|
1528
|
+
return { action: 'failed', reason: failed.reason, receipt, quarantine, ioFailure: true }
|
|
1529
|
+
}
|
|
1530
|
+
const complete = {
|
|
1531
|
+
...receiptBase, receipt, status: 'complete', quarantine,
|
|
1532
|
+
removedAt: isoTimestamp(atMs), removed: true,
|
|
1533
|
+
snapshot: {
|
|
1534
|
+
digest: second.digest, entries: second.entries, files: second.files,
|
|
1535
|
+
allocatedBytes: second.allocatedBytes, newestMtimeMs: second.newestMtimeMs,
|
|
1536
|
+
},
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
writeReceipt(ctx.root, complete, receipt)
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
return { action: 'failed', reason: `the tree was removed but the complete receipt cannot be written: ${error.message}`, receipt, quarantine, ioFailure: true }
|
|
1542
|
+
}
|
|
1543
|
+
const updated = readManifest(ctx.root)
|
|
1544
|
+
const next = updated.entries.map((item) => item?.id === entry.id
|
|
1545
|
+
? { ...item, cleanupState: 'removed', removedAt: isoTimestamp(atMs), receipt: normPath(receipt) }
|
|
1546
|
+
: item)
|
|
1547
|
+
try {
|
|
1548
|
+
writeManifest(ctx.root, { ...updated, version: MANIFEST_VERSION, entries: next })
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
return { action: 'removed', reason: `removed and receipted, but the manifest marker could not be written: ${error.message}`, receipt, quarantine, ioFailure: true }
|
|
1551
|
+
}
|
|
1552
|
+
return { action: 'removed', reason: `quarantined, verified, and removed; receipt ${receipt}`, receipt, quarantine }
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
export function applyRetention({
|
|
1556
|
+
repo, generatedDays = 7, artifactDays = 30, now, probe = liveProcessesUsing, bounds = {}, hooks = {},
|
|
1557
|
+
} = {}) {
|
|
1558
|
+
assertDays(generatedDays, '--generated-days')
|
|
1559
|
+
assertDays(artifactDays, '--artifact-days')
|
|
1560
|
+
const nowMs = nowValue(now)
|
|
1561
|
+
const { root } = canonicalRoot(repo)
|
|
1562
|
+
if (!existsSync(root)) return { ...planRetention({ repo, generatedDays, artifactDays, now: nowMs, probe, bounds }), command: 'apply', applied: false, actions: [], ioFailures: [] }
|
|
1563
|
+
const lock = acquireLock(root)
|
|
1564
|
+
try {
|
|
1565
|
+
const manifest = readManifest(root)
|
|
1566
|
+
const ctx = baseContext({ repo, generatedDays, artifactDays, now: nowMs, probe, bounds: normalizeBounds(bounds) }, manifest)
|
|
1567
|
+
ctx.hooks = hooks
|
|
1568
|
+
const rows = manifest.entries.map((entry, index) => {
|
|
1569
|
+
try { return { entry, row: evaluateEntry({ entry, index, manifest, ctx, mode: 'apply' }) } } catch (error) {
|
|
1570
|
+
return { entry, row: { id: entry?.id ?? null, path: entry?.path ?? null, status: 'refused', reason: `entry evaluation failed: ${error?.message ?? error}`, checks: [] } }
|
|
1571
|
+
}
|
|
1572
|
+
})
|
|
1573
|
+
rows.sort((a, b) => (b.row.bytes ?? 0) - (a.row.bytes ?? 0))
|
|
1574
|
+
const ioFailures = []
|
|
1575
|
+
const results = []
|
|
1576
|
+
for (const { entry, row } of rows) {
|
|
1577
|
+
if (row.status === 'already-removed') {
|
|
1578
|
+
const latest = readManifest(root)
|
|
1579
|
+
const saved = latest.entries.find((item) => item?.id === entry.id)
|
|
1580
|
+
if (saved && saved.cleanupState !== 'removed') {
|
|
1581
|
+
try {
|
|
1582
|
+
writeManifest(root, {
|
|
1583
|
+
...latest,
|
|
1584
|
+
entries: latest.entries.map((item) => (item?.id === entry.id
|
|
1585
|
+
? { ...item, cleanupState: 'removed', removedAt: item.removedAt ?? isoTimestamp(nowMs), receipt: row.receipt }
|
|
1586
|
+
: item)),
|
|
1587
|
+
})
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
ioFailures.push({ id: entry.id, path: entry.path, reason: error.message })
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
results.push({ ...publicRow(row), action: 'already-removed' })
|
|
1593
|
+
continue
|
|
1594
|
+
}
|
|
1595
|
+
if (row.status !== 'ready') {
|
|
1596
|
+
results.push({ ...publicRow(row), action: row.status })
|
|
1597
|
+
continue
|
|
1598
|
+
}
|
|
1599
|
+
let outcome
|
|
1600
|
+
try { outcome = applyOne({ entry, ctx, row }, row) } catch (error) {
|
|
1601
|
+
outcome = { action: 'failed', reason: `apply failed safely: ${error?.message ?? error}`, ioFailure: true }
|
|
1602
|
+
}
|
|
1603
|
+
if (outcome.action === 'failed' && outcome.receipt) {
|
|
1604
|
+
try {
|
|
1605
|
+
const latest = readManifest(root)
|
|
1606
|
+
writeManifest(root, {
|
|
1607
|
+
...latest,
|
|
1608
|
+
entries: latest.entries.map((item) => (item?.id === entry.id
|
|
1609
|
+
? { ...item, cleanupState: 'failed', receipt: outcome.receipt, failedAt: isoTimestamp(nowMs) }
|
|
1610
|
+
: item)),
|
|
1611
|
+
})
|
|
1612
|
+
} catch (error) { ioFailures.push({ id: entry.id, path: entry.path, reason: error.message }) }
|
|
1613
|
+
}
|
|
1614
|
+
if (outcome.ioFailure) ioFailures.push({ id: entry.id, path: entry.path, reason: outcome.reason })
|
|
1615
|
+
results.push({ ...publicRow(row), ...outcome })
|
|
1616
|
+
}
|
|
1617
|
+
return {
|
|
1618
|
+
version: 1, command: 'apply', ok: ioFailures.length === 0, applied: true,
|
|
1619
|
+
repo: ctx.repo, root: ctx.root,
|
|
1620
|
+
generatedDays, artifactDays, now: isoTimestamp(nowMs),
|
|
1621
|
+
evidenceScanComplete: ctx.evidence.complete,
|
|
1622
|
+
lockRecovery: lock.recovery,
|
|
1623
|
+
entries: results,
|
|
1624
|
+
summary: summarize(results.map((r) => ({ ...r, status: r.action === 'removed' ? 'removed' : r.status }))),
|
|
1625
|
+
ioFailures,
|
|
1626
|
+
errors: ioFailures.map((f) => f.reason),
|
|
1627
|
+
}
|
|
1628
|
+
} finally {
|
|
1629
|
+
lock.release()
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// ---- register ----
|
|
1634
|
+
|
|
1635
|
+
function assertTimestamp(value, flag) {
|
|
1636
|
+
if (timestamp(value) === null) throw new UsageError(`${flag} must be an ISO-8601 UTC timestamp such as 2026-10-01T00:00:00.000Z`)
|
|
1637
|
+
return new Date(timestamp(value)).toISOString()
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// The format and its explicit rebuild inputs are caller choices, so a wrong combination is
|
|
1641
|
+
// a usage error and never a guess. A generic resource records no input proof at all.
|
|
1642
|
+
function formatInputs(format, inputs) {
|
|
1643
|
+
if (!nonEmpty(format)) throw new UsageError('--format must be a format name')
|
|
1644
|
+
if (!FORMATS.has(format)) throw new UsageError(`--format must be ${[...FORMATS].join('|')}`)
|
|
1645
|
+
const list = (Array.isArray(inputs) ? inputs : [inputs]).filter((value) => value !== undefined)
|
|
1646
|
+
for (const value of list) {
|
|
1647
|
+
if (!nonEmpty(value) || !isAbsolute(value)) throw new UsageError('--input must be an absolute canonical rebuild input path')
|
|
1648
|
+
}
|
|
1649
|
+
if (format === 'generic') {
|
|
1650
|
+
if (list.length) throw new UsageError('--input is only meaningful with a typed --format; a generic resource records no rebuild input proof')
|
|
1651
|
+
return []
|
|
1652
|
+
}
|
|
1653
|
+
if (!list.length) throw new UsageError(`--format ${format} needs its canonical rebuild inputs (--input <absolute path>)`)
|
|
1654
|
+
return list
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
export function registerResource({
|
|
1658
|
+
repo, run, unit, path, kind, lastUsedAt, recipe, releaseOwner = false, format = 'generic', inputs = [], dryRun = false, now,
|
|
1659
|
+
bounds = {},
|
|
1660
|
+
} = {}) {
|
|
1661
|
+
const nowMs = nowValue(now)
|
|
1662
|
+
const { root } = canonicalRoot(repo, { mustExist: true })
|
|
1663
|
+
if (!nonEmpty(unit)) throw new UsageError('--unit must be a unit id')
|
|
1664
|
+
if (!KINDS.has(kind)) throw new UsageError('--kind must be generated or artifact')
|
|
1665
|
+
if (!nonEmpty(path) || !isAbsolute(path)) throw new UsageError('--path must be an absolute path')
|
|
1666
|
+
const rebuildInputs = formatInputs(format, inputs)
|
|
1667
|
+
if (lastUsedAt === undefined) throw new UsageError('--last-used-at is required; activity dates are caller-supplied, never inferred')
|
|
1668
|
+
const usedAt = assertTimestamp(lastUsedAt, '--last-used-at')
|
|
1669
|
+
if (timestamp(usedAt) > nowMs) throw new RetentionError('timestamp', `--last-used-at ${usedAt} is in the future`)
|
|
1670
|
+
if (kind === 'generated' && !nonEmpty(recipe)) throw new UsageError('a generated resource needs a nonempty --recipe for reconstruction')
|
|
1671
|
+
if (kind === 'artifact' && recipe !== undefined && typeof recipe !== 'string') throw new UsageError('--recipe must be text')
|
|
1672
|
+
if (!nonEmpty(run) || !isAbsolute(run)) throw new UsageError('--run must be an absolute run directory')
|
|
1673
|
+
const runReal = canonicalExistingDir(run, 'run directory')
|
|
1674
|
+
const rootReal = real(root)
|
|
1675
|
+
if (!strictInside(rootReal, runReal)) throw new RetentionError('ownership', `run ${runReal} is not inside the canonical root ${root}`)
|
|
1676
|
+
if (symlinkOnPath(rootReal, resolve(run))) throw new RetentionError('symlink', `run ${run} passes through a symbolic link`)
|
|
1677
|
+
const planPath = join(runReal, 'task-plan.json')
|
|
1678
|
+
const plan = readPlanFile(planPath)
|
|
1679
|
+
if (!plan) throw new RetentionError('plan', `owner task-plan ${planPath} is missing or unreadable`)
|
|
1680
|
+
const member = unitOfPlan(plan, unit)
|
|
1681
|
+
if (!member) throw new RetentionError('ownership', `unit ${unit} is not recorded in ${planPath}`)
|
|
1682
|
+
const lexicalTarget = resolve(path)
|
|
1683
|
+
const lexicalRelative = relative(rootReal, lexicalTarget)
|
|
1684
|
+
if (!lexicalRelative.startsWith(`..${sep}`) && lexicalRelative !== '..' && !isAbsolute(lexicalRelative)
|
|
1685
|
+
&& symlinkOnPath(rootReal, lexicalTarget)) {
|
|
1686
|
+
throw new RetentionError('symlink', `${lexicalTarget} passes through a symbolic link below the canonical root`)
|
|
1687
|
+
}
|
|
1688
|
+
const target = canonicalExistingDir(path, 'resource path')
|
|
1689
|
+
if (!strictInside(real(root), target)) throw new RetentionError('ownership', `${target} is not inside the canonical root ${root}`)
|
|
1690
|
+
if (symlinkOnPath(real(root), target)) throw new RetentionError('symlink', `${target} passes through a symbolic link`)
|
|
1691
|
+
const ownership = ownershipProblem({ root, run: runReal, unit: member, unitId: unit, target, plan })
|
|
1692
|
+
if (ownership) throw new RetentionError('ownership', ownership)
|
|
1693
|
+
const completed = unitCompleted(member)
|
|
1694
|
+
if (member.worker != null && member.worker !== '') {
|
|
1695
|
+
throw new RetentionError('owner-active', `unit ${unit} records active worker ${member.worker}; release it before registering its resources`)
|
|
1696
|
+
}
|
|
1697
|
+
// Inventory is recorded before worker admission. Only eligibility grants a
|
|
1698
|
+
// release; registering unfinished work must not authorize its deletion.
|
|
1699
|
+
const inspection = inspectTree(target, { ...normalizeBounds(bounds), hashContent: true })
|
|
1700
|
+
// The typed proof is taken from this same inspection, so registering a format costs one
|
|
1701
|
+
// tree walk, not two. Nothing is relaxed until the format is positively proven.
|
|
1702
|
+
const typed = format === 'generic' ? null : inspectResourceFormat({ format, path: target, inputs: rebuildInputs, inspection })
|
|
1703
|
+
if (typed && !typed.ok) throw new RetentionError('format', `${format} is not proven for ${target}: ${typed.reason}`)
|
|
1704
|
+
const unsafe = treeSafety(target, inspection, typed)
|
|
1705
|
+
if (unsafe) throw new RetentionError('refused', unsafe)
|
|
1706
|
+
const index = planIndex(real(root), normalizeBounds(bounds))
|
|
1707
|
+
const evidence = requiredEvidenceOverlap(target, index.evidence)
|
|
1708
|
+
if (evidence) throw new RetentionError('evidence', evidence)
|
|
1709
|
+
const claim = claimConflict(target, index.claims, { run: runReal, unit })
|
|
1710
|
+
if (claim) throw new RetentionError('overlap', claim.reason)
|
|
1711
|
+
const id = `${kind}-${createHash('sha256').update(target).digest('hex').slice(0, 12)}`
|
|
1712
|
+
const identity = { dev: lstatSync(target).dev, ino: lstatSync(target).ino }
|
|
1713
|
+
const entryFor = (existing) => ({
|
|
1714
|
+
id,
|
|
1715
|
+
path: target,
|
|
1716
|
+
kind,
|
|
1717
|
+
format,
|
|
1718
|
+
...(typed ? { inputProofs: typed.inputProofs } : {}),
|
|
1719
|
+
owner: { run: runReal, unit: String(unit) },
|
|
1720
|
+
lastUsedAt: usedAt,
|
|
1721
|
+
disposable: true,
|
|
1722
|
+
pinned: existing?.pinned === true,
|
|
1723
|
+
recipe: kind === 'generated' ? String(recipe).trim() : String(recipe ?? ''),
|
|
1724
|
+
identity,
|
|
1725
|
+
authorization: { source: 'explicit-registration', at: isoTimestamp(nowMs) },
|
|
1726
|
+
...(releaseOwner === true ? { ownerReleased: true } : {}),
|
|
1727
|
+
})
|
|
1728
|
+
const registered = (entry, saved, existing) => ({
|
|
1729
|
+
version: 1, command: 'register', ok: true, applied: !dryRun, dryRun,
|
|
1730
|
+
repo: canonicalRepo(repo), root, manifest: saved,
|
|
1731
|
+
entry,
|
|
1732
|
+
resource: {
|
|
1733
|
+
id, path: target, kind, format, identity, owner: entry.owner,
|
|
1734
|
+
...(typed ? { inputProofs: typed.inputProofs } : {}),
|
|
1735
|
+
},
|
|
1736
|
+
authorization: entry.authorization,
|
|
1737
|
+
ownerCompleted: completed,
|
|
1738
|
+
ownerReleased: entry.ownerReleased === true,
|
|
1739
|
+
...(existing ? { refreshed: true } : {}),
|
|
1740
|
+
...(typed ? { markers: typed.markers } : {}),
|
|
1741
|
+
})
|
|
1742
|
+
if (dryRun) {
|
|
1743
|
+
// Preview: every safety and reuse check above ran, but nothing was acquired or written.
|
|
1744
|
+
const manifest = readManifest(root)
|
|
1745
|
+
const existing = manifest.entries.find((item) => item?.id === id)
|
|
1746
|
+
if (!existing) {
|
|
1747
|
+
const overlap = overlappingPairs([...manifest.entries, { id, path: target }]).get(manifest.entries.length)
|
|
1748
|
+
if (overlap?.length) throw new RetentionError('overlap', `${target} overlaps registered resource(s) ${overlap.join(', ')}; overlapping registrations are refused`)
|
|
1749
|
+
}
|
|
1750
|
+
return registered(entryFor(existing), manifestPath(root), existing)
|
|
1751
|
+
}
|
|
1752
|
+
const lock = acquireLock(root)
|
|
1753
|
+
try {
|
|
1754
|
+
// Ownership can change while the lock is acquired: another unit or run may have
|
|
1755
|
+
// declared the same tree, so the claim scan is repeated from the plans on disk.
|
|
1756
|
+
const freshClaim = claimConflict(target, planIndex(real(root), normalizeBounds(bounds)).claims, { run: runReal, unit })
|
|
1757
|
+
if (freshClaim) throw new RetentionError('overlap', freshClaim.reason)
|
|
1758
|
+
const manifest = readManifest(root)
|
|
1759
|
+
const existing = manifest.entries.find((entry) => entry?.id === id)
|
|
1760
|
+
if (!existing) {
|
|
1761
|
+
const overlap = overlappingPairs([...manifest.entries, { id, path: target }]).get(manifest.entries.length)
|
|
1762
|
+
if (overlap?.length) throw new RetentionError('overlap', `${target} overlaps registered resource(s) ${overlap.join(', ')}; overlapping registrations are refused`)
|
|
1763
|
+
}
|
|
1764
|
+
const entry = entryFor(existing)
|
|
1765
|
+
const next = existing
|
|
1766
|
+
? manifest.entries.map((item) => (item?.id === id ? entry : item))
|
|
1767
|
+
: [...manifest.entries, entry]
|
|
1768
|
+
const saved = writeManifest(root, { ...manifest, version: MANIFEST_VERSION, entries: next })
|
|
1769
|
+
return { ...registered(entry, saved, existing), lockRecovery: lock.recovery }
|
|
1770
|
+
} finally {
|
|
1771
|
+
lock.release()
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ---- status ----
|
|
1776
|
+
|
|
1777
|
+
// Read-only reuse verdict for one registered resource. It re-proves the recorded format
|
|
1778
|
+
// from a fresh (cheap, content-free) tree inspection and re-validates the recorded rebuild
|
|
1779
|
+
// inputs; it never writes, never executes the recipe, and never acquires the lock. A
|
|
1780
|
+
// partial root left by a manual cleanup stays partial: presence of the parent directory is
|
|
1781
|
+
// not evidence that the resource is usable.
|
|
1782
|
+
function statusOf(entry, bounds) {
|
|
1783
|
+
const row = {
|
|
1784
|
+
id: entry?.id ?? null,
|
|
1785
|
+
path: entry?.path ?? null,
|
|
1786
|
+
kind: entry?.kind ?? null,
|
|
1787
|
+
format: nonEmpty(entry?.format) ? entry.format : 'generic',
|
|
1788
|
+
recipe: typeof entry?.recipe === 'string' ? entry.recipe : null,
|
|
1789
|
+
owner: entry?.owner ?? null,
|
|
1790
|
+
lastUsedAt: entry?.lastUsedAt ?? null,
|
|
1791
|
+
inputs: Array.isArray(entry?.inputProofs)
|
|
1792
|
+
? entry.inputProofs.map((proof) => ({ name: proof?.name ?? null, path: proof?.path ?? null }))
|
|
1793
|
+
: [],
|
|
1794
|
+
state: 'invalid',
|
|
1795
|
+
ready: false,
|
|
1796
|
+
rebuildRequired: true,
|
|
1797
|
+
reason: null,
|
|
1798
|
+
}
|
|
1799
|
+
const parsed = parseEntry(entry, { forStatus: true })
|
|
1800
|
+
if (parsed.problems.length) { row.reason = parsed.problems.join('; '); return row }
|
|
1801
|
+
row.format = parsed.format
|
|
1802
|
+
if (entry.cleanupState === 'removed') {
|
|
1803
|
+
row.state = 'removed'
|
|
1804
|
+
row.reason = 'the manifest records this resource as removed; only its recipe can recreate it'
|
|
1805
|
+
return row
|
|
1806
|
+
}
|
|
1807
|
+
const target = resolve(entry.path)
|
|
1808
|
+
const st = lstatOrNull(target)
|
|
1809
|
+
if (!st) {
|
|
1810
|
+
row.state = 'missing'
|
|
1811
|
+
row.reason = `${target} does not exist; rebuild it with the retained recipe`
|
|
1812
|
+
return row
|
|
1813
|
+
}
|
|
1814
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
1815
|
+
row.state = 'partial'
|
|
1816
|
+
row.reason = `${target} is not a real directory, so the recorded resource is no longer there`
|
|
1817
|
+
return row
|
|
1818
|
+
}
|
|
1819
|
+
// Readiness is not deletion eligibility. Pins, credentials and reference links
|
|
1820
|
+
// protect cleanup but do not make a structurally valid installation broken.
|
|
1821
|
+
const typed = parsed.format === 'generic'
|
|
1822
|
+
? { typed: false, ok: true }
|
|
1823
|
+
: { typed: true, ...classifyRebuildState({ format: parsed.format, path: target, inputProofs: entry.inputProofs }) }
|
|
1824
|
+
if (typed.typed && !typed.ok) {
|
|
1825
|
+
// A partial install or a changed lockfile is deferred work, never a silent success.
|
|
1826
|
+
row.state = typed.state === 'missing' ? 'missing' : typed.state === 'inputs-changed' ? 'inputs-changed' : 'partial'
|
|
1827
|
+
row.reason = typed.reason
|
|
1828
|
+
return row
|
|
1829
|
+
}
|
|
1830
|
+
row.state = 'ready'
|
|
1831
|
+
row.ready = true
|
|
1832
|
+
row.rebuildRequired = false
|
|
1833
|
+
row.reason = typed.typed
|
|
1834
|
+
? `${parsed.format} markers and rebuild inputs still match the registration`
|
|
1835
|
+
: 'present; a generic resource carries no typed format proof'
|
|
1836
|
+
return row
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function statusFilter({ run, unit } = {}) {
|
|
1840
|
+
if (run === undefined && unit === undefined) return { matches: () => true, describe: null }
|
|
1841
|
+
if (run === undefined || unit === undefined) throw new UsageError('status takes --run and --unit together')
|
|
1842
|
+
if (!nonEmpty(run) || !isAbsolute(run)) throw new UsageError('--run must be an absolute run directory')
|
|
1843
|
+
const runReal = canonicalExistingDir(run, 'run directory')
|
|
1844
|
+
return {
|
|
1845
|
+
matches: (entry) => isObject(entry?.owner) && entry.owner.run === runReal && String(entry.owner.unit) === String(unit),
|
|
1846
|
+
describe: { run: runReal, unit: String(unit) },
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
export function resourceStatus({ repo, run, unit, bounds = {} } = {}) {
|
|
1851
|
+
const { root, repo: canonical } = canonicalRoot(repo)
|
|
1852
|
+
const filter = statusFilter({ run, unit })
|
|
1853
|
+
const manifest = existsSync(root) ? readManifest(root) : { version: MANIFEST_VERSION, entries: [] }
|
|
1854
|
+
const entries = manifest.entries
|
|
1855
|
+
.filter((entry) => filter.matches(entry))
|
|
1856
|
+
.map((entry) => {
|
|
1857
|
+
try {
|
|
1858
|
+
return statusOf(entry, bounds)
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
return {
|
|
1861
|
+
id: entry?.id ?? null, path: entry?.path ?? null, state: 'unreadable', ready: false,
|
|
1862
|
+
rebuildRequired: true, reason: `status could not be read: ${error?.message ?? error}`,
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
})
|
|
1866
|
+
.sort((a, b) => String(a.path).localeCompare(String(b.path)))
|
|
1867
|
+
const counts = {}
|
|
1868
|
+
for (const entry of entries) counts[entry.state] = (counts[entry.state] ?? 0) + 1
|
|
1869
|
+
return {
|
|
1870
|
+
version: 1, command: 'status', ok: true, readOnly: true, applied: false,
|
|
1871
|
+
repo: canonical, root, filter: filter.describe, entries, counts,
|
|
1872
|
+
summary: {
|
|
1873
|
+
total: entries.length,
|
|
1874
|
+
ready: entries.filter((entry) => entry.ready).length,
|
|
1875
|
+
rebuildRequired: entries.filter((entry) => entry.rebuildRequired).length,
|
|
1876
|
+
byState: counts,
|
|
1877
|
+
},
|
|
1878
|
+
errors: [],
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// ---- recover ----
|
|
1883
|
+
|
|
1884
|
+
// Read-only receipt inventory. A receipt is the only record of what an interrupted apply
|
|
1885
|
+
// reached, so recovery reports it and changes nothing about it.
|
|
1886
|
+
function receiptReport(root) {
|
|
1887
|
+
const dir = join(root, RECEIPT_DIR)
|
|
1888
|
+
let names
|
|
1889
|
+
try { names = readdirSync(dir) } catch {
|
|
1890
|
+
return { dir, count: 0, entries: [], counts: { total: 0, applying: 0, complete: 0, failed: 0, other: 0 }, truncated: false }
|
|
1891
|
+
}
|
|
1892
|
+
const files = names.filter((name) => name.endsWith('.json')).sort()
|
|
1893
|
+
const entries = []
|
|
1894
|
+
for (const name of files.slice(0, MAX_STATUS_RECEIPTS)) {
|
|
1895
|
+
const path = join(dir, name)
|
|
1896
|
+
const st = lstatOrNull(path)
|
|
1897
|
+
if (!st || st.isSymbolicLink() || !st.isFile() || st.size > 4 * 1024 * 1024) {
|
|
1898
|
+
entries.push({ file: name, status: 'unreadable' })
|
|
1899
|
+
continue
|
|
1900
|
+
}
|
|
1901
|
+
let saved
|
|
1902
|
+
try { saved = JSON.parse(readFileSync(path, 'utf8')) } catch { entries.push({ file: name, status: 'unreadable' }); continue }
|
|
1903
|
+
entries.push({
|
|
1904
|
+
file: name,
|
|
1905
|
+
id: saved?.id ?? null,
|
|
1906
|
+
path: saved?.path ?? null,
|
|
1907
|
+
status: saved?.status ?? 'unknown',
|
|
1908
|
+
at: saved?.at ?? null,
|
|
1909
|
+
quarantine: saved?.quarantine ?? null,
|
|
1910
|
+
removed: saved?.removed === true,
|
|
1911
|
+
reason: saved?.reason ?? null,
|
|
1912
|
+
})
|
|
1913
|
+
}
|
|
1914
|
+
const counts = { total: files.length, applying: 0, complete: 0, failed: 0, other: 0 }
|
|
1915
|
+
for (const entry of entries) {
|
|
1916
|
+
if (entry.status === 'applying') counts.applying += 1
|
|
1917
|
+
else if (entry.status === 'complete') counts.complete += 1
|
|
1918
|
+
else if (entry.status === 'failed') counts.failed += 1
|
|
1919
|
+
else counts.other += 1
|
|
1920
|
+
}
|
|
1921
|
+
return { dir, count: files.length, entries, counts, truncated: files.length > MAX_STATUS_RECEIPTS }
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
export function recoverRetention({ repo, apply = false } = {}) {
|
|
1925
|
+
const { root } = canonicalRoot(repo)
|
|
1926
|
+
const canonical = canonicalRepo(repo)
|
|
1927
|
+
if (!existsSync(root)) {
|
|
1928
|
+
return {
|
|
1929
|
+
version: 1, command: 'recover', ok: true, applied: false, dryRun: !apply,
|
|
1930
|
+
repo: canonical, root, lockPath: join(root, LOCK_NAME), status: 'absent', action: 'none',
|
|
1931
|
+
reason: 'root-missing', detail: `${root} does not exist`, cleared: { lock: false, tombstones: [] },
|
|
1932
|
+
remaining: { lock: false, tombstones: [] }, tombstones: [], quarantines: [],
|
|
1933
|
+
receipts: { dir: join(root, RECEIPT_DIR), count: 0, entries: [], counts: { total: 0, applying: 0, complete: 0, failed: 0, other: 0 }, truncated: false },
|
|
1934
|
+
resourcesUntouched: true, claimsSuccessor: false,
|
|
1935
|
+
note: 'there is nothing to recover; no cleanup state was read or changed',
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const recovery = recoverRetentionLock(root, { apply })
|
|
1939
|
+
return {
|
|
1940
|
+
...recovery,
|
|
1941
|
+
version: 1,
|
|
1942
|
+
command: 'recover',
|
|
1943
|
+
repo: canonical,
|
|
1944
|
+
ok: recovery.failures.length === 0,
|
|
1945
|
+
applied: recovery.cleared.lock || recovery.cleared.tombstones.length > 0,
|
|
1946
|
+
receipts: receiptReport(recovery.root),
|
|
1947
|
+
resourcesUntouched: true,
|
|
1948
|
+
claimsSuccessor: false,
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// ---- register-owned (bounded batch inventory) ----
|
|
1953
|
+
|
|
1954
|
+
// The batch form reads only what the unit's plan declares. It needs an explicit retention
|
|
1955
|
+
// record per resource and never adopts an unrecorded path, so a path the plan does not
|
|
1956
|
+
// carry stays invisible here no matter how old or how large it is.
|
|
1957
|
+
function retentionSpec(resource) {
|
|
1958
|
+
const raw = resource?.retention
|
|
1959
|
+
if (!isObject(raw)) return { ok: false, reason: 'the declared resource carries no retention metadata ({kind, format, recipe, inputs})' }
|
|
1960
|
+
if (!KINDS.has(raw.kind)) return { ok: false, reason: `retention.kind ${String(raw.kind)} is not generated|artifact` }
|
|
1961
|
+
if (!nonEmpty(raw.format) || !FORMATS.has(raw.format)) {
|
|
1962
|
+
return { ok: false, reason: `retention.format ${String(raw.format)} is not ${[...FORMATS].join('|')}` }
|
|
1963
|
+
}
|
|
1964
|
+
if (typeof raw.recipe !== 'string') return { ok: false, reason: 'retention.recipe must be text' }
|
|
1965
|
+
if (raw.inputs !== undefined && !Array.isArray(raw.inputs)) return { ok: false, reason: 'retention.inputs must be an array of absolute paths' }
|
|
1966
|
+
for (const input of Array.isArray(raw.inputs) ? raw.inputs : []) {
|
|
1967
|
+
if (!nonEmpty(input) || !isAbsolute(input)) return { ok: false, reason: 'retention.inputs must be absolute canonical file paths' }
|
|
1968
|
+
}
|
|
1969
|
+
return {
|
|
1970
|
+
ok: true,
|
|
1971
|
+
kind: raw.kind,
|
|
1972
|
+
format: raw.format,
|
|
1973
|
+
recipe: raw.recipe,
|
|
1974
|
+
inputs: Array.isArray(raw.inputs) ? raw.inputs : [],
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
export function registerOwned({ repo, run, unit, lastUsedAt, apply = false, now, bounds = {} } = {}) {
|
|
1979
|
+
const nowMs = nowValue(now)
|
|
1980
|
+
const { root } = canonicalRoot(repo, { mustExist: true })
|
|
1981
|
+
if (!nonEmpty(unit)) throw new UsageError('--unit must be a unit id')
|
|
1982
|
+
if (!nonEmpty(run) || !isAbsolute(run)) throw new UsageError('--run must be an absolute run directory')
|
|
1983
|
+
if (lastUsedAt === undefined) throw new UsageError('--last-used-at is required; activity dates are caller-supplied, never inferred')
|
|
1984
|
+
const usedAt = assertTimestamp(lastUsedAt, '--last-used-at')
|
|
1985
|
+
if (timestamp(usedAt) > nowMs) throw new RetentionError('timestamp', `--last-used-at ${usedAt} is in the future`)
|
|
1986
|
+
const runReal = canonicalExistingDir(run, 'run directory')
|
|
1987
|
+
const rootReal = real(root)
|
|
1988
|
+
if (!strictInside(rootReal, runReal)) throw new RetentionError('ownership', `run ${runReal} is not inside the canonical root ${root}`)
|
|
1989
|
+
if (symlinkOnPath(rootReal, resolve(run))) throw new RetentionError('symlink', `run ${run} passes through a symbolic link`)
|
|
1990
|
+
const planPath = join(runReal, 'task-plan.json')
|
|
1991
|
+
const plan = readPlanFile(planPath)
|
|
1992
|
+
if (!plan) throw new RetentionError('plan', `owner task-plan ${planPath} is missing or unreadable`)
|
|
1993
|
+
const member = unitOfPlan(plan, unit)
|
|
1994
|
+
if (!member) throw new RetentionError('ownership', `unit ${unit} is not recorded in ${planPath}`)
|
|
1995
|
+
const declared = member.lifecycle?.resources
|
|
1996
|
+
if (declared !== undefined && !Array.isArray(declared)) {
|
|
1997
|
+
throw new RetentionError('plan', `unit ${unit} resources are not an array, so the declared inventory cannot be read`)
|
|
1998
|
+
}
|
|
1999
|
+
const declaredTotal = Array.isArray(declared) ? declared.length : 0
|
|
2000
|
+
const list = (Array.isArray(declared) ? declared : []).slice(0, MAX_BATCH_RESOURCES)
|
|
2001
|
+
const truncated = list.length < declaredTotal
|
|
2002
|
+
const registrations = []
|
|
2003
|
+
const deferred = []
|
|
2004
|
+
const uncovered = []
|
|
2005
|
+
const failed = []
|
|
2006
|
+
for (const resource of list) {
|
|
2007
|
+
const declaredPath = isObject(resource) && nonEmpty(resource.path) ? resource.path : null
|
|
2008
|
+
const lexical = declaredPath === null ? null : (isAbsolute(declaredPath) ? resolve(declaredPath) : resolve(runReal, declaredPath))
|
|
2009
|
+
if (!lexical) {
|
|
2010
|
+
uncovered.push({ path: declaredPath, reason: 'the declared resource names no path, so it cannot be registered' })
|
|
2011
|
+
continue
|
|
2012
|
+
}
|
|
2013
|
+
const spec = retentionSpec(resource)
|
|
2014
|
+
if (!spec.ok) {
|
|
2015
|
+
uncovered.push({ path: lexical, reason: spec.reason })
|
|
2016
|
+
continue
|
|
2017
|
+
}
|
|
2018
|
+
try {
|
|
2019
|
+
const result = registerResource({
|
|
2020
|
+
repo, run: runReal, unit, path: lexical, kind: spec.kind, format: spec.format,
|
|
2021
|
+
inputs: spec.inputs, recipe: spec.recipe, lastUsedAt: usedAt, now: nowMs, bounds,
|
|
2022
|
+
dryRun: !apply,
|
|
2023
|
+
})
|
|
2024
|
+
registrations.push({
|
|
2025
|
+
path: result.resource.path,
|
|
2026
|
+
id: result.resource.id,
|
|
2027
|
+
kind: spec.kind,
|
|
2028
|
+
format: spec.format,
|
|
2029
|
+
recipe: spec.recipe,
|
|
2030
|
+
inputs: spec.inputs,
|
|
2031
|
+
action: apply ? 'registered' : 'would-register',
|
|
2032
|
+
state: apply ? 'registered' : 'verified',
|
|
2033
|
+
refreshed: result.refreshed === true,
|
|
2034
|
+
ownerReleased: result.ownerReleased === true,
|
|
2035
|
+
})
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
const entry = {
|
|
2038
|
+
path: lexical,
|
|
2039
|
+
kind: spec.kind,
|
|
2040
|
+
format: spec.format,
|
|
2041
|
+
recipe: spec.recipe,
|
|
2042
|
+
inputs: spec.inputs,
|
|
2043
|
+
state: 'deferred',
|
|
2044
|
+
deferredReason: error?.code ?? 'error',
|
|
2045
|
+
reason: error?.message ?? String(error),
|
|
2046
|
+
}
|
|
2047
|
+
// An I/O failure is not a deferral: the command must not report full coverage.
|
|
2048
|
+
if (entry.deferredReason === 'io') failed.push(entry)
|
|
2049
|
+
else deferred.push(entry)
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const counts = {
|
|
2053
|
+
declared: list.length,
|
|
2054
|
+
declaredTotal,
|
|
2055
|
+
registrations: registrations.length,
|
|
2056
|
+
deferred: deferred.length,
|
|
2057
|
+
uncovered: uncovered.length,
|
|
2058
|
+
failed: failed.length,
|
|
2059
|
+
bounded: truncated,
|
|
2060
|
+
}
|
|
2061
|
+
const complete = deferred.length === 0 && uncovered.length === 0 && failed.length === 0 && !truncated
|
|
2062
|
+
return {
|
|
2063
|
+
version: 1, command: 'register-owned', ok: complete, applied: apply, dryRun: !apply,
|
|
2064
|
+
repo: canonicalRepo(repo), root, run: runReal, unit: String(unit), lastUsedAt: usedAt,
|
|
2065
|
+
ownerCompleted: unitCompleted(member),
|
|
2066
|
+
// The batch form records inventory; it never releases an unfinished owner's resource.
|
|
2067
|
+
ownerReleaseRequested: false,
|
|
2068
|
+
registrations, deferred, uncovered, failed, counts,
|
|
2069
|
+
coverage: {
|
|
2070
|
+
complete,
|
|
2071
|
+
reason: list.length === 0
|
|
2072
|
+
? `unit ${unit} declares no resources`
|
|
2073
|
+
: truncated
|
|
2074
|
+
? `the declared inventory exceeds the ${MAX_BATCH_RESOURCES}-resource batch bound; ${declaredTotal - list.length} resource(s) were not examined`
|
|
2075
|
+
: complete
|
|
2076
|
+
? `every declared resource with retention metadata was ${apply ? 'registered' : 'verified'}`
|
|
2077
|
+
: `${counts.deferred} deferred, ${counts.uncovered} uncovered, ${counts.failed} failed of ${counts.declared} declared`,
|
|
2078
|
+
},
|
|
2079
|
+
errors: failed.map((entry) => entry.reason),
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// ---- CLI ----
|
|
2084
|
+
|
|
2085
|
+
function positiveOrZero(value, flag) {
|
|
2086
|
+
const number = Number(value)
|
|
2087
|
+
if (!Number.isSafeInteger(number) || number < 0) throw new UsageError(`${flag} must be a nonnegative integer`)
|
|
2088
|
+
return number
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
const COMMANDS = new Set(['plan', 'apply', 'register', 'register-owned', 'status', 'recover'])
|
|
2092
|
+
const APPLY_COMMANDS = new Set(['apply', 'recover', 'register-owned'])
|
|
2093
|
+
|
|
2094
|
+
// Which flags each command accepts. Anything else is a usage error, so a misplaced flag can
|
|
2095
|
+
// never quietly change what a command does.
|
|
2096
|
+
const COMMAND_FLAGS = {
|
|
2097
|
+
plan: ['--repo', '--generated-days', '--artifact-days'],
|
|
2098
|
+
apply: ['--repo', '--generated-days', '--artifact-days'],
|
|
2099
|
+
register: ['--repo', '--run', '--unit', '--path', '--kind', '--last-used-at', '--recipe', '--format', '--input'],
|
|
2100
|
+
'register-owned': ['--repo', '--run', '--unit', '--last-used-at'],
|
|
2101
|
+
status: ['--repo', '--run', '--unit'],
|
|
2102
|
+
recover: ['--repo'],
|
|
2103
|
+
}
|
|
2104
|
+
const BOOLEAN_FLAGS = {
|
|
2105
|
+
register: ['--release-owner'],
|
|
2106
|
+
}
|
|
2107
|
+
const REGISTER_ARG_FLAGS = new Set(['--run', '--unit', '--path', '--kind', '--last-used-at', '--recipe', '--format', '--input'])
|
|
2108
|
+
|
|
2109
|
+
export function parseArgs(argv) {
|
|
2110
|
+
const opts = {
|
|
2111
|
+
command: null, repo: null, generatedDays: 7, artifactDays: 30, json: false, apply: false,
|
|
2112
|
+
run: null, unit: null, path: null, kind: null, lastUsedAt: null, recipe: undefined,
|
|
2113
|
+
format: null, inputs: [], releaseOwner: false, help: false,
|
|
2114
|
+
}
|
|
2115
|
+
const seen = new Set()
|
|
2116
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2117
|
+
const arg = argv[i]
|
|
2118
|
+
if (!opts.command && COMMANDS.has(arg)) { opts.command = arg; continue }
|
|
2119
|
+
if (arg === '--apply') { opts.apply = true; continue }
|
|
2120
|
+
if (arg === '--json') { opts.json = true; continue }
|
|
2121
|
+
if (arg === '--release-owner') { opts.releaseOwner = true; continue }
|
|
2122
|
+
if (arg === '--help' || arg === '-h') { opts.help = true; continue }
|
|
2123
|
+
const value = () => {
|
|
2124
|
+
if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) throw new UsageError(`${arg} needs a value`)
|
|
2125
|
+
return argv[++i]
|
|
2126
|
+
}
|
|
2127
|
+
if (arg === '--repo') opts.repo = value()
|
|
2128
|
+
else if (arg === '--generated-days') opts.generatedDays = positiveOrZero(value(), '--generated-days')
|
|
2129
|
+
else if (arg === '--artifact-days') opts.artifactDays = positiveOrZero(value(), '--artifact-days')
|
|
2130
|
+
else if (arg === '--run') opts.run = value()
|
|
2131
|
+
else if (arg === '--unit') opts.unit = value()
|
|
2132
|
+
else if (arg === '--path') opts.path = value()
|
|
2133
|
+
else if (arg === '--kind') opts.kind = value()
|
|
2134
|
+
else if (arg === '--last-used-at') opts.lastUsedAt = value()
|
|
2135
|
+
else if (arg === '--recipe') opts.recipe = value()
|
|
2136
|
+
else if (arg === '--format') opts.format = value()
|
|
2137
|
+
else if (arg === '--input') opts.inputs.push(value())
|
|
2138
|
+
else throw new UsageError(`unrecognized argument ${arg}`)
|
|
2139
|
+
if (['--generated-days', '--artifact-days', '--run', '--unit', '--path', '--kind', '--last-used-at', '--recipe', '--format', '--input'].includes(arg)) seen.add(arg)
|
|
2140
|
+
}
|
|
2141
|
+
if (opts.help) return opts
|
|
2142
|
+
if (!opts.command) opts.command = opts.apply ? 'apply' : 'plan'
|
|
2143
|
+
if (opts.apply && !APPLY_COMMANDS.has(opts.command)) throw new UsageError(`${opts.command} does not take --apply`)
|
|
2144
|
+
if (!opts.repo) throw new UsageError('--repo is required')
|
|
2145
|
+
const allowed = new Set(COMMAND_FLAGS[opts.command])
|
|
2146
|
+
const booleans = new Set(BOOLEAN_FLAGS[opts.command] ?? [])
|
|
2147
|
+
for (const flag of seen) {
|
|
2148
|
+
if (!allowed.has(flag)) {
|
|
2149
|
+
throw new UsageError(REGISTER_ARG_FLAGS.has(flag)
|
|
2150
|
+
? `${opts.command} does not take register arguments (${flag})`
|
|
2151
|
+
: `${opts.command} does not take ${flag}`)
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
if (opts.releaseOwner && !booleans.has('--release-owner')) throw new UsageError(`${opts.command} does not take --release-owner`)
|
|
2155
|
+
if (opts.command === 'register') {
|
|
2156
|
+
for (const [flag, key] of [['--run', 'run'], ['--unit', 'unit'], ['--path', 'path'], ['--kind', 'kind'], ['--last-used-at', 'lastUsedAt']]) {
|
|
2157
|
+
if (!nonEmpty(opts[key])) throw new UsageError(`register needs ${flag}`)
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
if (opts.command === 'register-owned') {
|
|
2161
|
+
if (!nonEmpty(opts.run)) throw new UsageError('register-owned needs --run')
|
|
2162
|
+
if (!nonEmpty(opts.unit)) throw new UsageError('register-owned needs --unit')
|
|
2163
|
+
}
|
|
2164
|
+
if (opts.command === 'status' && nonEmpty(opts.run) !== nonEmpty(opts.unit)) {
|
|
2165
|
+
throw new UsageError('status takes --run and --unit together')
|
|
2166
|
+
}
|
|
2167
|
+
if (opts.command === 'register-owned' && !nonEmpty(opts.lastUsedAt)) throw new UsageError('register-owned needs --last-used-at')
|
|
2168
|
+
return opts
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function renderPlan(result) {
|
|
2172
|
+
const lines = [`retention plan (read-only): ${result.root}`,
|
|
2173
|
+
` generated ${result.generatedDays}d, artifact ${result.artifactDays}d`]
|
|
2174
|
+
for (const row of result.entries) {
|
|
2175
|
+
lines.push(` ${row.status.toUpperCase().padEnd(16)} ${row.path ?? '(invalid entry)'} ${row.reason ?? ''}`)
|
|
2176
|
+
}
|
|
2177
|
+
const s = result.summary
|
|
2178
|
+
lines.push(` ${s.total} entr${s.total === 1 ? 'y' : 'ies'}: ${s.ready} ready (${s.candidatesBytes} byte(s)), ${s.retained} retained, ${s.refused} refused, ${s.alreadyRemoved} already removed`)
|
|
2179
|
+
if (result.inventory.runs.length) {
|
|
2180
|
+
lines.push(' largest run directories (inventory only, never removal candidates):')
|
|
2181
|
+
for (const run of result.inventory.runs.slice(0, 10)) lines.push(` ${run.bytes} ${run.run}${run.truncated ? ' (bounded)' : ''}`)
|
|
2182
|
+
}
|
|
2183
|
+
if (result.inventory.resources?.length) {
|
|
2184
|
+
lines.push(' largest run resources (inventory only; only manifest entries are candidates):')
|
|
2185
|
+
for (const resource of result.inventory.resources.slice(0, 10)) lines.push(` ${resource.bytes} ${resource.path}${resource.truncated ? ' (bounded)' : ''}`)
|
|
2186
|
+
}
|
|
2187
|
+
return lines.join('\n')
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function renderApply(result) {
|
|
2191
|
+
const lines = [`retention apply: ${result.root}`]
|
|
2192
|
+
for (const row of result.entries) {
|
|
2193
|
+
lines.push(` ${row.action.toUpperCase().padEnd(16)} ${row.path ?? '(invalid entry)'} ${row.reason ?? ''}`)
|
|
2194
|
+
}
|
|
2195
|
+
lines.push(` ${result.entries.length} row(s), ${result.ioFailures.length} I/O failure(s)`)
|
|
2196
|
+
return lines.join('\n')
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
function renderRegister(result) {
|
|
2200
|
+
return [
|
|
2201
|
+
`${result.dryRun ? 'would register' : 'registered'} ${result.resource.kind} ${result.resource.path}`,
|
|
2202
|
+
` id ${result.resource.id} format ${result.resource.format} identity ${result.resource.identity.dev}/${result.resource.identity.ino} owner ${result.resource.owner.unit}`,
|
|
2203
|
+
...(result.resource.inputProofs ? result.resource.inputProofs.map((proof) => ` input ${proof.name} ${proof.path}`) : []),
|
|
2204
|
+
` authorization ${result.authorization.source} at ${result.authorization.at}${result.ownerReleased ? ' (owner released)' : ''}`,
|
|
2205
|
+
` manifest ${result.manifest}${result.dryRun ? ' (not written; preview)' : ''}`,
|
|
2206
|
+
].join('\n')
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function renderStatus(result) {
|
|
2210
|
+
const lines = [`retention status (read-only): ${result.root}`]
|
|
2211
|
+
if (result.filter) lines.push(` filter run ${result.filter.run} unit ${result.filter.unit}`)
|
|
2212
|
+
for (const row of result.entries) {
|
|
2213
|
+
lines.push(` ${row.state.toUpperCase().padEnd(15)} ${row.path ?? '(invalid entry)'} ${row.reason ?? ''}`)
|
|
2214
|
+
if (row.recipe) lines.push(` recipe: ${row.recipe}`)
|
|
2215
|
+
if (row.inputs?.length) lines.push(` inputs: ${row.inputs.map((input) => `${input.name} ${input.path}`).join(', ')}`)
|
|
2216
|
+
}
|
|
2217
|
+
lines.push(` ${result.summary.total} registered: ${result.summary.ready} ready, ${result.summary.rebuildRequired} rebuild required`)
|
|
2218
|
+
return lines.join('\n')
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
function renderRecover(result) {
|
|
2222
|
+
const lines = [
|
|
2223
|
+
`retention recover${result.dryRun ? ' (read-only)' : ''}: ${result.root}`,
|
|
2224
|
+
` status ${result.status} action ${result.action} reason ${result.reason ?? '(none)'} ${result.detail ?? ''}`,
|
|
2225
|
+
` cleared lock ${result.cleared.lock ? 'yes' : 'no'}, tombstones ${result.cleared.tombstones.length}`,
|
|
2226
|
+
]
|
|
2227
|
+
const receipts = result.receipts
|
|
2228
|
+
lines.push(` receipts: ${receipts.counts.total} total, ${receipts.counts.applying} applying, ${receipts.counts.failed} failed, ${receipts.counts.complete} complete (unchanged)`)
|
|
2229
|
+
for (const receipt of receipts.entries.filter((entry) => entry.status !== 'complete').slice(0, 10)) {
|
|
2230
|
+
lines.push(` ${receipt.status} ${receipt.id ?? receipt.file} ${receipt.path ?? ''} ${receipt.quarantine ? `quarantine ${receipt.quarantine}` : ''}`)
|
|
2231
|
+
}
|
|
2232
|
+
lines.push(' no resource, receipt, or quarantined tree was removed; recovery never claims a successor apply')
|
|
2233
|
+
return lines.join('\n')
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
function renderRegisterOwned(result) {
|
|
2237
|
+
const lines = [
|
|
2238
|
+
`retention register-owned${result.dryRun ? ' (preview)' : ''}: ${result.root}`,
|
|
2239
|
+
` run ${result.run} unit ${result.unit} last-used ${result.lastUsedAt}`,
|
|
2240
|
+
]
|
|
2241
|
+
for (const row of result.registrations) lines.push(` ${row.action.toUpperCase().padEnd(16)} ${row.path} ${row.format} ${row.kind}`)
|
|
2242
|
+
for (const row of result.deferred) lines.push(` DEFERRED ${row.path} ${row.reason}`)
|
|
2243
|
+
for (const row of result.failed) lines.push(` FAILED ${row.path} ${row.reason}`)
|
|
2244
|
+
for (const row of result.uncovered) lines.push(` UNCOVERED ${row.path ?? '(no path)'} ${row.reason}`)
|
|
2245
|
+
lines.push(` ${result.counts.declared} declared: ${result.counts.registrations} ${result.dryRun ? 'verified' : 'registered'}, ${result.counts.deferred} deferred, ${result.counts.uncovered} uncovered, ${result.counts.failed} failed`)
|
|
2246
|
+
lines.push(` coverage ${result.coverage.complete ? 'complete' : 'partial'}: ${result.coverage.reason}`)
|
|
2247
|
+
return lines.join('\n')
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
function failure(command, error) {
|
|
2251
|
+
const code = error instanceof UsageError ? 'usage' : (error?.code ?? 'error')
|
|
2252
|
+
return { version: 1, command, ok: false, applied: false, error: { code, message: error?.message ?? String(error) }, errors: [error?.message ?? String(error)] }
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
export function run(argv, runtime = {}) {
|
|
2256
|
+
let opts
|
|
2257
|
+
try { opts = parseArgs(argv) } catch (error) {
|
|
2258
|
+
return { code: 2, error: error.message, json: failure(null, error), text: `retention: ${error.message}\n${USAGE}` }
|
|
2259
|
+
}
|
|
2260
|
+
if (opts.help) return { code: 0, text: `${USAGE}\n` }
|
|
2261
|
+
const shared = {
|
|
2262
|
+
repo: opts.repo,
|
|
2263
|
+
generatedDays: opts.generatedDays,
|
|
2264
|
+
artifactDays: opts.artifactDays,
|
|
2265
|
+
...(runtime.now !== undefined ? { now: runtime.now } : {}),
|
|
2266
|
+
...(runtime.probe ? { probe: runtime.probe } : {}),
|
|
2267
|
+
...(runtime.bounds ? { bounds: runtime.bounds } : {}),
|
|
2268
|
+
...(runtime.hooks ? { hooks: runtime.hooks } : {}),
|
|
2269
|
+
}
|
|
2270
|
+
try {
|
|
2271
|
+
if (opts.command === 'register') {
|
|
2272
|
+
const result = registerResource({
|
|
2273
|
+
...shared, run: opts.run, unit: opts.unit, path: opts.path, kind: opts.kind,
|
|
2274
|
+
lastUsedAt: opts.lastUsedAt, recipe: opts.recipe, releaseOwner: opts.releaseOwner,
|
|
2275
|
+
...(opts.format !== null ? { format: opts.format } : {}),
|
|
2276
|
+
inputs: opts.inputs,
|
|
2277
|
+
})
|
|
2278
|
+
return { code: 0, json: result, text: renderRegister(result) }
|
|
2279
|
+
}
|
|
2280
|
+
if (opts.command === 'register-owned') {
|
|
2281
|
+
const result = registerOwned({
|
|
2282
|
+
...shared, run: opts.run, unit: opts.unit, lastUsedAt: opts.lastUsedAt, apply: opts.apply,
|
|
2283
|
+
})
|
|
2284
|
+
return { code: result.ok ? 0 : 1, json: result, text: renderRegisterOwned(result) }
|
|
2285
|
+
}
|
|
2286
|
+
if (opts.command === 'status') {
|
|
2287
|
+
const result = resourceStatus({ ...shared, run: opts.run ?? undefined, unit: opts.unit ?? undefined })
|
|
2288
|
+
return { code: 0, json: result, text: renderStatus(result) }
|
|
2289
|
+
}
|
|
2290
|
+
if (opts.command === 'recover') {
|
|
2291
|
+
const result = recoverRetention({ repo: opts.repo, apply: opts.apply })
|
|
2292
|
+
return { code: result.failures?.length ? 1 : 0, json: result, text: renderRecover(result) }
|
|
2293
|
+
}
|
|
2294
|
+
if (opts.command === 'apply') {
|
|
2295
|
+
const result = applyRetention(shared)
|
|
2296
|
+
return { code: result.ioFailures.length ? 1 : 0, json: result, text: renderApply(result) }
|
|
2297
|
+
}
|
|
2298
|
+
const result = planRetention(shared)
|
|
2299
|
+
return { code: 0, json: result, text: renderPlan(result) }
|
|
2300
|
+
} catch (error) {
|
|
2301
|
+
const code = error instanceof RetentionError ? (error.code === 'io' ? 1 : 2) : 1
|
|
2302
|
+
return { code, error: error.message, json: failure(opts.command, error), text: `retention: ${error.message}` }
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
export function main(argv) {
|
|
2307
|
+
const result = run(argv)
|
|
2308
|
+
if (argv.includes('--json')) process.stdout.write(`${JSON.stringify(result.json ?? failure(null, new Error(result.error ?? 'unknown error')), null, 2)}\n`)
|
|
2309
|
+
else if (result.error) process.stderr.write(`${result.text}\n`)
|
|
2310
|
+
else process.stdout.write(`${result.text}\n`)
|
|
2311
|
+
return result.code
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
if (isMainModule(import.meta.url)) process.exitCode = main(process.argv.slice(2))
|