rvlib-shipkit 1.0.1 → 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 (63) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +30 -4
  4. package/src/check.ts +6 -0
  5. package/src/checks/SK005-rvlib-versions.check.ts +18 -3
  6. package/src/checks/SK021-github-login.check.ts +123 -0
  7. package/src/cli/cmd_Asks.ts +1 -1
  8. package/src/cli/cmd_Check.ts +1 -1
  9. package/src/cli/cmd_Ci.ts +1 -1
  10. package/src/cli/cmd_Fleet.ts +5 -57
  11. package/src/cli.ts +2 -3
  12. package/src/collections/checks.ts +12 -2
  13. package/src/collections/collection-types.ts +34 -18
  14. package/src/collections/comfy.ts +1 -0
  15. package/src/collections/posts.ts +1 -0
  16. package/src/collections/providers.ts +71 -43
  17. package/src/collections/reflections.ts +1 -0
  18. package/src/collections/state-rules.ts +29 -13
  19. package/src/collections/tests.ts +1 -0
  20. package/src/collections/wikis.ts +1 -0
  21. package/src/core/budget.ts +60 -0
  22. package/src/core/check-ctx.ts +2 -1
  23. package/src/core/check-runner.ts +43 -6
  24. package/src/core/fd-table.ts +37 -0
  25. package/src/core/git-root.ts +26 -2
  26. package/src/core/lib-version.ts +16 -0
  27. package/src/core/migrate-layout.ts +2 -1
  28. package/src/core/motherhome.ts +2 -4
  29. package/src/core/package-manager.ts +4 -2
  30. package/src/core/raw-access.ts +11 -3
  31. package/src/core/reexec.ts +26 -0
  32. package/src/core/run-check.ts +36 -16
  33. package/src/core/run-ci.ts +29 -24
  34. package/src/core/self-update.ts +59 -24
  35. package/src/core/update-policy.ts +25 -0
  36. package/src/fleet/scan-fleet.ts +4 -15
  37. package/src/fleet/update-fleet.ts +85 -0
  38. package/src/ledger/gate-checks.ts +19 -5
  39. package/src/ledger/git-key.ts +10 -5
  40. package/src/ledger/git-scan.ts +29 -17
  41. package/src/ledger/ledger.ts +14 -6
  42. package/src/ledger/repo-view.ts +8 -6
  43. package/src/ledger/run-log.ts +10 -4
  44. package/src/ledger/run-timeline.ts +21 -9
  45. package/src/problems/problem-board.ts +13 -2
  46. package/src/problems/problem-line.ts +9 -0
  47. package/src/repo-config.ts +22 -1
  48. package/src/serve/hub-app/check-view.tsx +12 -11
  49. package/src/serve/hub-app/cols-svc.ts +16 -9
  50. package/src/serve/hub-app/dur.ts +10 -1
  51. package/src/serve/hub-app/header-bar.tsx +11 -0
  52. package/src/serve/hub-app/help.tsx +1 -0
  53. package/src/serve/hub-app/hub-st.ts +3 -4
  54. package/src/serve/hub-app/hub-types.ts +5 -0
  55. package/src/serve/hub-app/hub.css +3 -0
  56. package/src/serve/hub-app/timeline-view.tsx +4 -4
  57. package/src/serve/hub-app/tree-build.ts +1 -2
  58. package/src/serve/hub-problems.ts +35 -0
  59. package/src/serve/run-serve.ts +51 -12
  60. package/src/serve/serve-hub.ts +10 -3
  61. package/src/serve/stale-restart.ts +77 -0
  62. package/src/suite/suite-cli.ts +14 -10
  63. package/src/ts/source-graph.ts +3 -2
@@ -5,7 +5,7 @@
5
5
  // the card.
6
6
  import { makeAutoObservable, runInAction } from 'mobx'
7
7
  import type { HubSt } from './hub-st.ts'
8
- import type { Col, ColMeta, Resource, Schedule } from './hub-types.ts'
8
+ import type { Col, ColMeta, HubProblem, Resource, Schedule } from './hub-types.ts'
9
9
 
10
10
  export class ColsSvc {
11
11
  /** exactly what the server last sent; `resources` is this plus the live
@@ -14,6 +14,10 @@ export class ColsSvc {
14
14
  colMeta: ColMeta[] = []
15
15
  /** the hub's daily health pass timer, as the last poll saw it */
16
16
  schedule: Schedule | null = null
17
+ /** what the server says is wrong with the hub itself */
18
+ hubProblems: HubProblem[] = []
19
+ /** the last poll failed: the page cannot reach its own server */
20
+ fetchErr: string | null = null
17
21
  actErr = ''
18
22
  remarkDraft = ''
19
23
  proofDraft = ''
@@ -37,20 +41,18 @@ export class ColsSvc {
37
41
  try {
38
42
  const r = await fetch('/api/resources.json')
39
43
  if (!r.ok) throw new Error(await r.text())
40
- const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null }
44
+ const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null; hub?: HubProblem[] }
41
45
  runInAction(() => {
42
46
  this.colMeta = j.collections
43
47
  this.fetched = j.resources
44
48
  this.schedule = j.schedule ?? null
49
+ this.hubProblems = j.hub ?? []
50
+ this.fetchErr = null
45
51
  })
46
52
  } catch (e) {
47
- // a dead server must SAY so on the tree, never render an empty
48
- // repo that reads like a healthy one
49
- const text = `resources fetch broke: ${(e as Error).message}`
50
- runInAction(() => {
51
- this.colMeta = [{ id: 'hub', title: 'hub', media: 'health' }]
52
- this.fetched = [{ id: 'hub:resources-broken', collection: 'hub', type: 'health', path: 'health/hub-fetch', virtual: true, title: text, text, state: 'red', severity: 'red' }]
53
- })
53
+ // a dead server must SAY so, in the header, never leave a tree
54
+ // that reads like a healthy repo. The last rows stay, dated by it
55
+ runInAction(() => { this.fetchErr = `the page cannot reach this hub: ${(e as Error).message}` })
54
56
  }
55
57
  }
56
58
 
@@ -62,6 +64,11 @@ export class ColsSvc {
62
64
  }
63
65
 
64
66
  /** the collections, as a grouping of the one array */
67
+ /** the header's red: the server's own problems, then an unreachable server */
68
+ get hubStatus(): HubProblem[] {
69
+ return [...this.hubProblems, ...(this.fetchErr == null ? [] : [{ id: 'fetch', text: this.fetchErr, severity: 'red' as const }])]
70
+ }
71
+
65
72
  get cols(): Col[] {
66
73
  return this.colMeta.map((m) => ({ ...m, instances: this.resources.filter((r) => r.collection === m.id) }))
67
74
  }
@@ -31,9 +31,18 @@ const group = (n: number): string => {
31
31
 
32
32
  export const fmtMs = (ms: number | null | undefined): string => (ms == null ? '' : `${group(Math.round(ms))}ms`)
33
33
 
34
- export function durationTone(ms: number | null | undefined, ceilingMs?: number | null): DurTone | null {
34
+ /** the tone of one duration. A check's own BUDGET, when the caller knows it,
35
+ * replaces the table above it: over the budget is red, past half of it
36
+ * orange, and a suite well inside a generous budget never reads red because
37
+ * a rule would be slow at that length (core/budget.ts) */
38
+ export function durationTone(ms: number | null | undefined, ceilingMs?: number | null, budgetMs?: number | null): DurTone | null {
35
39
  if (ms == null) return null
36
40
  if (ceilingMs != null && ceilingMs > 0 && ms > ceilingMs * CEILING_SHARE) return 'bad'
41
+ if (budgetMs != null && budgetMs > 0) {
42
+ if (ms > budgetMs) return 'bad'
43
+ if (ms > budgetMs / 2) return 'slow'
44
+ return ms < FAST_MS ? 'fast' : 'plain'
45
+ }
37
46
  if (ms < FAST_MS) return 'fast'
38
47
  if (ms < PLAIN_MS) return 'plain'
39
48
  if (ms < SLOW_MS) return 'slow'
@@ -46,6 +46,16 @@ const RunState = observer(({ app }: { app: HubSt }) => {
46
46
  )
47
47
  })
48
48
 
49
+ /** what is wrong with the hub itself, one chip per problem. Nothing when all
50
+ * is well: the header states trouble, never health */
51
+ const HubStatus = observer(({ app }: { app: HubSt }) => (
52
+ <>
53
+ {app.cols.hubStatus.map((p) => (
54
+ <span key={p.id} className={`hstatus ${p.severity === 'red' ? 'red' : 'warn'}`} title={p.text}>{p.text}</span>
55
+ ))}
56
+ </>
57
+ ))
58
+
49
59
  export const HeaderBar = observer(({ app }: { app: HubSt }) => {
50
60
  const u = app.checks
51
61
  return (
@@ -57,6 +67,7 @@ export const HeaderBar = observer(({ app }: { app: HubSt }) => {
57
67
  ))}
58
68
  </nav>
59
69
  <span className="sp" />
70
+ <HubStatus app={app} />
60
71
  <RunState app={app} />
61
72
  <button className="primary" disabled={u.busy} onClick={() => void u.run()}>run the health pass</button>
62
73
  <button disabled={u.busy} onClick={() => void u.run(undefined, { gate: true, label: 'the gate' })}>run the gate</button>
@@ -14,6 +14,7 @@ export const TERMS: Record<string, string> = {
14
14
  advisory: 'it can fail without stopping anything: the gate reports it and goes on',
15
15
  blocking: 'its failure stops the gate, and stops you',
16
16
  ceiling: 'the time it is killed at: a check creeping toward its ceiling is visible before it trips',
17
+ budget: 'what a healthy run takes: a run that passes over it warns and reads yellow, and never blocks. Set it with budgetMs in the check file, or budgets in repo.config.ts',
17
18
  cached: 'it did not run: the answer came from the cache, because nothing it depends on moved since it last ran',
18
19
  ran: 'it ran and was timed, so the number is what it costs today',
19
20
  slowest: 'the worst run on record, reds included',
@@ -134,10 +134,9 @@ export class HubSt {
134
134
  // the runs page is a page, not a resource, so no collection lists it
135
135
  out.push({ id: 'page:runs', label: 'runs', section: 'page', run: () => this.cols.select('runs', RUNS_ID) })
136
136
  for (const col of this.cols.sidebarCols) {
137
- const health = col.id === 'health'
138
- out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => (health ? this.showStatus() : this.showCol(col.id)) })
137
+ out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
139
138
  for (const i of col.instances)
140
- out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => (health ? this.showStatus() : this.cols.select(col.id, i.id)) })
139
+ out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => this.cols.select(col.id, i.id) })
141
140
  }
142
141
  return out
143
142
  }
@@ -203,7 +202,7 @@ export class HubSt {
203
202
  this.cols.select(r.collection, r.id)
204
203
  }
205
204
 
206
- /** collection view — one card per instance (health has StatusCard instead) */
205
+ /** collection view — one card per instance */
207
206
  showCol(id: string): void {
208
207
  this.setSel({ t: 'col', id })
209
208
  }
@@ -105,6 +105,7 @@ export type CheckFacts = {
105
105
  ttl?: string
106
106
  never?: string
107
107
  timeoutMs: number
108
+ budgetMs: number
108
109
  status: CheckStatus | null
109
110
  replayed: boolean
110
111
  lastMs: number | null
@@ -121,6 +122,10 @@ export type CheckFacts = {
121
122
  recent?: RecentRun[]
122
123
  }
123
124
 
125
+ /** what is wrong with the hub itself, as the resources feed sends it
126
+ * (serve/hub-problems.ts): the header shows it, never the tree */
127
+ export type HubProblem = { id: string; text: string; severity: 'red' | 'yellow' }
128
+
124
129
  /** the hub's daily health pass timer, as the resources feed sends it
125
130
  * (serve/health-schedule.ts). null = this hub runs no timer */
126
131
  export type Schedule = { everyMs: number; lastPassAt: number | null; nextAt: number; running: boolean }
@@ -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
+ }
@@ -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
  }