@huseynsnmz/pi-suite 0.0.0-stage → 0.2.0

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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Tiny dependency-free synchronous file lock helpers for local agent-board artifacts.
3
+ * Locks use atomic mkdir on a sibling .lock directory and are cleaned up in finally.
4
+ *
5
+ * Failure model (issue #33): acquisition failures are classified.
6
+ * - EEXIST (contention): wait in 20ms ticks until the stale window passes, then
7
+ * force-steal (bounded to MAX_STEAL_ATTEMPTS). This preserves the original
8
+ * wait/steal contract (see test/locks.test.mjs).
9
+ * - Anything else (deleted parent, read-only fs, permissions, disk full, ...):
10
+ * MAX_ENV_ATTEMPTS quick retries — each retry re-runs ensureDir so a parent
11
+ * deleted mid-acquisition self-heals — then throw LOCK_TIMEOUT. Never spin.
12
+ */
13
+ import { randomBytes } from "node:crypto";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
+ import * as path from "node:path";
16
+ import { ensureDir } from "./atomic.mjs";
17
+ import { isAlive } from "./pid.mjs";
18
+ import * as P from "./paths.mjs";
19
+
20
+ const DEFAULT_STALE_MS = 30_000;
21
+ /** Minimum contention window before a fresh lock can be force-stolen. */
22
+ const MIN_WINDOW_MS = 250;
23
+ const WAIT_TICK_MS = 20;
24
+ /** Max stale-lock steal attempts before giving up. */
25
+ const MAX_STEAL_ATTEMPTS = 2;
26
+ /** Max quick retries for environmental failures before giving up. */
27
+ const MAX_ENV_ATTEMPTS = 3;
28
+
29
+ export const defaultLocksFs = Object.freeze({ existsSync, mkdirSync, readFileSync, rmSync, writeFileSync });
30
+
31
+ /**
32
+ * @template T
33
+ * @param {string} lockPath
34
+ * @param {() => T} fn
35
+ * @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
36
+ * @returns {T}
37
+ */
38
+ export function withFileLockSync(lockPath, fn, opts = {}) {
39
+ const fs = opts.fs ?? defaultLocksFs;
40
+ acquireLock(lockPath, opts.staleMs ?? DEFAULT_STALE_MS, fs);
41
+ let result;
42
+ try {
43
+ result = fn();
44
+ } finally {
45
+ releaseLock(lockPath, fs);
46
+ }
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * @template T
52
+ * @param {string} root
53
+ * @param {string} viewId
54
+ * @param {string} name
55
+ * @param {() => T} fn
56
+ * @param {{ staleMs?: number, fs?: typeof defaultLocksFs }} [opts]
57
+ * @returns {T}
58
+ */
59
+ export function withViewLockSync(root, viewId, name, fn, opts = {}) {
60
+ return withFileLockSync(P.viewLockPath(root, viewId, name), fn, opts);
61
+ }
62
+
63
+ /** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
64
+ function acquireLock(lockPath, staleMs, fs) {
65
+ const deadline = Date.now() + Math.max(MIN_WINDOW_MS, staleMs);
66
+ let steals = 0;
67
+ let envAttempts = 0;
68
+ for (;;) {
69
+ let created = false;
70
+ try {
71
+ // Re-run every attempt: a parent deleted mid-acquisition self-heals here.
72
+ ensureDir(path.dirname(lockPath));
73
+ fs.mkdirSync(lockPath);
74
+ created = true;
75
+ fs.writeFileSync(
76
+ path.join(lockPath, "owner.json"),
77
+ JSON.stringify({ pid: process.pid, at: Date.now() }),
78
+ "utf8",
79
+ );
80
+ return;
81
+ } catch (err) {
82
+ if (err && err.code === "EEXIST") {
83
+ const expired = Date.now() >= deadline;
84
+ if (isLockStale(lockPath, staleMs, fs) || expired) {
85
+ if (steals >= MAX_STEAL_ATTEMPTS) {
86
+ throw lockError(lockPath, `stale lock could not be stolen after ${steals} attempts`);
87
+ }
88
+ steals += 1;
89
+ releaseLock(lockPath, fs);
90
+ continue;
91
+ }
92
+ sleep(WAIT_TICK_MS);
93
+ continue;
94
+ }
95
+ // Environmental failure: bounded quick retries, then fail fast.
96
+ if (created) releaseLock(lockPath, fs);
97
+ envAttempts += 1;
98
+ if (envAttempts >= MAX_ENV_ATTEMPTS) {
99
+ const reason = (err && (err.code || err.message)) || "unknown error";
100
+ throw lockError(lockPath, `lock path unusable (${reason})`);
101
+ }
102
+ sleep(WAIT_TICK_MS);
103
+ }
104
+ }
105
+ }
106
+
107
+ /** @param {string} lockPath @param {string} reason */
108
+ function lockError(lockPath, reason) {
109
+ const err = new Error(`file lock unavailable: ${lockPath} (${reason})`);
110
+ err.code = "LOCK_TIMEOUT";
111
+ return err;
112
+ }
113
+
114
+ /** @param {number} ms */
115
+ function sleep(ms) {
116
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
117
+ }
118
+
119
+ /** @param {string} lockPath @param {number} staleMs @param {typeof defaultLocksFs} fs */
120
+ function isLockStale(lockPath, staleMs, fs) {
121
+ try {
122
+ if (!fs.existsSync(lockPath)) return false;
123
+ const raw = fs.readFileSync(path.join(lockPath, "owner.json"), "utf8");
124
+ const owner = JSON.parse(raw);
125
+ return Date.now() - Number(owner.at ?? 0) > staleMs;
126
+ } catch {
127
+ return true;
128
+ }
129
+ }
130
+
131
+ /** @param {string} lockPath @param {typeof defaultLocksFs} fs */
132
+ function releaseLock(lockPath, fs) {
133
+ try {
134
+ fs.rmSync(lockPath, { recursive: true, force: true });
135
+ } catch {
136
+ /* best effort */
137
+ }
138
+ }
139
+
140
+ // ---- owner-safe leases (issue #70) ----------------------------------------
141
+
142
+ /** Lease fs = lock fs + rename (candidate publish + quarantine reclaim). */
143
+ const defaultLeaseFs = Object.freeze({ ...defaultLocksFs, renameSync });
144
+ /** Max stale-reclaim → republish rounds inside a single acquire attempt. */
145
+ const MAX_LEASE_RECLAIM_ATTEMPTS = 3;
146
+ /**
147
+ * Age past which an identity-less lock is treated as an orphan candidate.
148
+ * Host-meta holds are millisecond-scale critical sections, so no legitimate
149
+ * holder reaches this (issue #112).
150
+ */
151
+ const ORPHAN_LEASE_AGE_MS = 5 * 60_000;
152
+ /** Age past which a heartbeating lease with no live proof of ownership is an orphan. */
153
+ export const LEASE_HEARTBEAT_STALE_MS = 60_000;
154
+ /** Consecutive supervised heartbeat failures after which a holder must vacate. */
155
+ export const MAX_CONSECUTIVE_TOUCH_FAILURES = 3;
156
+
157
+ /**
158
+ * A token-fenced lease over the view-lock directory. Every operation re-reads
159
+ * owner.json and only acts while it still carries this lease's token, so a
160
+ * superseded owner can never touch — heartbeat or delete — the current owner's
161
+ * lock. Unlike withFileLockSync, a held lock is never stolen by age alone; it
162
+ * can only be reclaimed when its recorded owner process is provably dead, or
163
+ * when a heartbeating owner stopped heartbeating (issue #224).
164
+ * @typedef {Object} LeaseReclaimed
165
+ * @property {number} ownerPid
166
+ * @property {number|null} heartbeatAgeMs
167
+ * @property {number} lockAgeMs
168
+ * @property {"declared" | "legacy"} eligibility
169
+ */
170
+
171
+ /**
172
+ * @typedef {Object} Lease
173
+ * @property {string} token
174
+ * @property {LeaseReclaimed|null} reclaimed metadata when this acquire reclaimed a stale lease
175
+ * @property {() => boolean} touch
176
+ * @property {() => boolean} isOwner
177
+ * @property {() => boolean} release
178
+ */
179
+
180
+ /**
181
+ * @param {string} root
182
+ * @param {string} viewId
183
+ * @param {string} name
184
+ * @param {{ waitMs?: number, identity?: object|null, heartbeatMs?: number, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
185
+ * @returns {Lease}
186
+ * @throws {Error & { code: "LOCK_TIMEOUT" }} when the lock is still held after waitMs
187
+ */
188
+ export function acquireOwnedViewLock(root, viewId, name, opts = {}) {
189
+ const lockPath = P.viewLockPath(root, viewId, name);
190
+ const deadline = Date.now() + Math.max(0, Number(opts.waitMs ?? 0));
191
+ for (;;) {
192
+ const attempt = attemptAcquireLease(lockPath, opts);
193
+ if (typeof attempt !== "string") return attempt;
194
+ if (Date.now() >= deadline) throw lockError(lockPath, `lease not acquired (${attempt})`);
195
+ sleep(WAIT_TICK_MS);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * @param {string} root
201
+ * @param {string} viewId
202
+ * @param {string} name
203
+ * @param {{ identity?: object|null, heartbeatMs?: number, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
204
+ * @returns {{ acquired: true, lease: Lease } | { acquired: false, reason: "busy"|"blocked" }}
205
+ */
206
+ export function tryAcquireOwnedViewLock(root, viewId, name, opts = {}) {
207
+ const attempt = attemptAcquireLease(P.viewLockPath(root, viewId, name), opts);
208
+ if (typeof attempt === "string") return { acquired: false, reason: attempt };
209
+ return { acquired: true, lease: attempt };
210
+ }
211
+
212
+ /**
213
+ * Whether a publish-rename failure means "the lock path already exists"
214
+ * (contention) rather than a genuine filesystem error.
215
+ *
216
+ * POSIX reports EEXIST/ENOTEMPTY when renaming a directory onto an existing
217
+ * one; Windows reports EPERM (errno -4048) for the same situation — this op's
218
+ * platform equivalent of EEXIST (issue #114: a crashed owner's lease could
219
+ * never be reclaimed because EPERM was rethrown before reclaimOrBlock).
220
+ * Routing a genuine permission error here stays safe: reclaimability is still
221
+ * decided solely by `classifyLeaseOwner`, so it resolves `blocked`/`busy`.
222
+ * @param {string|undefined|null} code
223
+ * @returns {boolean}
224
+ */
225
+ export function isPublishConflictCode(code) {
226
+ return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM";
227
+ }
228
+
229
+ /**
230
+ * Single-shot acquire round: publish a complete candidate lock (owner.json
231
+ * written BEFORE the lock path exists) via atomic rename, and on contention
232
+ * either reclaim a provably-dead owner's lock via quarantine or report.
233
+ * @param {string} lockPath
234
+ * @param {{ identity?: object|null, heartbeatMs?: number, fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} opts
235
+ * @returns {Lease | "busy" | "blocked"}
236
+ */
237
+ function attemptAcquireLease(lockPath, opts) {
238
+ const fs = { ...defaultLeaseFs, ...(opts.fs ?? {}) };
239
+ const now = opts.clock ?? Date.now;
240
+ const isProcessDead = opts.isProcessDead ?? ((pid) => !isAlive(pid));
241
+ const identity = opts.identity ?? null;
242
+ const heartbeatMs = Number(opts.heartbeatMs ?? 0);
243
+ // Declaring the period is what makes this lease reclaimable on staleness
244
+ // alone (issue #224 D2); unset keeps the pre-#224 record shape.
245
+ const declaresHeartbeat = Number.isFinite(heartbeatMs) && heartbeatMs > 0;
246
+ let reclaimed = null;
247
+ for (let attempt = 0; attempt < MAX_LEASE_RECLAIM_ATTEMPTS; attempt++) {
248
+ const token = randomBytes(16).toString("hex");
249
+ const candidate = `${lockPath}.candidate.${token}`;
250
+ ensureDir(path.dirname(lockPath));
251
+ try {
252
+ // Candidate dir is unique per token; a rename publishes the COMPLETE
253
+ // record atomically — no observer can see a lock without owner.json.
254
+ fs.mkdirSync(candidate);
255
+ fs.writeFileSync(
256
+ path.join(candidate, "owner.json"),
257
+ JSON.stringify({
258
+ token, pid: process.pid, identity, startedAt: now(),
259
+ ...(declaresHeartbeat ? { heartbeatMs } : {}),
260
+ }),
261
+ "utf8",
262
+ );
263
+ fs.renameSync(candidate, lockPath);
264
+ return makeLease(lockPath, token, fs, now, reclaimed);
265
+ } catch (err) {
266
+ try { fs.rmSync(candidate, { recursive: true, force: true }); } catch { /* best effort */ }
267
+ const code = /** @type {NodeJS.ErrnoException|undefined} */ (err)?.code;
268
+ if (!isPublishConflictCode(code)) throw err;
269
+ const outcome = reclaimOrBlock(lockPath, token, fs, isProcessDead, now);
270
+ if (typeof outcome === "string") return outcome;
271
+ reclaimed = outcome.reclaimed;
272
+ // Reclaimed a stale owner's lock — retry the publish on the next round.
273
+ }
274
+ }
275
+ return "busy";
276
+ }
277
+
278
+ /**
279
+ * Decide what to do with an inspected lease owner. Pure: the caller supplies
280
+ * `now`, a pid-liveness probe and the observed heartbeat age.
281
+ *
282
+ * Heartbeat eligibility comes first, because a reused pid looks alive while the
283
+ * recorded owner is long gone (issue #224). Two mutually exclusive paths:
284
+ * - declared: the owner wrote `heartbeatMs` into owner.json. Threshold is
285
+ * `max(heartbeatStaleMs, 3 × heartbeatMs)`; a missing heartbeat falls back to
286
+ * the lock age. This path applies to identity and identity-less owners and
287
+ * bypasses the identity-less age gate, or a crash would take 5 minutes to heal.
288
+ * - legacy: a `heartbeat.<token>` file exists (hence `heartbeatAgeMs != null`)
289
+ * and the lock is past `orphanAgeMs`. The age gate keeps millisecond-scale
290
+ * short-hold leases (`host-meta`, `host-start`) out of this path.
291
+ *
292
+ * Without heartbeat evidence the pre-#224 liveness rules apply unchanged: a
293
+ * full identity (pid + startToken) reclaims exactly when its pid is dead; an
294
+ * identity-less owner is only reclaimable past `orphanAgeMs` with a provably
295
+ * dead pid, so fresh identity-less locks stay blocked (issue #112).
296
+ * @param {any} owner parsed owner.json
297
+ * @param {number} now
298
+ * @param {(pid: number) => boolean} isProcessDead
299
+ * @param {{ orphanAgeMs?: number, heartbeatAgeMs?: number|null, heartbeatMs?: number, heartbeatStaleMs?: number }} [opts]
300
+ * @returns {"reclaim" | "busy" | "blocked"}
301
+ */
302
+ export function classifyLeaseOwner(owner, now, isProcessDead, opts = {}) {
303
+ if (!owner || typeof owner !== "object") return "blocked";
304
+ const identityPid = Number(owner?.identity?.pid ?? 0);
305
+ const hasIdentity = Number.isFinite(identityPid) && identityPid > 0 && typeof owner?.identity?.startToken === "string";
306
+ const pid = Number(owner?.identity?.pid ?? owner?.pid ?? 0);
307
+ if (!Number.isFinite(pid) || pid <= 0) return "blocked";
308
+ // Quarantine-mode reclaim verifies by token that it renamed the lock it
309
+ // inspected — without one nothing may be deleted.
310
+ const reclaimable = typeof owner.token === "string";
311
+ const orphanAgeMs = Number(opts.orphanAgeMs ?? ORPHAN_LEASE_AGE_MS);
312
+ // The declared period may arrive via opts (I/O shell) or already be on the
313
+ // parsed owner record (pure callers); opts wins.
314
+ const heartbeatMs = Number(opts.heartbeatMs ?? owner?.heartbeatMs ?? 0);
315
+ const declared = Number.isFinite(heartbeatMs) && heartbeatMs > 0;
316
+ const heartbeatStaleMs = Number(opts.heartbeatStaleMs ?? LEASE_HEARTBEAT_STALE_MS);
317
+ const lockAgeMs = Number(now) - Number(owner?.startedAt ?? 0);
318
+ if (declared) {
319
+ const heartbeatAgeMs = Math.max(0, Number(opts.heartbeatAgeMs ?? lockAgeMs));
320
+ if (heartbeatAgeMs >= Math.max(heartbeatStaleMs, 3 * heartbeatMs)) return reclaimable ? "reclaim" : "blocked";
321
+ } else if (opts.heartbeatAgeMs != null && lockAgeMs >= orphanAgeMs) {
322
+ const heartbeatAgeMs = Math.max(0, Number(opts.heartbeatAgeMs));
323
+ if (heartbeatAgeMs >= heartbeatStaleMs) return reclaimable ? "reclaim" : "blocked";
324
+ }
325
+ if (hasIdentity) {
326
+ if (!isProcessDead(pid)) return "busy";
327
+ return reclaimable ? "reclaim" : "blocked";
328
+ }
329
+ if (!declared && lockAgeMs < orphanAgeMs) return "blocked";
330
+ if (!isProcessDead(pid)) return "busy";
331
+ return reclaimable ? "reclaim" : "blocked";
332
+ }
333
+
334
+ /**
335
+ * Read the owner-written heartbeat and return its age in ms. A future `at`
336
+ * (clock skew) clamps to 0: a heartbeat must never look older than it is.
337
+ * @param {string} lockPath
338
+ * @param {any} token the recorded owner token (names the heartbeat file)
339
+ * @param {{ readFileSync: (path: string, encoding: string) => string }} [fs]
340
+ * @param {number} [now]
341
+ * @returns {number|null} null when the file is missing or its `at` is unusable
342
+ */
343
+ export function readLeaseHeartbeat(lockPath, token, fs = defaultLocksFs, now = Date.now()) {
344
+ if (typeof token !== "string" || token.length === 0) return null;
345
+ let parsed;
346
+ try {
347
+ parsed = JSON.parse(fs.readFileSync(path.join(lockPath, `heartbeat.${token}`), "utf8"));
348
+ } catch {
349
+ return null;
350
+ }
351
+ const at = parsed?.at;
352
+ if (typeof at !== "number" || !Number.isFinite(at)) return null;
353
+ return Math.max(0, now - at);
354
+ }
355
+
356
+ /**
357
+ * Decide whether an existing lock may be reclaimed. Verdicts come from the
358
+ * pure `classifyLeaseOwner`; reclaim itself quarantines via rename first so a
359
+ * concurrent winner can only ever delete the directory it itself renamed.
360
+ *
361
+ * The observed heartbeat age is the only I/O this adds: a missing or broken
362
+ * heartbeat file resolves to null, which grants no eligibility to an
363
+ * undeclared lease (fail closed) and falls back to the lock age for a declared
364
+ * one (issue #224 D3).
365
+ * @param {string} lockPath
366
+ * @param {string} token the caller's own token (names the quarantine dir)
367
+ * @param {typeof defaultLeaseFs} fs
368
+ * @param {(pid: number) => boolean} isProcessDead
369
+ * @param {() => number} now clock (matching the caller's candidate timestamps)
370
+ * @returns {{ reclaimed: LeaseReclaimed } | "busy" | "blocked"}
371
+ */
372
+ function reclaimOrBlock(lockPath, token, fs, isProcessDead, now) {
373
+ let owner;
374
+ try {
375
+ owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
376
+ } catch {
377
+ return "blocked";
378
+ }
379
+ const at = now();
380
+ const heartbeatAgeMs = readLeaseHeartbeat(lockPath, owner?.token, fs, at);
381
+ const declaredHeartbeatMs = Number(owner?.heartbeatMs ?? 0);
382
+ const declared = Number.isFinite(declaredHeartbeatMs) && declaredHeartbeatMs > 0;
383
+ const verdict = classifyLeaseOwner(owner, at, isProcessDead, {
384
+ heartbeatAgeMs,
385
+ heartbeatMs: declared ? declaredHeartbeatMs : undefined,
386
+ heartbeatStaleMs: LEASE_HEARTBEAT_STALE_MS,
387
+ });
388
+ if (verdict !== "reclaim") return verdict;
389
+ // classifyLeaseOwner only returns "reclaim" when owner.token is a string.
390
+ const inspectedToken = owner.token;
391
+ const quarantine = `${lockPath}.reclaim.${token}`;
392
+ try {
393
+ fs.renameSync(lockPath, quarantine);
394
+ } catch {
395
+ // Someone else released/reclaimed concurrently — plain contention.
396
+ return "busy";
397
+ }
398
+ // Verify we quarantined exactly the lock we inspected: a concurrent winner
399
+ // may have reclaimed the dead owner and published a fresh lock at lockPath
400
+ // between our read and this rename. If so, restore it and report contention
401
+ // instead of deleting a successor's lock.
402
+ let quarantinedToken = null;
403
+ try {
404
+ quarantinedToken = JSON.parse(fs.readFileSync(path.join(quarantine, "owner.json"), "utf8"))?.token ?? null;
405
+ } catch { /* unreadable quarantine content */ }
406
+ if (quarantinedToken !== inspectedToken) {
407
+ try { fs.renameSync(quarantine, lockPath); } catch { /* best effort restore */ }
408
+ return "busy";
409
+ }
410
+ try { fs.rmSync(quarantine, { recursive: true, force: true }); } catch { /* best effort */ }
411
+ return {
412
+ reclaimed: {
413
+ ownerPid: Number(owner?.identity?.pid ?? owner?.pid ?? 0),
414
+ heartbeatAgeMs,
415
+ lockAgeMs: at - Number(owner?.startedAt ?? 0),
416
+ eligibility: declared ? "declared" : "legacy",
417
+ },
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Summarize why an existing lock is not acquirable, so the coordinator can
423
+ * record a diagnostic instead of exiting silently (issue #224 D6).
424
+ *
425
+ * Returns exactly the six keys that become the `coordinator_lease_unavailable`
426
+ * details — no more, no less. `reason` is the raw `classifyLeaseOwner` verdict
427
+ * at call time, so on the acquisition-failure path it is `"busy"` or
428
+ * `"blocked"`.
429
+ * @param {string} root
430
+ * @param {string} viewId
431
+ * @param {string} name
432
+ * @param {{ fs?: Partial<typeof defaultLeaseFs>, clock?: () => number, isProcessDead?: (pid: number) => boolean }} [opts]
433
+ * @returns {{ reason: "reclaim"|"busy"|"blocked", ownerPid: number, hasIdentity: boolean, lockAgeMs: number, heartbeatAgeMs: number|null, heartbeatMs: number|null } | null}
434
+ */
435
+ export function describeLeaseConflict(root, viewId, name, opts = {}) {
436
+ const fs = { ...defaultLeaseFs, ...(opts.fs ?? {}) };
437
+ const lockPath = P.viewLockPath(root, viewId, name);
438
+ let owner;
439
+ try {
440
+ owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
441
+ } catch {
442
+ return null;
443
+ }
444
+ if (!owner || typeof owner !== "object") return null;
445
+ const at = (opts.clock ?? Date.now)();
446
+ const isProcessDead = opts.isProcessDead ?? ((pid) => !isAlive(pid));
447
+ const identityPid = Number(owner?.identity?.pid ?? 0);
448
+ const heartbeatAgeMs = readLeaseHeartbeat(lockPath, owner?.token, fs, at);
449
+ const declaredHeartbeatMs = Number(owner?.heartbeatMs ?? 0);
450
+ const heartbeatMs = Number.isFinite(declaredHeartbeatMs) && declaredHeartbeatMs > 0 ? declaredHeartbeatMs : null;
451
+ return {
452
+ reason: classifyLeaseOwner(owner, at, isProcessDead, {
453
+ heartbeatAgeMs,
454
+ heartbeatMs: heartbeatMs ?? undefined,
455
+ heartbeatStaleMs: LEASE_HEARTBEAT_STALE_MS,
456
+ }),
457
+ ownerPid: Number(owner?.identity?.pid ?? owner?.pid ?? 0),
458
+ hasIdentity: Number.isFinite(identityPid) && identityPid > 0 && typeof owner?.identity?.startToken === "string",
459
+ lockAgeMs: at - Number(owner?.startedAt ?? 0),
460
+ heartbeatAgeMs,
461
+ heartbeatMs,
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Supervised heartbeat failure policy: a holder that cannot prove liveness for
467
+ * `maxFailures` consecutive ticks must vacate rather than be preempted later.
468
+ * @param {number} consecutiveFailures
469
+ * @param {number} [maxFailures]
470
+ * @returns {"continue" | "abandon"}
471
+ */
472
+ export function heartbeatFailureAction(consecutiveFailures, maxFailures = MAX_CONSECUTIVE_TOUCH_FAILURES) {
473
+ return Number(consecutiveFailures) >= Number(maxFailures) ? "abandon" : "continue";
474
+ }
475
+
476
+ /**
477
+ * @param {string} lockPath
478
+ * @param {string} token
479
+ * @param {typeof defaultLeaseFs} fs
480
+ * @param {() => number} now
481
+ * @param {LeaseReclaimed|null} [reclaimed]
482
+ * @returns {Lease}
483
+ */
484
+ function makeLease(lockPath, token, fs, now, reclaimed = null) {
485
+ const ownerPath = path.join(lockPath, "owner.json");
486
+ const heartbeatPath = path.join(lockPath, `heartbeat.${token}`);
487
+ const stillOwns = () => {
488
+ try {
489
+ return JSON.parse(fs.readFileSync(ownerPath, "utf8"))?.token === token;
490
+ } catch {
491
+ return false;
492
+ }
493
+ };
494
+ return {
495
+ token,
496
+ reclaimed,
497
+ isOwner: () => stillOwns(),
498
+ touch() {
499
+ if (!stillOwns()) return false;
500
+ try {
501
+ // Heartbeat lives in a token-named file: old tokens can never
502
+ // overwrite the canonical owner record or a successor's heartbeat.
503
+ fs.writeFileSync(heartbeatPath, JSON.stringify({ at: now() }), "utf8");
504
+ return true;
505
+ } catch {
506
+ return false;
507
+ }
508
+ },
509
+ release() {
510
+ if (!stillOwns()) return false;
511
+ try {
512
+ fs.rmSync(lockPath, { recursive: true, force: true });
513
+ return true;
514
+ } catch {
515
+ return false;
516
+ }
517
+ },
518
+ };
519
+ }
520
+
521
+ /**
522
+ * Simulates a late release() from a superseded token. Exported so tests can
523
+ * prove old tokens can never delete a successor's lock.
524
+ * @visibleForTesting
525
+ * @param {string} root @param {string} viewId @param {string} name @param {string} token
526
+ * @returns {boolean}
527
+ */
528
+ export function releaseWithToken(root, viewId, name, token) {
529
+ return makeLease(P.viewLockPath(root, viewId, name), token, defaultLeaseFs, Date.now).release();
530
+ }