rvlib-shipkit 1.1.1 → 1.3.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 (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -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
@@ -20,9 +20,11 @@ export type HashSel =
20
20
  /** where the test checks' time goes: every one of them (check null), or
21
21
  * one test check (`test`, `test:<unit>`) */
22
22
  | { t: 'tests'; check: string | null }
23
+ /** what administers the repo: archive, reload its hub, keep it active */
24
+ | { t: 'admin' }
23
25
 
24
26
  export type ParsedHash = {
25
- kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'none'
27
+ kind: 'asset' | 'cat' | 'col' | 'item' | 'status' | 'tests' | 'admin' | 'none'
26
28
  /** the collection, for an item. null for everything else */
27
29
  col: string | null
28
30
  id: string
@@ -37,6 +39,7 @@ const CAT = 'cat-'
37
39
  /** prefixed, so no asset id can shadow it */
38
40
  const STATUS = 'page:status'
39
41
  const TESTS = 'page:tests'
42
+ const ADMIN = 'page:admin'
40
43
 
41
44
  /** what this selection puts in the url. null only for no selection at all,
42
45
  * and then the hash is left alone rather than wiped */
@@ -49,6 +52,7 @@ export function hashFor(sel: HashSel | null): string | null {
49
52
  case 'item': return `${ITEM}${sel.col}:${sel.id}`
50
53
  case 'status': return STATUS
51
54
  case 'tests': return sel.check == null ? TESTS : `${TESTS}:${sel.check}`
55
+ case 'admin': return ADMIN
52
56
  }
53
57
  }
54
58
 
@@ -57,6 +61,7 @@ export function parseHash(raw: string): ParsedHash {
57
61
  if (raw === '') return none
58
62
  if (raw === STATUS) return { kind: 'status', col: null, id: '', tools: null }
59
63
  if (raw === TESTS) return { kind: 'tests', col: null, id: '', tools: null }
64
+ if (raw === ADMIN) return { kind: 'admin', col: null, id: '', tools: null }
60
65
  // a check id carries colons of its own (`test:rvlib-shipkit`)
61
66
  if (raw.startsWith(`${TESTS}:`)) {
62
67
  const check = raw.slice(TESTS.length + 1)
@@ -98,6 +103,7 @@ export function selOf(p: ParsedHash): HashSel | null {
98
103
  case 'item': return { t: 'item', col: p.col!, id: p.id }
99
104
  case 'status': return { t: 'status' }
100
105
  case 'tests': return { t: 'tests', check: p.id === '' ? null : p.id }
106
+ case 'admin': return { t: 'admin' }
101
107
  }
102
108
  }
103
109
 
@@ -1,54 +1,65 @@
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.
1
+ // A repo's pieces of the top bar, beside its icon and its name: what runs now
2
+ // and the two runs that are not one check (RepoRun), then on the right its
3
+ // processes, the hub's own trouble and modernize while it is behind
4
+ // (RepoTools), and at the far right end the editor icons (RepoOpeners). On
5
+ // the home they ride in the home strip (home-strip.tsx); a hub of its own
6
+ // draws them in its own header, with its name. The repo's sections are the
7
+ // left pane's (repo-sections.tsx). What runs, drawn, is the dock at the
8
+ // bottom of the page (dock-view.tsx).
7
9
  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'
10
+ import { Icon, ICON } from './icons.tsx'
11
+ import type { RepoSt } from './repo-st.ts'
13
12
 
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
13
+ /** the repo's hub has not answered on the home: its reason and its fix show
14
+ * instead of the page. A screen this browser kept shows meanwhile, until
15
+ * the machine says the hub is not up */
16
+ export const notUp = (app: RepoSt): boolean => {
17
+ const m = app.home.machine
18
+ if (m == null || app.started) return false
19
+ if (!app.cols.cached) return true
20
+ return m.fresh && m.worker(app.key)?.status !== 'up'
32
21
  }
33
22
 
34
23
  /** 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 }) => {
24
+ * most, else the newest run the record has in flight. Cut to its width, the
25
+ * whole line on hover */
26
+ const RunState = observer(({ app }: { app: RepoSt }) => {
37
27
  const own = app.checks.strip
38
28
  const run = app.live.view?.runs[0]
39
29
  const text = own !== '' ? own : run != null ? `${run.label} · ${run.done} of ${run.total}${run.reds > 0 ? ` · ${run.reds} red so far` : ''}` : 'idle'
40
30
  const going = app.checks.busy || run != null
41
31
  return (
42
32
  <span className={`hstate${going ? ' on' : ''}`} title={text}>
43
- {going ? <span className="spin" /> : null}
33
+ {/* the spinner's slot is always there, so the text never moves */}
34
+ <span className={`spin${going ? '' : ' idle'}`} />
44
35
  <span className="htext">{text}</span>
45
36
  </span>
46
37
  )
47
38
  })
48
39
 
40
+ export const HEALTH_TITLE = 'run the health pass: every check but the build steps, as shipkit check runs them (creds, probes, rules, contracts)'
41
+ export const GATE_TITLE = 'run the gate: every check shipkit ci runs, the build steps included (lint, typecheck, tests)'
42
+
43
+ /** the run state, then the two runs, compact: below a width their labels
44
+ * give way to their icons, so the bar never wraps */
45
+ export const RepoRun = observer(({ app }: { app: RepoSt }) => {
46
+ const u = app.checks
47
+ return (
48
+ <span className="hrun">
49
+ <RunState app={app} />
50
+ <button className="primary hbtn" disabled={u.busy} title={HEALTH_TITLE} aria-label="health pass" onClick={() => void u.run()}>
51
+ <Icon d={ICON.health} /><span className="lbl">health pass</span>
52
+ </button>
53
+ <button className="hbtn" disabled={u.busy} title={GATE_TITLE} aria-label="gate" onClick={() => void u.run(undefined, { gate: true, label: 'the gate' })}>
54
+ <Icon d={ICON.gate} /><span className="lbl">gate</span>
55
+ </button>
56
+ </span>
57
+ )
58
+ })
59
+
49
60
  /** what is wrong with the hub itself, one chip per problem. Nothing when all
50
- * is well: the header states trouble, never health */
51
- const HubStatus = observer(({ app }: { app: HubSt }) => (
61
+ * is well: the bar states trouble, never health */
62
+ const HubStatus = observer(({ app }: { app: RepoSt }) => (
52
63
  <>
53
64
  {app.cols.hubStatus.map((p) => (
54
65
  <span key={p.id} className={`hstatus ${p.severity === 'red' ? 'red' : 'warn'}`} title={p.text}>{p.text}</span>
@@ -56,73 +67,69 @@ const HubStatus = observer(({ app }: { app: HubSt }) => (
56
67
  </>
57
68
  ))
58
69
 
59
- export const HeaderBar = observer(({ app }: { app: HubSt }) => {
60
- const u = app.checks
70
+ /** modernize, while the repo runs an older shipkit than the home */
71
+ const Modernize = observer(({ app }: { app: RepoSt }) => {
72
+ const m = app.home.machine
73
+ const behind = m?.data?.behind[app.key]
74
+ if (m == null || behind == null) return null
75
+ return <button className="primary hbtn" onClick={() => void m.modernize(app.key)} title={`this repo runs shipkit ${behind}, the home runs ${m.data?.version}`}>modernize {behind} → {m.data?.version}</button>
76
+ })
77
+
78
+ /** the repo in its editor and in its git client, one icon each, at the far
79
+ * right end of the bar. Only on the home: the machine opens them */
80
+ export const RepoOpeners = observer(({ app }: { app: RepoSt }) => {
81
+ const m = app.home.machine
82
+ if (m == null) return null
61
83
  return (
62
- <header className="hdr">
63
- <span className="hname">{app.name !== '' ? app.name : 'hub'}</span>
64
- <nav className="hnav">
65
- {corePages(app).map((p) => (
66
- <button key={p.id} className={p.on ? 'on' : ''} onClick={p.go}>{p.label}</button>
67
- ))}
68
- </nav>
69
- <span className="sp" />
70
- <HubStatus app={app} />
71
- <RunState app={app} />
72
- <button className="primary" disabled={u.busy} onClick={() => void u.run()}>run the health pass</button>
73
- <button disabled={u.busy} onClick={() => void u.run(undefined, { gate: true, label: 'the gate' })}>run the gate</button>
74
- </header>
84
+ <>
85
+ <button className="hicon" title="open in VS Code" aria-label={`open ${app.key} in VS Code`} onClick={() => m.openInCode(app.key)}><Icon d={ICON.vscode} /></button>
86
+ <button className="hicon" title="open in Fork" aria-label={`open ${app.key} in Fork`} onClick={() => void app.home.activity.openFork(app.key)}><Icon d={ICON.fork} /></button>
87
+ </>
75
88
  )
76
89
  })
77
90
 
78
- /** the key that folds and unfolds the strip, printed on its chevron */
79
- export const LIVE_KEY = 'l'
80
-
81
- /** the strip's own row, in px: the one height the folded strip has */
82
- export const LIVE_ROW_H = 30
83
-
84
- /** what the strip is, in the (?) beside its chevron */
85
- 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"
91
+ /** the right side of the bar for a repo, before the machine's trouble and
92
+ * the openers, which close it */
93
+ export const RepoTools = observer(({ app }: { app: RepoSt }) => (
94
+ <>
95
+ <ProcessChips app={app} />
96
+ <HubStatus app={app} />
97
+ <Modernize app={app} />
98
+ </>
99
+ ))
86
100
 
87
- const Chevron = ({ open }: { open: boolean }) => (
88
- <svg className={open ? 'cvt open' : 'cvt'} viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
89
- <path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" />
90
- </svg>
91
- )
101
+ /** the bar of a hub of its own: its name, then the same pieces the home
102
+ * strip carries for a repo */
103
+ export const HeaderBar = observer(({ app }: { app: RepoSt }) => (
104
+ <header className="hdr">
105
+ <span className="hname" style={app.name === '' ? undefined : { color: app.home.projects.identityOf(app.name).colorDark }}>{app.name !== '' ? app.name : 'hub'}</span>
106
+ <RepoRun app={app} />
107
+ <span className="sp" />
108
+ <RepoTools app={app} />
109
+ <RepoOpeners app={app} />
110
+ </header>
111
+ ))
92
112
 
93
- export const LiveStrip = observer(({ app }: { app: HubSt }) => {
94
- const l = app.live
95
- const v = l.view
96
- const run = v?.runs[0]
97
- const hidden = l.chart.layout?.hidden ?? 0
98
- const waits = v == null ? '' : [
99
- v.queued > 0 ? `${v.queued} queued for a free slot` : '',
100
- ...v.blocked.map((w) => `${w.id} waits on ${w.deps.join(', ')}`),
101
- ].filter((x) => x !== '').join('\n')
102
- const who = run == null ? 'nothing running' : `${run.label}`
103
- const chart = (
104
- <TimelineChart view={l.chart} compact={!l.open} repos={v?.repos ?? []} follow zoomable={l.open}
105
- onBar={(b) => app.cols.select('checks', `check:${b.id}`)}
106
- onRepo={(r) => void l.openRepo(r.repo)} />
107
- )
113
+ /** one switch per process the repo declares (serve.processes): its dot, its
114
+ * id, a click starts or stops it, and a link once it serves */
115
+ export const ProcessChips = observer(({ app }: { app: RepoSt }) => {
116
+ const s = app.processes
117
+ if (s.list.length === 0) return null
108
118
  return (
109
- <div className={`live${l.open ? ' open' : ''}${run == null ? ' idle' : ''}`} style={l.open ? { height: LIVE_ROW_H + l.held.h } : undefined}>
110
- <div className="lrow">
111
- <button className="lfold" onClick={() => l.toggle()} title={`${l.open ? 'fold' : 'unfold'} the live timeline (${LIVE_KEY})`}>
112
- <Chevron open={l.open} /><span className="sk">{LIVE_KEY}</span>
113
- </button>
114
- <Help text={LIVE_HELP} />
115
- <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` : ''}`}>
116
- {who}{run == null ? null : <i>{byWords(run.by)}</i>}{v != null && v.runs.length > 1 ? <b>+{v.runs.length - 1}</b> : null}
117
- </span>
118
- {l.open ? <span className="sp" /> : <div className="lchart">{chart}</div>}
119
- {l.note !== '' ? <span className="lnote">{l.note}</span> : null}
120
- {hidden > 0 ? <span className="lmore" title="check runs that did not fit in the folded strip: unfold it to see them">+{hidden}</span> : null}
121
- <span className="lbusy" title={waits === '' ? 'checks in flight in this repo, against the runner cap' : waits}>
122
- in flight <span className="dnum">{v?.busy ?? 0}</span> / cap <span className="dnum">{v?.cap ?? 0}</span>
123
- </span>
124
- </div>
125
- {l.open ? <div className="lopen">{chart}</div> : null}
126
- </div>
119
+ <span className="hprocs">
120
+ {s.list.map((p) => {
121
+ const on = p.status === 'up' || p.status === 'starting'
122
+ const title = [p.cmd, p.note, p.external ? 'started outside the hub' : null, p.tail.at(-1)].filter((x) => x != null && x !== '').join('\n')
123
+ return (
124
+ <span key={p.id} className="hproc">
125
+ <button className={`ghost ${p.status}`} title={title} disabled={p.status === 'stopping' || (on && p.external)}
126
+ onClick={() => void s.act(p.id, on ? 'stop' : 'start')}>
127
+ <i className={`pdot ${p.status}`} />{p.id}
128
+ </button>
129
+ {p.url != null && p.status === 'up' ? <a href={p.url} target="_blank" rel="noreferrer" title={p.url}>↗</a> : null}
130
+ </span>
131
+ )
132
+ })}
133
+ </span>
127
134
  )
128
135
  })
@@ -5,14 +5,14 @@
5
5
  // every check has its own page (checks-svc.ts owns the run itself). This card
6
6
  // exists so the whole text of a run stays reachable.
7
7
  import { makeAutoObservable, runInAction } from 'mobx'
8
- import type { HubSt } from './hub-st.ts'
8
+ import type { RepoSt } from './repo-st.ts'
9
9
 
10
10
  export class HealthSvc {
11
11
  statusMd: string | null = null
12
12
  /** the lines of the run in flight, newest last */
13
13
  live: string[] = []
14
14
 
15
- constructor(private st: HubSt) {
15
+ constructor(private st: RepoSt) {
16
16
  makeAutoObservable<this, 'st'>(this, { st: false })
17
17
  }
18
18
 
@@ -25,7 +25,7 @@ export class HealthSvc {
25
25
 
26
26
  async load(): Promise<void> {
27
27
  try {
28
- const s = (await (await fetch('/api/status.json')).json()) as { statusMd: string | null }
28
+ const s = (await (await fetch(this.st.url('/api/status.json'))).json()) as { statusMd: string | null }
29
29
  runInAction(() => { this.statusMd = s.statusMd })
30
30
  } catch (e) {
31
31
  runInAction(() => { this.statusMd = `FAIL status fetch broke: ${(e as Error).message}` })
@@ -1,10 +1,12 @@
1
1
  // THE help affordance, once, for every surface of the hub: a small `(?)` that
2
- // shows one sentence the moment it is hovered or focused. CSS does the
3
- // showing (.hlp in hub.css), so there is no library, no delay and no state.
2
+ // shows one sentence when it is hovered or focused, placed by floating-ui
3
+ // (floating.tsx) above it, or wherever it fits.
4
4
  //
5
5
  // A term lives in TERMS rather than in the component that shows it, so the
6
6
  // same word is explained the same way wherever it appears.
7
7
 
8
+ import { Tip } from './floating.tsx'
9
+
8
10
  export const TERMS: Record<string, string> = {
9
11
  always: 'it runs on every gate and every health pass',
10
12
  release: 'it runs on the release path only: the full gate and the health pass run it, the quick gate does not',
@@ -23,8 +25,9 @@ export const TERMS: Record<string, string> = {
23
25
 
24
26
  /** one sentence, either given here or named by its term. `text` takes a node
25
27
  * so a legend can ride in the same bubble as a sentence */
26
- export const Help = ({ text, term }: { text?: React.ReactNode; term?: string }): React.ReactElement => (
27
- <span className="hlp" tabIndex={0} role="note">
28
- ?<span className="hbub">{text ?? (term != null ? TERMS[term] ?? term : '')}</span>
29
- </span>
30
- )
28
+ export const Help = ({ text, term }: { text?: React.ReactNode; term?: string }): React.ReactElement => {
29
+ const c = text ?? (term != null ? TERMS[term] ?? term : '')
30
+ // the sentence also rides inside the glyph, out of sight, so a screen
31
+ // reader and a search of the page find it without a hover
32
+ return <Tip className="hlp" tabIndex={0} tipClass="hbub" content={c}>?<span className="vh">{c}</span></Tip>
33
+ }
@@ -0,0 +1,278 @@
1
+ // HomeSt: THE root of the page (build/app-state-tree), exposed as window.app.
2
+ // One tree for every page the home serves, '/', '/planned', '/activity' and
3
+ // '/r/<repo>/': the machine, the project list, the plan, the activity, and one RepoSt per repo visited, built on
4
+ // the first visit and kept for the life of the page (same name, same
5
+ // instance), so a repo you come back to shows what it showed. Only the shown
6
+ // repo polls. Every other repo whose hub is up is read ahead in the
7
+ // background, one at a time, so its first visit has its data at once. A hub serving on its own is the same tree with no machine and
8
+ // one repo at base ''.
9
+ //
10
+ // the one constraint a reader breaks first: the url and the tree move
11
+ // together, and only `show` moves the page. A list click, a link, back,
12
+ // forward and a hand edited address all land there.
13
+ import { makeAutoObservable, observableShallow, reaction, type IReactionDisposer } from 'mobx'
14
+ import { currentHash, hashFor } from './hash-route.ts'
15
+ import { MachineSt, type MachineOpts } from './machine-st.ts'
16
+ import { LayoutSt } from './layout-st.ts'
17
+ import { DockSt } from './dock-st.ts'
18
+ import { PaletteSt } from './palette-st.ts'
19
+ import { PlannedSt } from './planned-st.ts'
20
+ import { ActivitySt } from './activity-st.ts'
21
+ import { ProjectsSvc } from './projects-svc.ts'
22
+ import { parseFilter, RepoSt } from './repo-st.ts'
23
+
24
+ /** the longest one repo's read ahead holds the next one back */
25
+ export const PREFETCH_WAIT_MS = 10_000
26
+
27
+ /** what the page shows: the machine overview, the plan, the activity, or one repo */
28
+ export type Page = 'overview' | 'planned' | 'activity' | 'archived' | 'repo'
29
+
30
+ /** the address of each page of the home that is no repo's */
31
+ export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/activity', archived: '/archived' } as const
32
+
33
+ /** where a repo's hub answers on the home */
34
+ export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
35
+ /** the page of a repo on the home */
36
+ export const repoPage = (name: string): string => `${repoBase(name)}/`
37
+
38
+ /** where a check's own page answers on the home */
39
+ export const checkAddress = (repo: string, check: string): string =>
40
+ `${repoPage(repo)}#${hashFor({ t: 'item', col: 'checks', id: `check:${check}` })}`
41
+
42
+ /** the page an address on the home names */
43
+ export function routeOf(pathname: string): { page: Page; repo: string | null } {
44
+ const m = /^\/r\/([^/]+)/.exec(pathname)
45
+ if (m != null) return { page: 'repo', repo: decodeURIComponent(m[1]!) }
46
+ if (/^\/planned\/?$/.test(pathname)) return { page: 'planned', repo: null }
47
+ if (/^\/activity\/?$/.test(pathname)) return { page: 'activity', repo: null }
48
+ if (/^\/archived\/?$/.test(pathname)) return { page: 'archived', repo: null }
49
+ return { page: 'overview', repo: null }
50
+ }
51
+
52
+ /** a plain left click: anything else keeps the browser's own behaviour
53
+ * (a new tab, a new window, a download) */
54
+ export const plainClick = (e: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean =>
55
+ e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
56
+
57
+ export type HomeOpts = MachineOpts & {
58
+ /** true on the home's web; false on a hub serving on its own */
59
+ home: boolean
60
+ }
61
+
62
+ export class HomeSt {
63
+ /** the machine, on the home; null on a hub of its own */
64
+ machine: MachineSt | null
65
+ /** the list of every repo on this disk */
66
+ projects: ProjectsSvc
67
+ /** the plan, polled while it is the page */
68
+ planned: PlannedSt
69
+ /** what every repo committed and holds, polled while it is the page */
70
+ activity: ActivitySt
71
+ /** the one picker, cmd+k */
72
+ palette: PaletteSt
73
+ /** the widths of the project list and a repo's sidebar */
74
+ layout: LayoutSt
75
+ /** the run chart docked under the whole page */
76
+ dock: DockSt
77
+ page: Page
78
+ /** the key of the repo picked last: its name on the home, '' for the one
79
+ * repo of a hub of its own. A global page keeps it (only `shown` says
80
+ * whether it is on screen); null until a repo is picked */
81
+ selected: string | null = null
82
+ /** every repo visited, by key, kept for the life of the page */
83
+ repos = new Map<string, RepoSt>()
84
+ private disposers: IReactionDisposer[] = []
85
+ /** a read ahead is going: one repo at a time, never two loops */
86
+ private prefetching = false
87
+ private readonly onUrl = (): void => this.fromUrl()
88
+
89
+ constructor(o: HomeOpts) {
90
+ this.projects = new ProjectsSvc()
91
+ this.planned = new PlannedSt(this)
92
+ this.activity = new ActivitySt(this)
93
+ this.palette = new PaletteSt(this)
94
+ this.layout = new LayoutSt()
95
+ this.dock = new DockSt(this)
96
+ this.machine = o.home ? new MachineSt(this, o) : null
97
+ this.page = o.home ? 'overview' : 'repo'
98
+ makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, activity: false, palette: false, layout: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
99
+ const m = this.machine
100
+ if (m != null) {
101
+ this.projects.onSelect = (name) => this.select(name)
102
+ this.projects.onPlace = (p) => this.navigate(PLACE_PATH[p])
103
+ this.projects.onPicker = () => this.palette.toggle()
104
+ this.projects.pickerOpen = () => this.palette.open
105
+ this.projects.onArchived = () => this.navigate(PLACE_PATH.archived)
106
+ this.projects.dotOf = (name) => m.dotOf(name)
107
+ this.projects.warnOf = (name) => m.warnOf(name)
108
+ } else {
109
+ this.projects.onSelect = (name) => void this.openInHome(name)
110
+ const own = this.repo('')
111
+ own.markSeen()
112
+ this.selected = own.key
113
+ }
114
+ }
115
+
116
+ /** the repo on screen, or null */
117
+ get shown(): RepoSt | null {
118
+ return this.page !== 'repo' || this.selected == null ? null : this.repos.get(this.selected) ?? null
119
+ }
120
+
121
+ /** every repo shown once, in visit order: each keeps its own mounted
122
+ * region. A repo only read ahead has no region yet */
123
+ get visited(): RepoSt[] {
124
+ return [...this.repos.values()].filter((r) => r.seen)
125
+ }
126
+
127
+ get title(): string {
128
+ if (this.page === 'overview') return 'home'
129
+ if (this.page === 'planned') return 'planned'
130
+ if (this.page === 'activity') return 'activity'
131
+ if (this.page === 'archived') return 'archived'
132
+ const r = this.shown
133
+ return r == null || r.name === '' ? 'hub' : `${r.name} · hub`
134
+ }
135
+
136
+ /** the repo of this key, built on its first visit and never again */
137
+ repo(key: string): RepoSt {
138
+ let r = this.repos.get(key)
139
+ if (r == null) {
140
+ r = new RepoSt(this, key, this.machine == null ? '' : repoBase(key))
141
+ this.repos.set(key, r)
142
+ // what this browser kept of it paints before its hub answers
143
+ void r.cols.restore()
144
+ }
145
+ return r
146
+ }
147
+
148
+ boot(): void {
149
+ this.machine?.boot()
150
+ this.dock.boot()
151
+ this.disposers.push(reaction(() => this.title, (t) => { document.title = t }, { fireImmediately: true }))
152
+ // back, forward, a pasted link and a hand edited hash all arrive
153
+ // here; fromUrl is idempotent, so the two events firing together
154
+ // for one move cost nothing
155
+ window.addEventListener('popstate', this.onUrl)
156
+ window.addEventListener('hashchange', this.onUrl)
157
+ this.fromUrl()
158
+ }
159
+
160
+ dispose(): void {
161
+ for (const d of this.disposers) d()
162
+ this.disposers = []
163
+ window.removeEventListener('popstate', this.onUrl)
164
+ window.removeEventListener('hashchange', this.onUrl)
165
+ this.machine?.stop()
166
+ this.planned.stop()
167
+ this.activity.stop()
168
+ this.dock.dispose()
169
+ for (const r of this.repos.values()) r.dispose()
170
+ }
171
+
172
+ /** a list click, a repo row: that repo, in place, as a history entry */
173
+ select(name: string): void {
174
+ this.show('repo', name, 'push')
175
+ }
176
+
177
+ /** a check: its own page in its repo, in place. The selection is written
178
+ * before the repo is shown, so the one history entry already names it */
179
+ openCheck(repo: string, check: string): void {
180
+ this.repo(repo).cols.select('checks', `check:${check}`)
181
+ this.select(repo)
182
+ }
183
+
184
+ /** a link to a page of the home: the same page, in place */
185
+ navigate(path: string): void {
186
+ const u = new URL(path, 'http://home.invalid')
187
+ const r = routeOf(u.pathname)
188
+ if (r.page === 'planned') this.planned.query = u.searchParams.get('q') ?? ''
189
+ this.show(r.page, r.repo, 'push')
190
+ }
191
+
192
+ /** the url decides the page: the first load, back, forward and a hand
193
+ * edited address all land here */
194
+ fromUrl(): void {
195
+ if (this.machine == null) {
196
+ this.shown?.applyHash()
197
+ return
198
+ }
199
+ const r = routeOf(location.pathname)
200
+ // an address from before repo pages had their own path (`/#<repo>`)
201
+ const legacy = r.page === 'overview' ? currentHash() : ''
202
+ if (legacy !== '') return this.show('repo', legacy, 'replace')
203
+ if (r.page === 'planned') this.planned.query = new URLSearchParams(location.search).get('q') ?? ''
204
+ if (r.page === 'repo') this.repo(r.repo!).filter = parseFilter(location.search)
205
+ this.show(r.page, r.repo, 'none')
206
+ this.shown?.applyHash()
207
+ }
208
+
209
+ /** the one place the page changes. A repo left behind pauses and keeps
210
+ * everything; the one shown resumes once its hub answers */
211
+ private show(page: Page, repo: string | null, how: 'push' | 'replace' | 'none'): void {
212
+ const prev = this.shown
213
+ const next = page === 'repo' && repo != null ? this.repo(repo) : null
214
+ // the page already shown is never a second history entry
215
+ if (how === 'push' && page === this.page && next === prev && page !== 'planned') how = 'none'
216
+ if (prev != null && prev !== next) prev.pause()
217
+ next?.markSeen()
218
+ this.page = page
219
+ // a global page is no repo's: the repo picked last stays selected
220
+ if (next != null) this.selected = next.key
221
+ this.projects.selected = this.selected
222
+ this.projects.away = next == null
223
+ this.projects.place = page === 'repo' || page === 'archived' ? null : page
224
+ if (how !== 'none') {
225
+ const to = next != null ? `${repoPage(next.key)}${next.search}${next.hash}` : page === 'planned' ? this.planned.address : page === 'activity' || page === 'archived' ? PLACE_PATH[page] : '/'
226
+ if (how === 'push') history.pushState(null, '', to)
227
+ else history.replaceState(null, '', to)
228
+ }
229
+ if (page === 'planned') this.planned.start()
230
+ else this.planned.stop()
231
+ if (page === 'activity') this.activity.start()
232
+ else this.activity.stop()
233
+ if (next != null && next !== prev && this.machine?.data != null) void this.machine.hub(next.key, 'ensure')
234
+ this.wake()
235
+ }
236
+
237
+ /** the shown repo polls once its hub answers, or at once on a hub of its
238
+ * own. One that started keeps going when its hub goes away, and says so */
239
+ wake(): void {
240
+ const r = this.shown
241
+ if (r == null) return
242
+ if (this.machine == null || r.started || (this.machine.fresh && this.machine.worker(r.key)?.status === 'up')) r.resume()
243
+ }
244
+
245
+ /** read ahead the first screen of every repo whose hub is up, in list
246
+ * order, one at a time, once the repo shown has its own. A repo read
247
+ * once is not read again: it polls when it shows */
248
+ async prefetch(): Promise<void> {
249
+ const m = this.machine
250
+ if (m?.data == null || !m.fresh || this.prefetching) return
251
+ const shown = this.shown
252
+ if (shown != null && !shown.cols.loaded) return
253
+ this.prefetching = true
254
+ try {
255
+ for (const p of this.projects.list) {
256
+ // a repo shown meanwhile has its own first screen to read:
257
+ // it goes first, and the next machine poll resumes this loop
258
+ const now = this.shown
259
+ if (now != null && !now.cols.loaded) return
260
+ if (m.worker(p.name)?.status !== 'up') continue
261
+ const r = this.repo(p.name)
262
+ if (r.started) continue
263
+ // a hub that hangs holds the loop back this long, never forever
264
+ await Promise.race([r.prefetch(), new Promise((ok) => setTimeout(ok, PREFETCH_WAIT_MS))])
265
+ }
266
+ } finally {
267
+ this.prefetching = false
268
+ }
269
+ }
270
+
271
+ /** a list click on a hub serving on its own: that repo's page on the
272
+ * home when one serves, else the folder in VS Code */
273
+ async openInHome(name: string): Promise<void> {
274
+ const home = await fetch('/api/home-url').then((r) => r.json() as Promise<{ port: number; up: boolean }>).catch(() => null)
275
+ if (home?.up !== true) return this.projects.open(name)
276
+ window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}`
277
+ }
278
+ }