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
@@ -2,7 +2,7 @@
2
2
  // time, embedded from its own /graph/<id> route. A tab is an item of the
3
3
  // wikis collection, so the open wiki is in the url like any other page.
4
4
  import { observer } from 'mobx-react-lite'
5
- import type { HubSt } from './hub-st.ts'
5
+ import type { RepoSt } from './repo-st.ts'
6
6
  import type { Resource } from './hub-types.ts'
7
7
 
8
8
  /** the collection id, and the address segment of every wiki */
@@ -11,10 +11,10 @@ export const WIKIS = 'wikis'
11
11
  /** `state` colours the tab label the way the tree colours the wiki's row */
12
12
  export type WikiTab = { id: string; label: string; state: Resource['state']; on: boolean; go: () => void }
13
13
 
14
- const wikisOf = (app: HubSt): Resource[] => app.cols.resources.filter((r) => r.collection === WIKIS)
14
+ const wikisOf = (app: RepoSt): Resource[] => app.cols.resources.filter((r) => r.collection === WIKIS)
15
15
 
16
16
  /** the tabs over the page, in the order the server mounted the wikis */
17
- export function wikiTabs(app: HubSt): WikiTab[] {
17
+ export function wikiTabs(app: RepoSt): WikiTab[] {
18
18
  const sel = app.sel
19
19
  return wikisOf(app).map((r) => ({
20
20
  id: r.id,
@@ -27,13 +27,13 @@ export function wikiTabs(app: HubSt): WikiTab[] {
27
27
 
28
28
  /** the header link: the first wiki, or the bare section while the first
29
29
  * poll has not listed any */
30
- export function openWikis(app: HubSt): void {
30
+ export function openWikis(app: RepoSt): void {
31
31
  const first = wikisOf(app)[0]
32
32
  if (first != null) app.cols.select(WIKIS, first.id)
33
33
  else app.showCol(WIKIS)
34
34
  }
35
35
 
36
- export const WikisView = observer(({ app, id }: { app: HubSt; id: string }) => {
36
+ export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) => {
37
37
  const i = app.cols.find(WIKIS, id)
38
38
  const tabs = (
39
39
  <span className="seg">
@@ -47,7 +47,11 @@ export const WikisView = observer(({ app, id }: { app: HubSt; id: string }) => {
47
47
  {tabs}
48
48
  <span className={`gvtext tl ${i.state}`}>{i.text}</span>
49
49
  </div>
50
- {i.href == null ? <div className="ph">this wiki carries no page to embed</div> : <iframe src={i.href} title={i.title} />}
50
+ {i.href == null ? <div className="ph">this wiki carries no page to embed</div> : (
51
+ // the spinner sits under the frame: the graph page paints its
52
+ // own background over it the moment it has something to show
53
+ <div className="gvframe"><span className="gvwait"><span className="spin" />reading the wiki…</span><iframe src={app.url(i.href)} title={i.title} /></div>
54
+ )}
51
55
  </div>
52
56
  )
53
57
  })
@@ -0,0 +1,550 @@
1
+ // one repo's hub, alive: its deps resolved from repo.config.ts, its request
2
+ // handler, the save watch over its tracked files, its declared processes,
3
+ // and its RED pings. No server and nothing process wide: `shipkit serve` puts
4
+ // one behind a Bun.serve of its own (run-serve.ts), the daemon runs one per
5
+ // house repo behind /r/<repo>/ of its one server (home/hubs.ts).
6
+ //
7
+ // the one constraint a reader breaks first: a hub starts no run on its own.
8
+ // It joins the host's plan (plan/planner.ts), which decides what runs when,
9
+ // and a save only tells the plan that something moved.
10
+ //
11
+ // what mounts comes from convention + repo.config's `serve` block: the wiki
12
+ // graph is always on (custom provider via serve.wikiGraph, else the generic md
13
+ // scan), the base-context wiki when a CLAUDE.md include names one, agent/
14
+ // graph when the dir exists, the studio when .shipkit/assets/kit.ts loads.
15
+ // Port of a hub serving on its own: --port > serve.port > a deterministic
16
+ // house-range default (4800-4979, hashed from the repo name, stable across
17
+ // launches; pin serve.port when two repos collide).
18
+ import { existsSync, readFileSync, statSync } from 'fs'
19
+ import { homedir } from 'os'
20
+ import { basename, dirname, join, resolve, sep } from 'path'
21
+ import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
22
+ import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
23
+ import { projectNeeds } from '../collections/collection-types.ts'
24
+ import { comfyCollection } from '../collections/comfy.ts'
25
+ import { ComfyPanel, comfyBox, defaultComfyPanelPort, scanComfyWorkflows } from '../comfy/comfy-panel.ts'
26
+ import { wikisCollection } from '../collections/wikis.ts'
27
+ import { pingTick } from '../collections/pings.ts'
28
+ import { buildCollections } from '../collections/providers.ts'
29
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
30
+ import { CheckCtx } from '../core/check-ctx.ts'
31
+ import { proseView } from '../prose/prose-view.ts'
32
+ import { allTasks, gateInventory } from '../ledger/gate-checks.ts'
33
+ import { runCheck, type CheckEvent } from '../core/run-check.ts'
34
+ import { runCi, type CiOpts } from '../core/run-ci.ts'
35
+ import type { GraphMount } from '../wiki/graph-mount.ts'
36
+ import { scanMdGraph } from '../wiki/scan-md.ts'
37
+ import { readInvocations, readLive, readRuns, readRunsSince, runsPath, sweepLive } from '../ledger/run-log.ts'
38
+ import { LIVE_HISTORY_MS, shapeLive, type LiveView } from '../ledger/live-timeline.ts'
39
+ import { parallelCap } from '../core/parallel.ts'
40
+ import { autoGatePool, takeSlot } from '../core/slots.ts'
41
+ import { liveJobs, startJob } from '../core/jobs.ts'
42
+ import { followJob } from '../core/job-follow.ts'
43
+ import { RUN_JOB } from '../home/home-words.ts'
44
+ import { OtherRepos } from './other-repos.ts'
45
+ import { HouseRuns } from './house-runs.ts'
46
+ import { recordStamp } from './project-gutter.ts'
47
+ import { RepoProcesses } from './repo-processes.ts'
48
+ import { shapeTimeline } from '../ledger/run-timeline.ts'
49
+ import { readSuiteRuns, readSuites } from '../ledger/suite-log.ts'
50
+ import { shapeTestCheck, shapeTestsRoot } from '../collections/test-time.ts'
51
+ import type { TestFacts } from '../collections/collection-types.ts'
52
+ import { declaredSlowFiles } from '../suite/test-units.ts'
53
+ import { BUILD_TIMEOUT_MS, CHECK_TIMEOUT_MS } from '../collections/checks.ts'
54
+ import { watchTracked } from './tracked-watch.ts'
55
+ import { repoSource } from '../plan/repo-source.ts'
56
+ import type { PlanSource, Planner } from '../plan/planner.ts'
57
+ import { ProblemBoard } from '../problems/problem-board.ts'
58
+ import { HubProblems } from './hub-problems.ts'
59
+ import { hubHandler, type CheckTap, type HubDeps, type HubHandler } from './serve-hub.ts'
60
+ import type { projectGutter } from './project-gutter-route.ts'
61
+ import { defaultHubPort } from './hub-port.ts'
62
+ import { LAYOUT } from '../core/layout.ts'
63
+ export { defaultHubPort }
64
+
65
+ export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
66
+
67
+ /** a hub serving on its own's view of the house: its own repo first, then
68
+ * every other house repo on this disk */
69
+ const houseOf = (root: string, name: string): HouseRuns => {
70
+ const others = new OtherRepos(root)
71
+ return new HouseRuns(() => [{ name, path: root }, ...others.repos().map((r) => ({ name: r.name, path: r.path }))])
72
+ }
73
+
74
+
75
+ // the BASE-CONTEXT wiki: the shared wiki this repo's CLAUDE.md include chain
76
+ // points at (`@~/dev/<other-repo>/wiki/index.md`). It lives in ANOTHER repo and
77
+ // binds every agent working here, so the hub makes it browsable. Never linted
78
+ // from here. The local-include forms (`@./agent/index.md`,
79
+ // `@./CLAUDE.local.md`) and any index inside this repo are skipped: those are
80
+ // already their own mounts.
81
+ export function baseWikiRoot(root: string): string | null {
82
+ const abs = resolve(root)
83
+ for (const f of ['CLAUDE.md', 'CLAUDE.local.md', 'CLAUDE.private.md']) {
84
+ const p = join(abs, f)
85
+ if (!existsSync(p)) continue
86
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
87
+ const m = line.trim().match(/^@(\S+\.md)$/)
88
+ if (m == null) continue
89
+ const raw = m[1]!
90
+ const target = resolve(abs, raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw)
91
+ const dir = dirname(target)
92
+ if (!existsSync(target)) continue
93
+ if (dir === abs || dir.startsWith(abs + sep)) continue
94
+ return dir
95
+ }
96
+ }
97
+ return null
98
+ }
99
+
100
+ // `~/dev/<repo>/wiki` reads as "<repo> wiki" — a dir literally named wiki says
101
+ // nothing on its own, so the owning repo names the card
102
+ /** the tab of the repo's own wiki, beside the house wiki named for its repo */
103
+ const WIKI_LABEL = "this repo's wiki"
104
+ const wikiLabel = (dir: string): string => (basename(dir) === 'wiki' ? `${basename(dirname(dir))} wiki` : basename(dir))
105
+
106
+ /** the config file's bytes, cheaply: a re-read costs a new module instance,
107
+ * so it happens only when the file really moved */
108
+ export function configStamp(root: string): string {
109
+ try {
110
+ const st = statSync(join(root, 'repo.config.ts'))
111
+ return `${st.mtimeMs}:${st.size}`
112
+ } catch {
113
+ return ''
114
+ }
115
+ }
116
+
117
+ /** a host that runs every gate as a detached job (the daemon): the cli the
118
+ * job runs, so a reload of the host never kills a gate in flight */
119
+ export type GateJobs = { cli: string; pollMs?: number }
120
+
121
+ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo, o?: { port?: number; base?: string; jobs?: GateJobs; planName?: string }): Promise<HubDeps & { plan: PlanSource }> {
122
+ // THE SWITCH WRITES repo.config.ts, so the surfaces built on the config
123
+ // read the one on disk rather than the one this process booted with. A
124
+ // toggle that never reached the rows would read as a toggle that did
125
+ // nothing. What is resolved ONCE stays once: the kit, the graphs, the
126
+ // studio and the comfy panel are mounts, and remounting a server under a
127
+ // live page is a different question from re-reading a declaration.
128
+ let c = boot
129
+ let stamp = configStamp(root)
130
+ const refresh = async (): Promise<void> => {
131
+ const now = configStamp(root)
132
+ if (now === stamp || now === '') return
133
+ stamp = now
134
+ try {
135
+ c = await loadRepoConfig(root, { fresh: true })
136
+ } catch (e) {
137
+ io.print(`WARN repo.config.ts did not load, so the page is reading the one this process booted with — ${(e as Error).message}`)
138
+ }
139
+ }
140
+
141
+ // ONE list: the routes, the /hub.json tools and the wikis section all
142
+ // read it, so nothing can advertise a graph the server does not serve.
143
+ // The repo's OWN wiki mounts when it has one (a game repo's hub
144
+ // advertising an empty wiki route was startup noise), a config-declared
145
+ // provider always mounts, and the base-context wiki (the shared one named
146
+ // by the CLAUDE.md include chain) mounts wherever it is reachable: an
147
+ // agent working here obeys that wiki, so a reader can open it.
148
+ const graphs: GraphMount[] = []
149
+ if (c.serve?.wikiGraph != null)
150
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
151
+ else if (existsSync(join(root, 'wiki')))
152
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
153
+ const baseWiki = baseWikiRoot(root)
154
+ if (baseWiki != null)
155
+ graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
156
+ const agentRoot = join(root, 'agent')
157
+ if (existsSync(agentRoot))
158
+ graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
159
+ // ONE page for every repo: the studio ALWAYS
160
+ // mounts — kit-less repos get an empty media section, never a second
161
+ // page. A broken kit degrades to the same empty kit, loudly.
162
+ let kit: ResolvedKit | null = null
163
+ if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
164
+ try {
165
+ kit = await loadKit(root)
166
+ } catch (e) {
167
+ io.print(`WARN kit not loaded (media section empty): ${(e as Error).message}`)
168
+ }
169
+ }
170
+ const emptyKit: ResolvedKit = { kinds: [], workspace: '.shipkit/assets/out' }
171
+ let studio: StudioRoutes | null = null
172
+ try {
173
+ studio = await studioRoutes({ kit: kit ?? emptyKit, root })
174
+ } catch (e) {
175
+ io.print(`WARN media API not mounted: ${(e as Error).message}`)
176
+ }
177
+ // a repo with no workflow module gets NO comfy section and no panel
178
+ // process: an empty section header is a surface that teaches nothing
179
+ const hasComfy = scanComfyWorkflows(root).length > 0
180
+ const panel = hasComfy ? new ComfyPanel({ root, port: c.serve?.comfyPort ?? defaultComfyPanelPort(c.name) }) : null
181
+ const allCollections = () => [
182
+ ...buildCollections(root, c, kit),
183
+ ...(hasComfy ? [comfyCollection(root)] : []),
184
+ wikisCollection(graphs, root),
185
+ ]
186
+ const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
187
+ // every run this process has going, whoever started it
188
+ let active = 0
189
+ // ONE run at a time, whoever asked: two gates over one tree would measure
190
+ // the same checks twice and race each other's repair pass
191
+ let chain: Promise<unknown> = Promise.resolve()
192
+ // the latest verdict of every check, printed as one problem frame after
193
+ // each run for the vscode problems pane (problems/problem-line.ts)
194
+ const board = new ProblemBoard()
195
+ // what is wrong with the hub itself rides the same frame, the moment it
196
+ // moves, so the pane never reads 0 while the page shows a hub red
197
+ const hubProblems = new HubProblems((all) => {
198
+ if (board.setHub(all)) for (const l of board.frame()) io.print(l)
199
+ })
200
+ const jobs = o?.jobs ?? null
201
+ // the frame the problems pane reads: one per verdict that moved the board
202
+ const onBoard = (e: CheckEvent): void => { if (board.take(e)) for (const l of board.frame()) io.print(l) }
203
+ // ONE way a run executes. In process for a hub of its own; as a detached
204
+ // job in the daemon, which follows it: its lines and verdicts reach the
205
+ // same taps, and a reload of the daemon leaves it running
206
+ const exec = async (kind: 'ci' | 'check', opts: Record<string, unknown>, tap: CheckTap, background: boolean): Promise<number> => {
207
+ if (jobs == null) {
208
+ const turn = background ? await takeSlot(autoGatePool(), { repo: c.name, check: 'auto gate' }, { pollMs: 250 }) : null
209
+ try {
210
+ if (kind === 'ci') return await runCi(root, { ...(opts as CiOpts), print: tap.line, config: c, onCheck: tap.check })
211
+ return await runCheck(root, { ...(opts as Parameters<typeof runCheck>[1]), print: tap.line, config: c, onCheck: tap.check })
212
+ } finally {
213
+ turn?.release()
214
+ }
215
+ }
216
+ // a job still running here, started before this image or by a terminal's --detach
217
+ while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
218
+ const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background } })
219
+ tap.job?.(j.id)
220
+ return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
221
+ }
222
+ const runChecks: HubDeps['runChecks'] = (tap, o) => {
223
+ const run = chain.then(async () => {
224
+ active++
225
+ // a run the hub starts on its own takes the machine's background
226
+ // turn, once it is this hub's turn, never while it queues
227
+ const background = o?.auto === true
228
+ try {
229
+ return await runIn({
230
+ ...tap,
231
+ check: (e) => {
232
+ // a frame per verdict that moved the board, so a tsc
233
+ // error reaches the pane when typecheck ends rather
234
+ // than when the whole gate does
235
+ onBoard(e)
236
+ tap.check(e)
237
+ },
238
+ }, o, background)
239
+ } finally {
240
+ active--
241
+ // and one per run, so every run ends on a frame
242
+ for (const l of board.frame()) io.print(l)
243
+ }
244
+ })
245
+ chain = run.catch(() => {})
246
+ return run
247
+ }
248
+ // a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
249
+ // in the same one-at-a-time chain as the hub's own runs, feeding the same
250
+ // problems frame, recorded as the cli's
251
+ const runCli: HubDeps['runCli'] = (kind, opts, print) => {
252
+ // never on a --json run: its output is the json and nothing else
253
+ if ((active > 0 || (jobs != null && liveJobs(root).length > 0)) && opts.json !== true) print('note: this hub has a run in flight, so this one starts when it ends')
254
+ const run = chain.then(async () => {
255
+ active++
256
+ try {
257
+ await refresh()
258
+ return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: print, check: onBoard, job: (id) => print(`${RUN_JOB}${id}`) }, false)
259
+ } finally {
260
+ active--
261
+ for (const l of board.frame()) io.print(l)
262
+ }
263
+ })
264
+ chain = run.catch(() => {})
265
+ return run
266
+ }
267
+ const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
268
+ const only = o?.only != null && o.only.length > 0 ? o.only : undefined
269
+ const by = o?.by ?? 'hub'
270
+ const buildIds = new Set((await allTasks(c, root)).tasks.map((t) => t.id))
271
+ const namedTasks = (only ?? []).filter((id) => buildIds.has(id))
272
+ // ONE runner, two questions: a build step is the gate's, so
273
+ // anything naming one (or asking for the gate itself) goes through
274
+ // runCi, and a set of everything else takes the health pass
275
+ if (o?.gate === true || namedTasks.length > 0)
276
+ return exec('ci', { ...hubGateOpts({ only, auto: o?.auto === true }), buildOnly: only != null && namedTasks.length === only.length, invokedBy: by }, tap, background)
277
+ // a NARROWED check run must not stamp STATUS.md: that file carries
278
+ // the verdict of a whole run, and one check is not one
279
+ return exec('check', { only, status: only == null, invokedBy: by }, tap, background)
280
+ }
281
+ // a daemon image that boots while a job of its previous image still runs
282
+ // follows it to its end: its verdicts reach the problems pane, and the
283
+ // next run queues behind it
284
+ if (jobs != null) {
285
+ const adopted = liveJobs(root)
286
+ if (adopted.length > 0) {
287
+ io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
288
+ active++
289
+ const run = Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).finally(() => {
290
+ active--
291
+ for (const l of board.frame()) io.print(l)
292
+ })
293
+ chain = run.catch(() => {})
294
+ }
295
+ }
296
+ // resolved once: it shells out to git, and the page asks on every poll
297
+ let record = ''
298
+ try { record = runsPath(root) } catch { record = '' }
299
+ // nothing runs in this process yet, so a live row holding its pid was
300
+ // left by an earlier image of it: `bun --watch` restarts in place
301
+ if (record !== '') sweepLive(record, Date.now(), { ownPid: process.pid })
302
+ // the page asks every second, from every open tab: the record is read
303
+ // only when its files moved, and one shaped answer serves every request
304
+ // inside LIVE_SHARE_MS
305
+ let house: HouseRuns | null = null
306
+ let local: { stamp: string; at: number; live: ReturnType<typeof readLive>; runs: ReturnType<typeof readRunsSince>; invocations: ReturnType<typeof readInvocations> } | null = null
307
+ let shaped: { at: number; view: LiveView } | null = null
308
+ const LIVE_SHARE_MS = 500
309
+ const live = (): LiveView => {
310
+ const now = Date.now()
311
+ if (shaped != null && now - shaped.at < LIVE_SHARE_MS) return shaped.view
312
+ const stamp = recordStamp(record)
313
+ // the window slides even when nothing is written, so the history is
314
+ // re-read at least every few seconds
315
+ if (local == null || local.stamp !== stamp || now - local.at > 5_000) {
316
+ const since = now - LIVE_HISTORY_MS
317
+ local = { stamp, at: now, live: readLive(record), runs: readRunsSince(record, since), invocations: readInvocations(record, since) }
318
+ }
319
+ const view = shapeLive({ now, cap: parallelCap(), live: local.live, runs: local.runs, invocations: local.invocations })
320
+ shaped = { at: now, view }
321
+ return view
322
+ }
323
+ // what the plan asks of this repo: its checks, their last results, what
324
+ // a save moved, and a batch through this runner, in the background
325
+ const plan = repoSource({
326
+ repo: o?.planName ?? c.name,
327
+ root,
328
+ config: () => c,
329
+ record,
330
+ run: (ids, g, why) => runChecks({ line: () => {}, check: () => {} }, { only: ids, gate: g.gate, by: why === 'asked' ? 'hub' : 'schedule', auto: true }),
331
+ })
332
+ return {
333
+ root,
334
+ name: c.name,
335
+ uses: c.serve?.uses ?? [],
336
+ processes: new RepoProcesses(root, c.serve?.processes ?? []),
337
+ port,
338
+ graphs,
339
+ studio,
340
+ refresh,
341
+ // the config and the run record, by their stats: what the warm rows
342
+ // and the runs page read besides the tracked files the watch sees
343
+ stamp: () => `${configStamp(root)}|${record === '' ? '' : recordStamp(record)}`,
344
+ runCli,
345
+ // the run happens IN THIS PROCESS, so every check result reaches the
346
+ // page as it finishes. A shelled run could only ever deliver one wall
347
+ // of text, which is what made seventy checks share one destination
348
+ runChecks,
349
+ plan,
350
+ // every run in flight, whoever started it: they all write the same
351
+ // record, so a terminal gate shows here the same as a button's
352
+ live,
353
+ // the dock of a hub serving on its own: this repo and every other
354
+ // house repo, built on the first ask (a hub the daemon loads is never
355
+ // asked, the daemon answers for the house)
356
+ house: () => (house ??= houseOf(root, c.name)).view(),
357
+ timeline: async () => {
358
+ const ceilings: Record<string, number> = {}
359
+ const budgets: Record<string, number> = {}
360
+ const inventory = await gateInventory(root, c)
361
+ for (const row of inventory) {
362
+ ceilings[row.decl.id] = row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS)
363
+ budgets[row.decl.id] = row.budgetMs
364
+ }
365
+ return shapeTimeline({
366
+ runs: readRuns(record),
367
+ invocations: readInvocations(record),
368
+ suites: readSuites(record),
369
+ ceilings,
370
+ budgets,
371
+ inventory: inventory.map((row) => row.decl.id),
372
+ declaredSlow: declaredSlowFiles(c),
373
+ cap: parallelCap(),
374
+ })
375
+ },
376
+ tests: async (check) => {
377
+ // the SAME test files the tree lists, from the same provider
378
+ const col = allCollections().find((x) => x.id === 'tests')
379
+ const facts = col == null ? [] : (await col.list()).flatMap((i): TestFacts[] => (i.test == null ? [] : [i.test]))
380
+ const input = { facts, runs: readRuns(record), suiteRuns: readSuiteRuns(record) }
381
+ return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
382
+ },
383
+ prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
384
+ needs: () => projectNeeds(allCollections(), hubProblems.collector),
385
+ hubProblems,
386
+ // a job outlives this image, so only an in process run holds a reload back
387
+ busy: () => jobs == null && active > 0,
388
+ collections: allCollections,
389
+ asksDir: c.asks?.dir ?? LAYOUT.asks,
390
+ comfy: panel == null ? null : { ensure: () => panel.ensure(), restart: () => panel.restart(), box: () => comfyBox(root), stop: () => panel.stop() },
391
+ // RED-only pings while serving; notify command is repo config data.
392
+ // Delivery failure must be LOUD — a silently broken notify path means
393
+ // a silent one reads as no blockers at all
394
+ pingTick: (needs) =>
395
+ pingTick(root, c.asks?.dir ?? LAYOUT.asks, needs, {
396
+ link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}${o?.base ?? ''}/?f=needs` : undefined,
397
+ run: (cmd) => {
398
+ const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
399
+ void p.exited.then(async (code) => {
400
+ if (code !== 0) io.printErr(`WARN ping delivery failed (exit ${code}): ${await new Response(p.stderr).text().then((s) => s.trim().slice(0, 160))}`)
401
+ })
402
+ },
403
+ notifyCommand: c.notifyCommand,
404
+ }),
405
+ }
406
+ }
407
+
408
+ /** a run the hub starts on its own waits for the machine's one background
409
+ * turn (autoGatePool), so several hubs never gate at once */
410
+ export async function inAutoTurn<T>(repo: string, run: () => Promise<T>, pool = autoGatePool()): Promise<T> {
411
+ const turn = await takeSlot(pool, { repo, check: 'auto gate' }, { pollMs: 250 })
412
+ try {
413
+ return await run()
414
+ } finally {
415
+ turn?.release()
416
+ }
417
+ }
418
+
419
+ /** how a gate the hub starts differs from `shipkit ci`. The gate repairs and
420
+ * stamps STATUS.md, a narrowed run does neither: one check is not the verdict
421
+ * of a gate. A run the hub starts ON ITS OWN never mutates the tree someone
422
+ * is editing: no repair pass, frozen (SK005 never updates a dependency), no
423
+ * status files, and the quick tier */
424
+ export function hubGateOpts(o: { only?: string[]; auto: boolean }): Pick<CiOpts, 'onlyIds' | 'fix' | 'status' | 'frozen' | 'tests'> {
425
+ const whole = o.only == null
426
+ if (o.auto) return { onlyIds: o.only, fix: false, status: false, frozen: true, tests: 'quick' }
427
+ return { onlyIds: o.only, fix: whole, status: whole }
428
+ }
429
+
430
+ /** how often the RED ping looks, on a hub that owns its timers */
431
+ export const PING_EVERY_MS = 60_000
432
+
433
+ export type HubRuntime = {
434
+ name: string
435
+ config: RepoConfig
436
+ deps: HubDeps
437
+ handler: HubHandler
438
+ /** the live half: the save watch, its place in the plan, the declared
439
+ * processes, and the ping interval when the runtime owns it. Nothing
440
+ * runs before it, so a hub that could not bind its port started nothing */
441
+ start: () => void
442
+ /** one RED ping tick */
443
+ ping: () => Promise<void>
444
+ /** a run is in flight: a reload now would kill it */
445
+ busy: () => boolean
446
+ /** everything this runtime started stops, and it leaves the plan. `procs`
447
+ * also stops the repo's declared processes: a real stop of the hub,
448
+ * never a process restart */
449
+ stop: (o?: { procs?: boolean }) => void
450
+ }
451
+
452
+ export type LoadHubOpts = {
453
+ /** where the page sits on the server that answers: '' or '/r/<repo>' */
454
+ base?: string
455
+ port?: number
456
+ /** the rail, shared when one process serves many repos */
457
+ projects?: ReturnType<typeof projectGutter>
458
+ /** the plan this hub joins: the daemon's one for the machine, or a hub
459
+ * of its own's one for its repo. It decides every run nobody asked for */
460
+ plan: Planner
461
+ /** the repo's name in that plan, which the activity signals use */
462
+ planName?: string
463
+ /** a start counts as a save: the checks whose answer would not replay
464
+ * run once it settles. Off where one process loads many repos */
465
+ gateAtStart: boolean
466
+ /** the ping interval runs here. Off = the host calls ping() itself */
467
+ ownTimers: boolean
468
+ /** re-import repo.config.ts rather than reuse the module already loaded */
469
+ fresh?: boolean
470
+ /** every gate runs as a detached job this host follows */
471
+ jobs?: GateJobs
472
+ }
473
+
474
+ /** how long a save burst settles before the warm answers recompute */
475
+ export const WARM_SETTLE_MS = 800
476
+
477
+ /** the hubs of this process warm one after the other: twenty repos walking
478
+ * at once would slow the one a page is waiting on */
479
+ let warming: Promise<void> = Promise.resolve()
480
+
481
+ export async function loadHub(root: string, io: ServeIo, o: LoadHubOpts): Promise<HubRuntime> {
482
+ const c = await loadRepoConfig(root, { fresh: o.fresh === true })
483
+ const deps = await resolveHubDeps(root, c, io, { port: o.port, base: o.base, jobs: o.jobs, planName: o.planName })
484
+ const name = deps.plan.repo
485
+ deps.planned = () => o.plan.rows(name)
486
+ deps.ask = (check) => {
487
+ const ok = o.plan.ask(name, check)
488
+ if (ok) o.plan.tick()
489
+ return ok
490
+ }
491
+ const handler = hubHandler(deps, { base: o.base, projects: o.projects })
492
+ const procs = deps.processes
493
+ // tick failures are LOUD (the hub's own lines), never swallowed
494
+ const ping = async (): Promise<void> => {
495
+ if (deps.pingTick == null) return
496
+ try {
497
+ deps.pingTick(await deps.needs())
498
+ } catch (e) {
499
+ io.printErr(`WARN ping tick broke: ${(e as Error).message}`)
500
+ }
501
+ }
502
+ const live: { stop: () => void }[] = []
503
+ const start = (): void => {
504
+ // the plan hears every save burst and asks the ledger what it moved.
505
+ // The watch decides nothing: the runner measures or replays like any run
506
+ void o.plan.add(deps.plan).then(() => {
507
+ // a start is a change like a save
508
+ if (o.gateAtStart) o.plan.touched(name)
509
+ })
510
+ // a burst of saves is one recompute of the warm answers, once it settles
511
+ let settle: ReturnType<typeof setTimeout> | null = null
512
+ const watcher = watchTracked(root, () => {
513
+ o.plan.touched(name)
514
+ if (settle != null) clearTimeout(settle)
515
+ settle = setTimeout(() => handler.invalidate(), WARM_SETTLE_MS)
516
+ }, { onError: (e) => io.printErr(`WARN the file watch stopped, so the plan hears no save until the hub reloads: ${e.message}`) })
517
+ if (watcher.error != null) io.printErr(`WARN no file watch (${watcher.error}), so the plan hears no save in this repo`)
518
+ live.push({ stop: () => watcher.close() }, { stop: () => o.plan.remove(name) }, { stop: () => { if (settle != null) clearTimeout(settle) } })
519
+ // the answers a page waits on, computed now, one hub at a time
520
+ // across the process, so a first visit reads them from memory
521
+ warming = warming.then(() => handler.warm())
522
+ // the repo's own processes: the auto ones start, a restart adopts the
523
+ // ones still running from their pid records
524
+ void procs?.startAuto()
525
+ if (procs != null && procs.size > 0) {
526
+ const t = setInterval(() => void procs.tick(), 2_000)
527
+ live.push({ stop: () => clearInterval(t) })
528
+ }
529
+ if (o.ownTimers) {
530
+ // one tick now (the state file guarantees a restart never re-fires), then every minute
531
+ void ping()
532
+ const t = setInterval(() => void ping(), PING_EVERY_MS)
533
+ live.push({ stop: () => clearInterval(t) })
534
+ }
535
+ }
536
+ return {
537
+ name: c.name,
538
+ config: c,
539
+ deps,
540
+ handler,
541
+ start,
542
+ ping,
543
+ busy: () => deps.busy?.() === true,
544
+ stop: (s) => {
545
+ for (const l of live.splice(0)) l.stop()
546
+ if (s?.procs === true) procs?.stopAll()
547
+ handler.stop()
548
+ },
549
+ }
550
+ }