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,8 +1,9 @@
1
- // shipkit serve — the repo's ONE Bun.serve, serving the ONE page (one server per repo, one surface, mobx+react). `/` is
2
- // the hub app (Bun HTML import — media studio + asks + creds + posts +
3
- // health + wikis as sections); the studio route table mounts as the media
4
- // API under /media/*; graph canvases stay as drill-down pages. Route logic
5
- // only — resolving WHAT to mount from repo.config is run-serve.ts's job.
1
+ // the hub's routes: ONE page (the hub app, a Bun HTML import: media studio,
2
+ // asks, creds, posts, health, wikis as sections), the studio route table as
3
+ // the media API under /media/*, graph canvases as drill-down pages. hubHandler
4
+ // is the routing alone, under a base path; serveHub puts it behind a
5
+ // Bun.serve of its own. Resolving WHAT to mount from repo.config is
6
+ // hub-runtime.ts's job.
6
7
  import { existsSync } from 'fs'
7
8
  import { join } from 'path'
8
9
  import { positionsFile } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
@@ -19,21 +20,27 @@ import type { ProseView } from '../prose/prose-view.ts'
19
20
  import type { Timeline } from '../ledger/run-timeline.ts'
20
21
  import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
21
22
  import type { LiveView } from '../ledger/live-timeline.ts'
23
+ import type { HouseView } from '../ledger/house-runs.ts'
22
24
 
23
25
  export type { LiveView }
24
- import type { HealthSchedule } from './health-schedule.ts'
26
+ import type { PlanRow } from '../plan/planner.ts'
25
27
  import type { HubProblems } from './hub-problems.ts'
26
28
  import hubIndex from './hub-app/index.html'
29
+ import { homePort, homeUp } from '../home/home-paths.ts'
30
+ import { RUN_EXIT } from '../home/home-words.ts'
31
+ import type { RepoProcesses } from './repo-processes.ts'
27
32
  import { projectGutter } from './project-gutter-route.ts'
28
33
  import { sameOrigin } from './same-origin.ts'
29
34
  import { LAYOUT } from '../core/layout.ts'
35
+ import { WarmCell } from './warm-cell.ts'
36
+ import { readmeRoutes } from './readme-route.ts'
30
37
 
31
38
  export { sameOrigin }
32
39
 
33
40
  export type HubTool = { path: string; label: string; desc: string }
34
41
 
35
42
  /** what a running gate streams back to the page */
36
- export type CheckTap = { line: (l: string) => void; check: (e: CheckEvent) => void }
43
+ export type CheckTap = { line: (l: string) => void; check: (e: CheckEvent) => void; job?: (id: string) => void }
37
44
  /** the comfy panel, as the hub needs it: the child process it embeds and the
38
45
  * box those workflows generate on (src/comfy/comfy-panel.ts owns both) */
39
46
  export type ComfyDeps = {
@@ -45,6 +52,12 @@ export type ComfyDeps = {
45
52
  export type HubDeps = {
46
53
  root: string
47
54
  name: string
55
+ /** the machine resources the repo declares in serve.uses */
56
+ uses?: string[]
57
+ /** the long running processes the repo declares in serve.processes */
58
+ processes?: RepoProcesses
59
+ /** a terminal's `shipkit ci` or `check`, run in this process; resolves to its exit code */
60
+ runCli?: (kind: 'ci' | 'check', opts: Record<string, unknown>, print: (l: string) => void) => Promise<number>
48
61
  /** 0 = random (tests); real launches pass repo.config/default */
49
62
  port: number
50
63
  /** every md graph this hub serves, in card order: /graph/<id>. run-serve
@@ -62,20 +75,23 @@ export type HubDeps = {
62
75
  * into a tree someone is editing. run-serve owns the routing between the
63
76
  * two engines. */
64
77
  runChecks: (tap: CheckTap, o?: { only?: string[]; gate?: boolean; by?: InvokedBy; auto?: boolean }) => Promise<number>
65
- /** the daily health pass timer (health-schedule.ts). The page reads its
66
- * state to say when a health check runs next; absent = no timer */
67
- schedule?: HealthSchedule | null
78
+ /** this repo's rows of the plan (plan/planner.ts): the page reads them to
79
+ * say when a check runs next and why; absent = no plan */
80
+ planned?: () => PlanRow[]
81
+ /** a click: this check runs next, through the plan. false = the plan
82
+ * does not know it */
83
+ ask?: (check: string) => boolean
68
84
  /** every run on record, shaped for the timeline page. Absent = the page
69
85
  * says there is no record rather than drawing an empty one */
70
86
  timeline?: () => Promise<Timeline>
71
87
  /** where the test checks' time goes: the `tests` page (check null) or one
72
88
  * test check's page (collections/test-time.ts). Absent = 404 */
73
89
  tests?: (check: string | null) => Promise<TestsRootView | TestCheckView>
74
- /** the live strip's view: the recent runs and the ones in flight, here
75
- * and in the other house repos, shaped on the server clock */
90
+ /** this repo's live view: the recent runs and the ones in flight, shaped
91
+ * on the server clock */
76
92
  live?: () => LiveView
77
- /** another house repo's hub url when it serves, null when it does not */
78
- repoHub?: (repo: string) => Promise<string | null>
93
+ /** the dock's view: every house repo's recent runs (house-runs.ts) */
94
+ house?: () => HouseView
79
95
  /** every human-action item, red/yellow, gathered per request from the
80
96
  * collection providers (run-serve builds it) */
81
97
  needs: () => Promise<NeedItem[]>
@@ -99,11 +115,40 @@ export type HubDeps = {
99
115
  * file, so every surface built on the config asks for this first, or a
100
116
  * toggle reads as a toggle that did nothing until the next restart */
101
117
  refresh?: () => Promise<void>
102
- /** RED-only ping tick, called on an interval while serving (null = off) */
118
+ /** a cheap fingerprint of what the rows and the run record read (file
119
+ * stats): a new one marks the warm answers stale. Absent = only a save
120
+ * and their age refresh them */
121
+ stamp?: () => string
122
+ /** RED-only ping tick, called on an interval by the hub runtime (null = off) */
103
123
  pingTick?: ((needs: NeedItem[]) => void) | null
104
124
  }
105
125
  export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; liveReload: boolean; stop: () => void }
106
126
 
127
+ /** one repo's request handling, apart from any server: `shipkit serve` puts it
128
+ * behind a Bun.serve of its own, the home behind /r/<repo>/ of its one */
129
+ export type HubHandler = {
130
+ tools: HubTool[]
131
+ /** a request whose path is relative to the hub's base ('/api/…', '/hub.json') */
132
+ handle: (req: Request, path: string, srv: Server) => Promise<Response>
133
+ /** a tracked file of the repo moved: every warm answer recomputes behind */
134
+ invalidate: () => void
135
+ /** compute the warm answers now, the rows first: the hub's load */
136
+ warm: () => Promise<void>
137
+ stop: () => void
138
+ }
139
+
140
+ /** past this age a warm answer recomputes in the background even when no
141
+ * save and no stamp said it moved: what no watch sees (a clock, a wiki in
142
+ * another repo, a file git ignores). Long, because a recompute holds the
143
+ * one process every repo answers from */
144
+ export const WARM_MAX_AGE_MS = 120_000
145
+
146
+ /** the one flat list of the repo, as /api/resources.json carries it before
147
+ * the plan rows and the hub problems, which are read live */
148
+ type Rows = { collections: { id: string; title: string; media: string }[]; resources: Awaited<ReturnType<typeof projectResources>> }
149
+ type Server = ReturnType<typeof Bun.serve>
150
+ type Gutter = ReturnType<typeof projectGutter>
151
+
107
152
  /** the tab refreshes ITSELF through bun's hot reload client, which rides in
108
153
  * the served page: it swaps a hub-app edit in, and reconnects across the
109
154
  * process restart a source edit triggers under `bun --watch`. Bun puts that
@@ -124,27 +169,79 @@ export function hubTools(d: Pick<HubDeps, 'graphs'>): HubTool[] {
124
169
  }
125
170
 
126
171
  // drill-down pages (graph canvases) get one fixed home link back to THE page
127
- const HOME_LINK = `<style>.skhome{position:fixed;left:10px;top:10px;z-index:9999;font-size:17px;text-decoration:none;
128
- background:#141619;border:1px solid #282c31;border-radius:3px;padding:5px 9px}</style><a class=skhome href="/" title="hub — THE page">⌂</a>`
129
- const withHome = (html: string): string => html.replace(/<body([^>]*)>/, `<body$1>${HOME_LINK}`)
172
+ const homeLink = (base: string): string => `<style>.skhome{position:fixed;left:10px;top:10px;z-index:9999;font-size:17px;text-decoration:none;
173
+ background:#141619;border:1px solid #282c31;border-radius:3px;padding:5px 9px}</style><a class=skhome href="${base}/" title="hub — THE page">⌂</a>`
174
+ const withHome = (html: string, base: string): string => html.replace(/<body([^>]*)>/, `<body$1>${homeLink(base)}`)
130
175
 
131
- export function serveHub(d: HubDeps): Hub {
176
+ /** the hub's routes under `base` ('' on a server of its own, '/r/<repo>' in
177
+ * the home). `projects` is the rail, shared when one process serves many */
178
+ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } = {}): HubHandler {
179
+ const base = o.base ?? ''
132
180
  const tools = hubTools(d)
133
- const projects = projectGutter({ root: d.root })
134
- const server: ReturnType<typeof Bun.serve> = Bun.serve({
135
- hostname: '127.0.0.1',
136
- port: d.port,
137
- // `/` IS the page — the react app; there is no other surface
138
- routes: { '/': hubIndex },
139
- async fetch(req): Promise<Response> {
140
- const path = new URL(req.url).pathname
181
+ const projects = o.projects ?? projectGutter({ root: d.root })
182
+ // the answers a page waits on when a repo shows, kept warm in memory: a
183
+ // request answers from here and never walks the repo (warm-cell.ts)
184
+ const warmOpts = { stamp: d.stamp, maxAgeMs: WARM_MAX_AGE_MS }
185
+ const rows = new WarmCell<Rows>(async () => {
186
+ await d.refresh?.()
187
+ const cols = d.collections()
188
+ return { collections: cols.map((c) => ({ id: c.id, title: c.title, media: c.media })), resources: await projectResources(cols, d.hubProblems?.collector) }
189
+ }, warmOpts)
190
+ // the run record and the config are all it reads, and the stamp sees both
191
+ const timeline = d.timeline == null ? null : new WarmCell(d.timeline, { stamp: d.stamp })
192
+ const graphs = new Map(d.graphs.map((g) => [g.id, new WarmCell(async () => g.load(), { maxAgeMs: WARM_MAX_AGE_MS })]))
193
+ const fresh = (req: Request): boolean => new URL(req.url).searchParams.get('fresh') === '1'
194
+ return {
195
+ tools,
196
+ invalidate: () => {
197
+ rows.invalidate()
198
+ timeline?.invalidate()
199
+ for (const g of graphs.values()) g.invalidate()
200
+ },
201
+ warm: async () => {
202
+ await rows.warm()
203
+ await timeline?.warm()
204
+ for (const g of graphs.values()) await g.warm()
205
+ },
206
+ stop: () => { d.comfy?.stop() },
207
+ async handle(req, path, srv): Promise<Response> {
141
208
  if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the hub page', { status: 403 })
142
209
  // identity probe: lets a second launch tell "same repo, another
143
210
  // window" from "a DIFFERENT repo hashed onto my port"
144
211
  // `root` rides with it so a page can put a file link into his
145
212
  // editor: only the server knows where this repo sits on disk
146
- if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, root: d.root, tools })
213
+ // `uses` tells the home which machine resources this repo needs,
214
+ // `base` where this hub's page sits on the server that answers
215
+ if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, root: d.root, base, tools, uses: d.uses ?? [] })
147
216
  if (path.startsWith('/api/projects')) return (await projects.handle(req, path)) ?? new Response('not found', { status: 404 })
217
+ // where the machine's home serves, and whether it does: a rail
218
+ // click opens the home on that repo, or VS Code while none runs
219
+ if (path === '/api/home-url') return Response.json({ port: homePort(), up: await homeUp() })
220
+ if (path === '/api/run' && req.method === 'POST' && d.runCli != null) {
221
+ const b = (await req.json().catch(() => ({}))) as { kind?: string; opts?: Record<string, unknown> }
222
+ if (b.kind !== 'ci' && b.kind !== 'check') return new Response('kind must be ci or check', { status: 400 })
223
+ const runCli = d.runCli
224
+ // a gate can stay silent for minutes (queued, or one long tsc):
225
+ // Bun would cut the connection after its idle timeout
226
+ srv.timeout(req, 0)
227
+ // wire lines as they come, then one EXIT line the client ends on
228
+ const stream = new ReadableStream<string>({
229
+ start(ctl) {
230
+ const put = (l: string): void => { try { ctl.enqueue(`${l}\n`) } catch { /* the terminal went away: the run finishes anyway */ } }
231
+ void runCli(b.kind as 'ci' | 'check', b.opts ?? {}, put)
232
+ .then((code) => put(`${RUN_EXIT}${code}`), (e: Error) => { put(`FAIL: ${e.message}`); put(`${RUN_EXIT}1`) })
233
+ .finally(() => { try { ctl.close() } catch { /* closed */ } })
234
+ },
235
+ })
236
+ return new Response(stream, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
237
+ }
238
+ if (path === '/api/processes.json') return Response.json({ processes: d.processes?.views() ?? [] })
239
+ if (path === '/api/processes/act' && req.method === 'POST') {
240
+ const b = (await req.json().catch(() => ({}))) as { id?: string; act?: string }
241
+ if (b.act !== 'start' && b.act !== 'stop' && b.act !== 'restart') return new Response('act must be start, stop or restart', { status: 400 })
242
+ const v = d.processes?.act(b.id ?? '', b.act)
243
+ return v == null ? new Response(`no process '${b.id ?? ''}' in serve.processes`, { status: 404 }) : Response.json(v)
244
+ }
148
245
  // the three surfaces built on the config read the config on disk
149
246
  if (path === '/api/needs.json' || path === '/api/resources.json' || path === '/api/checks.json' || path === '/api/collections.json') await d.refresh?.()
150
247
  if (path === '/api/needs.json') {
@@ -155,11 +252,11 @@ export function serveHub(d: HubDeps): Hub {
155
252
  // ONE flat list for the whole repo: the page groups it by
156
253
  // path and by type, and a second endpoint would be a second
157
254
  // answer to drift from this one
158
- const cols = d.collections()
255
+ const r = await rows.get({ fresh: fresh(req) })
159
256
  return Response.json({
160
- collections: cols.map((c) => ({ id: c.id, title: c.title, media: c.media })),
161
- resources: await projectResources(cols, d.hubProblems?.collector),
162
- schedule: d.schedule?.state() ?? null,
257
+ collections: r.collections,
258
+ resources: r.resources,
259
+ planned: d.planned?.() ?? null,
163
260
  hub: d.hubProblems?.list() ?? [],
164
261
  })
165
262
  }
@@ -228,17 +325,17 @@ export function serveHub(d: HubDeps): Hub {
228
325
  }
229
326
  }
230
327
  if (path === '/api/live.json') {
231
- if (d.live == null) return Response.json({ now: Date.now(), historyMs: 0, cap: 0, busy: 0, invocations: [], repos: [], runs: [], queued: 0, blocked: [] } satisfies LiveView)
328
+ if (d.live == null) return Response.json({ now: Date.now(), historyMs: 0, cap: 0, busy: 0, invocations: [], runs: [], queued: 0, blocked: [] } satisfies LiveView)
232
329
  return Response.json(d.live(), { headers: { 'cache-control': 'no-store' } })
233
330
  }
234
- if (path === '/api/repo-hub') {
235
- const repo = new URL(req.url).searchParams.get('repo') ?? ''
236
- return Response.json({ url: d.repoHub == null ? null : await d.repoHub(repo) })
331
+ if (path === '/api/house-runs.json') {
332
+ if (d.house == null) return new Response('this hub draws no house runs', { status: 404 })
333
+ return Response.json(d.house(), { headers: { 'cache-control': 'no-store' } })
237
334
  }
238
335
  if (path === '/api/timeline.json') {
239
- if (d.timeline == null) return new Response('this hub keeps no run record', { status: 404 })
336
+ if (timeline == null) return new Response('this hub keeps no run record', { status: 404 })
240
337
  try {
241
- return Response.json(await d.timeline())
338
+ return Response.json(await timeline.get({ fresh: fresh(req) }))
242
339
  } catch (e) {
243
340
  return new Response(`the run record could not be shaped: ${(e as Error).message}`, { status: 500 })
244
341
  }
@@ -252,6 +349,9 @@ export function serveHub(d: HubDeps): Hub {
252
349
  return new Response(`the test times could not be shaped: ${(e as Error).message}`, { status: 500 })
253
350
  }
254
351
  }
352
+ // the empty page shows the README, its images served from the repo
353
+ const readme = await readmeRoutes(d.root, path)
354
+ if (readme != null) return readme
255
355
  if (path === '/api/status.json') {
256
356
  const p = join(d.root, LAYOUT.status)
257
357
  return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
@@ -319,30 +419,33 @@ export function serveHub(d: HubDeps): Hub {
319
419
  })
320
420
  return new Response(stream, { headers: { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store' } })
321
421
  }
322
- const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
422
+ const page = (s: string): Response => new Response(withHome(s, base), { headers: { 'content-type': 'text/html; charset=utf-8' } })
323
423
  for (const g of d.graphs) {
324
424
  if (!path.startsWith(`/graph/${g.id}`)) continue
325
- const routes = graphRoutes({ load: g.load, title: g.title, file: positionsFile(d.root, g.id), saveUrl: `/graph/${g.id}/positions` })
425
+ // the graph as the warm cell holds it: a page open never rescans the wiki
426
+ const held = await graphs.get(g.id)!.get()
427
+ const routes = graphRoutes({ load: () => held, title: g.title, file: positionsFile(d.root, g.id), saveUrl: `${base}/graph/${g.id}/positions` })
326
428
  if (path === `/graph/${g.id}`) return page(routes.page())
327
- if (path === `/graph/${g.id}/graph.json`) return Response.json(g.load())
429
+ if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
328
430
  if (path === `/graph/${g.id}/positions`) return routes.save(req)
329
431
  }
330
432
  // the media API namespace — the studio route table, ONE prefix strip
331
433
  if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
332
434
  // stale bookmarks (/assets/, /status, …) land here — name the way home
333
- return new Response('not found — the hub page is at /', { status: 404 })
435
+ return new Response(`not found — the hub page is at ${base}/`, { status: 404 })
334
436
  },
335
- })
336
- // RED-only pings ride the serving process: one tick now (state file
337
- // guarantees a restart never re-fires), then every minute
338
- let pingTimer: ReturnType<typeof setInterval> | null = null
339
- if (d.pingTick != null) {
340
- // tick failures are LOUD (console = the serve terminal), never swallowed
341
- const tick = (): void => void d.needs().then((n) => d.pingTick?.(n)).catch((e: Error) => console.error(`WARN ping tick broke: ${e.message}`))
342
- tick()
343
- pingTimer = setInterval(tick, 60_000)
344
437
  }
438
+ }
439
+
440
+ export function serveHub(d: HubDeps, h: HubHandler = hubHandler(d)): Hub {
441
+ const server: Server = Bun.serve({
442
+ hostname: '127.0.0.1',
443
+ port: d.port,
444
+ // `/` IS the page — the react app; there is no other surface
445
+ routes: { '/': hubIndex },
446
+ fetch: (req, srv) => h.handle(req, new URL(req.url).pathname, srv),
447
+ })
345
448
  // the panel process belongs to this hub: leaving it behind means the next
346
449
  // launch adopts a server holding the previous run's modules
347
- return { server, tools, liveReload: canLiveReload(), stop: () => { if (pingTimer != null) clearInterval(pingTimer); d.schedule?.stop(); d.comfy?.stop(); server.stop(true) } }
450
+ return { server, tools: h.tools, liveReload: canLiveReload(), stop: () => { h.stop(); server.stop(true) } }
348
451
  }
@@ -20,6 +20,9 @@ export type StaleRestartOpts = {
20
20
  /** why the file table leaked, null while it did not. Default: this
21
21
  * process's own table (core/fd-table.ts) */
22
22
  leak?: () => string | null
23
+ /** one line on source that moved under a process with no bun watch, null
24
+ * while nothing moved */
25
+ moved?: () => string | null
23
26
  /** a run is in flight: a restart now would kill it */
24
27
  busy: () => boolean
25
28
  /** never returns when it works. `why` is one line on what went stale */
@@ -62,6 +65,8 @@ export class StaleRestart {
62
65
  const disk = this.o.disk()
63
66
  // a package.json half written by a bump reads as `?`, and is no verdict
64
67
  if (disk !== '?' && disk !== this.o.boot) return { why: `this hub runs shipkit ${this.o.boot} but ${disk} is on disk`, leak: false }
68
+ const moved = this.o.moved?.() ?? null
69
+ if (moved != null) return { why: moved, leak: false }
65
70
  const leak = (this.o.leak ?? fdTableLeak)()
66
71
  return leak == null ? null : { why: leak, leak: true }
67
72
  }
@@ -1,72 +1,13 @@
1
- // when the hub runs the quick gate on its own: once at start, then after the
2
- // tracked files have been quiet for QUIET_MS. The trigger only STARTS ordinary
3
- // gate runs. Every answer comes out of the one runner, measured or replayed
4
- // from the ledger like any other run, so nothing here owns a verdict: a missed
5
- // file event costs a stale problems pane until the next save, never a green.
6
- //
7
- // never two runs at once: a request during a run queues exactly one more, run
8
- // after it ends, however many saves landed meanwhile.
9
- import { watch, type FSWatcher } from 'fs'
1
+ // the save watch: which files moving under a repo count as the owner's edit.
2
+ // It only says THAT something moved; the plan (plan/planner.ts) then asks the
3
+ // ledger which checks that moved, so a missed file event costs a stale
4
+ // problems pane until the next save, never a green.
5
+ import { statSync, watch, type FSWatcher } from 'fs'
6
+ import { join } from 'path'
10
7
  import { GENERATED_IGNORE_LINES } from '../rules/untrack-generated.ts'
11
8
  import { gitBin } from '../core/git-bin.ts'
12
9
  import { LAYOUT } from '../core/layout.ts'
13
10
 
14
- export const QUIET_MS = 2000
15
-
16
- export type Timers = {
17
- set: (fn: () => void, ms: number) => unknown
18
- clear: (handle: unknown) => void
19
- }
20
-
21
- const realTimers: Timers = {
22
- set: (fn, ms) => setTimeout(fn, ms),
23
- clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
24
- }
25
-
26
- export class GateTrigger {
27
- private timer: unknown = null
28
- private running: Promise<void> | null = null
29
- private pending = false
30
- private readonly timers: Timers
31
- private readonly quietMs: number
32
-
33
- constructor(private readonly o: { run: () => Promise<unknown>; quietMs?: number; timers?: Timers; onError?: (e: Error) => void }) {
34
- this.timers = o.timers ?? realTimers
35
- this.quietMs = o.quietMs ?? QUIET_MS
36
- }
37
-
38
- /** a tracked file moved: the run starts once nothing has moved for the
39
- * quiet window, so a save burst is one run */
40
- touched(): void {
41
- if (this.timer != null) this.timers.clear(this.timer)
42
- this.timer = this.timers.set(() => {
43
- this.timer = null
44
- this.request()
45
- }, this.quietMs)
46
- }
47
-
48
- /** run now, or once more after the run in flight */
49
- request(): void {
50
- if (this.running != null) { this.pending = true; return }
51
- this.running = (async () => {
52
- do {
53
- this.pending = false
54
- try { await this.o.run() } catch (e) { this.o.onError?.(e instanceof Error ? e : new Error(String(e))) }
55
- } while (this.pending)
56
- })().finally(() => { this.running = null })
57
- }
58
-
59
- /** resolves when no run is in flight or queued */
60
- async idle(): Promise<void> {
61
- while (this.running != null) await this.running
62
- }
63
-
64
- stop(): void {
65
- if (this.timer != null) this.timers.clear(this.timer)
66
- this.timer = null
67
- }
68
- }
69
-
70
11
  /** every path a run of the gate or the hub itself writes, whatever the repo
71
12
  * ignores: the status files, the per run logs under the scratch dir, an atomic write's
72
13
  * temp file, the comfy state the hub writes at boot. A save there is the
@@ -106,6 +47,13 @@ export async function gitIgnored(root: string, paths: string[]): Promise<Set<str
106
47
  }
107
48
  }
108
49
 
50
+ /** a commit, a fetch or a tag moved a ref: the one write inside .git a check
51
+ * keyed on the refs (SK009) must hear. Lock files are git mid write */
52
+ export function isRefMove(rel: string): boolean {
53
+ if (rel.endsWith('.lock')) return false
54
+ return rel === '.git/HEAD' || rel === '.git/packed-refs' || rel.startsWith('.git/refs/')
55
+ }
56
+
109
57
  /** calls onChange whenever a file the owner edits moves under root: tracked,
110
58
  * or untracked and not ignored. What the gate itself writes (GATE_WRITES,
111
59
  * the ledger in .git) never counts, ignored or not, so a run never triggers
@@ -134,6 +82,7 @@ export function watchTracked(root: string, onChange: () => void, o: { ignored?:
134
82
  w = watch(root, { recursive: true }, (_event, name) => {
135
83
  if (name == null) return onChange()
136
84
  const rel = String(name).replace(/\\/g, '/')
85
+ if (isRefMove(rel)) return onChange()
137
86
  if (rel === '.git' || rel.startsWith('.git/') || rel.split('/').includes('node_modules') || isGateWrite(rel)) return
138
87
  pending.add(rel)
139
88
  void flush()
@@ -146,3 +95,26 @@ export function watchTracked(root: string, onChange: () => void, o: { ignored?:
146
95
  }
147
96
  return { close: () => w?.close(), error }
148
97
  }
98
+
99
+ /** did the owner edit anything after `since`: a tracked file, or an untracked
100
+ * one git does not ignore, written later, or a ref that moved. What the gate
101
+ * writes never counts. A boot asks it once per repo, so a burst the previous
102
+ * image had not asked about is picked up (plan/repo-source.ts pending) */
103
+ export async function movedSince(root: string, since: number): Promise<boolean> {
104
+ let out = ''
105
+ try {
106
+ const p = Bun.spawn([gitBin(), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
107
+ out = await new Response(p.stdout).text()
108
+ if ((await p.exited) !== 0) return false
109
+ } catch {
110
+ return false
111
+ }
112
+ const later = (rel: string): boolean => {
113
+ try { return statSync(join(root, rel)).mtimeMs > since } catch { return false }
114
+ }
115
+ for (const rel of out.split('\0')) {
116
+ if (rel === '' || rel.split('/').includes('node_modules') || isGateWrite(rel)) continue
117
+ if (later(rel)) return true
118
+ }
119
+ return later('.git/HEAD') || later('.git/packed-refs')
120
+ }
@@ -0,0 +1,95 @@
1
+ // one answer of a hub kept warm in memory: computed when the hub loads,
2
+ // recomputed in the background when something says it moved (a save, a
3
+ // stamp, its age), and served from memory. A request never pays for the
4
+ // walk behind it, except the very first one and one that asks for `fresh`.
5
+ //
6
+ // the one constraint a reader breaks first: `get()` answers what is in
7
+ // memory even when it is known stale. Whoever just changed the truth (an
8
+ // act, the end of a run) asks for `fresh`, which waits on a compute that
9
+ // started after its call.
10
+
11
+ export type WarmCellOpts = {
12
+ /** a cheap fingerprint of what the answer reads (file stats): a new one
13
+ * marks the answer stale */
14
+ stamp?: () => string
15
+ /** past this age the answer is recomputed in the background even when
16
+ * nothing said it moved: what no watch sees (a clock, another repo) */
17
+ maxAgeMs?: number
18
+ now?: () => number
19
+ }
20
+
21
+ export class WarmCell<T> {
22
+ private value: { v: T; at: number; stamp: string } | null = null
23
+ private running: Promise<T> | null = null
24
+ /** bumped by invalidate: a compute that started before it is stale */
25
+ private gen = 0
26
+ private runGen = -1
27
+
28
+ constructor(private readonly compute: () => Promise<T>, private readonly o: WarmCellOpts = {}) {}
29
+
30
+ private now(): number {
31
+ return this.o.now?.() ?? Date.now()
32
+ }
33
+
34
+ /** a compute, shared by every caller while it runs */
35
+ private run(): Promise<T> {
36
+ if (this.running != null) return this.running
37
+ const gen = this.gen
38
+ const stamp = this.o.stamp?.() ?? ''
39
+ this.runGen = gen
40
+ const p = (async (): Promise<T> => {
41
+ try {
42
+ // never synchronously: `running` is set before this settles
43
+ const v = await Promise.resolve().then(() => this.compute())
44
+ this.value = { v, at: this.now(), stamp }
45
+ return v
46
+ } finally {
47
+ this.running = null
48
+ // what said it moved while this ran is not in this answer
49
+ if (this.gen !== gen) this.kick()
50
+ }
51
+ })()
52
+ this.running = p
53
+ return p
54
+ }
55
+
56
+ /** a compute in the background: on a failure the last answer stays */
57
+ private kick(): void {
58
+ void this.run().catch(() => {})
59
+ }
60
+
61
+ get stale(): boolean {
62
+ const v = this.value
63
+ if (v == null) return true
64
+ if (this.o.maxAgeMs != null && this.now() - v.at > this.o.maxAgeMs) return true
65
+ return this.o.stamp != null && this.o.stamp() !== v.stamp
66
+ }
67
+
68
+ /** the answer in memory, a stale one kicking a recompute behind it. With
69
+ * nothing in memory yet, or `fresh`, it waits on a compute */
70
+ async get(o: { fresh?: boolean } = {}): Promise<T> {
71
+ if (o.fresh === true) {
72
+ // a compute that started before this call may miss what the
73
+ // caller just changed: wait for it, then for one of our own
74
+ if (this.running != null) await this.running.catch(() => {})
75
+ return this.run()
76
+ }
77
+ const v = this.value
78
+ // nothing in memory, a failed compute included: wait on a new one
79
+ if (v == null) return this.run()
80
+ if (this.running == null && (this.stale || this.gen !== this.runGen)) this.kick()
81
+ return v.v
82
+ }
83
+
84
+ /** something this answer reads moved: recompute in the background */
85
+ invalidate(): void {
86
+ this.gen++
87
+ if (this.running == null) this.kick()
88
+ }
89
+
90
+ /** compute now unless an answer is already in memory: the hub's load */
91
+ async warm(): Promise<void> {
92
+ if (this.value != null) return
93
+ await this.run().catch(() => {})
94
+ }
95
+ }