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,426 +1,72 @@
1
- // shipkit serve — resolve the repo's toolbox and run the hub. What mounts
2
- // comes from convention + repo.config's `serve` block: the wiki graph is
3
- // always on (custom provider via serve.wikiGraph, else the generic md
4
- // scan), the base-context wiki when a CLAUDE.md include names one, agent/
5
- // graph when the dir exists, the studio when .shipkit/assets/
6
- // kit.ts loads. Port: --port > serve.port > a deterministic house-range
7
- // default (4800-4979, hashed from the repo name — stable across launches;
8
- // pin serve.port when two repos collide).
9
- import { existsSync, readFileSync, statSync } from 'fs'
10
- import { homedir } from 'os'
11
- import { basename, dirname, join, resolve, sep } from 'path'
12
- import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
13
- import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
14
- import { projectNeeds } from '../collections/collection-types.ts'
15
- import { comfyCollection } from '../collections/comfy.ts'
16
- import { ComfyPanel, comfyBox, defaultComfyPanelPort, scanComfyWorkflows } from '../comfy/comfy-panel.ts'
17
- import { wikisCollection } from '../collections/wikis.ts'
18
- import { pingTick } from '../collections/pings.ts'
19
- import { buildCollections } from '../collections/providers.ts'
20
- import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
21
- import { CheckCtx } from '../core/check-ctx.ts'
22
- import { proseView } from '../prose/prose-view.ts'
23
- import { allTasks, gateInventory } from '../ledger/gate-checks.ts'
24
- import { runCheck } from '../core/run-check.ts'
25
- import { runCi, type CiOpts } from '../core/run-ci.ts'
26
- import type { GraphMount } from '../wiki/graph-mount.ts'
27
- import { scanMdGraph } from '../wiki/scan-md.ts'
28
- import { lastHealthPassAt, readInvocations, readLive, readRuns, readRunsSince, runsPath, sweepLive } from '../ledger/run-log.ts'
29
- import { LIVE_HISTORY_MS, shapeLive, type LiveView } from '../ledger/live-timeline.ts'
30
- import { parallelCap } from '../core/parallel.ts'
1
+ // shipkit serve: the process around one repo's hub. On a machine with a home
2
+ // it hands the hub to the home and streams its lines; else it serves the hub
3
+ // runtime (hub-runtime.ts) behind a Bun.serve of its own, restarts in place
4
+ // when the lib moved under it, and stops everything on a signal.
5
+ import { basename } from 'path'
31
6
  import { reclaimPreviousImage } from '../core/instance.ts'
32
7
  import { isLinkedWorktree } from '../core/git-root.ts'
33
- import { OtherRepos } from './other-repos.ts'
34
- import { recordStamp } from './project-gutter.ts'
35
- import { shapeTimeline } from '../ledger/run-timeline.ts'
36
- import { readSuiteRuns, readSuites } from '../ledger/suite-log.ts'
37
- import { shapeTestCheck, shapeTestsRoot } from '../collections/test-time.ts'
38
- import type { TestFacts } from '../collections/collection-types.ts'
39
- import { declaredSlowFiles } from '../suite/test-units.ts'
40
- import { BUILD_TIMEOUT_MS, CHECK_TIMEOUT_MS } from '../collections/checks.ts'
41
- import { HealthSchedule } from './health-schedule.ts'
42
- import { GateTrigger, watchTracked } from './gate-trigger.ts'
43
- import { ProblemBoard } from '../problems/problem-board.ts'
44
- import { HubProblems } from './hub-problems.ts'
45
- import { StaleRestart } from './stale-restart.ts'
46
- import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
47
- import { serveHub, type HubDeps } from './serve-hub.ts'
48
- import { defaultHubPort } from './hub-port.ts'
49
- import { LAYOUT } from '../core/layout.ts'
50
8
  import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
51
- export { defaultHubPort }
52
-
53
- export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
54
-
9
+ import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
10
+ import { attachToHome } from '../home/attach.ts'
11
+ import { daemonUp, WORKER_ENV } from '../home/home-paths.ts'
12
+ import { homeWanted, startHome } from '../home/home-start.ts'
13
+ import { loadHub, type ServeIo } from './hub-runtime.ts'
14
+ import { homeRepo } from './project-gutter.ts'
15
+ import { serveHub } from './serve-hub.ts'
16
+ import { StaleRestart } from './stale-restart.ts'
17
+ import { Planner } from '../plan/planner.ts'
55
18
 
56
- // the BASE-CONTEXT wiki: the shared wiki this repo's CLAUDE.md include chain
57
- // points at (`@~/dev/<other-repo>/wiki/index.md`). It lives in ANOTHER repo and
58
- // binds every agent working here, so the hub makes it browsable. Never linted
59
- // from here. The local-include forms (`@./agent/index.md`,
60
- // `@./CLAUDE.local.md`) and any index inside this repo are skipped: those are
61
- // already their own mounts.
62
- export function baseWikiRoot(root: string): string | null {
63
- const abs = resolve(root)
64
- for (const f of ['CLAUDE.md', 'CLAUDE.local.md', 'CLAUDE.private.md']) {
65
- const p = join(abs, f)
66
- if (!existsSync(p)) continue
67
- for (const line of readFileSync(p, 'utf8').split('\n')) {
68
- const m = line.trim().match(/^@(\S+\.md)$/)
69
- if (m == null) continue
70
- const raw = m[1]!
71
- const target = resolve(abs, raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw)
72
- const dir = dirname(target)
73
- if (!existsSync(target)) continue
74
- if (dir === abs || dir.startsWith(abs + sep)) continue
75
- return dir
76
- }
77
- }
78
- return null
79
- }
19
+ export type { ServeIo }
80
20
 
81
- // `~/dev/<repo>/wiki` reads as "<repo> wiki" — a dir literally named wiki says
82
- // nothing on its own, so the owning repo names the card
83
- /** the tab of the repo's own wiki, beside the house wiki named for its repo */
84
- const WIKI_LABEL = "this repo's wiki"
85
- const wikiLabel = (dir: string): string => (basename(dir) === 'wiki' ? `${basename(dirname(dir))} wiki` : basename(dir))
21
+ /** how often a hub of its own looks at its plan */
22
+ export const PLAN_TICK_MS = 2_000
86
23
 
87
- /** the config file's bytes, cheaply: a re-read costs a new module instance,
88
- * so it happens only when the file really moved */
89
- function configStamp(root: string): string {
90
- try {
91
- const st = statSync(join(root, 'repo.config.ts'))
92
- return `${st.mtimeMs}:${st.size}`
93
- } catch {
94
- return ''
95
- }
24
+ /** what a `shipkit serve` does with its hub. On a machine with a home, no
25
+ * hub runs outside it: a home that answers takes it, and an installed one
26
+ * that does not is started first. `serve` only inside the home, a linked
27
+ * worktree (its hub is its own checkout), a machine with no home, and
28
+ * SHIPKIT_NO_HOME=1, the one lift */
29
+ export async function serveMode(root: string, env: Record<string, string | undefined> = process.env, o: { up?: () => Promise<boolean>; installed?: () => boolean } = {}): Promise<'serve' | 'attach' | 'start'> {
30
+ const set = (v: string | undefined): boolean => v != null && v !== ''
31
+ if (set(env[WORKER_ENV]) || set(env.SHIPKIT_NO_HOME)) return 'serve'
32
+ if (isLinkedWorktree(root)) return 'serve'
33
+ if (await (o.up ?? (() => daemonUp()))()) return 'attach'
34
+ return (o.installed ?? (() => homeWanted(env)))() ? 'start' : 'serve'
96
35
  }
97
36
 
98
- export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo, o?: { port?: number }): Promise<HubDeps> {
99
- // THE SWITCH WRITES repo.config.ts, so the surfaces built on the config
100
- // read the one on disk rather than the one this process booted with. A
101
- // toggle that never reached the rows would read as a toggle that did
102
- // nothing. What is resolved ONCE stays once: the kit, the graphs, the
103
- // studio and the comfy panel are mounts, and remounting a server under a
104
- // live page is a different question from re-reading a declaration.
105
- let c = boot
106
- let stamp = configStamp(root)
107
- const refresh = async (): Promise<void> => {
108
- const now = configStamp(root)
109
- if (now === stamp || now === '') return
110
- stamp = now
111
- try {
112
- c = await loadRepoConfig(root, { fresh: true })
113
- } catch (e) {
114
- io.print(`WARN repo.config.ts did not load, so the page is reading the one this process booted with — ${(e as Error).message}`)
115
- }
116
- }
117
-
118
- // ONE list: the routes, the /hub.json tools and the wikis section all
119
- // read it, so nothing can advertise a graph the server does not serve.
120
- // The repo's OWN wiki mounts when it has one (a game repo's hub
121
- // advertising an empty wiki route was startup noise), a config-declared
122
- // provider always mounts, and the base-context wiki (the shared one named
123
- // by the CLAUDE.md include chain) mounts wherever it is reachable: an
124
- // agent working here obeys that wiki, so a reader can open it.
125
- const graphs: GraphMount[] = []
126
- if (c.serve?.wikiGraph != null)
127
- 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 } })
128
- else if (existsSync(join(root, 'wiki')))
129
- graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
130
- const baseWiki = baseWikiRoot(root)
131
- if (baseWiki != null)
132
- graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
133
- const agentRoot = join(root, 'agent')
134
- if (existsSync(agentRoot))
135
- 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 } })
136
- // ONE page for every repo: the studio ALWAYS
137
- // mounts — kit-less repos get an empty media section, never a second
138
- // page. A broken kit degrades to the same empty kit, loudly.
139
- let kit: ResolvedKit | null = null
140
- if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
37
+ export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
38
+ const mode = await serveMode(root)
39
+ if (mode === 'start') {
40
+ io.print(`the home is installed but not running: starting it`)
141
41
  try {
142
- kit = await loadKit(root)
42
+ await startHome()
143
43
  } catch (e) {
144
- io.print(`WARN kit not loaded (media section empty): ${(e as Error).message}`)
145
- }
146
- }
147
- const emptyKit: ResolvedKit = { kinds: [], workspace: '.shipkit/assets/out' }
148
- let studio: StudioRoutes | null = null
149
- try {
150
- studio = await studioRoutes({ kit: kit ?? emptyKit, root })
151
- } catch (e) {
152
- io.print(`WARN media API not mounted: ${(e as Error).message}`)
153
- }
154
- // a repo with no workflow module gets NO comfy section and no panel
155
- // process: an empty section header is a surface that teaches nothing
156
- const hasComfy = scanComfyWorkflows(root).length > 0
157
- const panel = hasComfy ? new ComfyPanel({ root, port: c.serve?.comfyPort ?? defaultComfyPanelPort(c.name) }) : null
158
- const allCollections = () => [
159
- ...buildCollections(root, c, kit),
160
- ...(hasComfy ? [comfyCollection(root)] : []),
161
- wikisCollection(graphs, root),
162
- ]
163
- const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
164
- // every run this process has going, whoever started it: the daily timer
165
- // never stacks a pass on top of one
166
- let active = 0
167
- // ONE run at a time, whoever asked: two gates over one tree would measure
168
- // the same checks twice and race each other's repair pass
169
- let chain: Promise<unknown> = Promise.resolve()
170
- // the latest verdict of every check, printed as one problem frame after
171
- // each run for the vscode problems pane (problems/problem-line.ts)
172
- const board = new ProblemBoard()
173
- // what is wrong with the hub itself rides the same frame, the moment it
174
- // moves, so the pane never reads 0 while the page shows a hub red
175
- const hubProblems = new HubProblems((all) => {
176
- if (board.setHub(all)) for (const l of board.frame()) io.print(l)
177
- })
178
- const runChecks: HubDeps['runChecks'] = (tap, o) => {
179
- const run = chain.then(async () => {
180
- active++
181
- try {
182
- return await runIn({
183
- line: tap.line,
184
- check: (e) => {
185
- // a frame per verdict that moved the board, so a tsc
186
- // error reaches the pane when typecheck ends rather
187
- // than when the whole gate does
188
- if (board.take(e)) for (const l of board.frame()) io.print(l)
189
- tap.check(e)
190
- },
191
- }, o)
192
- } finally {
193
- active--
194
- // and one per run, so every run ends on a frame
195
- for (const l of board.frame()) io.print(l)
196
- }
197
- })
198
- chain = run.catch(() => {})
199
- return run
200
- }
201
- const runIn: HubDeps['runChecks'] = async (tap, o) => {
202
- const only = o?.only != null && o.only.length > 0 ? o.only : undefined
203
- const by = o?.by ?? 'hub'
204
- const buildIds = new Set((await allTasks(c, root)).tasks.map((t) => t.id))
205
- const namedTasks = (only ?? []).filter((id) => buildIds.has(id))
206
- // ONE runner, two questions: a build step is the gate's, so
207
- // anything naming one (or asking for the gate itself) goes through
208
- // runCi, and a set of everything else takes the health pass
209
- if (o?.gate === true || namedTasks.length > 0)
210
- return runCi(root, {
211
- ...hubGateOpts({ only, auto: o?.auto === true }),
212
- buildOnly: only != null && namedTasks.length === only.length,
213
- print: tap.line,
214
- config: c,
215
- onCheck: tap.check,
216
- invokedBy: by,
217
- })
218
- // a NARROWED check run must not stamp STATUS.md: that file carries
219
- // the verdict of a whole run, and one check is not one
220
- return runCheck(root, { only, status: only == null, print: tap.line, config: c, onCheck: tap.check, invokedBy: by })
221
- }
222
- // resolved once: it shells out to git, and the page asks on every poll
223
- let record = ''
224
- try { record = runsPath(root) } catch { record = '' }
225
- // nothing runs in this process yet, so a live row holding its pid was
226
- // left by an earlier image of it: `bun --watch` restarts in place
227
- if (record !== '') sweepLive(record, Date.now(), { ownPid: process.pid })
228
- // the live strip asks every second, from every open tab: the record is
229
- // read only when its files moved, and one shaped answer serves every
230
- // request inside LIVE_SHARE_MS
231
- const others = new OtherRepos(root)
232
- let local: { stamp: string; at: number; live: ReturnType<typeof readLive>; runs: ReturnType<typeof readRunsSince>; invocations: ReturnType<typeof readInvocations> } | null = null
233
- let shaped: { at: number; view: LiveView } | null = null
234
- const LIVE_SHARE_MS = 500
235
- const live = (): LiveView => {
236
- const now = Date.now()
237
- if (shaped != null && now - shaped.at < LIVE_SHARE_MS) return shaped.view
238
- const stamp = recordStamp(record)
239
- // the window slides even when nothing is written, so the history is
240
- // re-read at least every few seconds
241
- if (local == null || local.stamp !== stamp || now - local.at > 5_000) {
242
- const since = now - LIVE_HISTORY_MS
243
- local = { stamp, at: now, live: readLive(record), runs: readRunsSince(record, since), invocations: readInvocations(record, since) }
44
+ io.printErr(`FAIL: ${(e as Error).message}. No hub runs outside the home on this machine: SHIPKIT_NO_HOME=1 serves this one in process`)
45
+ return 1
244
46
  }
245
- const view = shapeLive({ now, cap: parallelCap(), live: local.live, runs: local.runs, invocations: local.invocations, others: others.read() })
246
- shaped = { at: now, view }
247
- return view
248
47
  }
249
- const schedule = new HealthSchedule({
250
- now: Date.now,
251
- lastPassAt: () => lastHealthPassAt(record),
252
- busy: () => active > 0,
253
- run: async () => {
254
- const t0 = Date.now()
255
- io.print(`health pass: the daily run starts`)
256
- const code = await runChecks({ line: () => {}, check: () => {} }, { by: 'schedule' })
257
- io.print(`health pass: the daily run ended, exit ${code}, ${Math.round((Date.now() - t0) / 1000)}s`)
258
- },
259
- })
260
- return {
261
- root,
262
- name: c.name,
263
- port,
264
- graphs,
265
- studio,
266
- refresh,
267
- // the run happens IN THIS PROCESS, so every check result reaches the
268
- // page as it finishes. A shelled run could only ever deliver one wall
269
- // of text, which is what made seventy checks share one destination
270
- runChecks,
271
- schedule,
272
- // every run in flight, whoever started it: they all write the same
273
- // record, so a terminal gate shows here the same as a button's
274
- live,
275
- repoHub: (repo) => others.hubOf(repo),
276
- timeline: async () => {
277
- const ceilings: Record<string, number> = {}
278
- const budgets: Record<string, number> = {}
279
- const inventory = await gateInventory(root, c)
280
- for (const row of inventory) {
281
- ceilings[row.decl.id] = row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS)
282
- budgets[row.decl.id] = row.budgetMs
283
- }
284
- return shapeTimeline({
285
- runs: readRuns(record),
286
- invocations: readInvocations(record),
287
- suites: readSuites(record),
288
- ceilings,
289
- budgets,
290
- inventory: inventory.map((row) => row.decl.id),
291
- declaredSlow: declaredSlowFiles(c),
292
- cap: parallelCap(),
293
- })
294
- },
295
- tests: async (check) => {
296
- // the SAME test files the tree lists, from the same provider
297
- const col = allCollections().find((x) => x.id === 'tests')
298
- const facts = col == null ? [] : (await col.list()).flatMap((i): TestFacts[] => (i.test == null ? [] : [i.test]))
299
- const input = { facts, runs: readRuns(record), suiteRuns: readSuiteRuns(record) }
300
- return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
301
- },
302
- prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
303
- needs: () => projectNeeds(allCollections(), hubProblems.collector),
304
- hubProblems,
305
- busy: () => active > 0,
306
- collections: allCollections,
307
- asksDir: c.asks?.dir ?? LAYOUT.asks,
308
- comfy: panel == null ? null : { ensure: () => panel.ensure(), restart: () => panel.restart(), box: () => comfyBox(root), stop: () => panel.stop() },
309
- // RED-only pings while serving; notify command is repo config data.
310
- // Delivery failure must be LOUD — a silently broken notify path means
311
- // a silent one reads as no blockers at all
312
- pingTick: (needs) =>
313
- pingTick(root, c.asks?.dir ?? LAYOUT.asks, needs, {
314
- link: c.serve?.publicHost != null ? `http://${c.serve.publicHost}:${port}/?f=needs` : undefined,
315
- run: (cmd) => {
316
- const p = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' })
317
- void p.exited.then(async (code) => {
318
- if (code !== 0) io.printErr(`WARN ping delivery failed (exit ${code}): ${await new Response(p.stderr).text().then((s) => s.trim().slice(0, 160))}`)
319
- })
320
- },
321
- notifyCommand: c.notifyCommand,
322
- }),
48
+ if (mode !== 'serve') {
49
+ // the home serves every repo on its one port, so a forced one cannot apply here
50
+ if (o?.port != null) io.printErr(`WARN --port ${o.port} is ignored: the home serves this hub on its own port. SHIPKIT_NO_HOME=1 serves it in process on ${o.port}`)
51
+ const r = await attachToHome(basename(homeRepo(root)), io)
52
+ if (r !== 'serve') return r
323
53
  }
324
- }
325
-
326
- /** how a gate the hub starts differs from `shipkit ci`. The gate repairs and
327
- * stamps STATUS.md, a narrowed run does neither: one check is not the verdict
328
- * of a gate. A run the hub starts ON ITS OWN never mutates the tree someone
329
- * is editing: no repair pass, frozen (SK005 never updates a dependency), no
330
- * status files, and the quick tier */
331
- export function hubGateOpts(o: { only?: string[]; auto: boolean }): Pick<CiOpts, 'onlyIds' | 'fix' | 'status' | 'frozen' | 'tests'> {
332
- const whole = o.only == null
333
- if (o.auto) return { onlyIds: o.only, fix: false, status: false, frozen: true, tests: 'quick' }
334
- return { onlyIds: o.only, fix: whole, status: whole }
335
- }
336
-
337
-
338
- // the / dashboard's data now comes from the collection providers
339
- // (src/collections/providers.ts) — never a parallel projection.
340
-
341
- export async function runServe(root: string, io: ServeIo, o?: { port?: number }): Promise<number> {
342
54
  // `bun --watch` restarts the hub in the same pid: what its previous image
343
55
  // held is gone with it, and the check children it spawned are still ours
344
56
  // and still running, so they die here, before this image spawns anything
345
57
  const reclaimed = reclaimPreviousImage()
346
58
  if (reclaimed.killed.length > 0)
347
59
  io.print(`ok: this hub restarted in place: killed ${reclaimed.killed.length} check process(es) its previous image left running (pid ${reclaimed.killed.join(', ')})`)
348
- const c = await loadRepoConfig(root)
349
- const deps = await resolveHubDeps(root, c, io, o)
60
+ // a hub of its own plans its one repo, which counts as active: someone
61
+ // runs it. The interval below is that plan's clock
62
+ const plan = new Planner({ onError: (_repo, e) => io.printErr(`WARN the plan broke: ${e.message}`) })
63
+ const rt = await loadHub(root, io, { port: o?.port, plan, gateAtStart: true, ownTimers: true })
64
+ const { config: c, deps } = rt
65
+ let hub: ReturnType<typeof serveHub>
350
66
  try {
351
- const hub = serveHub(deps)
352
- const base = `http://localhost:${hub.server.port}`
353
- // a banner, not a checklist: one clickable line, then what is inside,
354
- // dimmed by indentation. Wire prefixes stay reserved for problems.
355
- io.print(`${c.name} hub → ${base}`)
356
- const routes = hub.tools.filter((t) => t.path !== '/').map((t) => `${t.label} ${t.path}`).join(' · ')
357
- if (routes !== '') io.print(` also: ${routes}`)
358
- // a page that cannot refresh itself looks identical to a broken one,
359
- // and the only tell is that every change needs a reload key
360
- if (!hub.liveReload) io.print(`WARN the page will not refresh itself: NODE_ENV=production strips bun hot reload, unset it and restart the hub`)
361
- // the off-machine door (phone, other machines) — publicHost is config
362
- if (c.serve?.publicHost != null) io.print(` phone: http://${c.serve.publicHost}:${hub.server.port}/`)
363
- // said once at boot, so a silent phone is never mistaken for no blockers
364
- if (c.notifyCommand != null && isLinkedWorktree(root)) io.print(` pings: off, this is a linked worktree (only the main checkout's hub notifies)`)
365
- // the daily health pass rides the serving process, and only it: a hub
366
- // built for a test never starts a timer
367
- deps.schedule?.start((e) => io.printErr(`WARN the daily health pass broke: ${e.message}`))
368
- // the quick gate at start, then after every save burst, so the
369
- // problems pane follows the code. It only asks the runner, which
370
- // measures or replays like any run: the watch decides WHEN, never
371
- // what is true
372
- const trigger = new GateTrigger({
373
- run: () => deps.runChecks({ line: () => {}, check: () => {} }, { gate: true, by: 'hub', auto: true }),
374
- onError: (e) => io.printErr(`WARN the hub's own gate run broke: ${e.message}`),
375
- })
376
- const watcher = watchTracked(root, () => trigger.touched(), { onError: (e) => io.printErr(`WARN the file watch stopped, so the problems pane refreshes only on a hub run: ${e.message}`) })
377
- if (watcher.error != null) io.printErr(`WARN no file watch (${watcher.error}), so the problems pane refreshes only on a hub run`)
378
- // a lib that moved under this process (under bun --watch the version
379
- // import already restarted it, so what is left is a plain serve), or
380
- // a file table the watch restarts leaked: it re-executes itself in
381
- // place (same pid, argv, port) once no run is in flight. Only a
382
- // restart that throws is said, in the hub's own status
383
- const stale = new StaleRestart({
384
- boot: LIB_VERSION,
385
- disk: diskLibVersion,
386
- busy: () => deps.busy?.() === true,
387
- restart: (why) => {
388
- // a refusal throws while the hub still serves, so the page can say it
389
- const blocked = reexecBlocker()
390
- if (blocked != null) throw new Error(blocked)
391
- io.print(`ok: ${why}: restarting in place`)
392
- stale.stop()
393
- trigger.stop()
394
- watcher.close()
395
- hub.stop()
396
- try {
397
- reexecSelf()
398
- } catch (e) {
399
- // the server is already down: nothing is left to show a status
400
- io.printErr(`FAIL: this hub stopped to restart and could not: ${(e as Error).message}. Restart the hub task`)
401
- process.exit(1)
402
- }
403
- },
404
- say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
405
- })
406
- stale.start()
407
- // a watch restart that leaked sheds it before the first run spawns anything
408
- stale.tick()
409
- trigger.request()
410
- // a child process (the comfy panel) outlives a parent nobody stopped,
411
- // and the next launch then adopts one holding the PREVIOUS run's
412
- // modules. A hard kill still leaks one, which is why adoption exists
413
- for (const sig of ['SIGINT', 'SIGTERM'] as const)
414
- process.on(sig, () => {
415
- stale.stop()
416
- trigger.stop()
417
- watcher.close()
418
- hub.stop()
419
- process.exit(0)
420
- })
421
- await new Promise(() => {}) // the hub owns the process — never resolve
422
- return 0
67
+ hub = serveHub(deps, rt.handler)
423
68
  } catch (e) {
69
+ rt.stop()
424
70
  if (e instanceof Error && e.message.includes('in use')) {
425
71
  // same repo in another window is fine; a different repo hashed
426
72
  // onto this port is a real collision — never claim its hub
@@ -440,4 +86,66 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
440
86
  }
441
87
  throw e
442
88
  }
89
+ const base = `http://localhost:${hub.server.port}`
90
+ // a banner, not a checklist: one clickable line, then what is inside,
91
+ // dimmed by indentation. Wire prefixes stay reserved for problems.
92
+ io.print(`${c.name} hub → ${base}`)
93
+ const routes = hub.tools.filter((t) => t.path !== '/').map((t) => `${t.label} ${t.path}`).join(' · ')
94
+ if (routes !== '') io.print(` also: ${routes}`)
95
+ // a page that cannot refresh itself looks identical to a broken one,
96
+ // and the only tell is that every change needs a reload key
97
+ if (!hub.liveReload) io.print(`WARN the page will not refresh itself: NODE_ENV=production strips bun hot reload, unset it and restart the hub`)
98
+ // the off-machine door (phone, other machines) — publicHost is config
99
+ if (c.serve?.publicHost != null) io.print(` phone: http://${c.serve.publicHost}:${hub.server.port}/`)
100
+ // said once at boot, so a silent phone is never mistaken for no blockers
101
+ if (c.notifyCommand != null && isLinkedWorktree(root)) io.print(` pings: off, this is a linked worktree (only the main checkout's hub notifies)`)
102
+ // a lib that moved under this process (under bun --watch the version
103
+ // import already restarted it, so what is left is a plain serve), or
104
+ // a file table the watch restarts leaked: it re-executes itself in
105
+ // place (same pid, argv, port) once no run is in flight. Only a
106
+ // restart that throws is said, in the hub's own status
107
+ const stale = new StaleRestart({
108
+ boot: LIB_VERSION,
109
+ disk: diskLibVersion,
110
+ busy: () => deps.busy?.() === true,
111
+ restart: (why) => {
112
+ // a refusal throws while the hub still serves, so the page can say it
113
+ const blocked = reexecBlocker()
114
+ if (blocked != null) throw new Error(blocked)
115
+ io.print(`ok: ${why}: restarting in place`)
116
+ stale.stop()
117
+ clearInterval(planClock)
118
+ rt.stop()
119
+ hub.stop()
120
+ try {
121
+ reexecSelf()
122
+ } catch (e) {
123
+ // the server is already down: nothing is left to show a status
124
+ io.printErr(`FAIL: this hub stopped to restart and could not: ${(e as Error).message}. Restart the hub task`)
125
+ process.exit(1)
126
+ }
127
+ },
128
+ say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
129
+ })
130
+ stale.start()
131
+ // a watch restart that leaked sheds it before the first run spawns anything
132
+ stale.tick()
133
+ // the save watch, the processes and the pings, and the checks whose
134
+ // answer would not replay once the start settles
135
+ rt.start()
136
+ const planClock = setInterval(() => plan.tick(), PLAN_TICK_MS)
137
+ // a child process (the comfy panel) outlives a parent nobody stopped,
138
+ // and the next launch then adopts one holding the PREVIOUS run's
139
+ // modules. A hard kill still leaks one, which is why adoption exists
140
+ for (const sig of ['SIGINT', 'SIGTERM'] as const)
141
+ process.on(sig, () => {
142
+ stale.stop()
143
+ clearInterval(planClock)
144
+ plan.stop()
145
+ rt.stop({ procs: true })
146
+ hub.stop()
147
+ process.exit(0)
148
+ })
149
+ await new Promise(() => {}) // the hub owns the process — never resolve
150
+ return 0
443
151
  }