approval-md 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +101 -13
  3. package/cli.js +134 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +72 -0
  8. package/dist/src/channels/contract.js +161 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.d.ts +25 -0
  17. package/dist/src/channels/tagging.js +63 -6
  18. package/dist/src/channels/tagging.js.map +1 -1
  19. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  20. package/dist/src/channels/telegram-minimal.js +618 -0
  21. package/dist/src/channels/telegram-minimal.js.map +1 -0
  22. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  23. package/dist/src/channels/telegram-webhook.js +536 -0
  24. package/dist/src/channels/telegram-webhook.js.map +1 -0
  25. package/dist/src/channels/telegram.d.ts +725 -20
  26. package/dist/src/channels/telegram.js +1332 -123
  27. package/dist/src/channels/telegram.js.map +1 -1
  28. package/dist/src/cli/amend.js +358 -5
  29. package/dist/src/cli/amend.js.map +1 -1
  30. package/dist/src/cli/attest.js +43 -6
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/audit-card.d.ts +1 -1
  33. package/dist/src/cli/audit-card.js +40 -1
  34. package/dist/src/cli/audit-card.js.map +1 -1
  35. package/dist/src/cli/audit.d.ts +7 -5
  36. package/dist/src/cli/audit.js +22 -10
  37. package/dist/src/cli/audit.js.map +1 -1
  38. package/dist/src/cli/channel-relay.d.ts +108 -0
  39. package/dist/src/cli/channel-relay.js +334 -0
  40. package/dist/src/cli/channel-relay.js.map +1 -0
  41. package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
  42. package/dist/src/cli/channel-telegram-webhook.js +716 -0
  43. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  44. package/dist/src/cli/channel-telegram.d.ts +347 -28
  45. package/dist/src/cli/channel-telegram.js +648 -56
  46. package/dist/src/cli/channel-telegram.js.map +1 -1
  47. package/dist/src/cli/channel.d.ts +1 -1
  48. package/dist/src/cli/channel.js +5 -1
  49. package/dist/src/cli/channel.js.map +1 -1
  50. package/dist/src/cli/daemon.js +37 -5
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +856 -111
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +29 -1
  56. package/dist/src/cli/execute.js +387 -21
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +10 -1
  65. package/dist/src/cli/help.js +283 -54
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook.d.ts +485 -6
  68. package/dist/src/cli/hook.js +2370 -70
  69. package/dist/src/cli/hook.js.map +1 -1
  70. package/dist/src/cli/init.js +16 -7
  71. package/dist/src/cli/init.js.map +1 -1
  72. package/dist/src/cli/instructions.js +24 -3
  73. package/dist/src/cli/instructions.js.map +1 -1
  74. package/dist/src/cli/log-anchor.d.ts +55 -0
  75. package/dist/src/cli/log-anchor.js +199 -60
  76. package/dist/src/cli/log-anchor.js.map +1 -1
  77. package/dist/src/cli/log-unlock.d.ts +23 -0
  78. package/dist/src/cli/log-unlock.js +109 -0
  79. package/dist/src/cli/log-unlock.js.map +1 -0
  80. package/dist/src/cli/main.d.ts +0 -12
  81. package/dist/src/cli/main.js +72 -4
  82. package/dist/src/cli/main.js.map +1 -1
  83. package/dist/src/cli/muse.d.ts +2 -0
  84. package/dist/src/cli/muse.js +62 -0
  85. package/dist/src/cli/muse.js.map +1 -0
  86. package/dist/src/cli/payload.js +79 -1
  87. package/dist/src/cli/payload.js.map +1 -1
  88. package/dist/src/cli/scaffold.js +6 -0
  89. package/dist/src/cli/scaffold.js.map +1 -1
  90. package/dist/src/cli/serve.d.ts +53 -0
  91. package/dist/src/cli/serve.js +270 -0
  92. package/dist/src/cli/serve.js.map +1 -0
  93. package/dist/src/cli/up.js +26 -2
  94. package/dist/src/cli/up.js.map +1 -1
  95. package/dist/src/cli/verb-registry.d.ts +2 -0
  96. package/dist/src/cli/verb-registry.js +338 -39
  97. package/dist/src/cli/verb-registry.js.map +1 -1
  98. package/dist/src/cli/wordmark.d.ts +2 -2
  99. package/dist/src/cli/wordmark.js +2 -2
  100. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  101. package/dist/src/core/attest-bootstrap.js +135 -0
  102. package/dist/src/core/attest-bootstrap.js.map +1 -0
  103. package/dist/src/core/attest.d.ts +20 -3
  104. package/dist/src/core/attest.js +25 -0
  105. package/dist/src/core/attest.js.map +1 -1
  106. package/dist/src/core/audit.d.ts +153 -12
  107. package/dist/src/core/audit.js +415 -20
  108. package/dist/src/core/audit.js.map +1 -1
  109. package/dist/src/core/channel-lease.d.ts +322 -0
  110. package/dist/src/core/channel-lease.js +663 -0
  111. package/dist/src/core/channel-lease.js.map +1 -0
  112. package/dist/src/core/command-class.d.ts +51 -0
  113. package/dist/src/core/command-class.js +766 -8
  114. package/dist/src/core/command-class.js.map +1 -1
  115. package/dist/src/core/daemon-host.d.ts +178 -0
  116. package/dist/src/core/daemon-host.js +228 -0
  117. package/dist/src/core/daemon-host.js.map +1 -0
  118. package/dist/src/core/daemon-identity.d.ts +238 -0
  119. package/dist/src/core/daemon-identity.js +252 -0
  120. package/dist/src/core/daemon-identity.js.map +1 -0
  121. package/dist/src/core/gate.d.ts +364 -6
  122. package/dist/src/core/gate.js +802 -27
  123. package/dist/src/core/gate.js.map +1 -1
  124. package/dist/src/core/harness-version.d.ts +128 -4
  125. package/dist/src/core/harness-version.js +179 -12
  126. package/dist/src/core/harness-version.js.map +1 -1
  127. package/dist/src/core/harness-wait.d.ts +82 -3
  128. package/dist/src/core/harness-wait.js +89 -3
  129. package/dist/src/core/harness-wait.js.map +1 -1
  130. package/dist/src/core/log-lock.d.ts +435 -0
  131. package/dist/src/core/log-lock.js +1093 -0
  132. package/dist/src/core/log-lock.js.map +1 -0
  133. package/dist/src/core/log-subscribe.d.ts +21 -0
  134. package/dist/src/core/log-subscribe.js +16 -6
  135. package/dist/src/core/log-subscribe.js.map +1 -1
  136. package/dist/src/core/log-write-layer.d.ts +55 -0
  137. package/dist/src/core/log-write-layer.js +91 -0
  138. package/dist/src/core/log-write-layer.js.map +1 -0
  139. package/dist/src/core/log.d.ts +130 -22
  140. package/dist/src/core/log.js +473 -50
  141. package/dist/src/core/log.js.map +1 -1
  142. package/dist/src/core/payload-census.d.ts +51 -0
  143. package/dist/src/core/payload-census.js +125 -2
  144. package/dist/src/core/payload-census.js.map +1 -1
  145. package/dist/src/core/payload-store.d.ts +31 -0
  146. package/dist/src/core/payload-store.js +113 -8
  147. package/dist/src/core/payload-store.js.map +1 -1
  148. package/dist/src/core/payload.d.ts +18 -3
  149. package/dist/src/core/payload.js +20 -4
  150. package/dist/src/core/payload.js.map +1 -1
  151. package/dist/src/core/policy-diff.d.ts +10 -0
  152. package/dist/src/core/policy-diff.js +10 -2
  153. package/dist/src/core/policy-diff.js.map +1 -1
  154. package/dist/src/core/policy-explain.d.ts +7 -1
  155. package/dist/src/core/policy-explain.js +16 -1
  156. package/dist/src/core/policy-explain.js.map +1 -1
  157. package/dist/src/core/policy-load.d.ts +24 -0
  158. package/dist/src/core/policy-load.js.map +1 -1
  159. package/dist/src/core/policy-match.d.ts +38 -0
  160. package/dist/src/core/policy-match.js +35 -0
  161. package/dist/src/core/policy-match.js.map +1 -1
  162. package/dist/src/core/policy-proposal.d.ts +40 -0
  163. package/dist/src/core/policy-proposal.js +37 -16
  164. package/dist/src/core/policy-proposal.js.map +1 -1
  165. package/dist/src/core/prompt-layout.d.ts +186 -1
  166. package/dist/src/core/prompt-layout.js +345 -3
  167. package/dist/src/core/prompt-layout.js.map +1 -1
  168. package/dist/src/core/read-scope.d.ts +10 -0
  169. package/dist/src/core/read-scope.js +4 -0
  170. package/dist/src/core/read-scope.js.map +1 -1
  171. package/dist/src/core/run-payload.d.ts +160 -0
  172. package/dist/src/core/run-payload.js +304 -0
  173. package/dist/src/core/run-payload.js.map +1 -0
  174. package/dist/src/core/sender-identity.d.ts +59 -11
  175. package/dist/src/core/sender-identity.js +79 -11
  176. package/dist/src/core/sender-identity.js.map +1 -1
  177. package/dist/src/core/state.d.ts +52 -1
  178. package/dist/src/core/state.js +52 -6
  179. package/dist/src/core/state.js.map +1 -1
  180. package/dist/src/core/telegram-config.d.ts +79 -0
  181. package/dist/src/core/telegram-config.js +108 -0
  182. package/dist/src/core/telegram-config.js.map +1 -1
  183. package/dist/src/core/validate.d.ts +20 -6
  184. package/dist/src/core/validate.js +0 -0
  185. package/dist/src/core/validate.js.map +1 -1
  186. package/dist/src/core/verify.d.ts +23 -1
  187. package/dist/src/core/verify.js +20 -2
  188. package/dist/src/core/verify.js.map +1 -1
  189. package/dist/src/daemon/audit.d.ts +80 -5
  190. package/dist/src/daemon/audit.js +126 -6
  191. package/dist/src/daemon/audit.js.map +1 -1
  192. package/dist/src/daemon/daemon.d.ts +174 -2
  193. package/dist/src/daemon/daemon.js +298 -7
  194. package/dist/src/daemon/daemon.js.map +1 -1
  195. package/dist/src/daemon/git-evidence.d.ts +2 -2
  196. package/dist/src/daemon/git-evidence.js +1 -1
  197. package/dist/src/daemon/projection.d.ts +13 -2
  198. package/dist/src/daemon/projection.js +39 -6
  199. package/dist/src/daemon/projection.js.map +1 -1
  200. package/dist/src/mcp/server.d.ts +39 -3
  201. package/dist/src/mcp/server.js +22 -3
  202. package/dist/src/mcp/server.js.map +1 -1
  203. package/dist/src/muse/server.d.ts +16 -0
  204. package/dist/src/muse/server.js +238 -0
  205. package/dist/src/muse/server.js.map +1 -0
  206. package/dist/src/serve/archive.d.ts +166 -0
  207. package/dist/src/serve/archive.js +363 -0
  208. package/dist/src/serve/archive.js.map +1 -0
  209. package/dist/src/serve/arguments.d.ts +93 -0
  210. package/dist/src/serve/arguments.js +228 -0
  211. package/dist/src/serve/arguments.js.map +1 -0
  212. package/dist/src/serve/credentials.d.ts +91 -0
  213. package/dist/src/serve/credentials.js +143 -0
  214. package/dist/src/serve/credentials.js.map +1 -0
  215. package/dist/src/serve/follow.d.ts +86 -0
  216. package/dist/src/serve/follow.js +101 -0
  217. package/dist/src/serve/follow.js.map +1 -0
  218. package/dist/src/serve/hook-thread.d.ts +134 -0
  219. package/dist/src/serve/hook-thread.js +333 -0
  220. package/dist/src/serve/hook-thread.js.map +1 -0
  221. package/dist/src/serve/hook-worker.d.ts +134 -0
  222. package/dist/src/serve/hook-worker.js +173 -0
  223. package/dist/src/serve/hook-worker.js.map +1 -0
  224. package/dist/src/serve/server.d.ts +412 -0
  225. package/dist/src/serve/server.js +1251 -0
  226. package/dist/src/serve/server.js.map +1 -0
  227. package/docs/README-extended.md +768 -0
  228. package/docs/cli-reference.md +2139 -67
  229. package/package.json +4 -2
  230. package/schema/event.schema.json +413 -30
  231. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  232. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  233. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  234. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  235. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  236. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  237. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  238. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  239. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  240. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  241. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  242. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  243. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  244. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  245. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  246. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  247. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  248. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  249. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  250. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  251. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  252. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  253. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  254. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  255. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  256. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  257. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  258. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  259. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  260. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  261. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  262. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  263. package/schema/fixtures/event/valid/daemon-identity.json +16 -0
  264. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  265. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  266. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  267. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  268. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  269. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  270. package/schema/policy.schema.json +102 -6
@@ -0,0 +1,1093 @@
1
+ /**
2
+ * The holder of `<log>.lock`, and the one way a lock left by a writer that died
3
+ * holding it is taken back (APRV-479).
4
+ *
5
+ * ## The defect this exists for
6
+ *
7
+ * `core/log.ts` serializes every append with a lockfile created `wx` and never
8
+ * stolen, because silently breaking somebody else's lock is how two writers
9
+ * come to share a `seq`. A writer that dies while it holds the lock never
10
+ * removes it: a harness hook killed by SIGTERM with no listener registered (the
11
+ * default disposition), any verb killed by SIGKILL, a Hermes gateway restart in
12
+ * the middle of an append. Every later writer, the daemon included, then waits
13
+ * its two seconds and refuses `lock-timeout`, and the tenant's gate stays
14
+ * wedged until a human removes the file.
15
+ *
16
+ * ## The rule every decision here follows
17
+ *
18
+ * A wrong reclaim (two writers under one lock, a forked chain) is far worse than
19
+ * a missed one (a wedge a human clears with one command). So a holder is LIVE
20
+ * unless this process can prove it gone, and a lock is taken back only by a
21
+ * writer that has already waited out its whole lock timeout.
22
+ *
23
+ * ## What this module does
24
+ *
25
+ * 1. **The lockfile names its holder.** {@link createLockFile} writes one JSON
26
+ * line into the file it creates: pid, host, boot, on Linux the pid and time
27
+ * namespaces and the process start time, when the lock was taken, which kind
28
+ * of holder (`append` | `hold`), and a nonce. An older writer's lockfile is
29
+ * empty.
30
+ * 2. **Only a regular file that parses strictly is judged.** Every read opens
31
+ * without blocking and without following links, and reads only a regular
32
+ * file of at most 4 KiB. Anything else at the lock's path (a FIFO, a link, a
33
+ * directory, an oversized or malformed file, a record of another version) is
34
+ * not this writer's to judge: it is LIVE.
35
+ * 3. **The judgement ({@link judgeHolder}).** Gone only when the holder is in
36
+ * this process's own pid namespace and boot, and provably not running:
37
+ * - Linux: `/proc` is this namespace's (`/proc/self` names this pid), the
38
+ * record's boot id and pid-namespace inode equal this process's, and then
39
+ * `kill(pid, 0)` answers ESRCH with `/proc/<pid>` absent or a zombie, or
40
+ * `/proc/<pid>/stat` names a process whose start time (read in the same
41
+ * time namespace) is not the holder's;
42
+ * - elsewhere (macOS): the same hostname, boot readings within
43
+ * {@link BOOT_SLACK_S} seconds, and `kill(pid, 0)` answering ESRCH.
44
+ *
45
+ * Everything else is LIVE: EPERM, another namespace or boot or host, a
46
+ * `/proc` entry that cannot be read.
47
+ * 4. **A lockfile with no holder record** (empty: an older version's, or a
48
+ * writer killed between the create and the write) is gone only once its
49
+ * mtime is {@link LEGACY_LOCK_RECLAIM_AGE_MS} old AND this writer watched
50
+ * that very file (inode, mtime, size) unchanged through its own wait.
51
+ * 5. **The reclaim ({@link reclaimStaleLock}) is two atomic steps, and nothing
52
+ * else is written.** The taker's own complete lockfile is written first,
53
+ * under `<lock>.take.<pid>.<nonce>`, so whoever finds a claim beside the
54
+ * lock also finds its taker and can judge it (`approval log unlock` refuses
55
+ * while one is seen running). Then the claim: `link(2)` of the lock's path to a name
56
+ * derived from the judged record, `<lock>.stale.<pid>.<created ms>`, which
57
+ * fails EEXIST for every other reclaimer of the same lockfile, and which is
58
+ * then read back to prove it names the very file that was judged (inode,
59
+ * mtime, bytes). Then the take: this writer's own lockfile is `rename(2)`d
60
+ * over the lock's path, once the claim and the lock's path are read again
61
+ * and are still the judged file. The lock's
62
+ * path is never empty, so no ordinary `wx` create slips in, and between the
63
+ * claim and the take nothing in the protocol can change it: the holder is
64
+ * dead, every other writer's create fails EEXIST, and every other
65
+ * reclaimer's claim fails EEXIST. (A bare `rename` of the lockfile aside
66
+ * cannot be made safe: a reclaimer that judged the dead lock and stalled
67
+ * would later move whatever live lock had replaced it.)
68
+ * 6. **The reclaim is in the log first.** The reclaimer appends
69
+ * `audit.lock_reclaimed` as the first record under the lock it took
70
+ * (`core/log.ts`), carrying the strictly parsed pid, op and `created` and
71
+ * nothing else from the file, and only then unlinks the stale name.
72
+ * 7. **A termination signal does not leave a lock behind.**
73
+ * {@link guardTerminationWhileLocked} is installed before a lockfile is
74
+ * created, and before a reclaim's claim, and held until the lock is removed: for SIGTERM, SIGINT and SIGHUP with
75
+ * no listener, a listener that re-raises the signal with its default
76
+ * disposition once the lock is released, so the process still dies of it.
77
+ * SIGKILL cannot be caught, which is what 1 to 6 are for.
78
+ *
79
+ * Never reclaimed automatically, whatever the holder: a lock beside a
80
+ * `log sync` snapshot (a sync killed part way may have left the working log at
81
+ * its committed bytes), and a lock beside a log that does not exist. A lock from
82
+ * another container or boot is never reclaimed automatically either; the
83
+ * refusal names the pid and the human verb, `approval log unlock --pid <n>`
84
+ * ({@link takeLockForUnlock}).
85
+ */
86
+ import { randomBytes } from "node:crypto";
87
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, linkSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, renameSync, unlinkSync, writeSync, } from "node:fs";
88
+ import { hostname, uptime } from "node:os";
89
+ import { basename, dirname, join } from "node:path";
90
+ /** The holder record's format version. A reader treats any other as not its to judge. */
91
+ export const LOCK_HOLDER_VERSION = 1;
92
+ /**
93
+ * How old an empty lockfile (no holder record) must be before it is reclaimed.
94
+ * An append holds the lock for milliseconds, and the longest holder in this
95
+ * runtime (`approval log sync`) for seconds.
96
+ */
97
+ export const LEGACY_LOCK_RECLAIM_AGE_MS = 10 * 60_000;
98
+ /**
99
+ * How long a writer must have watched an empty lockfile unchanged (the same
100
+ * inode, mtime and size since the first refusal of its wait) before it may take
101
+ * it as `legacy-aged`. A writer of this version fills its record within
102
+ * microseconds of its create, so an empty file that stays empty across a retry
103
+ * interval is not one; an mtime alone can read old through a stepped clock or
104
+ * a file server's lagging one (APRV-479 R3-8).
105
+ */
106
+ export const LEGACY_LOCK_WATCH_MS = 20;
107
+ /**
108
+ * Off Linux, how far apart two boot readings (`now - uptime`, in seconds) may
109
+ * be and still count as one boot. A wall-clock step moves the reading, so a
110
+ * larger gap is "another boot, or a stepped clock": live, never gone.
111
+ */
112
+ export const BOOT_SLACK_S = 60;
113
+ /** The largest pid a record may name: anything past a signed 32-bit pid is no process at all. */
114
+ const MAX_PID = 2 ** 31 - 1;
115
+ /** The most bytes a lockfile may hold: a record is one short line. */
116
+ const MAX_LOCK_FILE_BYTES = 4096;
117
+ /** The one shape `created` may take: what `Date#toISOString` writes. */
118
+ const CREATED_SHAPE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
119
+ /**
120
+ * `created` exactly as `Date#toISOString` writes it AND a real instant: the
121
+ * string must survive a round trip, which `2026-02-30T00:00:00.000Z` (a shape
122
+ * match that V8 rolls over to March 2nd) does not.
123
+ */
124
+ export function isCanonicalTimestamp(value) {
125
+ if (!CREATED_SHAPE.test(value))
126
+ return false;
127
+ const instant = new Date(value);
128
+ return Number.isFinite(instant.getTime()) && instant.toISOString() === value;
129
+ }
130
+ /**
131
+ * A holder's hostname as a message may show it: the hostname charset, at most
132
+ * 64 characters. It comes from a file another process wrote and never reaches
133
+ * the log.
134
+ */
135
+ function displayHost(host) {
136
+ if (host === "")
137
+ return "an unnamed host";
138
+ const shown = host.replace(/[^A-Za-z0-9._-]/gu, "?");
139
+ return shown.length > 64 ? `${shown.slice(0, 64)}...` : shown;
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // This process
143
+ // ---------------------------------------------------------------------------
144
+ function errnoOf(cause) {
145
+ return cause?.code;
146
+ }
147
+ function readTrimmed(path) {
148
+ try {
149
+ const text = readFileSync(path, "utf8").trim();
150
+ return text === "" ? undefined : text;
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ function readLinkOrUndefined(path) {
157
+ try {
158
+ return readlinkSync(path, "utf8");
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ }
164
+ /**
165
+ * Parse `/proc/<pid>/stat`: the command name is parenthesized and may contain
166
+ * spaces and parentheses, so the fields are counted from the LAST `)`. After it,
167
+ * index 0 is field 3 (state) and index 19 is field 22 (starttime).
168
+ */
169
+ export function parseProcStat(text) {
170
+ const close = text.lastIndexOf(")");
171
+ if (close < 0)
172
+ return null;
173
+ const fields = text.slice(close + 1).trim().split(/\s+/u);
174
+ const state = fields[0];
175
+ const start = fields[19];
176
+ if (state === undefined || start === undefined || !/^\d+$/u.test(start))
177
+ return null;
178
+ return { state, start };
179
+ }
180
+ function readProcStat(path) {
181
+ let text;
182
+ try {
183
+ text = readFileSync(path, "utf8");
184
+ }
185
+ catch (cause) {
186
+ const code = errnoOf(cause);
187
+ if (code === "ENOENT" || code === "ESRCH")
188
+ return { kind: "absent" };
189
+ return { kind: "unreadable", why: code ?? "error" };
190
+ }
191
+ const parsed = parseProcStat(text);
192
+ return parsed === null ? { kind: "unreadable", why: "an unparseable stat line" } : { kind: "stat", ...parsed };
193
+ }
194
+ /** The real probe: `/proc` and `kill(pid, 0)`. */
195
+ export const NODE_LIVENESS_PROBE = {
196
+ linuxStat: (pid) => readProcStat(`/proc/${String(pid)}/stat`),
197
+ signalZero(pid) {
198
+ try {
199
+ process.kill(pid, 0);
200
+ return "ok";
201
+ }
202
+ catch (cause) {
203
+ const code = errnoOf(cause);
204
+ return code === "ESRCH" ? "ESRCH" : code === "EPERM" ? "EPERM" : "other";
205
+ }
206
+ },
207
+ };
208
+ /**
209
+ * Is `/proc` this process's own pid namespace's procfs? `/proc/self` resolves
210
+ * to the reader's pid as THAT procfs numbers it, so it names `process.pid` only
211
+ * when the procfs belongs to the namespace `process.pid` was issued in, or when
212
+ * the pid happens to be the same number in both (APRV-479 R3-9). So the
213
+ * `NSpid:` line of `/proc/self/status` must also hold exactly one field, this
214
+ * pid: it lists the pid in every namespace from the procfs's own down to the
215
+ * reader's, and one field means they are the same namespace. A kernel without
216
+ * the line (before 4.1) proves nothing, and every holder is then live.
217
+ */
218
+ export function procIsOwnNamespace(read = (path) => readlinkSync(path, "utf8"), pid = process.pid, status = (path) => readFileSync(path, "utf8")) {
219
+ try {
220
+ if (read("/proc/self") !== String(pid))
221
+ return false;
222
+ const line = status("/proc/self/status").split("\n").find((text) => text.startsWith("NSpid:"));
223
+ if (line === undefined)
224
+ return false;
225
+ const fields = line.slice("NSpid:".length).trim().split(/\s+/u);
226
+ return fields.length === 1 && fields[0] === String(pid);
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+ let cachedSelf;
233
+ /** This process's identity, read once: none of it changes while the process lives. */
234
+ export function selfIdentity() {
235
+ if (cachedSelf !== undefined && cachedSelf.pid === process.pid)
236
+ return cachedSelf;
237
+ const linux = process.platform === "linux";
238
+ let pidns;
239
+ let start;
240
+ let timens;
241
+ let boot;
242
+ let procIsOwn;
243
+ if (linux) {
244
+ boot = readTrimmed("/proc/sys/kernel/random/boot_id");
245
+ procIsOwn = procIsOwnNamespace();
246
+ if (procIsOwn) {
247
+ pidns = readLinkOrUndefined("/proc/self/ns/pid");
248
+ timens = readLinkOrUndefined("/proc/self/ns/time");
249
+ const own = readProcStat("/proc/self/stat");
250
+ start = own.kind === "stat" ? own.start : undefined;
251
+ }
252
+ }
253
+ else {
254
+ boot = approximateBoot();
255
+ }
256
+ cachedSelf = { pid: process.pid, host: safeHostname(), boot, pidns, start, timens, linux };
257
+ if (procIsOwn !== undefined)
258
+ cachedSelf.procIsOwn = procIsOwn;
259
+ return cachedSelf;
260
+ }
261
+ function approximateBoot() {
262
+ try {
263
+ const seconds = Math.round(Date.now() / 1000 - uptime());
264
+ return Number.isFinite(seconds) ? `~${String(seconds)}` : undefined;
265
+ }
266
+ catch {
267
+ return undefined;
268
+ }
269
+ }
270
+ function safeHostname() {
271
+ try {
272
+ return hostname();
273
+ }
274
+ catch {
275
+ return "";
276
+ }
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // The record
280
+ // ---------------------------------------------------------------------------
281
+ /** The record this process writes into a lockfile it creates now. */
282
+ export function holderRecord(op, now = new Date()) {
283
+ const self = selfIdentity();
284
+ const record = {
285
+ v: LOCK_HOLDER_VERSION,
286
+ pid: self.pid,
287
+ host: self.host,
288
+ boot: self.boot ?? "",
289
+ created: now.toISOString(),
290
+ op,
291
+ nonce: randomBytes(8).toString("hex"),
292
+ };
293
+ if (self.pidns !== undefined)
294
+ record.pidns = self.pidns;
295
+ if (self.start !== undefined)
296
+ record.start = self.start;
297
+ if (self.timens !== undefined)
298
+ record.timens = self.timens;
299
+ return record;
300
+ }
301
+ /**
302
+ * What a lockfile's bytes say: a strictly parsed v1 record, `legacy` (empty or
303
+ * whitespace only: no holder record at all), or `unknown` (anything else: a
304
+ * malformed record, another version, text that is not JSON), which is never
305
+ * judged.
306
+ */
307
+ export function parseHolder(bytes) {
308
+ const text = (typeof bytes === "string" ? bytes : bytes.toString("utf8")).trim();
309
+ if (text === "")
310
+ return { kind: "legacy" };
311
+ let value;
312
+ try {
313
+ value = JSON.parse(text);
314
+ }
315
+ catch {
316
+ return { kind: "unknown" };
317
+ }
318
+ if (typeof value !== "object" || value === null || Array.isArray(value))
319
+ return { kind: "unknown" };
320
+ const { v, pid, host, boot, pidns, start, timens, created, op, nonce } = value;
321
+ if (v !== LOCK_HOLDER_VERSION ||
322
+ typeof pid !== "number" ||
323
+ !Number.isSafeInteger(pid) ||
324
+ pid <= 0 ||
325
+ pid > MAX_PID ||
326
+ typeof host !== "string" ||
327
+ typeof boot !== "string" ||
328
+ (pidns !== undefined && typeof pidns !== "string") ||
329
+ (start !== undefined && typeof start !== "string") ||
330
+ (timens !== undefined && typeof timens !== "string") ||
331
+ typeof created !== "string" ||
332
+ !isCanonicalTimestamp(created) ||
333
+ (op !== "append" && op !== "hold") ||
334
+ typeof nonce !== "string") {
335
+ return { kind: "unknown" };
336
+ }
337
+ const holder = { v: LOCK_HOLDER_VERSION, pid, host, boot, created, op, nonce };
338
+ if (pidns !== undefined)
339
+ holder.pidns = pidns;
340
+ if (start !== undefined)
341
+ holder.start = start;
342
+ if (timens !== undefined)
343
+ holder.timens = timens;
344
+ return { kind: "v1", holder };
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // The judgement
348
+ // ---------------------------------------------------------------------------
349
+ function bootSeconds(boot) {
350
+ if (boot === undefined || !/^~-?\d+$/u.test(boot))
351
+ return null;
352
+ const seconds = Number(boot.slice(1));
353
+ return Number.isSafeInteger(seconds) ? seconds : null;
354
+ }
355
+ /**
356
+ * Is the process that wrote `holder` provably gone? Pure: the identity of the
357
+ * reader and the operating system's answers are parameters. "Live" means "not
358
+ * provably gone". The holder's boot and pid namespace are established before
359
+ * any pid is looked up, because a pid means nothing outside the namespace it
360
+ * was issued in.
361
+ */
362
+ export function judgeHolder(holder, self, probe) {
363
+ const who = `pid ${String(holder.pid)} on ${displayHost(holder.host)}`;
364
+ const live = (why, running = false) => ({ state: "live", why: `${who} ${why}`, running });
365
+ const gone = (why) => ({ state: "gone", why: `${who} ${why}` });
366
+ if (self.linux) {
367
+ if (self.procIsOwn === false) {
368
+ return live("cannot be checked: this process's /proc belongs to another pid namespace (/proc/self does not name it)");
369
+ }
370
+ if (self.boot === undefined || self.pidns === undefined) {
371
+ return live("cannot be checked: this process cannot read its own boot id or pid namespace");
372
+ }
373
+ if (holder.boot === "" || holder.pidns === undefined) {
374
+ return live("cannot be checked: its record names no Linux boot id or pid namespace");
375
+ }
376
+ if (holder.boot !== self.boot) {
377
+ return live("took the lock under another boot id (an earlier boot of this host, or another host or sandbox kernel sharing this filesystem), whose processes this process cannot see");
378
+ }
379
+ if (holder.pidns !== self.pidns) {
380
+ return live("is in another pid namespace (another container), whose processes this process cannot see");
381
+ }
382
+ const signal = probe.signalZero(holder.pid);
383
+ const stat = probe.linuxStat(holder.pid);
384
+ const comparable = holder.start !== undefined && holder.timens === self.timens;
385
+ if (stat.kind === "stat" && comparable && stat.start !== holder.start) {
386
+ return gone("has exited: the pid now names a process that started later");
387
+ }
388
+ const zombie = stat.kind === "stat" && (stat.state === "Z" || stat.state === "X");
389
+ if (signal === "ESRCH" && (stat.kind === "absent" || zombie))
390
+ return gone("has exited");
391
+ if (stat.kind === "stat" && !zombie && comparable)
392
+ return live("is running", true);
393
+ if (signal === "EPERM")
394
+ return live("is running as another user, or cannot be checked (kill(pid, 0) answers EPERM)");
395
+ if (stat.kind === "unreadable")
396
+ return live(`cannot be checked: its /proc entry could not be read (${stat.why})`);
397
+ return live("cannot be proved gone (kill(pid, 0) does not answer ESRCH, and no start time can be compared)");
398
+ }
399
+ if (holder.pidns !== undefined)
400
+ return live("wrote its record under Linux, whose processes this process cannot see");
401
+ if (holder.host === "" || holder.host !== self.host) {
402
+ return live("is on another host, whose processes this process cannot see");
403
+ }
404
+ const theirs = bootSeconds(holder.boot);
405
+ const ours = bootSeconds(self.boot);
406
+ if (theirs === null || ours === null || Math.abs(theirs - ours) > BOOT_SLACK_S) {
407
+ return live(`took the lock under another boot, or before a clock step (boot readings more than ${String(BOOT_SLACK_S)} s apart), so its pid proves nothing here`);
408
+ }
409
+ const signal = probe.signalZero(holder.pid);
410
+ if (signal === "ESRCH")
411
+ return gone("has exited");
412
+ if (signal === "EPERM")
413
+ return live("is running as another user (kill(pid, 0) answers EPERM)", true);
414
+ if (signal === "ok")
415
+ return live("is running (this platform cannot read its start time, so the pid is taken to be the holder)", true);
416
+ return live("cannot be checked (kill(pid, 0) failed)");
417
+ }
418
+ let judgeForTests = null;
419
+ /**
420
+ * Replace the liveness judgement (tests only: the pin that a live holder's
421
+ * lock survives only because of the check). `null` restores the real one.
422
+ */
423
+ export function setLockLivenessForTests(judge) {
424
+ judgeForTests = judge;
425
+ }
426
+ function judge(holder) {
427
+ return judgeForTests === null ? judgeHolder(holder, selfIdentity(), NODE_LIVENESS_PROBE) : judgeForTests(holder);
428
+ }
429
+ /** `O_RDONLY | O_NONBLOCK | O_NOFOLLOW`: an open must never block (a FIFO) and never follow a link. */
430
+ const SAFE_OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOFOLLOW ?? 0);
431
+ /**
432
+ * Read one name beside the log as hostile input: opened without blocking or
433
+ * following links, accepted only when the opened descriptor is a regular file
434
+ * of at most {@link MAX_LOCK_FILE_BYTES}, read through that one descriptor.
435
+ */
436
+ function readEntry(path) {
437
+ let fd;
438
+ try {
439
+ fd = openSync(path, SAFE_OPEN_FLAGS);
440
+ }
441
+ catch (cause) {
442
+ const code = errnoOf(cause);
443
+ if (code === "ENOENT" || code === "ENOTDIR")
444
+ return { kind: "absent" };
445
+ return { kind: "unreadable", why: code === "ELOOP" || code === "EMLINK" ? "a symbolic link" : (code ?? "an open error") };
446
+ }
447
+ try {
448
+ const stat = fstatSync(fd, { bigint: true });
449
+ if (!stat.isFile())
450
+ return { kind: "unreadable", why: "not a regular file" };
451
+ if (stat.size > BigInt(MAX_LOCK_FILE_BYTES))
452
+ return { kind: "unreadable", why: "larger than any record" };
453
+ const buffer = Buffer.alloc(MAX_LOCK_FILE_BYTES);
454
+ let length = 0;
455
+ for (;;) {
456
+ const read = readSync(fd, buffer, length, buffer.length - length, null);
457
+ if (read <= 0)
458
+ break;
459
+ length += read;
460
+ if (length >= buffer.length)
461
+ break;
462
+ }
463
+ return {
464
+ kind: "file",
465
+ seen: { ino: stat.ino, mtimeNs: stat.mtimeNs, mtimeMs: Number(stat.mtimeMs), uid: Number(stat.uid), bytes: buffer.subarray(0, length) },
466
+ };
467
+ }
468
+ catch (cause) {
469
+ return { kind: "unreadable", why: errnoOf(cause) ?? "a read error" };
470
+ }
471
+ finally {
472
+ closeSync(fd);
473
+ }
474
+ }
475
+ /**
476
+ * The same file with the same contents: inode, mtime to the nanosecond, and
477
+ * bytes. A v1 record carries a random nonce, and an empty lockfile is judged
478
+ * only once its mtime is ten minutes old, so a later file never matches.
479
+ */
480
+ function sameLock(a, b) {
481
+ return a.ino === b.ino && a.mtimeNs === b.mtimeNs && a.bytes.equals(b.bytes);
482
+ }
483
+ function unlinkQuietly(path) {
484
+ try {
485
+ unlinkSync(path);
486
+ }
487
+ catch {
488
+ // A name that is already gone is the outcome wanted.
489
+ }
490
+ }
491
+ let seamForTests = null;
492
+ /** Run `seam` at each named step (tests only: a stall, a signal, a planted file). `null` removes it. */
493
+ export function setLockSeamForTests(seam) {
494
+ seamForTests = seam;
495
+ }
496
+ function step(name) {
497
+ if (seamForTests !== null)
498
+ seamForTests(name);
499
+ }
500
+ /**
501
+ * Create `path` with `wx` and write this process's holder record into it.
502
+ * Throws what `openSync` throws, so `EEXIST` means another writer holds it. A
503
+ * record that cannot be written leaves an empty lockfile, which is still a lock.
504
+ * The caller installs the signal guard BEFORE calling this.
505
+ */
506
+ export function createLockFile(path, op) {
507
+ const fd = openSync(path, "wx");
508
+ let bytes = Buffer.alloc(0);
509
+ let ino = 0n;
510
+ try {
511
+ step("after-open");
512
+ try {
513
+ ino = fstatSync(fd, { bigint: true }).ino;
514
+ }
515
+ catch {
516
+ ino = 0n;
517
+ }
518
+ const line = Buffer.from(`${JSON.stringify(holderRecord(op))}\n`, "utf8");
519
+ try {
520
+ if (writeSync(fd, line, 0, line.length) === line.length)
521
+ bytes = line;
522
+ }
523
+ catch {
524
+ // The lock is held either way; an empty file reads as unattributed.
525
+ }
526
+ }
527
+ finally {
528
+ closeSync(fd);
529
+ }
530
+ return { path, ino, bytes };
531
+ }
532
+ /**
533
+ * Remove the lock this process holds, and only it: anything else at the path
534
+ * (another inode, other bytes) is left where it is.
535
+ */
536
+ export function releaseLockFile(own) {
537
+ const entry = readEntry(own.path);
538
+ if (entry.kind !== "file")
539
+ return;
540
+ if (own.ino !== 0n && entry.seen.ino !== own.ino)
541
+ return;
542
+ if (own.bytes.length > 0 && !entry.seen.bytes.equals(own.bytes))
543
+ return;
544
+ unlinkQuietly(own.path);
545
+ }
546
+ /** How far before the log's last record a holder's `created` may lie and still be recorded. */
547
+ export const CREATED_BEFORE_LAST_RECORD_MS = 24 * 60 * 60_000;
548
+ /** How far past this process's clock a holder's `created` may lie and still be recorded. */
549
+ export const CREATED_AFTER_NOW_MS = 5 * 60_000;
550
+ /**
551
+ * The record of a reclaim carries a holder's `created` (and the age derived
552
+ * from it) only when it is plausible: no earlier than the log's last record's
553
+ * `ts` minus {@link CREATED_BEFORE_LAST_RECORD_MS}, and no later than now plus
554
+ * {@link CREATED_AFTER_NOW_MS}. Anything else is a value the file chose (any
555
+ * canonical instant parses, year 0000 and 9999 included), so the record says
556
+ * `created: null` and carries no age. With no last record to measure from, no
557
+ * `created` is plausible. (APRV-479 R3-7, the stricter rule.)
558
+ */
559
+ export function plausibleCreated(note, lastTs, now) {
560
+ if (note.holder === undefined || note.holder.created === null)
561
+ return note;
562
+ const at = Date.parse(note.holder.created);
563
+ if (lastTs !== null && at >= lastTs - CREATED_BEFORE_LAST_RECORD_MS && at <= now + CREATED_AFTER_NOW_MS)
564
+ return note;
565
+ const bounded = { lockfile: note.lockfile, reason: note.reason, holder: { ...note.holder, created: null }, stale: note.stale, why: note.why };
566
+ return bounded;
567
+ }
568
+ /** The human verb, as a refusal names it. */
569
+ export function unlockCommand(pid) {
570
+ return `approval log unlock --pid ${pid === null ? "none" : String(pid)}`;
571
+ }
572
+ /** What is at `<logPath>.lock` now, without opening it: a regular file's identity, or `null`. */
573
+ export function observeLock(logPath, now = Date.now()) {
574
+ try {
575
+ const stat = lstatSync(`${logPath}.lock`, { bigint: true });
576
+ return stat.isFile() ? { ino: stat.ino, mtimeNs: stat.mtimeNs, size: stat.size, at: now } : null;
577
+ }
578
+ catch {
579
+ return null;
580
+ }
581
+ }
582
+ function staleName(lockPath, holder, seen) {
583
+ return holder === null
584
+ ? `${lockPath}.stale.legacy.${seen.mtimeNs.toString()}`
585
+ : `${lockPath}.stale.${String(holder.pid)}.${String(Date.parse(holder.created))}`;
586
+ }
587
+ function noteFor(lockPath, holder, seen, now, reason, why) {
588
+ const from = holder === null ? seen.mtimeMs : Date.parse(holder.created);
589
+ const ageMs = Math.max(0, Math.round(now - from));
590
+ const note = {
591
+ lockfile: basename(lockPath),
592
+ reason,
593
+ age_ms: Number.isSafeInteger(ageMs) ? ageMs : 0,
594
+ stale: staleName(lockPath, holder, seen),
595
+ why,
596
+ };
597
+ if (holder !== null)
598
+ note.holder = { pid: holder.pid, op: holder.op, created: holder.created };
599
+ return note;
600
+ }
601
+ function judgeLock(lockPath, seen, now, watched) {
602
+ const lockfile = basename(lockPath);
603
+ const parsed = parseHolder(seen.bytes);
604
+ if (parsed.kind === "unknown") {
605
+ return { kind: "unjudgeable", why: `${lockfile} does not hold a holder record this version reads` };
606
+ }
607
+ if (parsed.kind === "legacy") {
608
+ const ageS = Math.round(Math.max(0, now - seen.mtimeMs) / 1000);
609
+ if (now - seen.mtimeMs < LEGACY_LOCK_RECLAIM_AGE_MS) {
610
+ return {
611
+ kind: "kept",
612
+ why: `${lockfile} names no holder (an older version's, or a writer killed between creating it and writing its record) and is ${String(ageS)} s old; it is reclaimed at ${String(LEGACY_LOCK_RECLAIM_AGE_MS / 60_000)} minutes`,
613
+ pid: null,
614
+ running: false,
615
+ };
616
+ }
617
+ // R3-8: an old mtime is not enough. The very file (inode, mtime, size)
618
+ // must have sat unchanged across this writer's own wait.
619
+ if (watched === null || watched.ino !== seen.ino || watched.mtimeNs !== seen.mtimeNs || watched.size !== BigInt(seen.bytes.length)) {
620
+ return { kind: "changed", why: `${lockfile} names no holder, and is not the file this writer saw when its wait began` };
621
+ }
622
+ if (now - watched.at < LEGACY_LOCK_WATCH_MS) {
623
+ return {
624
+ kind: "kept",
625
+ why: `${lockfile} names no holder and is ${String(ageS)} s old by its mtime, but this writer did not wait to see it stay unchanged (a try-once caller never takes an empty lockfile; a writer that waits does)`,
626
+ pid: null,
627
+ running: false,
628
+ };
629
+ }
630
+ return { kind: "gone", note: noteFor(lockPath, null, seen, now, "legacy-aged", `${lockfile} names no holder and is ${String(ageS)} s old`) };
631
+ }
632
+ const verdict = judge(parsed.holder);
633
+ if (verdict.state === "live") {
634
+ return { kind: "kept", why: `held by ${verdict.why} (${parsed.holder.op}, since ${parsed.holder.created})`, pid: parsed.holder.pid, running: verdict.running };
635
+ }
636
+ return { kind: "gone", note: noteFor(lockPath, parsed.holder, seen, now, "holder-dead", verdict.why) };
637
+ }
638
+ function isDirectory(path) {
639
+ try {
640
+ return lstatSync(path).isDirectory();
641
+ }
642
+ catch {
643
+ return false;
644
+ }
645
+ }
646
+ /**
647
+ * Why a claim failed, for a person: a link refused EPERM or EACCES (Linux
648
+ * `fs.protected_hardlinks`: a file the claimant neither owns nor can read and
649
+ * write) can be made only by the lockfile's owner or root.
650
+ */
651
+ function claimFailure(lockPath, seen, code, unlock) {
652
+ if (code !== "EPERM" && code !== "EACCES")
653
+ return `the claim on ${basename(lockPath)} failed (link: ${code}); a human runs \`${unlock}\``;
654
+ return `the claim on ${basename(lockPath)} failed (link: ${code}): it is owned by uid ${String(seen.uid)}, and only that user or root may link it; run \`${unlock}\` as that user or root, or \`rm -v ${lockPath}\` once no writer runs`;
655
+ }
656
+ /** A directory at the stale name, which neither a writer nor `approval log unlock` removes. */
657
+ function staleDirectory(stale) {
658
+ return `${basename(stale)} is a directory, so no claim can be made there; once no writer is running, a human removes it (\`rm -r ${stale}\`) and runs \`approval log unlock\` again`;
659
+ }
660
+ /**
661
+ * The claim: `link(2)` the lock's path to `stale`, which exactly one reclaimer
662
+ * of a given lockfile can do, then read the new name back and prove it is the
663
+ * judged file.
664
+ */
665
+ function claim(lockPath, seen, stale) {
666
+ try {
667
+ linkSync(lockPath, stale);
668
+ }
669
+ catch (cause) {
670
+ const code = errnoOf(cause);
671
+ if (code === "ENOENT")
672
+ return { kind: "moved" };
673
+ if (code !== "EEXIST")
674
+ return { kind: "failed", code: code ?? "an error" };
675
+ const now = readEntry(lockPath);
676
+ if (now.kind !== "file" || !sameLock(now.seen, seen))
677
+ return { kind: "moved" };
678
+ const there = readEntry(stale);
679
+ return { kind: "occupied", judged: there.kind === "file" && sameLock(there.seen, seen), directory: isDirectory(stale) };
680
+ }
681
+ const there = readEntry(stale);
682
+ if (there.kind !== "file" || !sameLock(there.seen, seen)) {
683
+ // The lock's path held another file by the time of the link: this name is
684
+ // this process's own second name for it, and removing it touches nothing.
685
+ unlinkQuietly(stale);
686
+ return { kind: "moved" };
687
+ }
688
+ return { kind: "claimed" };
689
+ }
690
+ /**
691
+ * Write this process's complete lockfile to `<lock>.take.<pid>.<nonce>`. It is
692
+ * written BEFORE the claim, so anyone who finds a claim beside the lock also
693
+ * finds the taker that made it, and can judge whether that taker is running
694
+ * ({@link scanTakers}). The caller has installed the signal guard.
695
+ */
696
+ function prepareTake(lockPath, op) {
697
+ const temporary = `${lockPath}.take.${String(process.pid)}.${randomBytes(8).toString("hex")}`;
698
+ try {
699
+ const own = createLockFile(temporary, op);
700
+ if (own.bytes.length === 0)
701
+ throw new Error("its holder record could not be written");
702
+ return { ok: true, prepared: { temporary, own } };
703
+ }
704
+ catch (cause) {
705
+ unlinkQuietly(temporary);
706
+ return { ok: false, why: cause.message };
707
+ }
708
+ }
709
+ /**
710
+ * The take: `rename(2)` this process's prepared lockfile over the lock's path,
711
+ * which holds the claimed file. Right before the rename, the claim and the
712
+ * lock's path are read again and must both still be the judged file: a claim
713
+ * a person's unlock removed, or a lock that changed hands, is seen here and
714
+ * nothing is moved. (Not atomic with the rename; it narrows the window, and
715
+ * {@link scanTakers} is what keeps a person from making it.) The signal guard
716
+ * was installed by the caller before its claim (the claim is the reclaim's
717
+ * first durable state) and is handed back with the lock. On failure the guard
718
+ * is released, and the claim with it when it is still this process's.
719
+ */
720
+ function take(lockPath, prepared, stale, seen, releaseGuard) {
721
+ const fail = (moved, why) => {
722
+ unlinkQuietly(prepared.temporary);
723
+ releaseGuard();
724
+ settleTerminationGuards();
725
+ return { ok: false, moved, why };
726
+ };
727
+ try {
728
+ step("before-take");
729
+ const claimed = readEntry(stale);
730
+ const current = readEntry(lockPath);
731
+ if (claimed.kind !== "file" || !sameLock(claimed.seen, seen) || current.kind !== "file" || !sameLock(current.seen, seen)) {
732
+ // Not this process's claim any more (or not the judged lock): leave both names as they are.
733
+ return fail(true, "its claim, or the lock it claimed, is no longer the file it judged");
734
+ }
735
+ renameSync(prepared.temporary, lockPath);
736
+ return { ok: true, own: { path: lockPath, ino: prepared.own.ino, bytes: prepared.own.bytes }, releaseGuard };
737
+ }
738
+ catch (cause) {
739
+ unlinkQuietly(stale);
740
+ return fail(false, cause.message);
741
+ }
742
+ }
743
+ /** The most `<lock>.take.*` names judged in one look; more is "cannot rule out a reclaim in flight". */
744
+ const MAX_TAKERS_JUDGED = 16;
745
+ /**
746
+ * Every `<lock>.take.<pid>.*` beside the lock except `except`, each judged by
747
+ * the same liveness rule as a lock's holder. A reclaimer writes it before its
748
+ * claim, so a claim found at a stale name has its taker here.
749
+ */
750
+ function scanTakers(lockPath, except) {
751
+ const dir = dirname(lockPath);
752
+ const prefix = `${basename(lockPath)}.take.`;
753
+ let names;
754
+ try {
755
+ names = readdirSync(dir).filter((name) => name.startsWith(prefix) && join(dir, name) !== except);
756
+ }
757
+ catch (cause) {
758
+ return { ok: false, why: `the log directory could not be listed (${errnoOf(cause) ?? "an error"})` };
759
+ }
760
+ if (names.length > MAX_TAKERS_JUDGED) {
761
+ return { ok: false, why: `more than ${String(MAX_TAKERS_JUDGED)} \`${prefix}*\` files are beside the lock` };
762
+ }
763
+ const takers = [];
764
+ for (const name of names.sort()) {
765
+ const entry = readEntry(join(dir, name));
766
+ if (entry.kind === "absent")
767
+ continue;
768
+ if (entry.kind === "unreadable") {
769
+ takers.push({ name, verdict: "unchecked", why: `${name} is ${entry.why}` });
770
+ continue;
771
+ }
772
+ const parsed = parseHolder(entry.seen.bytes);
773
+ if (parsed.kind !== "v1") {
774
+ takers.push({ name, verdict: "unchecked", why: `${name} holds no holder record this version reads` });
775
+ continue;
776
+ }
777
+ const verdict = judge(parsed.holder);
778
+ takers.push({ name, verdict: verdict.state === "gone" ? "gone" : verdict.running ? "running" : "unchecked", why: verdict.why });
779
+ }
780
+ return { ok: true, takers };
781
+ }
782
+ /** The refusal for a reclaim in flight whose taker is running. */
783
+ function inFlight(lockfile, taker) {
784
+ return `a reclaim of ${lockfile} is in flight (${taker.name}): ${taker.why}; let it finish, or stop that process, rather than unlock`;
785
+ }
786
+ /**
787
+ * Judge `<logPath>.lock` once and, when its holder is provably gone, take it:
788
+ * claim, prove the claim, take. Called by a writer only after its whole lock
789
+ * timeout has passed. Nothing is written but the stale name (the claim) and
790
+ * this writer's own lockfile.
791
+ */
792
+ export function reclaimStaleLock(logPath, op, options = {}) {
793
+ const lockPath = `${logPath}.lock`;
794
+ const lockfile = basename(lockPath);
795
+ const entry = readEntry(lockPath);
796
+ if (entry.kind === "absent")
797
+ return { kind: "retry", why: `${lockfile} was released` };
798
+ if (entry.kind === "unreadable") {
799
+ return {
800
+ kind: "kept",
801
+ why: `${lockfile} is ${entry.why}, not a lockfile any writer made, so it is not this writer's to judge; once no writer is running, a human removes it (\`rm -v ${lockPath}\`)`,
802
+ };
803
+ }
804
+ const now = options.now ?? Date.now();
805
+ const judged = judgeLock(lockPath, entry.seen, now, options.watched ?? null);
806
+ if (judged.kind === "changed")
807
+ return { kind: "retry", why: judged.why };
808
+ if (judged.kind === "unjudgeable") {
809
+ return { kind: "kept", why: `${judged.why}, so it is not this writer's to judge; once no writer is running, a human removes it (\`rm -v ${lockPath}\`)` };
810
+ }
811
+ if (judged.kind === "kept") {
812
+ const human = judged.running
813
+ ? "a running holder's lock is never taken"
814
+ : `a holder this process cannot check is never reclaimed automatically; once it is known to be gone, a human runs \`${unlockCommand(judged.pid)}\``;
815
+ return { kind: "kept", why: `${judged.why}; ${human}` };
816
+ }
817
+ const unlock = unlockCommand(judged.note.holder?.pid ?? null);
818
+ if (existsSync(`${logPath}.sync-snapshot`)) {
819
+ return {
820
+ kind: "kept",
821
+ why: `${judged.note.why}, but a log sync snapshot is beside the log, so a sync may have stopped part way; run \`approval log verify\` and \`approval log sync\`, then \`${unlock}\``,
822
+ };
823
+ }
824
+ if (!existsSync(logPath)) {
825
+ return { kind: "kept", why: `${judged.note.why}, but the log itself is absent, so there is nothing a reclaim record could follow; a human runs \`${unlock}\`` };
826
+ }
827
+ const context = options.recordContext?.() ?? { ok: true, lastTs: null };
828
+ if (!context.ok) {
829
+ return { kind: "kept", why: `${judged.note.why}, but this writer could not record the reclaim (${context.why}), so the lock is kept for a writer that can` };
830
+ }
831
+ const note = plausibleCreated(judged.note, context.lastTs, now);
832
+ if (options.recordValid !== undefined && !options.recordValid(note)) {
833
+ return { kind: "kept", why: `${note.why}, but the record of its reclaim would not be valid, so it is kept; a human runs \`${unlock}\`` };
834
+ }
835
+ step("judged");
836
+ // The claim is the reclaim's first durable state: a termination signal from
837
+ // here on is held until the lock this reclaim takes is released, or settled
838
+ // at once on every path that takes nothing.
839
+ const releaseGuard = guardTerminationWhileLocked();
840
+ const prepared = prepareTake(lockPath, op);
841
+ if (!prepared.ok) {
842
+ releaseGuard();
843
+ settleTerminationGuards();
844
+ return { kind: "kept", why: `${note.why}, but this writer could not write its own lockfile (${prepared.why})` };
845
+ }
846
+ const claimed = claim(lockPath, entry.seen, note.stale);
847
+ if (claimed.kind !== "claimed") {
848
+ unlinkQuietly(prepared.prepared.temporary);
849
+ releaseGuard();
850
+ settleTerminationGuards();
851
+ }
852
+ switch (claimed.kind) {
853
+ case "moved":
854
+ return { kind: "retry", why: `${lockfile} changed hands while it was being judged` };
855
+ case "occupied": {
856
+ if (claimed.directory)
857
+ return { kind: "kept", why: `${note.why}, but ${staleDirectory(note.stale)}` };
858
+ if (!claimed.judged) {
859
+ return { kind: "kept", why: `${note.why}, but ${basename(note.stale)} already exists and is not that lockfile, so no writer can claim the reclaim; once no writer is running, \`${unlock}\` clears both` };
860
+ }
861
+ // The claimant wrote its own lockfile before its claim: a running one is
862
+ // waited for, and the human verb is named only when none is seen running.
863
+ const takers = scanTakers(lockPath, null);
864
+ const running = takers.ok ? takers.takers.find((taker) => taker.verdict === "running") : undefined;
865
+ return running !== undefined
866
+ ? { kind: "retry", why: `${note.why}, and ${inFlight(lockfile, running)}` }
867
+ : { kind: "retry", why: `${note.why}, and another writer has claimed its reclaim (${basename(note.stale)}); if no writer is running, \`${unlock}\` finishes it` };
868
+ }
869
+ case "failed":
870
+ return { kind: "kept", why: `${note.why}, but ${claimFailure(lockPath, entry.seen, claimed.code, unlock)}` };
871
+ case "claimed":
872
+ break;
873
+ }
874
+ step("claimed");
875
+ const taken = take(lockPath, prepared.prepared, note.stale, entry.seen, releaseGuard);
876
+ if (!taken.ok) {
877
+ return taken.moved
878
+ ? { kind: "retry", why: `${lockfile} changed hands before this writer's take (${taken.why})` }
879
+ : { kind: "kept", why: `${note.why}, but this writer could not take the lock (${taken.why})` };
880
+ }
881
+ return { kind: "taken", own: taken.own, releaseGuard: taken.releaseGuard, note };
882
+ }
883
+ /** The stale name of a reclaim whose record is now in the log. */
884
+ export function finishReclaim(note) {
885
+ unlinkQuietly(note.stale);
886
+ }
887
+ /**
888
+ * `approval log unlock --pid <n|none>`: a human's take of a lock no writer
889
+ * reclaims (another container, another boot, a pid that cannot be checked, a
890
+ * claim whose reclaimer died, a planted stale name). The human asserts that no
891
+ * writer is running; this refuses only what it can see is wrong: a `--pid`
892
+ * that is not the record's, a holder it sees running, a lock beside a sync
893
+ * snapshot, and anything at the lock's path that is not a lockfile (which `rm`
894
+ * removes). It takes the lock with the same claim and take as a writer's
895
+ * reclaim, so the record of it (reason `operator-cleared`) is the first under
896
+ * the lock it takes.
897
+ */
898
+ export function takeLockForUnlock(logPath, expected, lastTs = null, now = Date.now()) {
899
+ const lockPath = `${logPath}.lock`;
900
+ const lockfile = basename(lockPath);
901
+ const entry = readEntry(lockPath);
902
+ if (entry.kind === "absent")
903
+ return { kind: "none" };
904
+ if (entry.kind === "unreadable") {
905
+ return { kind: "refused", why: `${lockfile} is ${entry.why}, not a lockfile; remove it by hand (\`rm -v ${lockPath}\`)` };
906
+ }
907
+ const parsed = parseHolder(entry.seen.bytes);
908
+ if (parsed.kind === "unknown") {
909
+ return { kind: "refused", why: `${lockfile} does not hold a holder record this version reads; remove it by hand (\`rm -v ${lockPath}\`)` };
910
+ }
911
+ const holder = parsed.kind === "v1" ? parsed.holder : null;
912
+ const pid = holder?.pid ?? null;
913
+ if (pid !== expected) {
914
+ return {
915
+ kind: "refused",
916
+ why: `${lockfile} ${pid === null ? "names no holder" : `names pid ${String(pid)}`}, not ${expected === null ? "none" : `pid ${String(expected)}`}; nothing was touched (\`${unlockCommand(pid)}\` names it)`,
917
+ };
918
+ }
919
+ if (existsSync(`${logPath}.sync-snapshot`)) {
920
+ return { kind: "refused", why: "a log sync snapshot is beside the log, so a sync may have stopped part way; run `approval log verify` and `approval log sync` first" };
921
+ }
922
+ let why = `${lockfile} names no holder`;
923
+ if (holder !== null) {
924
+ const verdict = judge(holder);
925
+ if (verdict.state === "live" && verdict.running) {
926
+ return { kind: "refused", why: `held by ${verdict.why}; stop that process, or let it finish, rather than unlock its lock` };
927
+ }
928
+ why = verdict.why;
929
+ }
930
+ // A reclaim in flight: its taker's own lockfile is beside the lock before its
931
+ // claim is. The person cannot tell a stopped reclaimer from a dead one; this
932
+ // can, so a running one is refused here rather than left to their word.
933
+ const before = scanTakers(lockPath, null);
934
+ if (!before.ok)
935
+ return { kind: "refused", why: `${before.why}, so a reclaim in flight cannot be ruled out; nothing was touched` };
936
+ const busy = before.takers.find((taker) => taker.verdict === "running");
937
+ if (busy !== undefined)
938
+ return { kind: "refused", why: inFlight(lockfile, busy) };
939
+ const note = plausibleCreated(noteFor(lockPath, holder, entry.seen, now, "operator-cleared", why), lastTs, now);
940
+ // As in a writer's reclaim: guarded from the take's own lockfile and the claim on.
941
+ const releaseGuard = guardTerminationWhileLocked();
942
+ const prepared = prepareTake(lockPath, "hold");
943
+ if (!prepared.ok) {
944
+ releaseGuard();
945
+ settleTerminationGuards();
946
+ return { kind: "refused", why: `the lock could not be taken (${prepared.why})` };
947
+ }
948
+ const refuse = (text) => {
949
+ unlinkQuietly(prepared.prepared.temporary);
950
+ releaseGuard();
951
+ settleTerminationGuards();
952
+ return { kind: "refused", why: text };
953
+ };
954
+ let claimed = claim(lockPath, entry.seen, note.stale);
955
+ if (claimed.kind === "occupied") {
956
+ // Judged again now that a claim is known to exist: its taker wrote its own
957
+ // lockfile before claiming, so a running one is found here.
958
+ const again = scanTakers(lockPath, prepared.prepared.temporary);
959
+ if (!again.ok)
960
+ return refuse(`${again.why}, so a reclaim in flight cannot be ruled out; nothing was touched`);
961
+ const busyNow = again.takers.find((taker) => taker.verdict === "running");
962
+ if (busyNow !== undefined)
963
+ return refuse(inFlight(lockfile, busyNow));
964
+ if (claimed.directory)
965
+ return refuse(staleDirectory(note.stale));
966
+ // A dead reclaimer's claim, or a planted file: the human says no writer is
967
+ // running, so it goes, and the claim is made again, exclusively.
968
+ unlinkQuietly(note.stale);
969
+ claimed = claim(lockPath, entry.seen, note.stale);
970
+ }
971
+ if (claimed.kind === "moved")
972
+ return refuse(`${lockfile} changed hands while it was being read: a writer is running; nothing was touched`);
973
+ if (claimed.kind === "failed")
974
+ return refuse(claimFailure(lockPath, entry.seen, claimed.code, unlockCommand(pid)));
975
+ if (claimed.kind !== "claimed")
976
+ return refuse(claimed.directory ? staleDirectory(note.stale) : `the claim on ${lockfile} failed (its stale name is taken again)`);
977
+ const taken = take(lockPath, prepared.prepared, note.stale, entry.seen, releaseGuard);
978
+ if (!taken.ok)
979
+ return { kind: "refused", why: `the lock could not be taken (${taken.why})` };
980
+ // Dead reclaimers' own lockfiles: their writers are provably gone, so
981
+ // nothing would ever move them. Anything not provably gone stays.
982
+ const after = scanTakers(lockPath, null);
983
+ if (after.ok)
984
+ for (const taker of after.takers)
985
+ if (taker.verdict === "gone")
986
+ unlinkQuietly(join(dirname(lockPath), taker.name));
987
+ return { kind: "taken", own: taken.own, releaseGuard: taken.releaseGuard, note };
988
+ }
989
+ // ---------------------------------------------------------------------------
990
+ // Termination signals while a lock is held
991
+ // ---------------------------------------------------------------------------
992
+ const GUARDED_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
993
+ const guards = new Map();
994
+ let locksHeld = 0;
995
+ let removalScheduled = false;
996
+ function removeGuards() {
997
+ for (const [signal, listener] of guards)
998
+ process.removeListener(signal, listener);
999
+ guards.clear();
1000
+ }
1001
+ /**
1002
+ * The guard's listener. It runs only on the event loop, and this process holds
1003
+ * a lock only inside synchronous code, so no lock is held here. Put the default
1004
+ * disposition back and raise the signal again, unless somebody else registered
1005
+ * a listener meanwhile, which then owns the outcome.
1006
+ */
1007
+ function reraise(signal) {
1008
+ removeGuards();
1009
+ if (process.listenerCount(signal) === 0)
1010
+ process.kill(process.pid, signal);
1011
+ }
1012
+ function scheduleGuardRemoval() {
1013
+ if (removalScheduled)
1014
+ return;
1015
+ removalScheduled = true;
1016
+ // Two hops cross a poll phase from any phase, so a signal that arrived while
1017
+ // the lock was held is dispatched to the guard before the guard goes:
1018
+ // removing the last listener first would drop it (APRV-478's notes).
1019
+ setImmediate(() => {
1020
+ setImmediate(() => {
1021
+ removalScheduled = false;
1022
+ if (locksHeld === 0)
1023
+ removeGuards();
1024
+ });
1025
+ });
1026
+ }
1027
+ /**
1028
+ * Hold SIGTERM, SIGINT and SIGHUP from just before a lockfile is created until
1029
+ * just after it is removed, for each signal nobody listens for (the default
1030
+ * disposition would kill the process with the lockfile in place). Returns the
1031
+ * release.
1032
+ *
1033
+ * Node shows a caught signal to JavaScript only when the event loop turns, and
1034
+ * removing the last listener before that turn drops it. So after the release:
1035
+ * a verb that returns or awaits dies of the signal at its next turn;
1036
+ * `approval run` awaits {@link yieldToTerminationSignals} before it spawns, so
1037
+ * a stop request during its append ends it first; and a synchronous wait calls
1038
+ * {@link settleTerminationGuards} before it blocks, so the wait runs under the
1039
+ * default disposition, at the price of losing a signal that landed in the
1040
+ * append's own milliseconds. A create that fails EEXIST releases and settles at
1041
+ * once, so a lock wait runs unguarded (a signal there kills at once, holding
1042
+ * nothing).
1043
+ *
1044
+ * Windows has no such signals to hold; there this is a no-op.
1045
+ */
1046
+ export function guardTerminationWhileLocked() {
1047
+ if (process.platform === "win32")
1048
+ return () => undefined;
1049
+ locksHeld += 1;
1050
+ for (const signal of GUARDED_SIGNALS) {
1051
+ if (guards.has(signal) || process.listenerCount(signal) > 0)
1052
+ continue;
1053
+ const listener = () => {
1054
+ reraise(signal);
1055
+ };
1056
+ guards.set(signal, listener);
1057
+ process.on(signal, listener);
1058
+ }
1059
+ let released = false;
1060
+ return () => {
1061
+ if (released)
1062
+ return;
1063
+ released = true;
1064
+ locksHeld -= 1;
1065
+ if (locksHeld === 0 && guards.size > 0)
1066
+ scheduleGuardRemoval();
1067
+ };
1068
+ }
1069
+ /**
1070
+ * Synchronous code about to block (an `Atomics.wait` poll, a `spawnSync`) calls
1071
+ * this first: when no lock is held, the guard is removed now, so the block is
1072
+ * not run with termination signals held. No-op while a lock is held.
1073
+ */
1074
+ export function settleTerminationGuards() {
1075
+ if (locksHeld > 0 || guards.size === 0)
1076
+ return;
1077
+ removeGuards();
1078
+ }
1079
+ /**
1080
+ * Turn the event loop past a poll phase, so a termination signal a released
1081
+ * lock's guard caught is dispatched now and the process dies of it here,
1082
+ * before the caller starts whatever it was about to start.
1083
+ */
1084
+ export async function yieldToTerminationSignals() {
1085
+ if (guards.size === 0)
1086
+ return;
1087
+ await new Promise((resolve) => {
1088
+ setImmediate(() => {
1089
+ setImmediate(resolve);
1090
+ });
1091
+ });
1092
+ }
1093
+ //# sourceMappingURL=log-lock.js.map