rvlib-shipkit 1.0.0 → 1.1.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 (72) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +30 -4
  4. package/src/assets/gen/derived.ts +1 -1
  5. package/src/check.ts +6 -0
  6. package/src/checks/SK004-gitignore-policy.check.ts +4 -4
  7. package/src/checks/SK005-rvlib-versions.check.ts +25 -7
  8. package/src/checks/SK021-github-login.check.ts +123 -0
  9. package/src/cli/cmd_Asks.ts +1 -1
  10. package/src/cli/cmd_Check.ts +1 -1
  11. package/src/cli/cmd_Ci.ts +1 -1
  12. package/src/cli/cmd_Fleet.ts +4 -47
  13. package/src/cli.ts +2 -3
  14. package/src/collections/checks.ts +12 -3
  15. package/src/collections/collection-types.ts +34 -18
  16. package/src/collections/comfy.ts +1 -0
  17. package/src/collections/posts.ts +1 -0
  18. package/src/collections/providers.ts +71 -43
  19. package/src/collections/reflections.ts +1 -0
  20. package/src/collections/state-rules.ts +29 -13
  21. package/src/collections/tests.ts +1 -0
  22. package/src/collections/wikis.ts +1 -0
  23. package/src/core/budget.ts +60 -0
  24. package/src/core/check-ctx.ts +2 -1
  25. package/src/core/check-runner.ts +43 -6
  26. package/src/core/fd-table.ts +37 -0
  27. package/src/core/floor-resolve.ts +2 -2
  28. package/src/core/git-root.ts +26 -2
  29. package/src/core/lib-version.ts +16 -0
  30. package/src/core/migrate-layout.ts +69 -9
  31. package/src/core/motherhome.ts +2 -4
  32. package/src/core/package-manager.ts +20 -16
  33. package/src/core/raw-access.ts +11 -3
  34. package/src/core/reexec.ts +26 -0
  35. package/src/core/run-check.ts +36 -16
  36. package/src/core/run-ci.ts +31 -27
  37. package/src/core/run-fix.ts +4 -1
  38. package/src/core/run-fixers.ts +19 -11
  39. package/src/core/self-update.ts +63 -26
  40. package/src/core/update-policy.ts +25 -0
  41. package/src/fleet/scan-fleet.ts +20 -15
  42. package/src/fleet/update-fleet.ts +85 -0
  43. package/src/hooks/leak-guard.ts +10 -1
  44. package/src/hooks/run-hooks.ts +3 -3
  45. package/src/ledger/gate-checks.ts +19 -5
  46. package/src/ledger/git-key.ts +10 -5
  47. package/src/ledger/git-scan.ts +29 -17
  48. package/src/ledger/ledger.ts +14 -6
  49. package/src/ledger/repo-view.ts +8 -6
  50. package/src/ledger/run-log.ts +10 -4
  51. package/src/ledger/run-timeline.ts +21 -9
  52. package/src/problems/problem-board.ts +13 -2
  53. package/src/problems/problem-line.ts +9 -0
  54. package/src/repo-config.ts +24 -4
  55. package/src/serve/hub-app/check-view.tsx +12 -11
  56. package/src/serve/hub-app/cols-svc.ts +16 -9
  57. package/src/serve/hub-app/dur.ts +10 -1
  58. package/src/serve/hub-app/header-bar.tsx +11 -0
  59. package/src/serve/hub-app/help.tsx +1 -0
  60. package/src/serve/hub-app/hub-st.ts +3 -4
  61. package/src/serve/hub-app/hub-types.ts +5 -0
  62. package/src/serve/hub-app/hub.css +3 -0
  63. package/src/serve/hub-app/timeline-view.tsx +4 -4
  64. package/src/serve/hub-app/tree-build.ts +1 -2
  65. package/src/serve/hub-problems.ts +35 -0
  66. package/src/serve/run-serve.ts +51 -12
  67. package/src/serve/serve-hub.ts +10 -3
  68. package/src/serve/stale-restart.ts +77 -0
  69. package/src/smoke/run-smoke.ts +6 -2
  70. package/src/suite/suite-cli.ts +14 -10
  71. package/src/ts/source-graph.ts +3 -2
  72. package/src/wiki/scan-md.ts +1 -1
@@ -37,6 +37,9 @@ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans
37
37
  .hnav button:hover{color:var(--ink);border-color:transparent;border-bottom-color:var(--line)}
38
38
  .hnav button.on{color:var(--accent);border-bottom-color:var(--accent)}
39
39
  /* the run state holds its width whatever it says, so a run never moves the buttons beside it */
40
+ .hstatus{max-width:420px;flex:0 1 auto;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
41
+ .hstatus.red{color:var(--red)}
42
+ .hstatus.warn{color:var(--warn)}
40
43
  .hstate{display:inline-flex;gap:var(--s1);align-items:center;width:260px;min-width:260px;flex:none;justify-content:flex-end;font-size:11.5px;color:var(--dim);overflow:hidden}
41
44
  .hstate.on{color:var(--ink)}
42
45
  .hstate .htext{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -163,7 +163,7 @@ const TestFiles = ({ app, rows, recorded }: { app: HubSt; rows: SuiteToFix[]; re
163
163
  )
164
164
  }
165
165
 
166
- const FLAG_WORD = { over: 'at its ceiling', near: 'past half its ceiling' } as const
166
+ const FLAG_WORD = { over: 'at its ceiling', near: 'past half its ceiling', budget: 'over its budget' } as const
167
167
 
168
168
  /** two numbers side by side need their names once, above the column */
169
169
  const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
@@ -174,9 +174,9 @@ const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
174
174
  {rows.map((r) => (
175
175
  <div key={r.id} className="rline rslow" onClick={() => app.timeline.openCheck(r.id)}>
176
176
  <span className="rtag">{r.id}</span>
177
- <span className={`rnum ${durationTone(r.meanMs, r.ceilingMs) ?? ''}`} title="mean of the runs that ran">{fmtMs(r.meanMs)}</span>
178
- <span className={`rnum ${durationTone(r.worstMs, r.ceilingMs) ?? ''}`} title="the worst run that ran">{fmtMs(r.worstMs)}</span>
179
- <span className={r.flag === 'over' ? 'rflag red' : 'rflag warn'} title={r.ceilingMs == null ? undefined : `ceiling ${fmtMs(r.ceilingMs)}`}>{r.flag == null ? '' : FLAG_WORD[r.flag]}</span>
177
+ <span className={`rnum ${durationTone(r.meanMs, r.ceilingMs, r.budgetMs) ?? ''}`} title="mean of the runs that ran">{fmtMs(r.meanMs)}</span>
178
+ <span className={`rnum ${durationTone(r.worstMs, r.ceilingMs, r.budgetMs) ?? ''}`} title="the worst run that ran">{fmtMs(r.worstMs)}</span>
179
+ <span className={r.flag === 'over' ? 'rflag red' : 'rflag warn'} title={[r.budgetMs == null ? null : `budget ${fmtMs(r.budgetMs)}`, r.ceilingMs == null ? null : `ceiling ${fmtMs(r.ceilingMs)}`].filter((x) => x != null).join(', ') || undefined}>{r.flag == null ? '' : FLAG_WORD[r.flag]}</span>
180
180
  </div>
181
181
  ))}
182
182
  </>
@@ -335,7 +335,7 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
335
335
  state: verdict[0],
336
336
  tone: verdict[1],
337
337
  dur: fmtMs(u.lastMs),
338
- durTone: durationTone(u.lastMs, u.timeoutMs),
338
+ durTone: durationTone(u.lastMs, u.timeoutMs, u.budgetMs),
339
339
  note: WHEN_NOTE[u.when],
340
340
  count: null,
341
341
  hint: [
@@ -391,7 +391,6 @@ export const TYPE_MARK: Record<string, TypeMark> = {
391
391
  workflow: { glyph: '\u29c9', hue: 'k6', word: 'a comfy workflow module' },
392
392
  wiki: { glyph: '\u2b21', hue: 'k3', word: 'a markdown wiki, opened as its link graph' },
393
393
  contract: { glyph: '\u00a7', hue: 'k4', word: 'a release contract of a venue' },
394
- health: { glyph: '\u229e', hue: 'k5', word: 'a health row that belongs to no other kind' },
395
394
  check: { glyph: '\u2299', hue: 'k6', word: 'a gate check: one question `shipkit ci` or `shipkit check` asks, whether it builds, guards a repo shape or probes the world. same page and same switch for all of them' },
396
395
  test: { glyph: '\u2263', hue: 'k5', word: 'a test file: the check that runs it, its last runs against its budget, the command that runs it alone' },
397
396
  reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
@@ -0,0 +1,35 @@
1
+ // what is wrong with the hub ITSELF, as opposed to the repo it serves: a
2
+ // collection it could not list, a restart it could not do. No row owns
3
+ // these, so they ride the hub's status: the header on the page, and the
4
+ // problems frame the hub task prints (problems/problem-board.ts), so the
5
+ // pane and the page count the same reds.
6
+
7
+ export type HubProblem = { id: string; text: string; severity: 'red' | 'yellow' }
8
+
9
+ export class HubProblems {
10
+ private readonly byId = new Map<string, HubProblem>()
11
+
12
+ constructor(private readonly onChange: (all: HubProblem[]) => void = () => {}) {}
13
+
14
+ /** a problem under this id, or none (null). Tells onChange only when the
15
+ * list moved */
16
+ set(id: string, p: Omit<HubProblem, 'id'> | null): void {
17
+ const before = this.byId.get(id)
18
+ if (p == null) {
19
+ if (before == null) return
20
+ this.byId.delete(id)
21
+ } else {
22
+ if (before != null && before.text === p.text && before.severity === p.severity) return
23
+ this.byId.set(id, { id, ...p })
24
+ }
25
+ this.onChange(this.list())
26
+ }
27
+
28
+ list(): HubProblem[] {
29
+ return [...this.byId.values()].sort((a, b) => a.id.localeCompare(b.id))
30
+ }
31
+
32
+ /** what projectResources / projectNeeds report per collection */
33
+ collector = (collection: string, error: string | null): void =>
34
+ this.set(`collector:${collection}`, error == null ? null : { text: `the hub could not list ${collection}: ${error}`, severity: 'red' })
35
+ }
@@ -9,7 +9,6 @@
9
9
  import { existsSync, readFileSync, statSync } from 'fs'
10
10
  import { homedir } from 'os'
11
11
  import { basename, dirname, join, resolve, sep } from 'path'
12
- import { fileURLToPath } from 'url'
13
12
  import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
14
13
  import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
15
14
  import { projectNeeds } from '../collections/collection-types.ts'
@@ -42,9 +41,13 @@ import { BUILD_TIMEOUT_MS, CHECK_TIMEOUT_MS } from '../collections/checks.ts'
42
41
  import { HealthSchedule } from './health-schedule.ts'
43
42
  import { GateTrigger, watchTracked } from './gate-trigger.ts'
44
43
  import { ProblemBoard } from '../problems/problem-board.ts'
44
+ import { HubProblems } from './hub-problems.ts'
45
+ import { StaleRestart } from './stale-restart.ts'
46
+ import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
45
47
  import { serveHub, type HubDeps } from './serve-hub.ts'
46
48
  import { defaultHubPort } from './hub-port.ts'
47
49
  import { LAYOUT } from '../core/layout.ts'
50
+ import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
48
51
  export { defaultHubPort }
49
52
 
50
53
  export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
@@ -153,7 +156,7 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
153
156
  const hasComfy = scanComfyWorkflows(root).length > 0
154
157
  const panel = hasComfy ? new ComfyPanel({ root, port: c.serve?.comfyPort ?? defaultComfyPanelPort(c.name) }) : null
155
158
  const allCollections = () => [
156
- ...buildCollections(root, c, kit, { bootVersion: BOOT_VERSION, libVersion }),
159
+ ...buildCollections(root, c, kit),
157
160
  ...(hasComfy ? [comfyCollection(root)] : []),
158
161
  wikisCollection(graphs, root),
159
162
  ]
@@ -167,6 +170,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
167
170
  // the latest verdict of every check, printed as one problem frame after
168
171
  // each run for the vscode problems pane (problems/problem-line.ts)
169
172
  const board = new ProblemBoard()
173
+ // what is wrong with the hub itself rides the same frame, the moment it
174
+ // moves, so the pane never reads 0 while the page shows a hub red
175
+ const hubProblems = new HubProblems((all) => {
176
+ if (board.setHub(all)) for (const l of board.frame()) io.print(l)
177
+ })
170
178
  const runChecks: HubDeps['runChecks'] = (tap, o) => {
171
179
  const run = chain.then(async () => {
172
180
  active++
@@ -267,13 +275,18 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
267
275
  repoHub: (repo) => others.hubOf(repo),
268
276
  timeline: async () => {
269
277
  const ceilings: Record<string, number> = {}
278
+ const budgets: Record<string, number> = {}
270
279
  const inventory = await gateInventory(root, c)
271
- for (const row of inventory) ceilings[row.decl.id] = row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS)
280
+ for (const row of inventory) {
281
+ ceilings[row.decl.id] = row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS)
282
+ budgets[row.decl.id] = row.budgetMs
283
+ }
272
284
  return shapeTimeline({
273
285
  runs: readRuns(record),
274
286
  invocations: readInvocations(record),
275
287
  suites: readSuites(record),
276
288
  ceilings,
289
+ budgets,
277
290
  inventory: inventory.map((row) => row.decl.id),
278
291
  declaredSlow: declaredSlowFiles(c),
279
292
  cap: parallelCap(),
@@ -287,7 +300,9 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
287
300
  return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
288
301
  },
289
302
  prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
290
- needs: () => projectNeeds(allCollections()),
303
+ needs: () => projectNeeds(allCollections(), hubProblems.collector),
304
+ hubProblems,
305
+ busy: () => active > 0,
291
306
  collections: allCollections,
292
307
  asksDir: c.asks?.dir ?? LAYOUT.asks,
293
308
  comfy: panel == null ? null : { ensure: () => panel.ensure(), restart: () => panel.restart(), box: () => comfyBox(root), stop: () => panel.stop() },
@@ -319,14 +334,6 @@ export function hubGateOpts(o: { only?: string[]; auto: boolean }): Pick<CiOpts,
319
334
  return { onlyIds: o.only, fix: whole, status: whole }
320
335
  }
321
336
 
322
- // the hub must KNOW when it is outdated — a stale morning
323
- // process serves broken-looking picks all day: version read at
324
- // boot vs on disk per attention load; mismatch = red line telling him to
325
- // restart the task
326
- const libVersion = (): string => {
327
- try { return (JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
328
- }
329
- const BOOT_VERSION = libVersion()
330
337
 
331
338
  // the / dashboard's data now comes from the collection providers
332
339
  // (src/collections/providers.ts) — never a parallel projection.
@@ -368,12 +375,44 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
368
375
  })
369
376
  const watcher = watchTracked(root, () => trigger.touched(), { onError: (e) => io.printErr(`WARN the file watch stopped, so the problems pane refreshes only on a hub run: ${e.message}`) })
370
377
  if (watcher.error != null) io.printErr(`WARN no file watch (${watcher.error}), so the problems pane refreshes only on a hub run`)
378
+ // a lib that moved under this process (under bun --watch the version
379
+ // import already restarted it, so what is left is a plain serve), or
380
+ // a file table the watch restarts leaked: it re-executes itself in
381
+ // place (same pid, argv, port) once no run is in flight. Only a
382
+ // restart that throws is said, in the hub's own status
383
+ const stale = new StaleRestart({
384
+ boot: LIB_VERSION,
385
+ disk: diskLibVersion,
386
+ busy: () => deps.busy?.() === true,
387
+ restart: (why) => {
388
+ // a refusal throws while the hub still serves, so the page can say it
389
+ const blocked = reexecBlocker()
390
+ if (blocked != null) throw new Error(blocked)
391
+ io.print(`ok: ${why}: restarting in place`)
392
+ stale.stop()
393
+ trigger.stop()
394
+ watcher.close()
395
+ hub.stop()
396
+ try {
397
+ reexecSelf()
398
+ } catch (e) {
399
+ // the server is already down: nothing is left to show a status
400
+ io.printErr(`FAIL: this hub stopped to restart and could not: ${(e as Error).message}. Restart the hub task`)
401
+ process.exit(1)
402
+ }
403
+ },
404
+ say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
405
+ })
406
+ stale.start()
407
+ // a watch restart that leaked sheds it before the first run spawns anything
408
+ stale.tick()
371
409
  trigger.request()
372
410
  // a child process (the comfy panel) outlives a parent nobody stopped,
373
411
  // and the next launch then adopts one holding the PREVIOUS run's
374
412
  // modules. A hard kill still leaks one, which is why adoption exists
375
413
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
376
414
  process.on(sig, () => {
415
+ stale.stop()
377
416
  trigger.stop()
378
417
  watcher.close()
379
418
  hub.stop()
@@ -10,7 +10,7 @@ import { graphRoutes } from 'rvlib-shipkit/src/wiki/serve-graph.ts'
10
10
  import type { StudioRoutes } from '../assets/server/studio-server.ts'
11
11
  import { comfyBoxUp, type ComfyBox, type ComfyPanelUp } from '../comfy/comfy-panel.ts'
12
12
  import { appendAskRemark, appendAssetNote, appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
13
- import { projectResources, type Collection, type Instance, type NeedItem } from '../collections/collection-types.ts'
13
+ import { brokenRow, projectResources, type Collection, type Instance, type NeedItem } from '../collections/collection-types.ts'
14
14
  import type { CheckEvent } from '../core/run-check.ts'
15
15
  import { configDirty, readGateBlock, writeGateBlock, type GateEdit } from '../core/when-block.ts'
16
16
  import type { InvokedBy } from '../ledger/run-log.ts'
@@ -22,6 +22,7 @@ import type { LiveView } from '../ledger/live-timeline.ts'
22
22
 
23
23
  export type { LiveView }
24
24
  import type { HealthSchedule } from './health-schedule.ts'
25
+ import type { HubProblems } from './hub-problems.ts'
25
26
  import hubIndex from './hub-app/index.html'
26
27
  import { projectGutter } from './project-gutter-route.ts'
27
28
  import { sameOrigin } from './same-origin.ts'
@@ -78,6 +79,11 @@ export type HubDeps = {
78
79
  /** every human-action item, red/yellow, gathered per request from the
79
80
  * collection providers (run-serve builds it) */
80
81
  needs: () => Promise<NeedItem[]>
82
+ /** a run is in flight (a restart now would kill it) */
83
+ busy?: () => boolean
84
+ /** what is wrong with the hub itself: the page header shows it, and the
85
+ * hub task's problems frame counts it. Absent = nothing is tracked */
86
+ hubProblems?: HubProblems
81
87
  /** BROWSE-ALL: the collections with every instance incl. healthy —
82
88
  * the page shows everything that exists, dimmed never hidden */
83
89
  collections: () => Collection[]
@@ -152,8 +158,9 @@ export function serveHub(d: HubDeps): Hub {
152
158
  const cols = d.collections()
153
159
  return Response.json({
154
160
  collections: cols.map((c) => ({ id: c.id, title: c.title, media: c.media })),
155
- resources: await projectResources(cols),
161
+ resources: await projectResources(cols, d.hubProblems?.collector),
156
162
  schedule: d.schedule?.state() ?? null,
163
+ hub: d.hubProblems?.list() ?? [],
157
164
  })
158
165
  }
159
166
  // the previous address, answered while pages opened before the
@@ -166,7 +173,7 @@ export function serveHub(d: HubDeps): Hub {
166
173
  try {
167
174
  out.push({ id: col.id, title: col.title, media: col.media, instances: await col.list() })
168
175
  } catch (e) {
169
- out.push({ id: col.id, title: col.title, media: col.media, instances: [{ id: `${col.id}:broken`, type: 'health', path: `health/${col.id}-collector`, virtual: true, text: `collector broke: ${(e as Error).message}`, severity: 'red' }] })
176
+ out.push({ id: col.id, title: col.title, media: col.media, instances: [brokenRow(col, e)] })
170
177
  }
171
178
  }
172
179
  return Response.json({ collections: out })
@@ -0,0 +1,77 @@
1
+ // a hub that went stale restarts itself. Stale is two things. Its lib moved
2
+ // under it: under `bun --watch` the version is a module import
3
+ // (core/lib-version.ts), so a bump restarts the hub before this ever sees a
4
+ // difference, and a hub NOT under a watch sees the package.json on disk move
5
+ // away from the version it booted with. Or its file table leaked
6
+ // (core/fd-table.ts): every `bun --watch` restart keeps the previous image's
7
+ // watch fds, and a full table fails every spawn with EBADF. Either way it
8
+ // re-executes in place (core/reexec.ts) once no run is in flight, which is a
9
+ // clean table too. Only a restart that throws reaches a human, once, through
10
+ // `say`.
11
+ import { assertLeakHealable, FD_HEALED_ENV, fdTableLeak } from '../core/fd-table.ts'
12
+
13
+ export type StaleTick = 'fresh' | 'busy' | 'restarted' | 'impossible'
14
+
15
+ export type StaleRestartOpts = {
16
+ /** the version this process booted with */
17
+ boot: string
18
+ /** the version on disk now, `?` when unreadable */
19
+ disk: () => string
20
+ /** why the file table leaked, null while it did not. Default: this
21
+ * process's own table (core/fd-table.ts) */
22
+ leak?: () => string | null
23
+ /** a run is in flight: a restart now would kill it */
24
+ busy: () => boolean
25
+ /** never returns when it works. `why` is one line on what went stale */
26
+ restart: (why: string) => void
27
+ /** the hub's status line: a text when the restart is impossible, null
28
+ * once there is nothing to say */
29
+ say: (text: string | null) => void
30
+ everyMs?: number
31
+ }
32
+
33
+ export class StaleRestart {
34
+ private timer: ReturnType<typeof setInterval> | null = null
35
+ private failed: string | null = null
36
+
37
+ constructor(private readonly o: StaleRestartOpts) {}
38
+
39
+ tick(): StaleTick {
40
+ const stale = this.why()
41
+ if (stale == null) {
42
+ if (this.failed != null) { this.failed = null; this.o.say(null) }
43
+ return 'fresh'
44
+ }
45
+ if (this.failed != null) return 'impossible'
46
+ if (this.o.busy()) return 'busy'
47
+ try {
48
+ if (stale.leak) {
49
+ assertLeakHealable()
50
+ process.env[FD_HEALED_ENV] = String(Date.now())
51
+ }
52
+ this.o.restart(stale.why)
53
+ return 'restarted'
54
+ } catch (e) {
55
+ this.failed = `${stale.why}, and this hub could not restart itself: ${(e as Error).message}. Restart the hub task`
56
+ this.o.say(this.failed)
57
+ return 'impossible'
58
+ }
59
+ }
60
+
61
+ private why(): { why: string; leak: boolean } | null {
62
+ const disk = this.o.disk()
63
+ // a package.json half written by a bump reads as `?`, and is no verdict
64
+ if (disk !== '?' && disk !== this.o.boot) return { why: `this hub runs shipkit ${this.o.boot} but ${disk} is on disk`, leak: false }
65
+ const leak = (this.o.leak ?? fdTableLeak)()
66
+ return leak == null ? null : { why: leak, leak: true }
67
+ }
68
+
69
+ start(): void {
70
+ this.timer ??= setInterval(() => this.tick(), this.o.everyMs ?? 2_000)
71
+ }
72
+
73
+ stop(): void {
74
+ if (this.timer != null) clearInterval(this.timer)
75
+ this.timer = null
76
+ }
77
+ }
@@ -379,8 +379,12 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
379
379
  const chars = (await page.locator('body').innerText().catch(() => '')).trim().length
380
380
  const matches = waitFor == null ? 0 : await page.locator(waitFor).count().catch(() => 0)
381
381
  const overflowPx = await page.evaluate(() => {
382
- const de = document.documentElement
383
- return Math.max(de.scrollWidth - de.clientWidth, document.body.scrollWidth - document.body.clientWidth)
382
+ // runs in the browser, typed locally: a consumer compiling this
383
+ // file may have no dom lib
384
+ type Box = { scrollWidth: number; clientWidth: number }
385
+ const doc = (globalThis as unknown as { document: { documentElement: Box; body: Box } }).document
386
+ const de = doc.documentElement
387
+ return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
384
388
  }).catch(() => 0)
385
389
  await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
386
390
  return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
@@ -6,8 +6,9 @@
6
6
  // `Ran N tests` so the gate can still report what ran.
7
7
  //
8
8
  // usage: suite-cli [--budget-ms N] [--slow file]... [--serial file]... [--declared-in <where>] -- <bun test args>
9
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
- import { availableParallelism, loadavg, tmpdir } from 'os'
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
+ import { availableParallelism, tmpdir } from 'os'
11
+ import { machineLoad } from '../core/budget.ts'
11
12
  import { join } from 'path'
12
13
  import { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
13
14
  import { CHECK_ID_ENV, recordSuites, type SuitePhase } from '../ledger/suite-log.ts'
@@ -15,16 +16,19 @@ import { killTree } from '../core/spawn-timeout.ts'
15
16
  import { parallelCap } from '../core/parallel.ts'
16
17
  import { poolDir, poolOccupancy } from '../core/slots.ts'
17
18
  import { keepTimings, seedTimings, timingsHome } from './kept-timings.ts'
18
- import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, type MachineLoad, WAIT_FACTOR, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
19
+ import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, WAIT_FACTOR, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
19
20
 
20
21
  type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
21
22
 
22
- async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, echo = true, cpuFile: string | null = null): Promise<Phase> {
23
+ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, tmp: string, echo = true, cpuFile: string | null = null): Promise<Phase> {
23
24
  const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
24
25
  // the per suite CPU figure rides in a preload of the runner's own, beside
25
26
  // the repo's (a command line preload adds to bunfig's, never replaces it)
26
27
  const cpu = cpuFile != null ? ['--preload', CPU_PRELOAD] : []
27
- const env = cpuFile != null ? { ...process.env, [SUITE_CPU_ENV]: cpuFile } : process.env
28
+ // TMPDIR is the run's own dir, deleted with it: whatever a suite forgets to
29
+ // clean dies here, never in the machine temp dir, whose listing bun reads on
30
+ // the first import from a fixture there (90k leaked dirs cost ~100ms each time)
31
+ const env = { ...process.env, TMPDIR: tmp, ...(cpuFile != null ? { [SUITE_CPU_ENV]: cpuFile } : {}) }
28
32
  const proc = Bun.spawn(['bun', 'test', ...cpu, ...timing, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env })
29
33
  // the workers are children of `bun test`, and a stuck one is the process
30
34
  // to reach, so the whole tree goes
@@ -59,8 +63,6 @@ const CPU_PRELOAD = join(import.meta.dir, 'suite-cpu-preload.ts')
59
63
 
60
64
  const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
61
65
 
62
- const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
63
-
64
66
  const overWords = (c: Confirmed): string =>
65
67
  c.over === 'cpu' ? `${c.file} ${secs(c.ms)} of CPU (${secs(c.wallMs)} wall)`
66
68
  : c.over === 'wait' ? `${c.file} ${secs(c.wallMs)} wall on an idle machine, more than ${WAIT_FACTOR}x the budget, with ${secs(c.cpuMs ?? 0)} of CPU: it waits (a timer, a sleep, a poll)`
@@ -103,6 +105,8 @@ export async function suiteCli(argv: string[]): Promise<number> {
103
105
  const workers = parallel ? poolWorkers({ cores, size: parallelCap(), held }) : undefined
104
106
  const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers })
105
107
  const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
108
+ const tmp = join(dir, 'temp')
109
+ mkdirSync(tmp)
106
110
  const notes: string[] = []
107
111
  if (!helpText.includes('--parallel')) notes.push(`skip: parallel suites (bun ${Bun.version} has no --parallel)`)
108
112
  if (!timed) notes.push(`skip: suite budget (bun ${Bun.version} writes no --timings)`)
@@ -114,7 +118,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
114
118
  if (kept != null) seedTimings(kept, join(dir, 'parallel.json'))
115
119
  const cpuFile = timed && parallel ? join(dir, 'cpu.tsv') : null
116
120
  const p1At = Date.now()
117
- const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, true, cpuFile)
121
+ const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, tmp, true, cpuFile)
118
122
  const p1Ms = Date.now() - p1At
119
123
  // bun merges what it read into what it writes, so a suite deleted since
120
124
  // the last run is judged and kept only if the workers say it ran here
@@ -130,7 +134,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
130
134
  }
131
135
  }
132
136
  const p2At = Date.now()
133
- const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null, ceilingMs) : null
137
+ const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null, ceilingMs, tmp) : null
134
138
  const p2Ms = p2 == null ? null : Date.now() - p2At
135
139
  const phases = [p1, ...(p2 != null ? [p2] : [])]
136
140
  const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
@@ -149,7 +153,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
149
153
  const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
150
154
  const aloneAt = Date.now()
151
155
  for (const [i, t] of rerun.entries()) {
152
- const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, false)
156
+ const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false)
153
157
  const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
154
158
  if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
155
159
  }
@@ -146,7 +146,8 @@ export function buildSourceGraph(cfg: RvtConfig = loadConfig()): SourceGraph {
146
146
  * knows a missing declared path makes a check uncacheable, and a throw here
147
147
  * would turn a stale declaration into a dead gate.
148
148
  */
149
- export function importClosure(cfg: RvtConfig, entries: string[], o: { dynamic?: boolean } = {}): string[] {
149
+ export function importClosure(cfg: RvtConfig, entries: string[], o: { dynamic?: boolean; read?: (rel: string) => string } = {}): string[] {
150
+ const read = o.read ?? ((rel: string): string => readFileSync(join(cfg.root, rel), 'utf8'))
150
151
  const probe = new Map<string, boolean>()
151
152
  const has = (p: string): boolean => {
152
153
  let r = probe.get(p)
@@ -163,7 +164,7 @@ export function importClosure(cfg: RvtConfig, entries: string[], o: { dynamic?:
163
164
  const file = queue.shift()!
164
165
  let content: string
165
166
  try {
166
- content = readFileSync(join(cfg.root, file), 'utf8')
167
+ content = read(file)
167
168
  } catch {
168
169
  continue
169
170
  }
@@ -7,7 +7,7 @@
7
7
  // Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
8
8
  // unreachable files stay in the graph with no depth (lint-md.ts turns those,
9
9
  // plus dangling refs, into failures — the graph itself is a viewer).
10
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
+ import { readdirSync, readFileSync, statSync } from 'fs'
11
11
  import { basename, dirname, join, relative, resolve } from 'path'
12
12
  import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
13
13