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
@@ -59,6 +59,27 @@ export type ServeConfig = {
59
59
  * prints beside localhost and rides on every ping message, because a
60
60
  * phone cannot resolve localhost */
61
61
  publicHost?: string
62
+ /** the long running processes this repo needs (a dev server, a watcher).
63
+ * The hub supervises them: a switch each on its page, a crash restarts,
64
+ * and they outlive a hub restart */
65
+ processes?: ProcessDecl[]
66
+ /** the ids of the global processes this repo needs (any house repo's
67
+ * `globalProcess` unit files). The home rail marks the repo while one is off */
68
+ uses?: string[]
69
+ }
70
+
71
+ /** one long running process a repo needs, supervised by its hub */
72
+ export type ProcessDecl = {
73
+ /** kebab-case, unique in this repo */
74
+ id: string
75
+ /** the command as a list of words, run from the repo root */
76
+ cmd: string[]
77
+ /** where it runs, repo-relative (default the repo root) */
78
+ cwd?: string
79
+ /** the url it serves, probed for up and shown as a link */
80
+ url?: string
81
+ /** starts with the hub. Default false: it starts from its switch */
82
+ auto?: boolean
62
83
  }
63
84
 
64
85
  export type RepoConfig = {
@@ -250,13 +271,34 @@ export type RepoConfig = {
250
271
  }
251
272
  /** the `shipkit serve` hub: port + optional graph hooks */
252
273
  serve?: ServeConfig
274
+ /** the repo's own colours, one #rrggbb hex each: colorLight reads on a
275
+ * light background, colorDark on a dark one. Every page that shows the
276
+ * repo draws its name and its rail tile in them. Rule SK024 warns when
277
+ * they are missing, and its fix derives them from the repo icon */
278
+ identity?: {
279
+ /** #rrggbb that reads on a light background */
280
+ colorLight: string
281
+ /** #rrggbb that reads on a dark background: the home page draws this one */
282
+ colorDark: string
283
+ }
253
284
  /** where a gate run writes its per task logs, repo-relative (default
254
285
  * '.shipkit/tmp/shipkit-ci'). Each run owns a subdir of it, so several agents in
255
286
  * one checkout never overwrite each other. Keep it gitignored. */
256
287
  logDir?: string
257
288
  }
258
289
 
259
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
290
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
291
+
292
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/
293
+ const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
294
+
295
+ /** why a process declaration cannot run, or null */
296
+ export function processProblem(d: ProcessDecl): string | null {
297
+ if (!KEBAB.test(d.id ?? '')) return `serve.processes: id '${String(d.id)}' must be kebab-case`
298
+ if (!words(d.cmd)) return `serve.processes['${d.id}'] needs cmd: a list of words`
299
+ if (d.url != null && !/^https?:\/\//.test(d.url)) return `serve.processes['${d.id}'].url must be an http url`
300
+ return null
301
+ }
260
302
 
261
303
  export function defineRepo(c: RepoConfig): RepoConfig {
262
304
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -266,11 +308,18 @@ export function defineRepo(c: RepoConfig): RepoConfig {
266
308
  // a half-declared reviewer would lint a path like `.claude/agents/.md`
267
309
  if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
268
310
  throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
311
+ const pids = new Set<string>()
312
+ for (const d of c.serve?.processes ?? []) {
313
+ const why = processProblem(d)
314
+ if (why != null) throw new Error(`defineRepo: ${why}`)
315
+ if (pids.has(d.id)) throw new Error(`defineRepo: serve.processes declares '${d.id}' twice`)
316
+ pids.add(d.id)
317
+ }
269
318
  // unknown keys die loud — a typo'd or removed key silently changing the
270
319
  // gate is exactly the never-silence failure mode (A23)
271
320
  for (const k of Object.keys(c))
272
321
  if (!KNOWN_KEYS.has(k))
273
- throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : ''}`)
322
+ throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
274
323
  // a typo'd unit key would silently drop a target and release the wrong shape
275
324
  for (const u of c.units ?? []) {
276
325
  if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
@@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react'
8
8
  import { CACHED } from '../../core/outcome-words.ts'
9
9
  import { durationTone, fmtMs } from './dur.ts'
10
10
  import { Help, TERMS } from './help.tsx'
11
- import type { HubSt } from './hub-st.ts'
11
+ import type { RepoSt } from './repo-st.ts'
12
12
  import type { SlowSuite, CheckFacts, RecentRun, WhenAt } from './hub-types.ts'
13
13
  import { renderMd, renderMdInline } from './md.ts'
14
14
  import { nextRun } from './next-run.ts'
@@ -89,7 +89,7 @@ export const copy = (text: string): void => {
89
89
 
90
90
  /** THE COMMAND, exactly as the gate spawns it from the repo root, or the one
91
91
  * line saying its body is a function inside the gate */
92
- export const CommandLine = ({ app, u }: { app: HubSt; u: CheckFacts }) => {
92
+ export const CommandLine = ({ app, u }: { app: RepoSt; u: CheckFacts }) => {
93
93
  const c = commandOf(u, app.root)
94
94
  if (c.kind === 'in-process')
95
95
  return (
@@ -132,7 +132,7 @@ export const CommandLine = ({ app, u }: { app: HubSt; u: CheckFacts }) => {
132
132
  * through the managed block, and leaves the file dirty: a change of place is
133
133
  * a change its owner reviews in a git client, never a commit made on their
134
134
  * behalf. Choosing never asks for the reason before it writes. */
135
- const RunWhen = observer(({ app, u }: { app: HubSt; u: CheckFacts }) => {
135
+ const RunWhen = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
136
136
  const s = app.checks
137
137
  const g = s.gate
138
138
  const shown = s.whenShown(u)
@@ -193,7 +193,7 @@ const Bars = ({ runs, ceiling, budget }: { runs: RecentRun[]; ceiling: number; b
193
193
  }
194
194
 
195
195
  /** how the last answer came: `ran 3m ago in 412ms`, or `cached 16m ago` */
196
- export const LastRunValue = observer(({ app, u }: { app: HubSt; u: CheckFacts }) => {
196
+ export const LastRunValue = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
197
197
  const l = lastRun(u)
198
198
  if (l == null) return <span className="dim">never here</span>
199
199
  return (
@@ -245,7 +245,7 @@ const LogLines = ({ lines }: { lines: string[] }) => (
245
245
 
246
246
  /** the lines of the run in flight, then the lines of the last one. It
247
247
  * scrolls itself while a run streams, so the newest line is on screen */
248
- const LastOutput = observer(({ app, lines }: { app: HubSt; lines: string[] }) => {
248
+ const LastOutput = observer(({ app, lines }: { app: RepoSt; lines: string[] }) => {
249
249
  const live = app.checks.liveLines
250
250
  const box = useRef<HTMLPreElement>(null)
251
251
  useEffect(() => { if (box.current != null) box.current.scrollTop = box.current.scrollHeight }, [live.length])
@@ -263,7 +263,7 @@ const LastOutput = observer(({ app, lines }: { app: HubSt; lines: string[] }) =>
263
263
  /** the slow suites declared under the test step, each with the case it
264
264
  * argued. Moving a suite between the tiers is the SAME managed block write,
265
265
  * and it demands both answers exactly as `defineRepo` does. */
266
- const SlowSuites = observer(({ app }: { app: HubSt }) => {
266
+ const SlowSuites = observer(({ app }: { app: RepoSt }) => {
267
267
  const s = app.checks
268
268
  const declared: SlowSuite[] = s.gate?.tests.slow ?? []
269
269
  return (
@@ -294,7 +294,7 @@ const SlowSuites = observer(({ app }: { app: HubSt }) => {
294
294
 
295
295
  // ── the pages ────────────────────────────────────────────────────────────
296
296
 
297
- export const CheckCard = observer(({ app, id }: { app: HubSt; id: string }) => {
297
+ export const CheckCard = observer(({ app, id }: { app: RepoSt; id: string }) => {
298
298
  const i = app.cols.find('checks', id)
299
299
  if (i == null) return <div className="ph">check vanished, the inventory moved on</div>
300
300
  const u = i.check
@@ -325,8 +325,8 @@ export const CheckCard = observer(({ app, id }: { app: HubSt; id: string }) => {
325
325
  <Remembered u={u} />
326
326
  {/* the help never depends on the second, so it reads the wall
327
327
  clock and the row does not repaint on every tick */}
328
- <Row k="next run" help={nextRun(u, app.cols.schedule, Date.now()).help}>
329
- <TimeAhead clock={app.clock} words={(now) => nextRun(u, app.cols.schedule, now)} />
328
+ <Row k="next run" help={nextRun(u, app.cols.planOf(u.checkId), Date.now()).help}>
329
+ <TimeAhead clock={app.clock} words={(now) => nextRun(u, app.cols.planOf(u.checkId), now)} />
330
330
  </Row>
331
331
  <Row k="last run" help={u.status == null
332
332
  ? 'no run of it is on record in this checkout'
@@ -18,7 +18,7 @@ const list = (xs: string[]): string => (xs.length < 2 ? xs.join('') : `${xs.slic
18
18
  const CADENCE: Record<WhenAt, string> = {
19
19
  always: 'on every run',
20
20
  release: 'on every release run',
21
- health: 'on every daily pass',
21
+ health: 'each time the plan runs it',
22
22
  off: 'nowhere, it is off',
23
23
  }
24
24
 
@@ -8,7 +8,7 @@
8
8
  // dropped when the run ends and the next poll brings the same facts back
9
9
  // from the one place that owns them.
10
10
  import { makeAutoObservable, runInAction } from 'mobx'
11
- import type { HubSt } from './hub-st.ts'
11
+ import type { RepoSt } from './repo-st.ts'
12
12
  import { applyRunEvent, idleRun, startRun, stripText, type RunProgress } from './run-strip.ts'
13
13
  import type { GateBlock, Resource, ResourceState, SlowSuite, CheckEvent, WhenAt } from './hub-types.ts'
14
14
  import { WHEN_OPTION } from './check-words.ts'
@@ -42,6 +42,10 @@ export class ChecksSvc {
42
42
  * stay after the run: they ARE the last output until the next one */
43
43
  liveLines: string[] = []
44
44
  private clearTimer: ReturnType<typeof setTimeout> | null = null
45
+
46
+ dispose(): void {
47
+ if (this.clearTimer != null) clearTimeout(this.clearTimer)
48
+ }
45
49
  /** the managed block in repo.config.ts as the server last sent it: the
46
50
  * hash the next write hands back, and whether the file is waiting for
47
51
  * a commit. null until the first read */
@@ -56,7 +60,7 @@ export class ChecksSvc {
56
60
  /** the slow suite form under the test check */
57
61
  suite: SlowSuite = { file: '', catches: '', whyNotFast: '' }
58
62
 
59
- constructor(private st: HubSt) {
63
+ constructor(private st: RepoSt) {
60
64
  makeAutoObservable<this, 'st' | 'clearTimer'>(this, { st: false, clearTimer: false })
61
65
  }
62
66
 
@@ -89,7 +93,7 @@ export class ChecksSvc {
89
93
  * silent overwrite */
90
94
  async loadGate(): Promise<void> {
91
95
  try {
92
- const r = await fetch('/api/checks/when')
96
+ const r = await fetch(this.st.url('/api/checks/when'))
93
97
  if (!r.ok) throw new Error(await r.text())
94
98
  const b = (await r.json()) as GateBlock
95
99
  runInAction(() => { this.gate = b })
@@ -109,14 +113,14 @@ export class ChecksSvc {
109
113
  // the hash of the file the page last read: without it a write
110
114
  // could land on top of an edit this page never saw
111
115
  if (this.gate == null) await this.loadGate()
112
- const r = await fetch('/api/checks/when', { method: 'POST', body: JSON.stringify({ ...edit, expect: this.gate?.hash }) })
116
+ const r = await fetch(this.st.url('/api/checks/when'), { method: 'POST', body: JSON.stringify({ ...edit, expect: this.gate?.hash }) })
113
117
  if (!r.ok) throw new Error(await r.text())
114
118
  const b = (await r.json()) as GateBlock
115
119
  runInAction(() => { this.gate = b; this.gateNote = said })
116
120
  took = true
117
121
  // the rows carry `when`, so they are right only after the server
118
122
  // has read the config it just wrote
119
- await this.st.cols.poll()
123
+ await this.st.cols.poll(true)
120
124
  } catch (e) {
121
125
  runInAction(() => { this.gateNote = `refused: ${(e as Error).message}` })
122
126
  // whatever the file really says now, so the next try is not
@@ -227,7 +231,7 @@ export class ChecksSvc {
227
231
  this.note = `running ${label}…`
228
232
  })
229
233
  try {
230
- const r = await fetch('/api/check/run', { method: 'POST', body: JSON.stringify({ ...(only == null ? {} : { only }), ...(gate ? { gate } : {}) }) })
234
+ const r = await fetch(this.st.url('/api/check/run'), { method: 'POST', body: JSON.stringify({ ...(only == null ? {} : { only }), ...(gate ? { gate } : {}) }) })
231
235
  const rd = r.body!.getReader()
232
236
  const dec = new TextDecoder()
233
237
  let buf = ''
@@ -270,9 +274,9 @@ export class ChecksSvc {
270
274
  this.clearTimer = setTimeout(() => runInAction(() => { this.progress = idleRun() }), 5_000)
271
275
  // the numbers a run just produced live on the server, in the log it
272
276
  // wrote: one poll and the page is reading them rather than a memory
273
- await this.st.cols.poll()
277
+ await this.st.cols.poll(true)
274
278
  void this.st.health.load()
275
- void this.st.timeline.load()
279
+ void this.st.timeline.load(true)
276
280
  // a test page on screen reads the record the run just wrote
277
281
  const sel = this.st.sel
278
282
  if (sel?.t === 'tests') void this.st.tests.load(sel.check)
@@ -5,7 +5,7 @@
5
5
  // the file wins).
6
6
  import { runInAction } from 'mobx'
7
7
  import { observer } from 'mobx-react-lite'
8
- import type { HubSt } from './hub-st.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
9
  import type { Resource } from './hub-types.ts'
10
10
  import { renderMd } from './md.ts'
11
11
 
@@ -24,7 +24,7 @@ const CredHead = ({ cred }: { cred: ReturnType<typeof credOf> }) => (
24
24
  </>
25
25
  )
26
26
 
27
- export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id: string }) => {
27
+ export const ColCard = observer(({ app, col, id }: { app: RepoSt; col: string; id: string }) => {
28
28
  const c = app.cols
29
29
  const i = c.find(col, id)
30
30
  if (i == null) return <div className="ph">item vanished — the truth file moved on</div>
@@ -103,7 +103,7 @@ export const ColCard = observer(({ app, col, id }: { app: HubSt; col: string; id
103
103
 
104
104
  // collection view — one card per visible instance, the stage-side twin of a
105
105
  // sidebar section (every section gets both views)
106
- export const ColGrid = observer(({ app, id }: { app: HubSt; id: string }) => {
106
+ export const ColGrid = observer(({ app, id }: { app: RepoSt; id: string }) => {
107
107
  const c = app.cols
108
108
  const col = c.sidebarCols.find((x) => x.id === id)
109
109
  if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
@@ -155,7 +155,7 @@ const wireClass = (l: string): string => {
155
155
 
156
156
  // the RAW view of a run: every check has its own page, and this is the one
157
157
  // node that keeps the whole text reachable
158
- export const StatusCard = observer(({ app }: { app: HubSt }) => {
158
+ export const StatusCard = observer(({ app }: { app: RepoSt }) => {
159
159
  const h = app.health
160
160
  const body = h.live.length > 0
161
161
  ? h.live.join('\n')
@@ -4,20 +4,40 @@
4
4
  // file through /api/act/*; errors come back as 400 text shown verbatim on
5
5
  // the card.
6
6
  import { makeAutoObservable, runInAction } from 'mobx'
7
- import type { HubSt } from './hub-st.ts'
8
- import type { Col, ColMeta, HubProblem, Resource, Schedule } from './hub-types.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
+ import type { Col, ColMeta, HubProblem, Resource } from './hub-types.ts'
9
+ import type { PlanRow } from '../../plan/planner.ts'
10
+ import { readScreen, SCREEN_WRITE_MS, writeScreen } from './screen-cache.ts'
11
+
12
+ /** what /api/resources.json answers */
13
+ type Answer = { collections: ColMeta[]; resources: Resource[]; planned?: PlanRow[] | null; hub?: HubProblem[] }
9
14
 
10
15
  export class ColsSvc {
11
16
  /** exactly what the server last sent; `resources` is this plus the live
12
17
  * results of a run in flight */
13
18
  fetched: Resource[] = []
14
19
  colMeta: ColMeta[] = []
15
- /** the hub's daily health pass timer, as the last poll saw it */
16
- schedule: Schedule | null = null
20
+ /** this repo's rows of the plan, null when no plan covers it */
21
+ planned: PlanRow[] | null = null
17
22
  /** what the server says is wrong with the hub itself */
18
23
  hubProblems: HubProblem[] = []
19
24
  /** the last poll failed: the page cannot reach its own server */
20
25
  fetchErr: string | null = null
26
+ /** a first answer arrived: until then the tree and the page links show a
27
+ * spinner where they will be, never an empty repo */
28
+ loaded = false
29
+ /** what shows was kept by this browser from an earlier load, and the
30
+ * hub has not answered yet (screen-cache.ts) */
31
+ cached = false
32
+ /** the first media category the kept screen had, until the media
33
+ * heartbeat lists the assets */
34
+ cachedCat: string | null = null
35
+ /** when this repo's screen was last kept, 0 for never */
36
+ private keptAt = 0
37
+ /** the last answer, and the media category last kept with it */
38
+ private last: Answer | null = null
39
+ private keptCat: string | null | undefined = undefined
40
+ private restoring: Promise<void> | null = null
21
41
  actErr = ''
22
42
  remarkDraft = ''
23
43
  proofDraft = ''
@@ -25,30 +45,89 @@ export class ColsSvc {
25
45
  foldOverride = new Map<string, boolean>()
26
46
  private timer: ReturnType<typeof setInterval> | null = null
27
47
 
28
- constructor(private st: HubSt) {
29
- makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false })
48
+ constructor(private st: RepoSt) {
49
+ makeAutoObservable<this, 'st' | 'timer' | 'keptAt' | 'restoring' | 'readKept' | 'last' | 'keptCat'>(this, { st: false, timer: false, keptAt: false, restoring: false, readKept: false, last: false, keptCat: false })
50
+ }
51
+
52
+ /** one check's row of the plan, null when no plan covers it */
53
+ planOf(check: string): PlanRow | null {
54
+ return this.planned?.find((r) => r.check === check) ?? null
55
+ }
56
+
57
+ /** where this repo's screen is kept: its name, or `self` on a hub of its own */
58
+ private get screenKey(): string {
59
+ return this.st.key === '' ? 'self' : this.st.key
60
+ }
61
+
62
+ /** paint the screen this browser kept, unless the hub answered first.
63
+ * One read per repo, whoever asks */
64
+ restore(): Promise<void> {
65
+ this.restoring ??= this.readKept()
66
+ return this.restoring
67
+ }
68
+
69
+ private async readKept(): Promise<void> {
70
+ const s = await readScreen(this.screenKey)
71
+ if (s == null) return
72
+ runInAction(() => {
73
+ if (this.loaded) return
74
+ this.colMeta = s.collections
75
+ this.fetched = s.resources
76
+ this.planned = s.planned
77
+ this.hubProblems = s.hub
78
+ this.cachedCat = s.mediaCat
79
+ this.cached = true
80
+ })
81
+ }
82
+
83
+ /** keep the screen for the next load: the first answer, then at most
84
+ * once a minute. The answer as parsed, never the observable copy, which
85
+ * IndexedDB cannot clone */
86
+ private keep(j: Answer, cat: string | null = this.st.media.assets[0]?.cat ?? this.cachedCat, force = false): void {
87
+ this.last = j
88
+ const now = Date.now()
89
+ if (!force && now - this.keptAt < SCREEN_WRITE_MS) return
90
+ this.keptAt = now
91
+ this.keptCat = cat
92
+ void writeScreen(this.screenKey, { at: now, collections: j.collections, resources: j.resources, planned: j.planned ?? null, hub: j.hub ?? [], mediaCat: cat })
93
+ }
94
+
95
+ /** the media heartbeat listed the assets: a first category the kept
96
+ * screen does not name yet is kept at once, so the header's media link
97
+ * is there on the next load */
98
+ mediaListed(): void {
99
+ const cat = this.st.media.assets[0]?.cat ?? null
100
+ if (this.last != null && cat !== this.keptCat) this.keep(this.last, cat, true)
30
101
  }
31
102
 
32
103
  start(): void {
104
+ if (this.timer != null) return
33
105
  void this.poll()
34
106
  this.timer = setInterval(() => void this.poll(), 10_000)
35
107
  }
36
- dispose(): void {
108
+ stop(): void {
37
109
  if (this.timer != null) clearInterval(this.timer)
110
+ this.timer = null
38
111
  }
39
112
 
40
- async poll(): Promise<void> {
113
+ /** `fresh`: this page just changed the truth (an act, a run that ended),
114
+ * so the answer waits on a walk that includes it rather than on the one
115
+ * the server keeps warm */
116
+ async poll(fresh = false): Promise<void> {
41
117
  try {
42
- const r = await fetch('/api/resources.json')
118
+ const r = await fetch(this.st.url(fresh ? '/api/resources.json?fresh=1' : '/api/resources.json'))
43
119
  if (!r.ok) throw new Error(await r.text())
44
- const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null; hub?: HubProblem[] }
120
+ const j = (await r.json()) as Answer
45
121
  runInAction(() => {
46
122
  this.colMeta = j.collections
47
123
  this.fetched = j.resources
48
- this.schedule = j.schedule ?? null
124
+ this.planned = j.planned ?? null
49
125
  this.hubProblems = j.hub ?? []
50
126
  this.fetchErr = null
127
+ this.loaded = true
128
+ this.cached = false
51
129
  })
130
+ this.keep(j)
52
131
  } catch (e) {
53
132
  // a dead server must SAY so, in the header, never leave a tree
54
133
  // that reads like a healthy repo. The last rows stay, dated by it
@@ -130,9 +209,9 @@ export class ColsSvc {
130
209
  private async act(path: string, body: Record<string, unknown>): Promise<boolean> {
131
210
  this.actErr = ''
132
211
  try {
133
- const r = await fetch(`/api/act/${path}`, { method: 'POST', body: JSON.stringify(body) })
212
+ const r = await fetch(this.st.url(`/api/act/${path}`), { method: 'POST', body: JSON.stringify(body) })
134
213
  if (!r.ok) throw new Error(await r.text())
135
- await this.poll()
214
+ await this.poll(true)
136
215
  return true
137
216
  } catch (e) {
138
217
  runInAction(() => { this.actErr = (e as Error).message })
@@ -4,7 +4,7 @@
4
4
  // it is something the panel SAID: the header never reports a state it guessed
5
5
  // from how the iframe looks.
6
6
  import { makeAutoObservable, runInAction } from 'mobx'
7
- import type { HubSt } from './hub-st.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
8
 
9
9
  export type ComfyBox = { url: string; up: boolean; err: string | null }
10
10
  /** what the panel says, narrowed to the messages this host acts on */
@@ -37,10 +37,16 @@ export class ComfySvc {
37
37
  private mountedFor = ''
38
38
  private frame: HTMLIFrameElement | null = null
39
39
  private handshake: ReturnType<typeof setTimeout> | null = null
40
+ private readonly listener = (e: MessageEvent): void => this.onMessage(e)
40
41
 
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))
42
+ dispose(): void {
43
+ window.removeEventListener('message', this.listener)
44
+ if (this.handshake != null) clearTimeout(this.handshake)
45
+ }
46
+
47
+ constructor(private st: RepoSt) {
48
+ makeAutoObservable<this, 'st' | 'frame' | 'handshake' | 'listener'>(this, { st: false, frame: false, handshake: false, listener: false })
49
+ window.addEventListener('message', this.listener)
44
50
  }
45
51
 
46
52
  setFrame(el: HTMLIFrameElement | null): void {
@@ -78,7 +84,7 @@ export class ComfySvc {
78
84
  }
79
85
 
80
86
  async probeBox(): Promise<void> {
81
- const box = await fetch('/api/comfy/box')
87
+ const box = await fetch(this.st.url('/api/comfy/box'))
82
88
  .then((r) => (r.ok ? (r.json() as Promise<ComfyBox>) : null))
83
89
  .catch(() => null)
84
90
  runInAction(() => {
@@ -107,7 +113,7 @@ export class ComfySvc {
107
113
  })
108
114
  void this.probeBox()
109
115
  try {
110
- const r = await fetch('/api/comfy/panel', { method: 'POST', body: JSON.stringify({ restart }) })
116
+ const r = await fetch(this.st.url('/api/comfy/panel'), { method: 'POST', body: JSON.stringify({ restart }) })
111
117
  if (!r.ok) throw new Error(await r.text())
112
118
  const up = (await r.json()) as { url: string }
113
119
  runInAction(() => {
@@ -4,7 +4,7 @@
4
4
  import { observer } from 'mobx-react-lite'
5
5
  import { useEffect } from 'react'
6
6
  import type { ComfySvc } from './comfy-svc.ts'
7
- import type { HubSt } from './hub-st.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
8
 
9
9
  const PANEL_CHIP: Record<ComfySvc['state'], { label: string; cls: string; title: string }> = {
10
10
  idle: { label: 'idle', cls: '', title: 'nothing started yet' },
@@ -16,7 +16,7 @@ const PANEL_CHIP: Record<ComfySvc['state'], { label: string; cls: string; title:
16
16
 
17
17
  // the same panel over an asset page. A popup, so the asset, its rolls and its
18
18
  // prompt stay on screen behind it while a value is tried
19
- export const ComfyOverlay = observer(({ app }: { app: HubSt }) => {
19
+ export const ComfyOverlay = observer(({ app }: { app: RepoSt }) => {
20
20
  const c = app.comfy
21
21
  if (!c.overlay) return null
22
22
  const chip = PANEL_CHIP[c.state]
@@ -47,7 +47,7 @@ export const ComfyOverlay = observer(({ app }: { app: HubSt }) => {
47
47
  )
48
48
  })
49
49
 
50
- export const ComfyView = observer(({ app, id }: { app: HubSt; id: string }) => {
50
+ export const ComfyView = observer(({ app, id }: { app: RepoSt; id: string }) => {
51
51
  const c = app.comfy
52
52
  const wf = id.replace(/^comfy:/, '')
53
53
  useEffect(() => {
@@ -0,0 +1,49 @@
1
+ // THE way the page shows anything that floats over it: a tooltip, a help
2
+ // bubble, a process log on hover, a hover card. @floating-ui/react places it
3
+ // (flip to stay in the window, shift off its edges, an offset from the
4
+ // trigger), a portal lifts it out of every box that clips, and hover opens
5
+ // it after a short delay, so a pointer passing by never flickers one open.
6
+ // Nothing here is positioned by hand.
7
+ import { autoUpdate, flip, FloatingPortal, offset, safePolygon, shift, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, type Placement } from '@floating-ui/react'
8
+ import { observer, useLocalObservable } from 'mobx-react-lite'
9
+ import type { HTMLAttributes, ReactNode } from 'react'
10
+
11
+ /** how long a pointer rests on a trigger before its float opens */
12
+ export const OPEN_MS = 150
13
+
14
+ /** the placement middleware every float shares; `fallbacks` are the sides
15
+ * tried in order when the chosen one does not fit */
16
+ export const middleware = (gap = 6, fallbacks?: Placement[]) => [offset(gap), flip({ padding: 8, fallbackPlacements: fallbacks }), shift({ padding: 8 })]
17
+
18
+ type TipProps = Omit<HTMLAttributes<HTMLSpanElement>, 'content'> & {
19
+ /** what floats; nothing floats while it is null */
20
+ content: ReactNode
21
+ /** the class of the floating box */
22
+ tipClass?: string
23
+ placement?: Placement
24
+ /** the sides tried in order when `placement` does not fit */
25
+ fallbacks?: Placement[]
26
+ /** the pointer can move into it (a log to scroll): it stays open on the way */
27
+ interactive?: boolean
28
+ }
29
+
30
+ /** a trigger and what floats over it on hover or focus */
31
+ export const Tip = observer(({ content, children, tipClass = 'ftip', placement = 'top', fallbacks, interactive = false, ...rest }: TipProps) => {
32
+ const st = useLocalObservable(() => ({ open: false, set(v: boolean) { this.open = v } }))
33
+ const f = useFloating({ open: st.open, onOpenChange: (v) => st.set(v), placement, strategy: 'fixed', middleware: middleware(6, fallbacks), whileElementsMounted: autoUpdate })
34
+ const hover = useHover(f.context, { delay: { open: OPEN_MS, close: interactive ? 100 : 0 }, handleClose: interactive ? safePolygon() : null })
35
+ const focus = useFocus(f.context)
36
+ const dismiss = useDismiss(f.context)
37
+ const role = useRole(f.context, { role: 'tooltip' })
38
+ const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role])
39
+ return (
40
+ <>
41
+ <span ref={f.refs.setReference} {...getReferenceProps(rest)}>{children}</span>
42
+ {st.open && content != null ? (
43
+ <FloatingPortal>
44
+ <div ref={f.refs.setFloating} className={tipClass} style={f.floatingStyles} {...getFloatingProps()}>{content}</div>
45
+ </FloatingPortal>
46
+ ) : null}
47
+ </>
48
+ )
49
+ })
@@ -7,7 +7,7 @@
7
7
  import { runInAction } from 'mobx'
8
8
  import { observer } from 'mobx-react-lite'
9
9
  import { useRef } from 'react'
10
- import type { HubSt } from './hub-st.ts'
10
+ import type { RepoSt } from './repo-st.ts'
11
11
  import type { Leg } from './hub-types.ts'
12
12
 
13
13
  export type Box = { scale: number; x: number; y: number; w: number; h: number }
@@ -27,7 +27,7 @@ const LEG_TAGS = ['L·front', 'L·back', 'R·back', 'R·front']
27
27
 
28
28
  type DragStart = { px: number; py: number; x: number; y: number; w: number; legs: Leg[]; cy: number; mode: 'move' | 'resize' }
29
29
 
30
- const LegBox = observer(({ app, box, i, leg }: { app: HubSt; box: Box; i: number; leg: Leg }) => {
30
+ const LegBox = observer(({ app, box, i, leg }: { app: RepoSt; box: Box; i: number; leg: Leg }) => {
31
31
  const m = app.media
32
32
  const start = useRef<{ px: number; py: number; legs: Leg[]; mode: 'move' | 'resize' } | null>(null)
33
33
  const px = (v: number): number => v * box.scale
@@ -80,7 +80,7 @@ const LegBox = observer(({ app, box, i, leg }: { app: HubSt; box: Box; i: number
80
80
  )
81
81
  })
82
82
 
83
- export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | null }) => {
83
+ export const GeometryLayer = observer(({ app, box }: { app: RepoSt; box: Box | null }) => {
84
84
  const m = app.media
85
85
  const start = useRef<DragStart | null>(null)
86
86
  const rect = m.rect
@@ -137,7 +137,7 @@ export const GeometryLayer = observer(({ app, box }: { app: HubSt; box: Box | nu
137
137
  )
138
138
  })
139
139
 
140
- export const Rectbar = observer(({ app }: { app: HubSt }) => {
140
+ export const Rectbar = observer(({ app }: { app: RepoSt }) => {
141
141
  const m = app.media
142
142
  const rect = m.rect
143
143
  const showSize = m.tools.has('size')
@@ -1,5 +1,5 @@
1
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
2
+ // purpose: the page's selection rules are worth pinning, and RepoSt cannot be
3
3
  // instantiated without a browser.
4
4
  //
5
5
  // The media forms are older than the rest and stay byte for byte what they