rvlib-shipkit 0.38.1 → 1.0.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 (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -5,6 +5,8 @@
5
5
  // never corrupts, never blocks.
6
6
  import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
7
7
  import { basename, dirname, join } from 'path'
8
+ import { LAYOUT } from './layout.ts'
9
+ import { announceInstance, instanceGone, thisInstance } from './instance.ts'
8
10
 
9
11
  // a writer that died without unlocking must not wedge the file forever
10
12
  const LOCK_STALE_MS = 120_000
@@ -60,9 +62,11 @@ export function atomicWrite(path: string, content: string): void {
60
62
  // grabbing.
61
63
  //
62
64
  // Bounded in every direction, because a gate that hangs is worse than a gate
63
- // that overlaps. A holder is stale when its pid is not running OR when the
64
- // lock has not been touched for LOCK_STALE_MS, so a pid reused by a long
65
- // lived process cannot wedge a repo forever. A holder that is genuinely still
65
+ // that overlaps. A holder is stale when its IMAGE is not running (its pid is
66
+ // gone, or the pid runs another image after a restart in place:
67
+ // core/instance.ts) OR when the lock has not been touched for LOCK_STALE_MS,
68
+ // so a pid reused by a long lived process cannot wedge a repo forever. A
69
+ // holder that is genuinely still
66
70
  // working HEARTBEATS the lock while it holds it, so the age rule only ever
67
71
  // fires on a run that is gone or wedged. A waiter gives up after maxWaitMs and
68
72
  // says so by name, path and escape hatch. Every line it prints carries a
@@ -78,37 +82,42 @@ const LEASE_HEARTBEAT_MS = 20_000
78
82
  * SIGTERMed mid wait with no cause on screen */
79
83
  const LEASE_MAX_WAIT_MS = 60_000
80
84
 
81
- type Holder = { pid: number; at: number; label: string }
85
+ type Holder = { pid: number; nonce?: string; at: number; label: string; meta?: Record<string, string> }
82
86
 
83
87
  let leaseSeq = 0
84
88
 
85
89
  const readHolder = (lockPath: string): Holder | null => {
86
90
  try {
87
91
  const h = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<Holder>
88
- return typeof h.pid === 'number' ? { pid: h.pid, at: typeof h.at === 'number' ? h.at : 0, label: String(h.label ?? '?') } : null
92
+ if (typeof h.pid !== 'number') return null
93
+ return {
94
+ pid: h.pid,
95
+ ...(typeof h.nonce === 'string' ? { nonce: h.nonce } : {}),
96
+ at: typeof h.at === 'number' ? h.at : 0,
97
+ label: String(h.label ?? '?'),
98
+ ...(h.meta != null && typeof h.meta === 'object' ? { meta: h.meta } : {}),
99
+ }
89
100
  } catch { return null }
90
101
  }
91
102
 
92
- /** is that pid still running? signal 0 asks without delivering anything.
93
- * EPERM means alive and owned by another user, which still counts as alive. */
94
- const alive = (pid: number): boolean => {
95
- if (!Number.isInteger(pid) || pid <= 0) return false
96
- try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
97
- }
98
-
99
- /** publish a COMPLETE lock under a name that did not exist a moment ago */
100
- const grabLease = (lockPath: string, label: string): boolean => {
103
+ /** publish a COMPLETE lock under a name that did not exist a moment ago.
104
+ * 'held' = the name exists, which is the only answer worth waiting on. Any
105
+ * other failure (the dir gone or not writable) comes back as its message:
106
+ * nobody holds that lock, and retrying cannot make it appear. */
107
+ const grabLease = (lockPath: string, label: string, meta?: Record<string, string>): 'taken' | 'held' | { error: string } => {
101
108
  const tmp = `${lockPath}.tmp-${process.pid}-${leaseSeq++}`
109
+ const me = announceInstance()
102
110
  try {
103
- writeFileSync(tmp, JSON.stringify({ pid: process.pid, at: Date.now(), label } satisfies Holder))
111
+ writeFileSync(tmp, JSON.stringify({ pid: me.pid, nonce: me.nonce, at: Date.now(), label, meta } satisfies Holder))
104
112
  linkSync(tmp, lockPath)
105
- return true
106
- } catch { return false }
107
- finally { rmSync(tmp, { force: true }) }
113
+ return 'taken'
114
+ } catch (e) {
115
+ return (e as NodeJS.ErrnoException).code === 'EEXIST' ? 'held' : { error: (e as Error).message }
116
+ } finally { rmSync(tmp, { force: true }) }
108
117
  }
109
118
 
110
119
  const sameHolder = (a: Holder | null, b: Holder | null): boolean =>
111
- a == null ? b == null : b != null && a.pid === b.pid && a.at === b.at
120
+ a == null ? b == null : b != null && a.pid === b.pid && a.nonce === b.nonce && a.at === b.at
112
121
 
113
122
  /** move the lock aside, and ONLY the lock we judged dead. Exactly one racer
114
123
  * can win the rename; the rest see ENOENT and go back to grabbing.
@@ -137,16 +146,78 @@ const stealLease = (lockPath: string, expect: Holder | null): boolean => {
137
146
  * lease two others believe they hold */
138
147
  const releaseLease = (lockPath: string): void => {
139
148
  const h = readHolder(lockPath)
140
- if (h == null || h.pid === process.pid) rmSync(lockPath, { force: true })
149
+ const me = thisInstance()
150
+ if (h == null || (h.pid === me.pid && (h.nonce == null || h.nonce === me.nonce))) rmSync(lockPath, { force: true })
141
151
  }
142
152
 
143
- export type LeaseOutcome<T> = { ok: true; value: T; waitedMs: number } | { ok: false; warn: string }
153
+ /** why the holder no longer holds, or null while it does: its image is gone
154
+ * (core/instance.ts), or it is wedged (nothing has touched it for the stale
155
+ * window, and a live holder heartbeats), or a body no reader can make sense
156
+ * of is old enough not to be a write in flight */
157
+ const deadBecause = (holder: Holder | null, ageMs: number): string | null => {
158
+ if (holder == null) return ageMs > 2_000 ? `an unreadable lock, untouched ${Math.round(ageMs / 1000)}s` : null
159
+ const gone = instanceGone(holder)
160
+ if (gone != null) return gone
161
+ return ageMs > LOCK_STALE_MS ? `pid ${holder.pid}, untouched ${Math.round(ageMs / 1000)}s` : null
162
+ }
163
+
164
+ /** a phase that legitimately outlives the stale window must keep proving it
165
+ * is working, or the age rule steals from it. unref'd, so a held lease never
166
+ * keeps a process alive on its own */
167
+ const holdLease = (lockPath: string): { release: () => void } => {
168
+ const beat = setInterval(() => { try { utimesSync(lockPath, new Date(), new Date()) } catch { /* stolen or gone: release sorts it out */ } }, LEASE_HEARTBEAT_MS)
169
+ beat.unref()
170
+ return { release: () => { clearInterval(beat); releaseLease(lockPath) } }
171
+ }
172
+
173
+ /** ONE non blocking attempt at `<path>.lock`: taken (a holder that is gone or
174
+ * wedged is stolen first), or null while a live holder has it. Held the same
175
+ * way withMutateLease holds it, heartbeat included. `meta` rides in the lock
176
+ * so a reader can say who holds it. */
177
+ export function tryLease(path: string, label: string, meta?: Record<string, string>): { release: () => void } | null {
178
+ const lockPath = `${path}.lock`
179
+ for (let round = 0; round < 3; round++) {
180
+ const grab = grabLease(lockPath, label, meta)
181
+ if (grab === 'taken') return holdLease(lockPath)
182
+ if (grab !== 'held') return null
183
+ const holder = readHolder(lockPath)
184
+ let ageMs = 0
185
+ try { ageMs = Date.now() - statSync(lockPath).mtimeMs } catch { continue }
186
+ if (deadBecause(holder, ageMs) == null) return null
187
+ stealLease(lockPath, holder)
188
+ }
189
+ return null
190
+ }
191
+
192
+ /** who holds `<path>.lock` right now, or null when nobody live does */
193
+ export function leaseHolder(path: string): { pid: number; at: number; label: string; meta: Record<string, string> } | null {
194
+ const lockPath = `${path}.lock`
195
+ const h = readHolder(lockPath)
196
+ let ageMs = 0
197
+ try { ageMs = Date.now() - statSync(lockPath).mtimeMs } catch { return null }
198
+ return h == null || deadBecause(h, ageMs) != null ? null : { pid: h.pid, at: h.at, label: h.label, meta: h.meta ?? {} }
199
+ }
200
+
201
+ export type LeaseOutcome<T> = { ok: true; value: T; waitedMs: number } | { ok: false; warn: string; aborted?: true }
144
202
 
145
203
  /** hold `<path>.lock` for the duration of fn. Blocking, stolen from a holder
146
204
  * that is gone or wedged, refused (never hung) past maxWaitMs. */
147
205
  export async function withMutateLease<T>(
148
206
  path: string,
149
- o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string; announceMs?: number },
207
+ o: {
208
+ label: string
209
+ print?: (line: string) => void
210
+ maxWaitMs?: number
211
+ escape?: string
212
+ announceMs?: number
213
+ pollMs?: number
214
+ /** called once, the first time a LIVE holder makes this caller wait */
215
+ onWait?: (holder: { pid: number; label: string }) => void
216
+ /** called on every steal this caller wins, with the dead holder */
217
+ onSteal?: (holder: { pid: number; nonce?: string }) => void
218
+ /** polled while waiting: true stops the wait, and fn never runs */
219
+ abort?: () => boolean
220
+ },
150
221
  fn: () => T | Promise<T>,
151
222
  ): Promise<LeaseOutcome<T>> {
152
223
  const lockPath = `${path}.lock`
@@ -156,23 +227,28 @@ export async function withMutateLease<T>(
156
227
  // the first announcement is due at LEASE_ANNOUNCE_MS, every later one a
157
228
  // re-announce interval after it
158
229
  let announced = -LEASE_REANNOUNCE_MS
230
+ let heard = false
159
231
  mkdirSync(dirname(lockPath), { recursive: true })
160
232
 
161
233
  for (;;) {
162
- if (grabLease(lockPath, o.label)) break
234
+ const grab = grabLease(lockPath, o.label)
235
+ if (grab === 'taken') break
236
+ if (grab !== 'held') return { ok: false, warn: `${o.label} lease: cannot create ${lockPath}: ${grab.error}` }
163
237
  const holder = readHolder(lockPath)
164
238
  let ageMs = 0
165
239
  // the lock vanished between grab and stat: the holder just finished,
166
240
  // so go straight back and take it
167
241
  try { ageMs = Date.now() - statSync(lockPath).mtimeMs } catch { continue }
168
- // gone (no such process), or wedged (nothing has touched it for the
169
- // stale window, and a live holder heartbeats), or a body no reader can
170
- // make sense of that is old enough not to be a write in flight
171
- const dead = holder == null ? ageMs > 2_000 : !alive(holder.pid) || ageMs > LOCK_STALE_MS
172
- if (dead) {
173
- if (stealLease(lockPath, holder)) print(`ok: stole the ${o.label} lease from a holder that is gone (pid ${holder?.pid ?? '?'}, untouched ${Math.round(ageMs / 1000)}s) — a run died mid phase`)
242
+ const dead = deadBecause(holder, ageMs)
243
+ if (dead != null) {
244
+ if (stealLease(lockPath, holder)) {
245
+ print(`ok: stole the ${o.label} lease from a holder that is gone (${dead}): a run died mid phase`)
246
+ if (holder != null) o.onSteal?.(holder)
247
+ }
174
248
  continue
175
249
  }
250
+ if (o.abort?.() === true) return { ok: false, warn: `${o.label} lease: stopped waiting`, aborted: true }
251
+ if (!heard && holder != null) { heard = true; o.onWait?.(holder) }
176
252
  const waited = Date.now() - start
177
253
  if (waited > maxWait)
178
254
  return {
@@ -183,26 +259,21 @@ export async function withMutateLease<T>(
183
259
  announced = waited
184
260
  print(`ok: waiting ${Math.round(waited / 1000)}s for the ${o.label} lease — ${holder == null ? 'an unreadable lock holds it' : `pid ${holder.pid} holds it (${holder.label})`}, lock ${lockPath}`)
185
261
  }
186
- await Bun.sleep(LEASE_POLL_MS)
262
+ await Bun.sleep(o.pollMs ?? LEASE_POLL_MS)
187
263
  }
188
264
 
189
- // a phase that legitimately outlives the stale window must keep proving it
190
- // is working, or the age rule steals from it. unref'd, so a held lease
191
- // never keeps a process alive on its own
192
- const beat = setInterval(() => { try { utimesSync(lockPath, new Date(), new Date()) } catch { /* stolen or gone: the finally sorts it out */ } }, LEASE_HEARTBEAT_MS)
193
- beat.unref()
265
+ const held = holdLease(lockPath)
194
266
  try {
195
267
  return { ok: true, value: await fn(), waitedMs: Date.now() - start }
196
268
  } finally {
197
- clearInterval(beat)
198
- releaseLease(lockPath)
269
+ held.release()
199
270
  }
200
271
  }
201
272
 
202
- /** where a repo's mutate leases live. Under the gitignored tmp/ dir so a lease
273
+ /** where a repo's mutate leases live. Under the gitignored scratch dir so a lease
203
274
  * file can never enter a commit, and per WORKTREE, because the phases it
204
275
  * guards rewrite worktree-local files. The NAME is the concern: two phases
205
276
  * that cannot corrupt each other take different leases, so `asks` never
206
277
  * blocks a `fix`. `git config` is the one shared-across-worktrees write and
207
278
  * carries its own retry (hooks/run-hooks.ts). */
208
- export const mutateLease = (root: string, name: string): string => join(root, 'tmp', `shipkit-${name}`)
279
+ export const mutateLease = (root: string, name: string): string => join(root, LAYOUT.tmp, `shipkit-${name}`)
@@ -18,10 +18,12 @@
18
18
  // not worth a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
19
19
  import { existsSync } from 'fs'
20
20
  import { join, resolve } from 'path'
21
+ import { gitRoot } from './git-root.ts'
21
22
  import { nestedConfigRoot } from './nesting.ts'
23
+ import { gitBin } from './git-bin.ts'
22
24
 
23
25
  const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
24
- const r = Bun.spawnSync(['git', ...args], { cwd })
26
+ const r = Bun.spawnSync([gitBin(), ...args], { cwd })
25
27
  return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}`.trim() }
26
28
  }
27
29
 
@@ -38,9 +40,8 @@ export function machineCommit(o: MachineCommit): boolean {
38
40
  if (process.env['SHIPKIT_NO_AUTOCOMMIT'] === '1') return false
39
41
  if (o.paths.length === 0) return false
40
42
  try {
41
- const top = git(resolve(o.anchor), ['rev-parse', '--show-toplevel'])
42
- if (!top.ok) return false // not a git repo, nothing to land
43
- const root = top.out
43
+ const root = gitRoot(resolve(o.anchor))?.top
44
+ if (root == null) return false // not a git repo, nothing to land
44
45
  // an anchor inside a nested shipkit repo would land on the parent's branch
45
46
  if (nestedConfigRoot(resolve(o.anchor), root) != null) return false
46
47
  // a detached HEAD or a repo with no commit yet has nowhere sane to land