@1aboveio/skills 1.0.18 → 1.0.19
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 +138 -132
- 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 +1 -1
- 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 +4 -2
- 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/workspaces.md +40 -10
- package/skills/engineering/resolve-issues/scripts/daily-cleanup.mjs +169 -0
- 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-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,548 @@
|
|
|
1
|
+
// Interrupted-retention lock for <root>/.retention-lock.
|
|
2
|
+
//
|
|
3
|
+
// A retention apply holds this lock while it snapshots, quarantines, receipts, and
|
|
4
|
+
// removes registered resources. A process killed mid-apply leaves the lock behind, and
|
|
5
|
+
// the lock is never reclaimed by age, so without a recovery path the next daily refuses
|
|
6
|
+
// forever. This module is the whole lock contract:
|
|
7
|
+
//
|
|
8
|
+
// acquireRetentionLock(root[, options]) -> { root, path, owner, release() }
|
|
9
|
+
// inspectRetentionLock(root) -> read-only report; mutates nothing
|
|
10
|
+
// recoverRetentionLock(root, { apply }) -> clears only a proven-dead local owner
|
|
11
|
+
//
|
|
12
|
+
// `root` is the directory that holds the lock; retention calls it `.resolve-issues`.
|
|
13
|
+
// acquire's options are the recorded identity: `pid` (default this process) and
|
|
14
|
+
// `hostname` (default os.hostname()). acquire fails with code 'locked' when any lock
|
|
15
|
+
// exists, including a provably dead one, so clearing is always an explicit decision.
|
|
16
|
+
//
|
|
17
|
+
// The owner record is a strong schema: format, nonce, pid, hostname, startIdentity and
|
|
18
|
+
// acquiredAt. Recovery requires all of it and a positive answer to one question: is the
|
|
19
|
+
// recorded owner provably dead? That means the hostname is this host and either the pid
|
|
20
|
+
// is gone (kill(pid, 0) => ESRCH) or the pid is alive with a start identity that differs
|
|
21
|
+
// from the recorded one, which proves the pid was reused by a later process. Every other
|
|
22
|
+
// answer is retained and reported, never guessed at: a legacy {pid, at} record (no host,
|
|
23
|
+
// nonce or identity), another host, an empty unreadable or unexpected lock, a symlink or
|
|
24
|
+
// hardlink, a pid that is not a positive integer (never probed), a pid that cannot be
|
|
25
|
+
// probed (EPERM), and a live pid whose start identity cannot be read or was not recorded.
|
|
26
|
+
// Age and idle time are not inputs, so an old lock with an unprovable owner stays.
|
|
27
|
+
//
|
|
28
|
+
// Clearing is atomic and identity-checked: the live lock directory is renamed inside the
|
|
29
|
+
// same parent to `.retention-lock.tombstone-<owner nonce>`, the tombstone is proven to be
|
|
30
|
+
// the same directory (device and inode) with the same owner nonce, and only then are the
|
|
31
|
+
// owner file and the empty tombstone removed. A crash between those steps leaves a
|
|
32
|
+
// tombstone, which is litter rather than a lock, and the next recover finishes it under
|
|
33
|
+
// the same proof. Nothing outside the lock directory is ever touched: receipts,
|
|
34
|
+
// quarantined resources and the cleanup outcome are only observed, so clearing the lock
|
|
35
|
+
// is not a claim that an interrupted apply finished.
|
|
36
|
+
|
|
37
|
+
import { randomBytes } from 'node:crypto'
|
|
38
|
+
import { execFileSync } from 'node:child_process'
|
|
39
|
+
import {
|
|
40
|
+
lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync,
|
|
41
|
+
unlinkSync, writeFileSync,
|
|
42
|
+
} from 'node:fs'
|
|
43
|
+
import { hostname as osHostname } from 'node:os'
|
|
44
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
45
|
+
|
|
46
|
+
export const LOCK_NAME = '.retention-lock'
|
|
47
|
+
export const OWNER_FILE = 'owner.json'
|
|
48
|
+
export const LOCK_FORMAT = 'retention-lock/2'
|
|
49
|
+
export const TOMBSTONE_PREFIX = `${LOCK_NAME}.tombstone-`
|
|
50
|
+
|
|
51
|
+
const TOMBSTONE_PATTERN = new RegExp(`^${TOMBSTONE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([0-9a-f]{32})$`)
|
|
52
|
+
const QUARANTINE_PREFIX = '.retention-quarantine-'
|
|
53
|
+
const NONCE_PATTERN = /^[0-9a-f]{32}$/
|
|
54
|
+
const MAX_OWNER_BYTES = 8 * 1024
|
|
55
|
+
const PS_CANDIDATES = ['/bin/ps', '/usr/bin/ps']
|
|
56
|
+
const PROC_BOOT_ID = '/proc/sys/kernel/random/boot_id'
|
|
57
|
+
|
|
58
|
+
export class RetentionLockError extends Error {
|
|
59
|
+
constructor(code, message, inspection = null) {
|
|
60
|
+
super(message)
|
|
61
|
+
this.name = 'RetentionLockError'
|
|
62
|
+
this.code = code
|
|
63
|
+
this.inspection = inspection
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
68
|
+
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0
|
|
69
|
+
const lstatOrNull = (path) => {
|
|
70
|
+
try { return lstatSync(path) } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error }
|
|
71
|
+
}
|
|
72
|
+
const boundedText = (value, limit) => (typeof value === 'string' ? value.slice(0, limit) : null)
|
|
73
|
+
const normalizeHost = (value) => (nonEmpty(value) ? value.trim().toLowerCase() : '')
|
|
74
|
+
|
|
75
|
+
// A root that does not exist has no lock; a root that exists must be a real directory.
|
|
76
|
+
function readRoot(root) {
|
|
77
|
+
if (!isAbsolute(root ?? '')) throw new RetentionLockError('usage', 'root must be an absolute directory path')
|
|
78
|
+
let real
|
|
79
|
+
try {
|
|
80
|
+
real = realpathSync(root)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return { root: resolve(root), missing: true }
|
|
83
|
+
throw new RetentionLockError('root', `cannot resolve ${root}: ${error?.code ?? error?.message}`)
|
|
84
|
+
}
|
|
85
|
+
const st = lstatOrNull(real)
|
|
86
|
+
if (!st || !st.isDirectory()) throw new RetentionLockError('root', `${real} is not a directory`)
|
|
87
|
+
return { root: real, missing: false }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mustExistRoot(root) {
|
|
91
|
+
const read = readRoot(root)
|
|
92
|
+
if (read.missing) throw new RetentionLockError('root', `canonical root ${read.root} does not exist`)
|
|
93
|
+
return read.root
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- owner identity ----
|
|
97
|
+
|
|
98
|
+
function readStartIdentity(pid) {
|
|
99
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return null
|
|
100
|
+
try {
|
|
101
|
+
if (process.platform === 'linux') {
|
|
102
|
+
const bootId = readFileSync(PROC_BOOT_ID, 'utf8').trim()
|
|
103
|
+
if (!bootId) return null
|
|
104
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
105
|
+
const end = stat.lastIndexOf(')')
|
|
106
|
+
if (end < 1) return null
|
|
107
|
+
// Field 22 (starttime) is stable for the life of a process; only that field is read.
|
|
108
|
+
const started = stat.slice(end + 2).trim().split(' ')[19]
|
|
109
|
+
if (!started || !/^\d+$/.test(started)) return null
|
|
110
|
+
return `linux:${bootId}:${started}`
|
|
111
|
+
}
|
|
112
|
+
if (process.platform === 'darwin' || process.platform === 'freebsd') {
|
|
113
|
+
const binary = PS_CANDIDATES.find((candidate) => lstatOrNull(candidate)?.isFile())
|
|
114
|
+
if (!binary) return null
|
|
115
|
+
// One pid, start time only: no process listing, no argument or environment text.
|
|
116
|
+
const out = execFileSync(binary, ['-o', 'lstart=', '-p', String(pid)], {
|
|
117
|
+
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
118
|
+
})
|
|
119
|
+
const started = out.replace(/\s+/g, ' ').trim()
|
|
120
|
+
return started ? `ps-lstart:${started}` : null
|
|
121
|
+
}
|
|
122
|
+
} catch { /* an unreadable identity is unknown, and unknown never authorizes a removal */ }
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// kill(pid, 0) is the only liveness probe used: it signals nothing, and only ESRCH proves
|
|
127
|
+
// death. EPERM (or any other error) means the process may exist, so it is unknown.
|
|
128
|
+
function probeLiveness(pid) {
|
|
129
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return 'unknown'
|
|
130
|
+
try {
|
|
131
|
+
process.kill(pid, 0)
|
|
132
|
+
return 'alive'
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return error?.code === 'ESRCH' ? 'gone' : 'unknown'
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function echoOwner(record) {
|
|
139
|
+
if (!isObject(record)) return null
|
|
140
|
+
if (record.format === undefined && record.nonce === undefined && record.hostname === undefined) {
|
|
141
|
+
return {
|
|
142
|
+
legacy: true,
|
|
143
|
+
pid: Number.isSafeInteger(record.pid) ? record.pid : null,
|
|
144
|
+
at: boundedText(record.at, 64),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
legacy: false,
|
|
149
|
+
format: boundedText(record.format, 64),
|
|
150
|
+
nonce: boundedText(record.nonce, 64),
|
|
151
|
+
pid: Number.isSafeInteger(record.pid) ? record.pid : null,
|
|
152
|
+
hostname: boundedText(record.hostname, 255),
|
|
153
|
+
startIdentity: boundedText(record.startIdentity, 255),
|
|
154
|
+
acquiredAt: boundedText(record.acquiredAt, 64),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateOwner(record) {
|
|
159
|
+
if (record.format !== LOCK_FORMAT) {
|
|
160
|
+
const legacy = record.nonce === undefined && record.hostname === undefined && record.startIdentity === undefined
|
|
161
|
+
return legacy
|
|
162
|
+
? { ok: false, reason: 'owner-record-legacy', detail: 'the lock was written by an older retention build as {pid, at}; it carries no host, nonce or process identity, so the owner cannot be proven dead. Inspect and remove it by hand' }
|
|
163
|
+
: { ok: false, reason: 'owner-record-unsupported', detail: `the owner record format is not ${LOCK_FORMAT}` }
|
|
164
|
+
}
|
|
165
|
+
if (!nonEmpty(record.nonce) || !NONCE_PATTERN.test(record.nonce)) {
|
|
166
|
+
return { ok: false, reason: 'owner-record-invalid', detail: 'the owner nonce is missing or malformed' }
|
|
167
|
+
}
|
|
168
|
+
if (!Number.isSafeInteger(record.pid) || record.pid < 1) {
|
|
169
|
+
return { ok: false, reason: 'owner-pid-invalid', detail: 'the owner pid is missing or is not a positive safe integer, so it was not probed' }
|
|
170
|
+
}
|
|
171
|
+
if (!nonEmpty(record.hostname)) {
|
|
172
|
+
return { ok: false, reason: 'owner-record-invalid', detail: 'the owner hostname is missing' }
|
|
173
|
+
}
|
|
174
|
+
return { ok: true }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function verdict(verdictName, reason, detail, owner = null) {
|
|
178
|
+
return {
|
|
179
|
+
present: true,
|
|
180
|
+
verdict: verdictName,
|
|
181
|
+
recoverable: verdictName === 'dead',
|
|
182
|
+
reason,
|
|
183
|
+
detail,
|
|
184
|
+
owner,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- inspection ----
|
|
189
|
+
|
|
190
|
+
function inspectDirAt(dirPath, { name }) {
|
|
191
|
+
const st = lstatOrNull(dirPath)
|
|
192
|
+
if (!st) {
|
|
193
|
+
return { name, path: dirPath, result: { present: false, verdict: 'absent', recoverable: false, reason: 'no-lock', detail: `${dirPath} does not exist`, owner: null }, dirIdentity: null, nonce: null }
|
|
194
|
+
}
|
|
195
|
+
const dirIdentity = { dev: st.dev, ino: st.ino }
|
|
196
|
+
const describe = (result) => ({ name, path: dirPath, result: { name, ...result }, dirIdentity, nonce: result.owner?.legacy === false ? result.owner.nonce : null })
|
|
197
|
+
if (st.isSymbolicLink()) return describe(verdict('unknown', 'lock-is-symlink', `${dirPath} is a symbolic link; it is not followed`))
|
|
198
|
+
if (!st.isDirectory()) return describe(verdict('unknown', 'lock-not-a-directory', `${dirPath} exists but is not a directory`))
|
|
199
|
+
let names
|
|
200
|
+
try { names = readdirSync(dirPath) } catch (error) {
|
|
201
|
+
return describe(verdict('unknown', 'lock-unreadable', `${dirPath} cannot be listed: ${error?.code ?? error?.message}`))
|
|
202
|
+
}
|
|
203
|
+
if (names.length === 0) {
|
|
204
|
+
return describe(verdict('unknown', 'owner-record-missing', `${dirPath} is empty; no owner can be proven`))
|
|
205
|
+
}
|
|
206
|
+
if (names.some((entry) => entry !== OWNER_FILE)) {
|
|
207
|
+
return describe(verdict('unknown', 'lock-unexpected-contents', `${dirPath} holds entries besides ${OWNER_FILE}, so its contents are not known to be disposable`))
|
|
208
|
+
}
|
|
209
|
+
const ownerPath = join(dirPath, OWNER_FILE)
|
|
210
|
+
const ownerStat = lstatOrNull(ownerPath)
|
|
211
|
+
if (!ownerStat) {
|
|
212
|
+
return describe(verdict('unknown', 'owner-record-missing', `${ownerPath} does not exist; the lock directory is the lock`))
|
|
213
|
+
}
|
|
214
|
+
if (ownerStat.isSymbolicLink() || !ownerStat.isFile()) {
|
|
215
|
+
return describe(verdict('unknown', 'owner-file-unsafe', `${ownerPath} is not a regular file; it is not read through`))
|
|
216
|
+
}
|
|
217
|
+
if (ownerStat.nlink > 1) {
|
|
218
|
+
return describe(verdict('unknown', 'owner-file-hardlinked', `${ownerPath} has ${ownerStat.nlink} links, so it is not known to be solely the lock's`))
|
|
219
|
+
}
|
|
220
|
+
if (ownerStat.size > MAX_OWNER_BYTES) {
|
|
221
|
+
return describe(verdict('unknown', 'owner-record-unreadable', `${ownerPath} is larger than ${MAX_OWNER_BYTES} bytes`))
|
|
222
|
+
}
|
|
223
|
+
let record
|
|
224
|
+
try { record = JSON.parse(readFileSync(ownerPath, 'utf8')) } catch (error) {
|
|
225
|
+
return describe(verdict('unknown', 'owner-record-unreadable', `${ownerPath} cannot be read as JSON: ${error?.code ?? error?.message}`))
|
|
226
|
+
}
|
|
227
|
+
if (!isObject(record)) {
|
|
228
|
+
return describe(verdict('unknown', 'owner-record-unreadable', `${ownerPath} does not hold an owner object`))
|
|
229
|
+
}
|
|
230
|
+
const owner = echoOwner(record)
|
|
231
|
+
const schema = validateOwner(record)
|
|
232
|
+
if (!schema.ok) return describe(verdict('unknown', schema.reason, schema.detail, owner))
|
|
233
|
+
if (normalizeHost(record.hostname) !== normalizeHost(osHostname())) {
|
|
234
|
+
return describe(verdict('unknown', 'owner-other-host', `the owner was recorded on ${owner.hostname}, not on this host; local liveness says nothing about it`, owner))
|
|
235
|
+
}
|
|
236
|
+
if (record.pid === process.pid) {
|
|
237
|
+
// This process is the one the record names, unless its own start identity proves the
|
|
238
|
+
// record belongs to an earlier process that held the same pid.
|
|
239
|
+
const selfIdentity = readStartIdentity(process.pid)
|
|
240
|
+
if (nonEmpty(record.startIdentity) && nonEmpty(selfIdentity) && record.startIdentity !== selfIdentity) {
|
|
241
|
+
return describe(verdict('dead', 'owner-pid-reused', `owner pid ${record.pid} is this process, which started later than the recorded owner`, owner))
|
|
242
|
+
}
|
|
243
|
+
return describe(verdict('live', 'owner-process-alive', `pid ${record.pid} is this process, which holds the lock`, owner))
|
|
244
|
+
}
|
|
245
|
+
const liveness = probeLiveness(record.pid)
|
|
246
|
+
if (liveness === 'gone') {
|
|
247
|
+
return describe(verdict('dead', 'owner-process-gone', `owner pid ${record.pid} is not running on this host`, owner))
|
|
248
|
+
}
|
|
249
|
+
if (liveness === 'unknown') {
|
|
250
|
+
return describe(verdict('unknown', 'owner-process-unverifiable', `owner pid ${record.pid} exists but cannot be probed, so it may be alive`, owner))
|
|
251
|
+
}
|
|
252
|
+
if (!nonEmpty(record.startIdentity)) {
|
|
253
|
+
return describe(verdict('unknown', 'owner-identity-unrecorded', `owner pid ${record.pid} is alive and the record has no start identity, so a reused pid cannot be excluded`, owner))
|
|
254
|
+
}
|
|
255
|
+
const current = readStartIdentity(record.pid)
|
|
256
|
+
if (!nonEmpty(current)) {
|
|
257
|
+
return describe(verdict('unknown', 'owner-process-identity-unavailable', `owner pid ${record.pid} is alive and its start identity cannot be read on this host`, owner))
|
|
258
|
+
}
|
|
259
|
+
if (current !== record.startIdentity) {
|
|
260
|
+
return describe(verdict('dead', 'owner-pid-reused', `owner pid ${record.pid} is alive but belongs to a process that started later than the recorded owner`, owner))
|
|
261
|
+
}
|
|
262
|
+
return describe(verdict('live', 'owner-process-alive', `the recorded owner is the process now running as pid ${record.pid}`, owner))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function scanTombstones(root) {
|
|
266
|
+
let entries
|
|
267
|
+
try { entries = readdirSync(root, { withFileTypes: true }) } catch { return [] }
|
|
268
|
+
const found = []
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
const match = TOMBSTONE_PATTERN.exec(entry.name)
|
|
271
|
+
if (!match) continue
|
|
272
|
+
const path = join(root, entry.name)
|
|
273
|
+
const inspected = inspectDirAt(path, { name: entry.name })
|
|
274
|
+
const expectedNonce = match[1]
|
|
275
|
+
const result = inspected.result.owner?.legacy === false && inspected.result.owner.nonce !== expectedNonce
|
|
276
|
+
? { ...inspected.result, verdict: 'unknown', recoverable: false, reason: 'tombstone-name-mismatch', detail: `the tombstone name carries nonce ${expectedNonce} but its owner record carries another` }
|
|
277
|
+
: inspected.result
|
|
278
|
+
found.push({ ...inspected, result })
|
|
279
|
+
}
|
|
280
|
+
return found.sort((a, b) => a.name.localeCompare(b.name))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function listQuarantines(root) {
|
|
284
|
+
try {
|
|
285
|
+
return readdirSync(root).filter((entry) => entry.startsWith(QUARANTINE_PREFIX)).sort()
|
|
286
|
+
} catch { return [] }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function inspectRetentionLock(root) {
|
|
290
|
+
const read = readRoot(root)
|
|
291
|
+
const lockPath = join(read.root, LOCK_NAME)
|
|
292
|
+
if (read.missing) {
|
|
293
|
+
return {
|
|
294
|
+
version: 1,
|
|
295
|
+
root: read.root,
|
|
296
|
+
lockPath,
|
|
297
|
+
present: false,
|
|
298
|
+
verdict: 'absent',
|
|
299
|
+
recoverable: false,
|
|
300
|
+
reason: 'root-missing',
|
|
301
|
+
detail: `${read.root} does not exist, so no retention lock can be held`,
|
|
302
|
+
owner: null,
|
|
303
|
+
tombstones: [],
|
|
304
|
+
quarantines: [],
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const lock = inspectDirAt(lockPath, { name: LOCK_NAME })
|
|
308
|
+
return {
|
|
309
|
+
version: 1,
|
|
310
|
+
root: read.root,
|
|
311
|
+
lockPath,
|
|
312
|
+
...lock.result,
|
|
313
|
+
tombstones: scanTombstones(read.root).map((tombstone) => tombstone.result),
|
|
314
|
+
quarantines: listQuarantines(read.root),
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- removal ----
|
|
319
|
+
|
|
320
|
+
function removeVerifiedDir(dirPath, dirIdentity, expectedNonce) {
|
|
321
|
+
const st = lstatOrNull(dirPath)
|
|
322
|
+
if (!st) return { cleared: false, reason: 'lock-vanished' }
|
|
323
|
+
if (st.isSymbolicLink() || !st.isDirectory() || st.dev !== dirIdentity.dev || st.ino !== dirIdentity.ino) {
|
|
324
|
+
return { cleared: false, reason: 'identity-changed' }
|
|
325
|
+
}
|
|
326
|
+
let names
|
|
327
|
+
try { names = readdirSync(dirPath) } catch { return { cleared: false, reason: 'lock-unreadable' } }
|
|
328
|
+
if (names.length !== 1 || names[0] !== OWNER_FILE) {
|
|
329
|
+
return { cleared: false, reason: 'lock-unexpected-contents' }
|
|
330
|
+
}
|
|
331
|
+
const ownerPath = join(dirPath, OWNER_FILE)
|
|
332
|
+
const ownerStat = lstatOrNull(ownerPath)
|
|
333
|
+
if (!ownerStat || ownerStat.isSymbolicLink() || !ownerStat.isFile() || ownerStat.nlink > 1) {
|
|
334
|
+
return { cleared: false, reason: 'identity-changed' }
|
|
335
|
+
}
|
|
336
|
+
let record
|
|
337
|
+
try { record = JSON.parse(readFileSync(ownerPath, 'utf8')) } catch { return { cleared: false, reason: 'identity-changed' } }
|
|
338
|
+
if (!isObject(record) || record.nonce !== expectedNonce) {
|
|
339
|
+
return { cleared: false, reason: 'identity-changed' }
|
|
340
|
+
}
|
|
341
|
+
try { unlinkSync(ownerPath) } catch (error) {
|
|
342
|
+
return { cleared: false, reason: 'unlink-failed', detail: error?.code ?? error?.message }
|
|
343
|
+
}
|
|
344
|
+
try { rmdirSync(dirPath) } catch (error) {
|
|
345
|
+
return { cleared: false, reason: 'rmdir-failed', detail: error?.code ?? error?.message }
|
|
346
|
+
}
|
|
347
|
+
return { cleared: true }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The verified removal only unlinks, so a directory that turns out not to be the inspected
|
|
351
|
+
// one is put back at the lock path while that path is still free. A live owner that
|
|
352
|
+
// replaced the lock inside the rename window therefore keeps its lock instead of losing it.
|
|
353
|
+
const RESTORABLE = new Set(['identity-changed', 'lock-unexpected-contents', 'lock-unreadable', 'unlink-failed'])
|
|
354
|
+
function restoreQuarantine(tombstonePath, lockPath) {
|
|
355
|
+
if (lstatOrNull(lockPath)) return false
|
|
356
|
+
try {
|
|
357
|
+
renameSync(tombstonePath, lockPath)
|
|
358
|
+
return true
|
|
359
|
+
} catch { return false }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Atomic quarantine: rename inside the same parent, prove the renamed directory is the
|
|
363
|
+
// inspected one with the same owner nonce, and only then unlink. A failure leaves a
|
|
364
|
+
// tombstone behind, which never blocks a new lock and is finished by the next recover.
|
|
365
|
+
function quarantineAndRemove(lockPath, dirIdentity, nonce) {
|
|
366
|
+
const tombstonePath = join(dirname(lockPath), `${TOMBSTONE_PREFIX}${nonce}`)
|
|
367
|
+
try {
|
|
368
|
+
renameSync(lockPath, tombstonePath)
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error?.code === 'ENOENT') return { cleared: false, reason: 'lock-vanished' }
|
|
371
|
+
if (error?.code === 'ENOTEMPTY' || error?.code === 'EEXIST') return { cleared: false, reason: 'tombstone-exists' }
|
|
372
|
+
return { cleared: false, reason: 'rename-failed', detail: error?.code ?? error?.message }
|
|
373
|
+
}
|
|
374
|
+
const removed = removeVerifiedDir(tombstonePath, dirIdentity, nonce)
|
|
375
|
+
if (!removed.cleared && RESTORABLE.has(removed.reason) && restoreQuarantine(tombstonePath, lockPath)) {
|
|
376
|
+
return { cleared: false, reason: 'identity-changed', detail: 'the lock changed while recovery ran; it was restored to the lock path' }
|
|
377
|
+
}
|
|
378
|
+
return { ...removed, tombstonePath }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function recoverRetentionLock(root, options = {}) {
|
|
382
|
+
const apply = options?.apply === true
|
|
383
|
+
const before = inspectRetentionLock(root)
|
|
384
|
+
const base = {
|
|
385
|
+
version: 1,
|
|
386
|
+
root: before.root,
|
|
387
|
+
lockPath: before.lockPath,
|
|
388
|
+
apply,
|
|
389
|
+
dryRun: !apply,
|
|
390
|
+
before,
|
|
391
|
+
cleared: { lock: false, tombstones: [] },
|
|
392
|
+
remaining: { lock: false, tombstones: [] },
|
|
393
|
+
failures: [],
|
|
394
|
+
quarantines: before.quarantines,
|
|
395
|
+
}
|
|
396
|
+
const tombstoneRoot = before.root
|
|
397
|
+
const tombstones = before.tombstones.map((entry) => ({ ...entry, path: join(tombstoneRoot, entry.name) }))
|
|
398
|
+
const clearableTombstones = tombstones.filter((entry) => entry.recoverable)
|
|
399
|
+
const blockers = tombstones.filter((entry) => !entry.recoverable)
|
|
400
|
+
let contended = false
|
|
401
|
+
|
|
402
|
+
// A tombstone is only a quarantined lock directory. The sweep needs the same proof as
|
|
403
|
+
// the lock itself, so an unprovable tombstone is reported and left alone.
|
|
404
|
+
if (apply) {
|
|
405
|
+
for (const entry of clearableTombstones) {
|
|
406
|
+
const inspected = inspectDirAt(entry.path, { name: entry.name })
|
|
407
|
+
const outcome = removeVerifiedDir(entry.path, inspected.dirIdentity, inspected.nonce)
|
|
408
|
+
if (outcome.cleared) base.cleared.tombstones.push(entry.name)
|
|
409
|
+
else if (outcome.reason === 'lock-vanished') continue
|
|
410
|
+
else if (outcome.reason === 'identity-changed') contended = true
|
|
411
|
+
else base.failures.push({ name: entry.name, reason: outcome.reason, detail: outcome.detail ?? null })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
base.remaining.tombstones = tombstones
|
|
415
|
+
.filter((entry) => !base.cleared.tombstones.includes(entry.name))
|
|
416
|
+
.map((entry) => entry.name)
|
|
417
|
+
|
|
418
|
+
// The mutation re-inspects immediately before it acts, so a lock replaced by a live
|
|
419
|
+
// owner between the first report and the rename is retained rather than broken.
|
|
420
|
+
let outcome = null
|
|
421
|
+
if (before.present && before.recoverable && apply) {
|
|
422
|
+
const fresh = inspectDirAt(before.lockPath, { name: LOCK_NAME })
|
|
423
|
+
if (!fresh.result.present) {
|
|
424
|
+
outcome = { cleared: false, reason: 'lock-vanished' }
|
|
425
|
+
} else if (!fresh.result.recoverable) {
|
|
426
|
+
contended = true
|
|
427
|
+
outcome = { cleared: false, reason: fresh.result.reason, detail: fresh.result.detail }
|
|
428
|
+
} else {
|
|
429
|
+
outcome = quarantineAndRemove(before.lockPath, fresh.dirIdentity, fresh.nonce)
|
|
430
|
+
if (!outcome.cleared && outcome.reason !== 'lock-vanished') {
|
|
431
|
+
if (outcome.reason === 'identity-changed' || outcome.reason === 'tombstone-exists') contended = true
|
|
432
|
+
else base.failures.push({ name: LOCK_NAME, reason: outcome.reason, detail: outcome.detail ?? null })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
base.cleared.lock = outcome?.cleared === true
|
|
437
|
+
base.remaining.lock = before.present && !base.cleared.lock && outcome?.reason !== 'lock-vanished'
|
|
438
|
+
|
|
439
|
+
const lockStillPresent = before.present && outcome?.reason !== 'lock-vanished'
|
|
440
|
+
const lockBlocked = lockStillPresent && !before.recoverable
|
|
441
|
+
const stillRecoverable = (lockStillPresent && before.recoverable && !base.cleared.lock)
|
|
442
|
+
|| tombstones.some((entry) => entry.recoverable && !base.cleared.tombstones.includes(entry.name))
|
|
443
|
+
const anythingBlocked = lockBlocked || blockers.length > 0
|
|
444
|
+
|
|
445
|
+
const status = base.failures.length > 0 ? 'failed'
|
|
446
|
+
: contended ? 'contended'
|
|
447
|
+
: base.cleared.lock || base.cleared.tombstones.length > 0 ? 'recovered'
|
|
448
|
+
: stillRecoverable ? 'recoverable'
|
|
449
|
+
: anythingBlocked ? 'retained'
|
|
450
|
+
: 'absent'
|
|
451
|
+
const action = base.cleared.lock ? 'cleared-lock'
|
|
452
|
+
: base.cleared.tombstones.length > 0 ? 'cleared-tombstone'
|
|
453
|
+
: stillRecoverable ? 'would-clear'
|
|
454
|
+
: 'none'
|
|
455
|
+
|
|
456
|
+
const primary = before.present ? before : (clearableTombstones[0] ?? blockers[0] ?? null)
|
|
457
|
+
const reason = outcome?.cleared === false ? base.failures[0]?.reason ?? outcome.reason : primary?.reason ?? 'no-lock'
|
|
458
|
+
let detail = primary?.detail ?? `${before.lockPath} does not exist`
|
|
459
|
+
if (base.cleared.lock) detail = `cleared the lock left by a dead owner (${before.detail})`
|
|
460
|
+
else if (base.cleared.tombstones.length > 0) detail = `cleared ${base.cleared.tombstones.length} tombstone(s) left by an interrupted recovery`
|
|
461
|
+
else if (contended) detail = 'the lock now has an owner that is not provably dead; nothing was removed'
|
|
462
|
+
else if (status === 'retained') detail = `${detail}; it is retained and nothing was changed`
|
|
463
|
+
else if (status === 'recoverable') detail = `${detail}; the owner is provably dead and recover would clear it`
|
|
464
|
+
else if (status === 'failed') detail = base.failures.map((failure) => `${failure.name}: ${failure.reason}`).join('; ')
|
|
465
|
+
|
|
466
|
+
const after = apply ? inspectRetentionLock(before.root) : null
|
|
467
|
+
return {
|
|
468
|
+
...base,
|
|
469
|
+
status,
|
|
470
|
+
action,
|
|
471
|
+
reason,
|
|
472
|
+
detail,
|
|
473
|
+
tombstones,
|
|
474
|
+
after,
|
|
475
|
+
note: 'clearing the lock changes no cleanup state: receipts, quarantined resources, and the outcome of an interrupted apply are left exactly as they were',
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---- acquire / release ----
|
|
480
|
+
|
|
481
|
+
function releaseOwnLock(lockPath, nonce) {
|
|
482
|
+
const st = lstatOrNull(lockPath)
|
|
483
|
+
if (!st) return { released: false, reason: 'lock-missing' }
|
|
484
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return { released: false, reason: 'lock-changed' }
|
|
485
|
+
let names
|
|
486
|
+
try { names = readdirSync(lockPath) } catch { return { released: false, reason: 'lock-unreadable' } }
|
|
487
|
+
if (names.length !== 1 || names[0] !== OWNER_FILE) return { released: false, reason: 'lock-unexpected-contents' }
|
|
488
|
+
let record
|
|
489
|
+
try { record = JSON.parse(readFileSync(join(lockPath, OWNER_FILE), 'utf8')) } catch {
|
|
490
|
+
return { released: false, reason: 'owner-record-unreadable' }
|
|
491
|
+
}
|
|
492
|
+
if (!isObject(record) || record.nonce !== nonce) return { released: false, reason: 'owner-changed' }
|
|
493
|
+
try { unlinkSync(join(lockPath, OWNER_FILE)) } catch (error) {
|
|
494
|
+
return { released: false, reason: 'unlink-failed', detail: error?.code ?? error?.message }
|
|
495
|
+
}
|
|
496
|
+
try { rmdirSync(lockPath) } catch (error) {
|
|
497
|
+
return { released: false, reason: 'rmdir-failed', detail: error?.code ?? error?.message }
|
|
498
|
+
}
|
|
499
|
+
return { released: true, reason: 'released' }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function acquireRetentionLock(root, options = {}) {
|
|
503
|
+
const canonical = mustExistRoot(root)
|
|
504
|
+
const pid = options?.pid ?? process.pid
|
|
505
|
+
const hostname = options?.hostname ?? osHostname()
|
|
506
|
+
if (!Number.isSafeInteger(pid) || pid < 1) throw new RetentionLockError('usage', 'pid must be a positive safe integer')
|
|
507
|
+
if (!nonEmpty(hostname)) throw new RetentionLockError('usage', 'hostname must be a non-empty string')
|
|
508
|
+
const lockPath = join(canonical, LOCK_NAME)
|
|
509
|
+
try {
|
|
510
|
+
mkdirSync(lockPath)
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error?.code === 'EEXIST') {
|
|
513
|
+
let inspection = null
|
|
514
|
+
try { inspection = inspectRetentionLock(canonical) } catch { /* the refusal stands without detail */ }
|
|
515
|
+
throw new RetentionLockError(
|
|
516
|
+
'locked',
|
|
517
|
+
`retention is locked at ${lockPath} (${inspection?.detail ?? 'the lock directory exists'}); acquire never removes an existing lock, so an interrupted owner is cleared explicitly by recover`,
|
|
518
|
+
inspection,
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
throw new RetentionLockError('io', `cannot create ${lockPath}: ${error?.code ?? error?.message}`)
|
|
522
|
+
}
|
|
523
|
+
const nonce = randomBytes(16).toString('hex')
|
|
524
|
+
const owner = {
|
|
525
|
+
format: LOCK_FORMAT,
|
|
526
|
+
nonce,
|
|
527
|
+
pid,
|
|
528
|
+
hostname,
|
|
529
|
+
startIdentity: readStartIdentity(pid),
|
|
530
|
+
acquiredAt: new Date().toISOString(),
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
writeFileSync(join(lockPath, OWNER_FILE), `${JSON.stringify(owner, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
|
|
534
|
+
} catch (error) {
|
|
535
|
+
try { rmdirSync(lockPath) } catch { /* an empty lock directory is retained as unknown */ }
|
|
536
|
+
throw new RetentionLockError('io', `cannot write the lock owner record at ${join(lockPath, OWNER_FILE)}: ${error?.code ?? error?.message}`)
|
|
537
|
+
}
|
|
538
|
+
let releaseOutcome = null
|
|
539
|
+
return {
|
|
540
|
+
root: canonical,
|
|
541
|
+
path: lockPath,
|
|
542
|
+
owner,
|
|
543
|
+
release() {
|
|
544
|
+
if (!releaseOutcome) releaseOutcome = releaseOwnLock(lockPath, nonce)
|
|
545
|
+
return releaseOutcome
|
|
546
|
+
},
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Canonical plan enumeration, deliberately separate from payload inventory.
|
|
2
|
+
import { lstatSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export function retentionPlanFiles(root, { maxPlans = 200, maxEntries = 20000 } = {}) {
|
|
6
|
+
const paths = new Set()
|
|
7
|
+
const errors = []
|
|
8
|
+
let entriesSeen = 0
|
|
9
|
+
const stat = path => {
|
|
10
|
+
try { return lstatSync(path) }
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code !== 'ENOENT') errors.push(`Cannot inspect ${path}: ${error.code}`)
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const planAt = run => {
|
|
17
|
+
const path = join(run, 'task-plan.json')
|
|
18
|
+
const info = stat(path)
|
|
19
|
+
if (!info) return
|
|
20
|
+
if (!info.isFile() || info.isSymbolicLink()) { errors.push(`Canonical plan is not a regular file: ${path}`); return }
|
|
21
|
+
if (paths.size >= maxPlans) { errors.push(`Canonical plan count exceeds ${maxPlans}`); return }
|
|
22
|
+
paths.add(path)
|
|
23
|
+
}
|
|
24
|
+
const children = parent => {
|
|
25
|
+
try {
|
|
26
|
+
const all = readdirSync(parent, { withFileTypes: true })
|
|
27
|
+
entriesSeen += all.length
|
|
28
|
+
if (entriesSeen > maxEntries) { errors.push(`Canonical enumeration exceeds ${maxEntries} entries`); return [] }
|
|
29
|
+
return all.sort((a, b) => a.name.localeCompare(b.name))
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== 'ENOENT') errors.push(`Cannot enumerate ${parent}: ${error.code}`)
|
|
32
|
+
return []
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const runChildren = container => {
|
|
36
|
+
for (const entry of children(container)) {
|
|
37
|
+
const path = join(container, entry.name)
|
|
38
|
+
if (entry.isSymbolicLink()) { errors.push(`Canonical run location is a symbolic link: ${path}`); continue }
|
|
39
|
+
if (entry.isDirectory()) planAt(path)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const rootInfo = stat(root)
|
|
43
|
+
if (!rootInfo) return { paths: [], complete: errors.length === 0, errors }
|
|
44
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) return { paths: [], complete: false, errors: [`Unsafe retention root: ${root}`] }
|
|
45
|
+
planAt(root)
|
|
46
|
+
for (const entry of children(root)) {
|
|
47
|
+
const path = join(root, entry.name)
|
|
48
|
+
if (entry.isSymbolicLink()) {
|
|
49
|
+
errors.push(`Possible canonical run or container is a symbolic link: ${path}`)
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (!entry.isDirectory()) continue
|
|
53
|
+
if (entry.name === 'rush' || entry.name.endsWith('-workspaces')) runChildren(path)
|
|
54
|
+
else planAt(path)
|
|
55
|
+
}
|
|
56
|
+
return { paths: [...paths].sort(), complete: errors.length === 0, errors }
|
|
57
|
+
}
|