rvlib-shipkit 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -1,14 +1,41 @@
1
- // the project gutter's state: every repo on this disk that shares the house
2
- // wiki, polled from /api/projects.json while the gutter is mounted. The
1
+ // the project list's state: every repo on this disk that shares the house
2
+ // wiki, polled from /api/projects.json while the list is mounted. The
3
3
  // server measures at most once per 5s whatever the number of tabs, so this
4
4
  // poll is cheap.
5
5
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
6
  import type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
7
- import type { HubSt } from './hub-st.ts'
7
+ import { identityOfName, type Identity } from '../../core/identity-color.ts'
8
8
 
9
9
  export type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
10
10
 
11
11
  export const PROJECTS_POLL_MS = 5_000
12
+ /** how long the colour picker rests before its colour is written */
13
+ export const IDENTITY_SAVE_MS = 400
14
+
15
+ /** where this browser keeps the last project list, painted at once on the
16
+ * next load while the daemon is asked again */
17
+ export const LIST_KEY = 'hub:projects'
18
+
19
+ /** the project list this browser last saw, or [] when storage is absent,
20
+ * blocked or holds anything else */
21
+ export function loadList(): Project[] {
22
+ try {
23
+ const v = JSON.parse(globalThis.localStorage?.getItem(LIST_KEY) ?? '[]') as unknown
24
+ return Array.isArray(v) && v.every((p) => typeof (p as Project)?.name === 'string') ? (v as Project[]) : []
25
+ } catch {
26
+ return []
27
+ }
28
+ }
29
+
30
+ /** keeps the list for the next load; false when storage refused it */
31
+ export function saveList(list: Project[]): boolean {
32
+ try {
33
+ globalThis.localStorage?.setItem(LIST_KEY, JSON.stringify(list))
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ }
12
39
 
13
40
  /** one of six kind hues, hashed from the name, so an initial keeps its
14
41
  * colour from one load to the next */
@@ -51,6 +78,11 @@ export const byActivity = (list: Project[]): string[] =>
51
78
  .sort((a, b) => (b.p.activity ?? -Infinity) - (a.p.activity ?? -Infinity) || a.i - b.i)
52
79
  .map(({ p }) => p.name)
53
80
 
81
+
82
+ /** the hub state dot the home draws on a tile, only when it needs a look:
83
+ * its hub starts or crashed. A hub that is up is the normal case and draws none */
84
+ export type HubDot = 'starting' | 'crashed' | null
85
+
54
86
  /** what the icon face shows, and nothing more: at most ONE badge (the open
55
87
  * asks, the only fact that needs a human's hands), dim while VS Code does
56
88
  * not have the folder open, and a pulsing edge while Claude sessions run */
@@ -99,22 +131,104 @@ export function cardModel(p: Project, now: number): CardModel {
99
131
  }
100
132
  }
101
133
 
134
+ /** a page of the home that is no repo's */
135
+ export type Place = 'overview' | 'planned'
136
+
102
137
  export class ProjectsSvc {
103
- list: Project[] = []
104
- /** why the last poll or open failed, shown in the gutter's tooltip */
138
+ /** the last list this browser saw until the first poll answers, so the
139
+ * list paints at once and nothing moves when the daemon answers */
140
+ list: Project[] = loadList()
141
+ /** why the last poll or open failed, shown on the list's head */
105
142
  error = ''
106
- /** the icon under the pointer and the middle of its row, for the card */
107
- hover: { name: string; y: number } | null = null
143
+ /** the daemon answered once: until then the list is the kept one */
144
+ fresh = false
145
+ /** the row under the pointer, whose hover card shows */
146
+ hover: string | null = null
108
147
  /** the tile being dragged, and the gap it would land in */
109
148
  drag: string | null = null
110
149
  gap: number | null = null
150
+ /** the repo the home page shows; null on the overview, the plan, and a
151
+ * hub of its own, whose repo is `current`. The list's api is always the
152
+ * page origin's own: the home's machine wide one, or this hub's */
153
+ selected: string | null = null
154
+ /** what a single click does: the home shows that repo in place, a hub
155
+ * of its own opens the home on it. Default: the folder in VS Code */
156
+ onSelect: (name: string) => void = (name) => void this.open(name)
157
+ /** the two places at the top of the list, the overview and the plan:
158
+ * the home sets it, a hub of its own has none */
159
+ onPlace: ((p: Place) => void) | null = null
160
+ /** the place shown, marked at the top of the list; null on a repo page */
161
+ place: Place | null = null
162
+ /** the home's hub state per repo; a hub draws none */
163
+ dotOf: (name: string) => HubDot = () => null
164
+ /** one line when a machine resource the repo uses is off */
165
+ warnOf: (name: string) => string | null = () => null
111
166
  private gen = 0
167
+ /** a colour write waiting for the picker to rest, per repo: a poll keeps
168
+ * the colour shown for that repo meanwhile */
169
+ private identitySaves = new Map<string, ReturnType<typeof setTimeout>>()
112
170
  /** bumped by every reorder, so a poll that left before it cannot put
113
171
  * the old order back */
114
172
  private orderGen = 0
115
173
 
116
- constructor(private st: HubSt) {
117
- makeAutoObservable<this, 'st' | 'gen' | 'orderGen'>(this, { st: false, gen: false, orderGen: false, list: observableRef })
174
+ constructor() {
175
+ makeAutoObservable<this, 'gen' | 'orderGen' | 'identitySaves' | 'saveIdentity' | 'kept' | 'keep'>(this, { gen: false, orderGen: false, identitySaves: false, saveIdentity: false, kept: false, keep: false, list: observableRef, onSelect: false, onPlace: false, dotOf: false, warnOf: false })
176
+ }
177
+
178
+ /** the tile drawn as the current one */
179
+ isCurrent(p: Project): boolean {
180
+ return this.selected != null ? p.name === this.selected : p.current
181
+ }
182
+
183
+ select(name: string): void {
184
+ this.selected = name
185
+ this.onSelect(name)
186
+ }
187
+
188
+
189
+ /** a click selects once it is sure no second click follows */
190
+ click(name: string): void {
191
+ this.select(name)
192
+ }
193
+
194
+ /** a double click also opens the folder in VS Code (its first click already selected) */
195
+ doubleClick(name: string): void {
196
+ void this.open(name)
197
+ }
198
+
199
+ /** a repo's colours: what its config declares, else the ones SK024 would
200
+ * derive from its name, so every repo reads in a colour of its own */
201
+ identityOf(name: string): Identity {
202
+ return this.list.find((p) => p.name === name)?.identity ?? identityOfName(name)
203
+ }
204
+
205
+ /** one colour set by hand: shown at once, written into the repo's
206
+ * repo.config.ts once the picker rests, so a drag across the colour
207
+ * field is one write, not one per frame */
208
+ setIdentity(name: string, patch: Partial<Identity>): void {
209
+ const next = { ...this.identityOf(name), ...patch }
210
+ this.list = this.list.map((p) => (p.name === name ? { ...p, identity: next } : p))
211
+ const t = this.identitySaves.get(name)
212
+ if (t != null) clearTimeout(t)
213
+ const h: ReturnType<typeof setTimeout> = setTimeout(() => void this.saveIdentity(name, next, h), IDENTITY_SAVE_MS)
214
+ this.identitySaves.set(name, h)
215
+ }
216
+
217
+ private async saveIdentity(name: string, id: Identity, h: ReturnType<typeof setTimeout>): Promise<void> {
218
+ try {
219
+ const r = await fetch('/api/projects/identity', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, ...id }) })
220
+ if (!r.ok) throw new Error(await r.text())
221
+ const j = (await r.json()) as { identity: Identity }
222
+ runInAction(() => {
223
+ if (this.identitySaves.get(name) === h) this.list = this.list.map((p) => (p.name === name ? { ...p, identity: j.identity } : p))
224
+ this.error = ''
225
+ })
226
+ } catch (e) {
227
+ runInAction(() => { this.error = `colours of ${name}: ${(e as Error).message}` })
228
+ } finally {
229
+ // a later pick rests on its own timer, which keeps the guard
230
+ if (this.identitySaves.get(name) === h) this.identitySaves.delete(name)
231
+ }
118
232
  }
119
233
 
120
234
  get dragFrom(): number {
@@ -127,16 +241,12 @@ export class ProjectsSvc {
127
241
  return this.gap
128
242
  }
129
243
 
130
- hoverIn(name: string, y: number): void {
131
- if (this.drag == null) this.hover = { name, y }
244
+ hoverIn(name: string): void {
245
+ if (this.drag == null) this.hover = name
132
246
  }
133
247
 
134
248
  hoverOut(name: string): void {
135
- if (this.hover?.name === name) this.hover = null
136
- }
137
-
138
- get hovered(): Project | null {
139
- return this.hover == null ? null : this.list.find((p) => p.name === this.hover!.name) ?? null
249
+ if (this.hover === name) this.hover = null
140
250
  }
141
251
 
142
252
  dragStart(name: string): void {
@@ -187,6 +297,7 @@ export class ProjectsSvc {
187
297
  const gen = ++this.gen
188
298
  const tick = async (): Promise<void> => {
189
299
  await this.load()
300
+ this.keep()
190
301
  if (gen === this.gen) timer = setTimeout(() => void tick(), PROJECTS_POLL_MS)
191
302
  }
192
303
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -197,6 +308,15 @@ export class ProjectsSvc {
197
308
  }
198
309
  }
199
310
 
311
+ /** the list as it is now, kept for the next load. Only a change is
312
+ * written: the poll repeats the same rows every five seconds */
313
+ private kept = ''
314
+ private keep(): void {
315
+ if (!this.fresh) return
316
+ const s = JSON.stringify(this.list)
317
+ if (s !== this.kept && saveList(this.list)) this.kept = s
318
+ }
319
+
200
320
  async load(): Promise<void> {
201
321
  const order = this.orderGen
202
322
  try {
@@ -206,8 +326,10 @@ export class ProjectsSvc {
206
326
  runInAction(() => {
207
327
  // a reorder since this left, or a drag in flight: the next poll answers
208
328
  if (order !== this.orderGen || this.drag != null) return
209
- this.list = j.projects
329
+ const mine = new Map(this.list.filter((p) => this.identitySaves.has(p.name)).map((p) => [p.name, p.identity]))
330
+ this.list = mine.size === 0 ? j.projects : j.projects.map((p) => (mine.has(p.name) ? { ...p, identity: mine.get(p.name)! } : p))
210
331
  this.error = ''
332
+ this.fresh = true
211
333
  })
212
334
  } catch (e) {
213
335
  runInAction(() => { this.error = `projects: ${(e as Error).message}` })
@@ -5,7 +5,7 @@
5
5
  // the same reasons a switch is.
6
6
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
7
  import type { ProseGroup, ProseView } from '../../prose/prose-view.ts'
8
- import type { HubSt } from './hub-st.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
9
 
10
10
  export type TierFilter = 'all' | 'blocking' | 'soft'
11
11
  export type ListKind = 'allow' | 'ban'
@@ -29,7 +29,7 @@ export class ProseHitsSt {
29
29
  full = new Set<string>()
30
30
  form: { kind: ListKind; word: string; to: string; reason: string } = { kind: 'allow', word: '', to: '', reason: '' }
31
31
 
32
- constructor(private st: HubSt) {
32
+ constructor(private st: RepoSt) {
33
33
  // .ref: the view is swapped whole on every load, never edited in place
34
34
  makeAutoObservable<this, 'st'>(this, { st: false, view: observableRef })
35
35
  }
@@ -37,7 +37,7 @@ export class ProseHitsSt {
37
37
  async load(): Promise<void> {
38
38
  runInAction(() => { this.loading = true })
39
39
  try {
40
- const r = await fetch('/api/prose.json')
40
+ const r = await fetch(this.st.url('/api/prose.json'))
41
41
  if (!r.ok) throw new Error(await r.text())
42
42
  const v = (await r.json()) as ProseView
43
43
  runInAction(() => { this.view = v; this.error = '' })
@@ -9,7 +9,7 @@ import type { ProseGroup, ProseViewHit } from '../../prose/prose-view.ts'
9
9
  import type { ProseWords } from '../../prose/ste.ts'
10
10
  import { editorHref } from './check-words.ts'
11
11
  import { Help } from './help.tsx'
12
- import type { HubSt } from './hub-st.ts'
12
+ import type { RepoSt } from './repo-st.ts'
13
13
  import { GROUP_CAP, HIT_CAP, ProseHitsSt, type ListKind, type TierFilter } from './prose-hits-st.ts'
14
14
 
15
15
  /** blocking tiers read red, the soft tier reads as a warning */
@@ -47,7 +47,7 @@ const HitLine = ({ root, h, shape }: { root: string; h: ProseViewHit; shape: boo
47
47
  )
48
48
  }
49
49
 
50
- const Group = observer(({ app, s, g }: { app: HubSt; s: ProseHitsSt; g: ProseGroup }) => {
50
+ const Group = observer(({ app, s, g }: { app: RepoSt; s: ProseHitsSt; g: ProseGroup }) => {
51
51
  const open = s.open.has(g.key)
52
52
  const t = TIER_WORD[g.tier] ?? { word: g.tier, tone: '' }
53
53
  const shown = s.full.has(g.key) ? g.hits : g.hits.slice(0, HIT_CAP)
@@ -77,7 +77,7 @@ const Group = observer(({ app, s, g }: { app: HubSt; s: ProseHitsSt; g: ProseGro
77
77
  )
78
78
  })
79
79
 
80
- const Hits = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
80
+ const Hits = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => {
81
81
  const v = s.view
82
82
  if (v == null) return <div className="dim">{s.error !== '' ? s.error : 'reading the hits…'}</div>
83
83
  if (!v.scoped) return <div className="dim">this repo declares no prose scope, so the check reads no file: add `prose: {'{'} include: [...] {'}'}` to repo.config.ts</div>
@@ -130,7 +130,7 @@ const ListRows = ({ s, words }: { s: ProseHitsSt; words: ProseWords }) => {
130
130
  )
131
131
  }
132
132
 
133
- const Lists = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
133
+ const Lists = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => {
134
134
  const f = s.form
135
135
  const g = app.checks.gate
136
136
  return (
@@ -154,7 +154,7 @@ const Lists = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
154
154
  })
155
155
 
156
156
  /** the two sections over one state, the part a test renders headless */
157
- export const ProseSections = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => (
157
+ export const ProseSections = observer(({ app, s }: { app: RepoSt; s: ProseHitsSt }) => (
158
158
  <>
159
159
  <section className="csec">
160
160
  <div className="cstitle">hits<Help text="every hit of the last read, grouped by the word or the rule that fired, the noisiest first. A file:line opens the editor on that line" /></div>
@@ -169,7 +169,7 @@ export const ProseSections = observer(({ app, s }: { app: HubSt; s: ProseHitsSt
169
169
 
170
170
  /** mounted by the check page for SK014 only: the state lives while the page
171
171
  * is open, and reads the hits once on the way in */
172
- export const ProseHits = observer(({ app }: { app: HubSt }) => {
172
+ export const ProseHits = observer(({ app }: { app: RepoSt }) => {
173
173
  const s = useMemo(() => new ProseHitsSt(app), [app])
174
174
  useEffect(() => { void s.load() }, [s])
175
175
  return <ProseSections app={app} s={s} />
@@ -1,9 +1,10 @@
1
- // HubSt — the ONE root store (build/app-state-tree: one central tree,
2
- // instantiated once at boot, services with backrefs, exposed as
3
- // window.app). Owns cross-service state: selection, the severity filter
4
- // (an ADDRESS: /?f=needs · /?f=red), and the hash router: every page and
5
- // every tab is a selection, every selection has an address (hash-route.ts),
6
- // and setSel is the one place both move together.
1
+ // RepoSt: one repo's hub page as a node of the page tree (home-st.ts owns
2
+ // the root, build/app-state-tree). Owns cross-service state: selection, the
3
+ // severity filter (an ADDRESS: ?f=needs · ?f=red), and the hash router: every
4
+ // page and every tab is a selection, every selection has an address
5
+ // (hash-route.ts), and setSel is the one place both move together. A repo
6
+ // that is not shown keeps all of it and polls nothing (resume, pause), and
7
+ // only the shown one writes the url.
7
8
  import { RUNS_ID } from '../../collections/collection-types.ts'
8
9
  import { makeAutoObservable } from 'mobx'
9
10
  import { ClockSvc } from './clock-svc.ts'
@@ -16,114 +17,197 @@ import { MediaSvc } from './media-svc.ts'
16
17
  import { TimelineSvc } from './timeline-svc.ts'
17
18
  import { TreeSvc } from './tree-svc.ts'
18
19
  import { ChecksSvc } from './checks-svc.ts'
19
- import { ProjectsSvc } from './projects-svc.ts'
20
+ import { ProcessesSvc } from './processes-svc.ts'
20
21
  import { TestsSvc } from './tests-svc.ts'
21
22
  import type { Resource } from './hub-types.ts'
23
+ import type { HomeSt } from './home-st.ts'
22
24
 
23
25
  export type Sel = HashSel | null
24
26
 
25
- /** one cmd+j target — run() fires the SAME action clicking would */
27
+ /** one picker target (cmd+k): run() fires the SAME action clicking would */
26
28
  export type Jump = { id: string; label: string; section: string; run: () => void }
27
29
 
28
30
  /** all · needs (yellow+red) · red — one severity ladder for every section */
29
31
  export type Filter = 'all' | 'needs' | 'red'
30
- const parseFilter = (search: string): Filter => {
32
+ export const parseFilter = (search: string): Filter => {
31
33
  const f = new URLSearchParams(search).get('f')
32
34
  return f === 'needs' || f === 'red' ? f : 'all'
33
35
  }
34
36
 
35
- export class HubSt {
36
- name = ''
37
+ export class RepoSt {
38
+ /** the repo's name, from /hub.json; its key until that answers */
39
+ name: string
37
40
  /** where this repo sits on disk, from /hub.json: what makes a source
38
41
  * path on a page a link that opens his editor */
39
42
  root = ''
40
- /** the one second tick every age on the page reads (clock-svc.ts) */
41
- clock = new ClockSvc()
42
- media = new MediaSvc(this)
43
- cols = new ColsSvc(this)
44
- tree = new TreeSvc(this)
45
- health = new HealthSvc(this)
46
- checks = new ChecksSvc(this)
47
- comfy = new ComfySvc(this)
48
- timeline = new TimelineSvc(this)
43
+ /** the one second tick every age of this repo reads (clock-svc.ts). It
44
+ * ticks only while the repo is shown, so a hidden page repaints nothing */
45
+ clock: ClockSvc
46
+ media: MediaSvc
47
+ cols: ColsSvc
48
+ tree: TreeSvc
49
+ health: HealthSvc
50
+ checks: ChecksSvc
51
+ comfy: ComfySvc
52
+ timeline: TimelineSvc
49
53
  /** the runs in flight, whoever started them (live-svc.ts) */
50
- live = new LiveSvc(this)
51
- projects = new ProjectsSvc(this)
54
+ live: LiveSvc
55
+ processes: ProcessesSvc
52
56
  /** the pages behind the test groups of the tree (tests-svc.ts) */
53
- tests = new TestsSvc(this)
57
+ tests: TestsSvc
54
58
  sel: Sel = null
55
- filter: Filter = parseFilter(location.search)
59
+ filter: Filter = 'all'
56
60
  /** sidebar search — live substring filter over asset + collection rows */
57
61
  query = ''
58
- palOpen = false
59
- palQuery = ''
60
- palIdx = 0
61
- private booted = false
62
+ /** the first resume or prefetch ran: on the home, only once this repo's
63
+ * hub answered */
64
+ started = false
65
+ /** the page showed it once: only a repo shown has a region mounted, so a
66
+ * repo read ahead in the background costs no render */
67
+ seen = false
68
+ /** the sidebar search box, so `/` focuses this repo's own */
69
+ searchEl: HTMLInputElement | null = null
70
+ /** its timers run: only the shown repo's do */
71
+ polling = false
72
+ private beat = false
73
+ /** the hub name, the gate block and STATUS.md were asked for */
74
+ private onceRead = false
62
75
 
63
- constructor() {
64
- makeAutoObservable<this, 'booted'>(this, { media: false, cols: false, tree: false, health: false, checks: false, comfy: false, timeline: false, live: false, projects: false, tests: false, booted: false })
76
+ /** `key` names the repo in the root's map, `base` is where its hub
77
+ * answers: '/r/<repo>' on the home, '' on a hub of its own */
78
+ constructor(readonly home: HomeSt, readonly key: string, readonly base: string) {
79
+ this.name = key
80
+ // children built in the ctor body: a field initializer would run
81
+ // before `home` is assigned
82
+ this.clock = new ClockSvc()
83
+ this.media = new MediaSvc(this)
84
+ this.cols = new ColsSvc(this)
85
+ this.tree = new TreeSvc(this)
86
+ this.health = new HealthSvc(this)
87
+ this.checks = new ChecksSvc(this)
88
+ this.comfy = new ComfySvc(this)
89
+ this.timeline = new TimelineSvc(this)
90
+ this.live = new LiveSvc(this)
91
+ this.processes = new ProcessesSvc(this)
92
+ this.tests = new TestsSvc(this)
93
+ makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, checks: false, comfy: false, timeline: false, live: false, processes: false, tests: false, searchEl: false, beat: false })
65
94
  }
66
95
 
67
- boot(): void {
68
- void fetch('/hub.json')
96
+ /** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
97
+ * repo's hub answers it. Every request of the repo goes through here */
98
+ url(path: string): string {
99
+ return path.startsWith('/') ? `${this.base}${path}` : path
100
+ }
101
+
102
+ /** the repo the page shows now: only it writes the url */
103
+ get isShown(): boolean {
104
+ return this.home.shown === this
105
+ }
106
+
107
+ /** what the url carries for this repo: its filter, then its selection */
108
+ get search(): string {
109
+ return this.filter === 'all' ? '' : `?f=${this.filter}`
110
+ }
111
+ get hash(): string {
112
+ const h = this.sel?.t === 'asset' ? this.media.hash : hashFor(this.sel)
113
+ return h == null ? '' : `#${h}`
114
+ }
115
+
116
+ /** what is read once, the first time the repo starts or is read ahead */
117
+ private readOnce(): Promise<unknown> {
118
+ this.started = true
119
+ this.onceRead = true
120
+ return Promise.all([this.readHub(), this.checks.loadGate(), this.health.load()])
121
+ }
122
+
123
+ /** the repo's name and where it sits on disk, from /hub.json */
124
+ private readHub(): Promise<void> {
125
+ return fetch(this.url('/hub.json'))
69
126
  .then((r) => r.json() as Promise<{ name: string; root?: string }>)
70
127
  .then((h) => { this.setHub(h.name, h.root ?? '') })
71
128
  .catch(() => {})
129
+ }
130
+
131
+ /** the first screen of the repo, read in the background while another
132
+ * one shows, so its first visit has its data at once. One step at a
133
+ * time: the browser opens six connections to the home, and the repo on
134
+ * screen keeps most of them. Nothing polls: a resume takes over when it
135
+ * shows */
136
+ async prefetch(): Promise<void> {
137
+ if (this.started) return
138
+ this.started = true
139
+ const steps: (() => Promise<unknown>)[] = [
140
+ () => this.cols.poll(),
141
+ () => this.readOnce(),
142
+ () => this.processes.load(),
143
+ () => this.media.heartbeat(),
144
+ () => this.live.poll(),
145
+ () => this.timeline.load(),
146
+ ]
147
+ for (const step of steps) {
148
+ // shown meanwhile: its resume polls everything, so stop here
149
+ if (this.polling) return
150
+ await step()
151
+ }
152
+ }
153
+
154
+ /** start polling. The first one also reads what is read once */
155
+ resume(): void {
156
+ if (this.polling) return
157
+ this.polling = true
158
+ if (!this.onceRead) {
159
+ void this.readOnce()
160
+ this.applyHash()
161
+ }
72
162
  this.clock.start()
163
+ this.processes.start()
73
164
  this.media.start()
74
165
  this.cols.start()
75
166
  this.live.start()
76
- void this.checks.loadGate()
77
- void this.health.load()
78
- // back, forward, a pasted link and a hand edited hash all arrive
79
- // here; applyHash is idempotent, so the two events firing together
80
- // for one move cost nothing
81
- window.addEventListener('hashchange', () => this.applyHash())
82
- window.addEventListener('popstate', () => this.applyHash())
83
- this.applyHash()
84
167
  }
168
+
169
+ /** stop polling, keep every state: a resume shows the same page */
170
+ pause(): void {
171
+ if (!this.polling) return
172
+ this.polling = false
173
+ this.clock.stop()
174
+ this.processes.stop()
175
+ this.media.stop()
176
+ this.cols.stop()
177
+ this.live.stop()
178
+ }
179
+
180
+ dispose(): void {
181
+ this.pause()
182
+ this.media.dispose()
183
+ this.checks.dispose()
184
+ this.comfy.dispose()
185
+ }
186
+
187
+ markSeen(): void {
188
+ this.seen = true
189
+ }
190
+
85
191
  private setHub(n: string, root: string): void {
86
192
  this.name = n
87
193
  this.root = root
88
- document.title = `${n} · hub`
89
194
  }
90
195
 
91
196
  setFilter(v: Filter): void {
92
197
  this.filter = v
198
+ if (!this.isShown) return
93
199
  const u = new URL(location.href)
94
200
  v === 'all' ? u.searchParams.delete('f') : u.searchParams.set('f', v)
95
201
  history.replaceState(null, '', u)
96
202
  }
203
+ /** the atelier rewrites its tool set in place: same page, no entry */
204
+ replaceHash(h: string): void {
205
+ if (this.isShown && location.hash.slice(1) !== h) history.replaceState(null, '', `#${h}`)
206
+ }
97
207
  setQuery(v: string): void {
98
208
  this.query = v
99
209
  }
100
210
 
101
- // ── cmd+j palette ─────────────────────────────────────────────────────
102
- palToggle(): void {
103
- this.palOpen = !this.palOpen
104
- this.palQuery = ''
105
- this.palIdx = 0
106
- }
107
- palClose(): void {
108
- this.palOpen = false
109
- }
110
- setPalQuery(v: string): void {
111
- this.palQuery = v
112
- this.palIdx = 0
113
- }
114
- setPalIdx(n: number): void {
115
- this.palIdx = n
116
- }
117
- palMove(d: number): void {
118
- const n = this.palResults.length
119
- if (n > 0) this.palIdx = (this.palIdx + d + n) % n
120
- }
121
- palRun(): void {
122
- const j = this.palResults[this.palIdx]
123
- if (j == null) return
124
- this.palOpen = false
125
- j.run()
126
- }
127
211
  /** every jump target: section titles, media assets, collection instances */
128
212
  get jumps(): Jump[] {
129
213
  const out: Jump[] = []
@@ -140,20 +224,6 @@ export class HubSt {
140
224
  }
141
225
  return out
142
226
  }
143
- /** ranked: prefix matches first, then substring; capped at 12 */
144
- get palResults(): Jump[] {
145
- const q = this.palQuery.trim().toLowerCase()
146
- if (q === '') return this.jumps.slice(0, 12)
147
- const pre: Jump[] = []
148
- const sub: Jump[] = []
149
- for (const j of this.jumps) {
150
- const l = j.label.toLowerCase()
151
- if (l.startsWith(q)) pre.push(j)
152
- else if (l.includes(q)) sub.push(j)
153
- }
154
- return [...pre, ...sub].slice(0, 12)
155
- }
156
-
157
227
  // ── selection + hash deep-links ───────────────────────────────────────
158
228
  selectAsset(id: string): void {
159
229
  this.setSel({ t: 'asset', id })
@@ -182,7 +252,7 @@ export class HubSt {
182
252
  setSel(sel: Sel): void {
183
253
  this.sel = sel
184
254
  const h = hashFor(sel)
185
- if (h == null) return
255
+ if (h == null || !this.isShown) return
186
256
  const cur = parseHash(currentHash())
187
257
  const next = parseHash(h)
188
258
  // the tool set rides after the asset id (media-svc syncHash), so an
@@ -220,6 +290,7 @@ export class HubSt {
220
290
  /** the url decides the page: a reload, back, forward and a pasted link
221
291
  * all land here, and only a hash naming something else moves the view */
222
292
  applyHash(): void {
293
+ if (!this.isShown) return
223
294
  const p = parseHash(currentHash())
224
295
  const named = selOf(p)
225
296
  if (named == null) return
@@ -243,8 +314,8 @@ export class HubSt {
243
314
  }
244
315
  /** first heartbeat has the asset list — only then can a hash resolve */
245
316
  afterFirstBeat(): void {
246
- if (this.booted) return
247
- this.booted = true
317
+ if (this.beat || !this.isShown) return
318
+ this.beat = true
248
319
  this.applyHash()
249
320
  }
250
321
  }
@@ -7,7 +7,7 @@ export type RunsTab = 'runs' | 'files' | 'slowest' | 'longest'
7
7
 
8
8
  /** the four lists, in tab order. `key` is what the address carries */
9
9
  export const RUNS_TABS: { key: RunsTab; label: string; help: string }[] = [
10
- { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, its save trigger and its daily health pass. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the live strip under the header: unfold it and press fit` },
10
+ { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, and the runs the plan starts. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the live strip under the header: unfold it and press fit` },
11
11
  { key: 'files', label: 'test files to fix', help: "bun test files, not checks: each file's last recorded run inside the check that ran it (test, test:<unit>), kept when a test in it failed or when it broke the quick tier budget per file. The same verdict colours the file in the tree under tests. Click a file for its page, the check tag for the check" },
12
12
  { key: 'slowest', label: 'slowest checks', help: 'the checks that cost the most when they really run: the mean and the worst of the runs that ran. A check whose worst run passed half its ceiling is flagged, because the next slow machine trips it' },
13
13
  { key: 'longest', label: 'longest runs', help: 'the invocations on record that took the longest, start to end, whatever ran them' },