rvlib-shipkit 0.38.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -1,12 +1,19 @@
1
- // Collections service — asks · creds · posts · health · graphs sections +
2
- // the act-in-place verbs. Every act hits a git-tracked truth file through
3
- // /api/act/*; errors come back as 400 text shown verbatim on the card.
1
+ // Resources service — ONE flat list for the whole repo (/api/resources.json)
2
+ // plus the act-in-place verbs. The collections are a GROUPING of that array,
3
+ // computed here, never a second fetch. Every act hits a git-tracked truth
4
+ // file through /api/act/*; errors come back as 400 text shown verbatim on
5
+ // the card.
4
6
  import { makeAutoObservable, runInAction } from 'mobx'
5
7
  import type { HubSt } from './hub-st.ts'
6
- import type { Col, ColInstance } from './hub-types.ts'
8
+ import type { Col, ColMeta, Resource, Schedule } from './hub-types.ts'
7
9
 
8
10
  export class ColsSvc {
9
- cols: Col[] = []
11
+ /** exactly what the server last sent; `resources` is this plus the live
12
+ * results of a run in flight */
13
+ fetched: Resource[] = []
14
+ colMeta: ColMeta[] = []
15
+ /** the hub's daily health pass timer, as the last poll saw it */
16
+ schedule: Schedule | null = null
10
17
  actErr = ''
11
18
  remarkDraft = ''
12
19
  proofDraft = ''
@@ -28,17 +35,37 @@ export class ColsSvc {
28
35
 
29
36
  async poll(): Promise<void> {
30
37
  try {
31
- const r = await fetch('/api/collections.json')
38
+ const r = await fetch('/api/resources.json')
32
39
  if (!r.ok) throw new Error(await r.text())
33
- const j = (await r.json()) as { collections: Col[] }
34
- runInAction(() => { this.cols = j.collections })
40
+ const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null }
41
+ runInAction(() => {
42
+ this.colMeta = j.collections
43
+ this.fetched = j.resources
44
+ this.schedule = j.schedule ?? null
45
+ })
35
46
  } 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}`
36
50
  runInAction(() => {
37
- this.cols = [{ id: 'hub', title: 'hub', media: 'health', instances: [{ id: 'hub:collections-broken', text: `collections fetch broke: ${(e as Error).message}`, severity: 'red' }] }]
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' }]
38
53
  })
39
54
  }
40
55
  }
41
56
 
57
+ /** the one array, with whatever a RUNNING gate has already reported laid
58
+ * over it: a row recolours the moment its check finishes, rather than on
59
+ * the next ten second poll */
60
+ get resources(): Resource[] {
61
+ return this.st.checks.overlay(this.fetched)
62
+ }
63
+
64
+ /** the collections, as a grouping of the one array */
65
+ get cols(): Col[] {
66
+ return this.colMeta.map((m) => ({ ...m, instances: this.resources.filter((r) => r.collection === m.id) }))
67
+ }
68
+
42
69
  /** media instances render richer through the media sidebar — the
43
70
  * collection twin would be a duplicate surface (kept in `cols` so the
44
71
  * asset card can show its remarks) */
@@ -46,10 +73,10 @@ export class ColsSvc {
46
73
  return this.cols.filter((c) => c.id !== 'media')
47
74
  }
48
75
  mediaRemarks(assetId: string): string[] {
49
- return this.cols.find((c) => c.id === 'media')?.instances.find((i) => i.id === `media:${assetId}`)?.remarks ?? []
76
+ return this.resources.find((i) => i.id === `media:${assetId}`)?.remarks ?? []
50
77
  }
51
78
 
52
- needsItem = (i: ColInstance): boolean => i.severity != null
79
+ needsItem = (i: Resource): boolean => i.severity != null
53
80
  isOpen(c: Col): boolean {
54
81
  // a live search must SHOW its matches — folds yield while it runs
55
82
  if (this.st.query.trim() !== '') return true
@@ -69,9 +96,9 @@ export class ColsSvc {
69
96
  return c.instances.some((i) => i.severity === 'red')
70
97
  }
71
98
  get mediaHasRed(): boolean {
72
- return this.cols.find((c) => c.id === 'media')?.instances.some((i) => i.severity === 'red') === true
99
+ return this.resources.some((i) => i.collection === 'media' && i.severity === 'red')
73
100
  }
74
- visibleInstances(c: Col): ColInstance[] {
101
+ visibleInstances(c: Col): Resource[] {
75
102
  const f = this.st.filter
76
103
  const base = f === 'all' ? c.instances
77
104
  : c.instances.filter((i) => (f === 'red' ? i.severity === 'red' : this.needsItem(i)))
@@ -80,17 +107,14 @@ export class ColsSvc {
80
107
  return base.filter((i) => i.text.toLowerCase().includes(q) || (i.file ?? '').toLowerCase().includes(q))
81
108
  }
82
109
  get redCount(): number {
83
- return this.cols.flatMap((c) => c.instances).filter((i) => i.severity === 'red').length
84
- }
85
- find(colId: string, id: string): ColInstance | null {
86
- return this.cols.find((c) => c.id === colId)?.instances.find((i) => i.id === id) ?? null
110
+ return this.resources.filter((i) => i.severity === 'red').length
87
111
  }
88
- colTitle(colId: string): string {
89
- return this.cols.find((c) => c.id === colId)?.title ?? colId
112
+ find(colId: string, id: string): Resource | null {
113
+ return this.resources.find((i) => i.collection === colId && i.id === id) ?? null
90
114
  }
91
115
 
92
116
  select(colId: string, id: string): void {
93
- this.st.sel = { t: 'item', col: colId, id }
117
+ this.st.setSel({ t: 'item', col: colId, id })
94
118
  this.actErr = ''
95
119
  this.remarkDraft = ''
96
120
  this.proofDraft = ''
@@ -0,0 +1,171 @@
1
+ // Comfy service — starts the repo's comfy-ts panel on demand and speaks the
2
+ // documented host protocol to the frame (comfy-ts src/cli/serve/hostProtocol.ts).
3
+ // The panel is CROSS-ORIGIN (its own port), so everything the hub knows about
4
+ // it is something the panel SAID: the header never reports a state it guessed
5
+ // from how the iframe looks.
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import type { HubSt } from './hub-st.ts'
8
+
9
+ export type ComfyBox = { url: string; up: boolean; err: string | null }
10
+ /** what the panel says, narrowed to the messages this host acts on */
11
+ type PanelMsg = { comfyTs?: unknown; module?: unknown; draft?: unknown; code?: unknown; message?: unknown }
12
+
13
+ /** `ready` never arriving is not the same as a dead panel: an older comfy-ts
14
+ * serves a working ui and speaks no protocol, so the chip says which it is */
15
+ const HANDSHAKE_MS = 10_000
16
+
17
+ export class ComfySvc {
18
+ /** the iframe src, set ONCE per hub session: re-pointing it remounts the
19
+ * panel and throws away its gallery, so a workflow switch is a message */
20
+ mountUrl = ''
21
+ /** bumped on a restart: it is the iframe's react key, so a fresh panel
22
+ * process gets a fresh frame instead of one pointing at a dead port */
23
+ mountNonce = 0
24
+ state: 'idle' | 'starting' | 'live' | 'silent' | 'dead' = 'idle'
25
+ err = ''
26
+ box: ComfyBox | null = null
27
+ /** the selection as the PANEL reports it — the hub follows, never the reverse */
28
+ live: { module: string; draft: string } | null = null
29
+ /** the panel over the ASSET page, so tuning starts where the asset already
30
+ * is instead of sending him to another section to find the same frame */
31
+ overlay = false
32
+ /** a draft asked for before the panel answered: the deep link carries the
33
+ * workflow only, so the draft rides a message once the handshake lands */
34
+ private wantDraft = ''
35
+ /** the workflow the mount url carries — the panel opens on it by deep link,
36
+ * so asking for it again by message would reset its draft for nothing */
37
+ private mountedFor = ''
38
+ private frame: HTMLIFrameElement | null = null
39
+ private handshake: ReturnType<typeof setTimeout> | null = null
40
+
41
+ constructor(private st: HubSt) {
42
+ makeAutoObservable<this, 'st' | 'frame' | 'handshake'>(this, { st: false, frame: false, handshake: false })
43
+ window.addEventListener('message', (e) => this.onMessage(e))
44
+ }
45
+
46
+ setFrame(el: HTMLIFrameElement | null): void {
47
+ this.frame = el
48
+ }
49
+
50
+ /** the comfy row's ONE action: mount the panel, or move the mounted one */
51
+ async open(id: string): Promise<void> {
52
+ if (this.mountUrl === '') return await this.start(id)
53
+ if (id !== (this.live?.module ?? this.mountedFor)) this.post({ comfyTs: 'set-selection', module: id, draft: 'default' })
54
+ }
55
+
56
+ /** an asset page's own action: tune THIS asset's workflow without leaving
57
+ * the asset. The draft is the KIND's, one file shared by every asset of
58
+ * that kind, and the asset's own prompt and seed land on top of it at run
59
+ * time */
60
+ async tune(module: string, draft: string): Promise<void> {
61
+ runInAction(() => {
62
+ this.overlay = true
63
+ this.wantDraft = draft
64
+ })
65
+ if (this.mountUrl === '') return await this.start(module)
66
+ this.selectWanted(module)
67
+ }
68
+
69
+ closeOverlay(): void {
70
+ this.overlay = false
71
+ }
72
+
73
+ /** the panel is live already, so the move is a message and the gallery stays */
74
+ private selectWanted(module: string): void {
75
+ const draft = this.wantDraft === '' ? 'default' : this.wantDraft
76
+ if (this.live?.module === module && this.live.draft === draft) return
77
+ this.post({ comfyTs: 'set-selection', module, draft })
78
+ }
79
+
80
+ async probeBox(): Promise<void> {
81
+ const box = await fetch('/api/comfy/box')
82
+ .then((r) => (r.ok ? (r.json() as Promise<ComfyBox>) : null))
83
+ .catch(() => null)
84
+ runInAction(() => {
85
+ this.box = box
86
+ })
87
+ }
88
+
89
+ /** an edited .cflow.ts module reaches the panel only through a new process:
90
+ * comfy-ts serve imports every module once, at boot */
91
+ async restart(): Promise<void> {
92
+ const id = this.live?.module ?? this.mountedFor
93
+ if (id === '') return
94
+ runInAction(() => {
95
+ this.live = null
96
+ this.mountUrl = ''
97
+ this.mountNonce++
98
+ })
99
+ await this.start(id, true)
100
+ }
101
+
102
+ private async start(id: string, restart = false): Promise<void> {
103
+ if (this.state === 'starting' && !restart) return
104
+ runInAction(() => {
105
+ this.state = 'starting'
106
+ this.err = ''
107
+ })
108
+ void this.probeBox()
109
+ try {
110
+ const r = await fetch('/api/comfy/panel', { method: 'POST', body: JSON.stringify({ restart }) })
111
+ if (!r.ok) throw new Error(await r.text())
112
+ const up = (await r.json()) as { url: string }
113
+ runInAction(() => {
114
+ this.mountedFor = id
115
+ this.mountUrl = `${up.url}/?workflow=${encodeURIComponent(id)}`
116
+ })
117
+ this.armHandshake()
118
+ } catch (e) {
119
+ runInAction(() => {
120
+ this.state = 'dead'
121
+ this.err = (e as Error).message
122
+ })
123
+ }
124
+ }
125
+
126
+ private armHandshake(): void {
127
+ if (this.handshake != null) clearTimeout(this.handshake)
128
+ this.handshake = setTimeout(
129
+ () =>
130
+ runInAction(() => {
131
+ if (this.state === 'starting') this.state = 'silent'
132
+ }),
133
+ HANDSHAKE_MS,
134
+ )
135
+ }
136
+
137
+ private post(msg: Record<string, unknown>): void {
138
+ const w = this.frame?.contentWindow
139
+ if (w == null || this.mountUrl === '') return
140
+ w.postMessage(msg, new URL(this.mountUrl).origin)
141
+ }
142
+
143
+ private onMessage(e: MessageEvent): void {
144
+ // the frame we mounted and nobody else: another embed on this page
145
+ // speaking first must never drive this header
146
+ if (this.frame == null || e.source !== this.frame.contentWindow) return
147
+ const m = e.data as PanelMsg | null
148
+ if (m == null || typeof m.comfyTs !== 'string') return
149
+ runInAction(() => {
150
+ if (m.comfyTs === 'ready') {
151
+ this.state = 'live'
152
+ this.err = ''
153
+ if (this.wantDraft !== '') this.selectWanted(this.mountedFor)
154
+ } else if (m.comfyTs === 'error' && m.code === 'boot') {
155
+ this.state = 'dead'
156
+ this.err = typeof m.message === 'string' ? m.message : 'the panel could not boot'
157
+ } else if (m.comfyTs === 'selection' && typeof m.module === 'string' && typeof m.draft === 'string') {
158
+ this.state = 'live'
159
+ this.live = { module: m.module, draft: m.draft }
160
+ if (m.draft === this.wantDraft) this.wantDraft = ''
161
+ // over an asset page the panel is a popup ON that asset: following
162
+ // its selection here would navigate off the asset it was opened from
163
+ if (this.overlay) return
164
+ // a switch made INSIDE the panel moves the hub's selected row,
165
+ // so the two never show different workflows
166
+ const id = `comfy:${m.module}`
167
+ if (this.st.sel?.t !== 'item' || this.st.sel.id !== id) this.st.cols.select('comfy', id)
168
+ }
169
+ })
170
+ }
171
+ }
@@ -0,0 +1,81 @@
1
+ // comfy item view — comfy-ts's own panel fills the stage, the header states
2
+ // what the hub cannot see from the outside: whether the panel handshook, and
3
+ // whether the box a run needs is answering.
4
+ import { observer } from 'mobx-react-lite'
5
+ import { useEffect } from 'react'
6
+ import type { ComfySvc } from './comfy-svc.ts'
7
+ import type { HubSt } from './hub-st.ts'
8
+
9
+ const PANEL_CHIP: Record<ComfySvc['state'], { label: string; cls: string; title: string }> = {
10
+ idle: { label: 'idle', cls: '', title: 'nothing started yet' },
11
+ starting: { label: 'starting', cls: '', title: 'the hub is starting comfy-ts serve' },
12
+ live: { label: 'live', cls: 'ok', title: 'the panel handshook over the host protocol' },
13
+ silent: { label: 'no handshake', cls: '', title: 'the panel never sent ready — it still works, this hub just cannot follow its selection' },
14
+ dead: { label: 'failed', cls: 'miss', title: 'the panel could not start' },
15
+ }
16
+
17
+ // the same panel over an asset page. A popup, so the asset, its rolls and its
18
+ // prompt stay on screen behind it while a value is tried
19
+ export const ComfyOverlay = observer(({ app }: { app: HubSt }) => {
20
+ const c = app.comfy
21
+ if (!c.overlay) return null
22
+ const chip = PANEL_CHIP[c.state]
23
+ const box = c.box
24
+ return (
25
+ <div className="cfov" onClick={() => c.closeOverlay()}>
26
+ <div className="cfbox" onClick={(e) => e.stopPropagation()}>
27
+ <div className="gvhead">
28
+ <span className="gvtext">{c.live?.module ?? ''}{c.live != null ? ` · ${c.live.draft}` : ''}</span>
29
+ <span className={`stchip ${chip.cls}`} title={chip.title}>{chip.label}</span>
30
+ {box != null ? (
31
+ <span className={`stchip ${box.up ? 'ok' : 'miss'}`} title={box.err ?? box.url}>
32
+ {box.up ? 'box up' : `box unreachable — ${box.url}`}
33
+ </span>
34
+ ) : null}
35
+ <button className="ghost" title="restart the panel process — the only way an edited .cflow.ts module reaches it"
36
+ onClick={() => void c.restart()}>restart panel</button>
37
+ <button className="ghost" onClick={() => c.closeOverlay()}>close</button>
38
+ </div>
39
+ {c.err !== '' ? <div className="err">✗ {c.err}</div> : null}
40
+ {c.mountUrl !== '' ? (
41
+ <iframe key={c.mountNonce} ref={(el) => c.setFrame(el)} src={c.mountUrl} title="comfy-ts panel" />
42
+ ) : (
43
+ <div className="ph">{c.state === 'dead' ? 'the panel did not start' : 'starting the comfy-ts panel…'}</div>
44
+ )}
45
+ </div>
46
+ </div>
47
+ )
48
+ })
49
+
50
+ export const ComfyView = observer(({ app, id }: { app: HubSt; id: string }) => {
51
+ const c = app.comfy
52
+ const wf = id.replace(/^comfy:/, '')
53
+ useEffect(() => {
54
+ void c.open(wf)
55
+ }, [c, wf])
56
+ const chip = PANEL_CHIP[c.state]
57
+ const box = c.box
58
+ return (
59
+ <div className="comfyview">
60
+ <div className="gvhead">
61
+ <span className="gvtext">{c.live?.module ?? wf}{c.live != null ? ` · ${c.live.draft}` : ''}</span>
62
+ <span className={`stchip ${chip.cls}`} title={chip.title}>{chip.label}</span>
63
+ {box != null ? (
64
+ <span className={`stchip ${box.up ? 'ok' : 'miss'}`} title={box.err ?? box.url}>
65
+ {box.up ? 'box up' : `box unreachable — ${box.url}`}
66
+ </span>
67
+ ) : null}
68
+ <button className="ghost" title="re-probe the box" onClick={() => void c.probeBox()}>↻</button>
69
+ <button className="ghost" title="restart the panel process — the only way an edited .cflow.ts module reaches it"
70
+ onClick={() => void c.restart()}>restart panel</button>
71
+ </div>
72
+ {c.err !== '' ? <div className="err">✗ {c.err}</div> : null}
73
+ {box?.err != null ? <div className="err">✗ {box.err}</div> : null}
74
+ {c.mountUrl !== '' ? (
75
+ <iframe key={c.mountNonce} ref={(el) => c.setFrame(el)} src={c.mountUrl} title="comfy-ts panel" />
76
+ ) : (
77
+ <div className="ph">{c.state === 'dead' ? 'the panel did not start' : 'starting the comfy-ts panel…'}</div>
78
+ )}
79
+ </div>
80
+ )
81
+ })
@@ -0,0 +1,41 @@
1
+ // ONE duration unit for the whole hub, and ONE tone table under it.
2
+ //
3
+ // Every number the page prints is milliseconds, grouped by a thin space, so a
4
+ // column of them compares by eye. Mixing `4.2s` into a column of `412ms` made
5
+ // the two biggest numbers on the page look like the two smallest.
6
+ //
7
+ // The tone is here rather than in a component because the tree row and the
8
+ // check page both colour the same number, and two tables would eventually say
9
+ // two things about one measurement.
10
+
11
+ /** the thousands separator: a thin space, so the digits stay a block */
12
+ export const THIN = ' '
13
+
14
+ /** green · plain ink · orange · red */
15
+ export type DurTone = 'fast' | 'plain' | 'slow' | 'bad'
16
+
17
+ const FAST_MS = 500
18
+ const PLAIN_MS = 2_000
19
+ const SLOW_MS = 10_000
20
+
21
+ /** past this share of its kill time a check is red whatever the table says:
22
+ * the next slow machine trips it */
23
+ const CEILING_SHARE = 0.5
24
+
25
+ const group = (n: number): string => {
26
+ const s = `${n}`
27
+ let out = ''
28
+ for (let i = 0; i < s.length; i++) out += (i > 0 && (s.length - i) % 3 === 0 ? THIN : '') + s[i]!
29
+ return out
30
+ }
31
+
32
+ export const fmtMs = (ms: number | null | undefined): string => (ms == null ? '' : `${group(Math.round(ms))}ms`)
33
+
34
+ export function durationTone(ms: number | null | undefined, ceilingMs?: number | null): DurTone | null {
35
+ if (ms == null) return null
36
+ if (ceilingMs != null && ceilingMs > 0 && ms > ceilingMs * CEILING_SHARE) return 'bad'
37
+ if (ms < FAST_MS) return 'fast'
38
+ if (ms < PLAIN_MS) return 'plain'
39
+ if (ms < SLOW_MS) return 'slow'
40
+ return 'bad'
41
+ }
@@ -0,0 +1,121 @@
1
+ // THE ADDRESS of what is on screen, both ways, and nothing else. Pure on
2
+ // purpose: the page's selection rules are worth pinning, and HubSt cannot be
3
+ // instantiated without a browser.
4
+ //
5
+ // The media forms are older than the rest and stay byte for byte what they
6
+ // were, because they are pasted around as share links: `#<assetId>` with an
7
+ // optional `?tools=a,b`, and `#cat-<Category>`.
8
+
9
+ /** every selection the page can show. Each one has an address, so nothing
10
+ * that decides which page is on screen lives outside the url */
11
+ import { RUNS_ID } from '../../collections/collection-types.ts'
12
+ import { runsTab, runsTabId } from './runs-tabs.ts'
13
+
14
+ export type HashSel =
15
+ | { t: 'asset'; id: string }
16
+ | { t: 'cat'; cat: string }
17
+ | { t: 'col'; id: string }
18
+ | { t: 'item'; col: string; id: string }
19
+ | { t: 'status' }
20
+ /** where the test checks' time goes: every one of them (check null), or
21
+ * one test check (`test`, `test:<unit>`) */
22
+ | { t: 'tests'; check: string | null }
23
+
24
+ export type ParsedHash = {
25
+ kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'none'
26
+ /** the collection, for an item. null for everything else */
27
+ col: string | null
28
+ id: string
29
+ /** the media tool set, when the address carried one. An empty array is a
30
+ * choice (every tool off), null is no statement at all */
31
+ tools: string[] | null
32
+ }
33
+
34
+ const ITEM = 'item:'
35
+ const COL = 'col:'
36
+ const CAT = 'cat-'
37
+ /** prefixed, so no asset id can shadow it */
38
+ const STATUS = 'page:status'
39
+ const TESTS = 'page:tests'
40
+
41
+ /** what this selection puts in the url. null only for no selection at all,
42
+ * and then the hash is left alone rather than wiped */
43
+ export function hashFor(sel: HashSel | null): string | null {
44
+ if (sel == null) return null
45
+ switch (sel.t) {
46
+ case 'asset': return sel.id
47
+ case 'cat': return `${CAT}${sel.cat}`
48
+ case 'col': return `${COL}${sel.id}`
49
+ case 'item': return `${ITEM}${sel.col}:${sel.id}`
50
+ case 'status': return STATUS
51
+ case 'tests': return sel.check == null ? TESTS : `${TESTS}:${sel.check}`
52
+ }
53
+ }
54
+
55
+ export function parseHash(raw: string): ParsedHash {
56
+ const none: ParsedHash = { kind: 'none', col: null, id: '', tools: null }
57
+ if (raw === '') return none
58
+ if (raw === STATUS) return { kind: 'status', col: null, id: '', tools: null }
59
+ if (raw === TESTS) return { kind: 'tests', col: null, id: '', tools: null }
60
+ // a check id carries colons of its own (`test:rvlib-shipkit`)
61
+ if (raw.startsWith(`${TESTS}:`)) {
62
+ const check = raw.slice(TESTS.length + 1)
63
+ return check === '' ? none : { kind: 'tests', col: null, id: check, tools: null }
64
+ }
65
+ if (raw.startsWith(ITEM)) {
66
+ const rest = raw.slice(ITEM.length)
67
+ const cut = rest.indexOf(':')
68
+ // an id carries colons of its own (`check:SK004`), so only the FIRST
69
+ // separator belongs to this format
70
+ if (cut <= 0) return none
71
+ const col = rest.slice(0, cut)
72
+ const id = rest.slice(cut + 1)
73
+ return id === '' ? none : { kind: 'item', col, id, tools: null }
74
+ }
75
+ if (raw.startsWith(COL)) {
76
+ const id = raw.slice(COL.length)
77
+ return id === '' ? none : { kind: 'col', col: null, id, tools: null }
78
+ }
79
+ if (raw.startsWith(CAT)) {
80
+ const cat = raw.slice(CAT.length)
81
+ return cat === '' ? none : { kind: 'cat', col: null, id: cat, tools: null }
82
+ }
83
+ const qi = raw.indexOf('?')
84
+ const id = qi === -1 ? raw : raw.slice(0, qi)
85
+ if (id === '') return none
86
+ const tools = qi === -1 ? null : new URLSearchParams(raw.slice(qi + 1)).get('tools')
87
+ return { kind: 'asset', col: null, id, tools: tools == null ? null : tools === '' ? [] : tools.split(',') }
88
+ }
89
+
90
+ /** the selection an address names, the inverse of hashFor. The media tool
91
+ * set is not a selection: it rides in `ParsedHash.tools` */
92
+ export function selOf(p: ParsedHash): HashSel | null {
93
+ switch (p.kind) {
94
+ case 'none': return null
95
+ case 'asset': return { t: 'asset', id: p.id }
96
+ case 'cat': return { t: 'cat', cat: p.id }
97
+ case 'col': return { t: 'col', id: p.id }
98
+ case 'item': return { t: 'item', col: p.col!, id: p.id }
99
+ case 'status': return { t: 'status' }
100
+ case 'tests': return { t: 'tests', check: p.id === '' ? null : p.id }
101
+ }
102
+ }
103
+
104
+ /** the one address of what a selection opens. A runs tab this build does
105
+ * not name opens the runs tab, so its address is the runs tab's: a stale or
106
+ * hand typed tab never stays in the url while another tab shows */
107
+ export function canonicalSel(sel: HashSel): HashSel {
108
+ if (sel.t !== 'item' || sel.col !== RUNS_ID) return sel
109
+ const id = runsTabId(runsTab(sel.id))
110
+ return id === sel.id ? sel : { ...sel, id }
111
+ }
112
+
113
+ /** the hash as the page reads it: no `#`, percent escapes undone */
114
+ export function currentHash(): string {
115
+ const raw = location.hash.slice(1)
116
+ try {
117
+ return decodeURIComponent(raw)
118
+ } catch {
119
+ return raw
120
+ }
121
+ }
@@ -0,0 +1,117 @@
1
+ // The header over every page, and the live strip under it. The header is the
2
+ // repo name, one link per core page, and on the right the two runs that are
3
+ // not one check with what is running now. The strip is the one run chart
4
+ // (timeline-chart.tsx) over a rolling window that ends now: folded, a thin
5
+ // bar of the last two minutes; unfolded, the full chart over ten, zoomable.
6
+ // Each fold has ONE height whatever runs, so a run never moves the page.
7
+ import { observer } from 'mobx-react-lite'
8
+ import { RUNS_ID } from '../../collections/collection-types.ts'
9
+ import { Help } from './help.tsx'
10
+ import { openWikis, WIKIS } from './wikis-view.tsx'
11
+ import type { HubSt } from './hub-st.ts'
12
+ import { byWords, TimelineChart } from './timeline-chart.tsx'
13
+
14
+ export type CorePage = { id: string; label: string; on: boolean; go: () => void }
15
+
16
+ /** the pages reached for most, in the order they are reached for. A page this
17
+ * repo does not have is not a link */
18
+ export function corePages(app: HubSt): CorePage[] {
19
+ const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
20
+ const sel = app.sel
21
+ const onCol = (col: string): boolean => (sel?.t === 'col' && sel.id === col) || (sel?.t === 'item' && sel.col === col)
22
+ const out: CorePage[] = []
23
+ if (has('checks')) out.push({ id: 'checks', label: 'checks', on: onCol('checks'), go: () => app.showCol('checks') })
24
+ // a page, never a resource: every hub keeps a run record, so it is
25
+ // always here
26
+ out.push({ id: 'runs', label: 'runs', on: onCol('runs'), go: () => app.cols.select('runs', RUNS_ID) })
27
+ if (has('asks')) out.push({ id: 'asks', label: 'asks', on: onCol('asks'), go: () => app.showCol('asks') })
28
+ const firstCat = app.media.assets[0]?.cat
29
+ if (firstCat != null) out.push({ id: 'media', label: 'media', on: sel?.t === 'asset' || sel?.t === 'cat', go: () => app.showCat(firstCat) })
30
+ if (has(WIKIS)) out.push({ id: WIKIS, label: 'wikis', on: onCol(WIKIS), go: () => openWikis(app) })
31
+ return out
32
+ }
33
+
34
+ /** what is running, in one line: the hub's own run first, since it says the
35
+ * most, else the newest run the record has in flight */
36
+ const RunState = observer(({ app }: { app: HubSt }) => {
37
+ const own = app.checks.strip
38
+ const run = app.live.view?.runs[0]
39
+ const text = own !== '' ? own : run != null ? `${run.label} · ${run.done} of ${run.total}${run.reds > 0 ? ` · ${run.reds} red so far` : ''}` : 'idle'
40
+ const going = app.checks.busy || run != null
41
+ return (
42
+ <span className={`hstate${going ? ' on' : ''}`} title={text}>
43
+ {going ? <span className="spin" /> : null}
44
+ <span className="htext">{text}</span>
45
+ </span>
46
+ )
47
+ })
48
+
49
+ export const HeaderBar = observer(({ app }: { app: HubSt }) => {
50
+ const u = app.checks
51
+ return (
52
+ <header className="hdr">
53
+ <span className="hname">{app.name !== '' ? app.name : 'hub'}</span>
54
+ <nav className="hnav">
55
+ {corePages(app).map((p) => (
56
+ <button key={p.id} className={p.on ? 'on' : ''} onClick={p.go}>{p.label}</button>
57
+ ))}
58
+ </nav>
59
+ <span className="sp" />
60
+ <RunState app={app} />
61
+ <button className="primary" disabled={u.busy} onClick={() => void u.run()}>run the health pass</button>
62
+ <button disabled={u.busy} onClick={() => void u.run(undefined, { gate: true, label: 'the gate' })}>run the gate</button>
63
+ </header>
64
+ )
65
+ })
66
+
67
+ /** the key that folds and unfolds the strip, printed on its chevron */
68
+ export const LIVE_KEY = 'l'
69
+
70
+ /** the strip's own row, in px: the one height the folded strip has */
71
+ export const LIVE_ROW_H = 30
72
+
73
+ /** what the strip is, in the (?) beside its chevron */
74
+ export const LIVE_HELP = "every check shipkit runs, whoever started it: this hub, an agent in a terminal, the daily pass, here and in the other repos. Folded, the last two minutes: a bar grows at the right while its check runs and slides left once it ends, in its verdict colour. Unfolded, the whole record, opened on the last ten minutes: the run bands, the time axis, and over the lanes how many checks ran at once, with the cap dashed and the stretches past it in orange. It follows now: the right edge is the present and the view slides with it. The wheel zooms and a drag pans; leaving the right edge stops the view where it is so a bar can be read, and ● live, the k key or panning back to the right edge follows now again. While a tooltip is open the view stands still. fit shows every run on record. A lane at the bottom is another repo with something running; a click opens its hub when it serves. The count is the checks in flight against the runner's cap"
75
+
76
+ const Chevron = ({ open }: { open: boolean }) => (
77
+ <svg className={open ? 'cvt open' : 'cvt'} viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
78
+ <path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" />
79
+ </svg>
80
+ )
81
+
82
+ export const LiveStrip = observer(({ app }: { app: HubSt }) => {
83
+ const l = app.live
84
+ const v = l.view
85
+ const run = v?.runs[0]
86
+ const hidden = l.chart.layout?.hidden ?? 0
87
+ const waits = v == null ? '' : [
88
+ v.queued > 0 ? `${v.queued} queued for a free slot` : '',
89
+ ...v.blocked.map((w) => `${w.id} waits on ${w.deps.join(', ')}`),
90
+ ].filter((x) => x !== '').join('\n')
91
+ const who = run == null ? 'nothing running' : `${run.label}`
92
+ const chart = (
93
+ <TimelineChart view={l.chart} compact={!l.open} repos={v?.repos ?? []} follow zoomable={l.open}
94
+ onBar={(b) => app.cols.select('checks', `check:${b.id}`)}
95
+ onRepo={(r) => void l.openRepo(r.repo)} />
96
+ )
97
+ return (
98
+ <div className={`live${l.open ? ' open' : ''}${run == null ? ' idle' : ''}`} style={l.open ? { height: LIVE_ROW_H + l.held.h } : undefined}>
99
+ <div className="lrow">
100
+ <button className="lfold" onClick={() => l.toggle()} title={`${l.open ? 'fold' : 'unfold'} the live timeline (${LIVE_KEY})`}>
101
+ <Chevron open={l.open} /><span className="sk">{LIVE_KEY}</span>
102
+ </button>
103
+ <Help text={LIVE_HELP} />
104
+ <span className="lwho" title={run == null ? '' : `${run.label}, started from the ${byWords(run.by)}${v != null && v.runs.length > 1 ? `\n${v.runs.length - 1} more in flight here` : ''}`}>
105
+ {who}{run == null ? null : <i>{byWords(run.by)}</i>}{v != null && v.runs.length > 1 ? <b>+{v.runs.length - 1}</b> : null}
106
+ </span>
107
+ {l.open ? <span className="sp" /> : <div className="lchart">{chart}</div>}
108
+ {l.note !== '' ? <span className="lnote">{l.note}</span> : null}
109
+ {hidden > 0 ? <span className="lmore" title="check runs that did not fit in the folded strip: unfold it to see them">+{hidden}</span> : null}
110
+ <span className="lbusy" title={waits === '' ? 'checks in flight in this repo, against the runner cap' : waits}>
111
+ in flight <span className="dnum">{v?.busy ?? 0}</span> / cap <span className="dnum">{v?.cap ?? 0}</span>
112
+ </span>
113
+ </div>
114
+ {l.open ? <div className="lopen">{chart}</div> : null}
115
+ </div>
116
+ )
117
+ })