rvlib-shipkit 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -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'
@@ -21,19 +22,23 @@ import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
21
22
  import type { LiveView } from '../ledger/live-timeline.ts'
22
23
 
23
24
  export type { LiveView }
24
- import type { HealthSchedule } from './health-schedule.ts'
25
+ import type { PlanRow } from '../plan/planner.ts'
25
26
  import type { HubProblems } from './hub-problems.ts'
26
27
  import hubIndex from './hub-app/index.html'
28
+ import { homePort, homeUp } from '../home/home-paths.ts'
29
+ import { RUN_EXIT } from '../home/home-words.ts'
30
+ import type { RepoProcesses } from './repo-processes.ts'
27
31
  import { projectGutter } from './project-gutter-route.ts'
28
32
  import { sameOrigin } from './same-origin.ts'
29
33
  import { LAYOUT } from '../core/layout.ts'
34
+ import { WarmCell } from './warm-cell.ts'
30
35
 
31
36
  export { sameOrigin }
32
37
 
33
38
  export type HubTool = { path: string; label: string; desc: string }
34
39
 
35
40
  /** what a running gate streams back to the page */
36
- export type CheckTap = { line: (l: string) => void; check: (e: CheckEvent) => void }
41
+ export type CheckTap = { line: (l: string) => void; check: (e: CheckEvent) => void; job?: (id: string) => void }
37
42
  /** the comfy panel, as the hub needs it: the child process it embeds and the
38
43
  * box those workflows generate on (src/comfy/comfy-panel.ts owns both) */
39
44
  export type ComfyDeps = {
@@ -45,6 +50,12 @@ export type ComfyDeps = {
45
50
  export type HubDeps = {
46
51
  root: string
47
52
  name: string
53
+ /** the machine resources the repo declares in serve.uses */
54
+ uses?: string[]
55
+ /** the long running processes the repo declares in serve.processes */
56
+ processes?: RepoProcesses
57
+ /** a terminal's `shipkit ci` or `check`, run in this process; resolves to its exit code */
58
+ runCli?: (kind: 'ci' | 'check', opts: Record<string, unknown>, print: (l: string) => void) => Promise<number>
48
59
  /** 0 = random (tests); real launches pass repo.config/default */
49
60
  port: number
50
61
  /** every md graph this hub serves, in card order: /graph/<id>. run-serve
@@ -62,9 +73,12 @@ export type HubDeps = {
62
73
  * into a tree someone is editing. run-serve owns the routing between the
63
74
  * two engines. */
64
75
  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
76
+ /** this repo's rows of the plan (plan/planner.ts): the page reads them to
77
+ * say when a check runs next and why; absent = no plan */
78
+ planned?: () => PlanRow[]
79
+ /** a click: this check runs next, through the plan. false = the plan
80
+ * does not know it */
81
+ ask?: (check: string) => boolean
68
82
  /** every run on record, shaped for the timeline page. Absent = the page
69
83
  * says there is no record rather than drawing an empty one */
70
84
  timeline?: () => Promise<Timeline>
@@ -99,11 +113,40 @@ export type HubDeps = {
99
113
  * file, so every surface built on the config asks for this first, or a
100
114
  * toggle reads as a toggle that did nothing until the next restart */
101
115
  refresh?: () => Promise<void>
102
- /** RED-only ping tick, called on an interval while serving (null = off) */
116
+ /** a cheap fingerprint of what the rows and the run record read (file
117
+ * stats): a new one marks the warm answers stale. Absent = only a save
118
+ * and their age refresh them */
119
+ stamp?: () => string
120
+ /** RED-only ping tick, called on an interval by the hub runtime (null = off) */
103
121
  pingTick?: ((needs: NeedItem[]) => void) | null
104
122
  }
105
123
  export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; liveReload: boolean; stop: () => void }
106
124
 
125
+ /** one repo's request handling, apart from any server: `shipkit serve` puts it
126
+ * behind a Bun.serve of its own, the home behind /r/<repo>/ of its one */
127
+ export type HubHandler = {
128
+ tools: HubTool[]
129
+ /** a request whose path is relative to the hub's base ('/api/…', '/hub.json') */
130
+ handle: (req: Request, path: string, srv: Server) => Promise<Response>
131
+ /** a tracked file of the repo moved: every warm answer recomputes behind */
132
+ invalidate: () => void
133
+ /** compute the warm answers now, the rows first: the hub's load */
134
+ warm: () => Promise<void>
135
+ stop: () => void
136
+ }
137
+
138
+ /** past this age a warm answer recomputes in the background even when no
139
+ * save and no stamp said it moved: what no watch sees (a clock, a wiki in
140
+ * another repo, a file git ignores). Long, because a recompute holds the
141
+ * one process every repo answers from */
142
+ export const WARM_MAX_AGE_MS = 120_000
143
+
144
+ /** the one flat list of the repo, as /api/resources.json carries it before
145
+ * the plan rows and the hub problems, which are read live */
146
+ type Rows = { collections: { id: string; title: string; media: string }[]; resources: Awaited<ReturnType<typeof projectResources>> }
147
+ type Server = ReturnType<typeof Bun.serve>
148
+ type Gutter = ReturnType<typeof projectGutter>
149
+
107
150
  /** the tab refreshes ITSELF through bun's hot reload client, which rides in
108
151
  * the served page: it swaps a hub-app edit in, and reconnects across the
109
152
  * process restart a source edit triggers under `bun --watch`. Bun puts that
@@ -124,27 +167,79 @@ export function hubTools(d: Pick<HubDeps, 'graphs'>): HubTool[] {
124
167
  }
125
168
 
126
169
  // 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}`)
170
+ const homeLink = (base: string): string => `<style>.skhome{position:fixed;left:10px;top:10px;z-index:9999;font-size:17px;text-decoration:none;
171
+ background:#141619;border:1px solid #282c31;border-radius:3px;padding:5px 9px}</style><a class=skhome href="${base}/" title="hub — THE page">⌂</a>`
172
+ const withHome = (html: string, base: string): string => html.replace(/<body([^>]*)>/, `<body$1>${homeLink(base)}`)
130
173
 
131
- export function serveHub(d: HubDeps): Hub {
174
+ /** the hub's routes under `base` ('' on a server of its own, '/r/<repo>' in
175
+ * the home). `projects` is the rail, shared when one process serves many */
176
+ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } = {}): HubHandler {
177
+ const base = o.base ?? ''
132
178
  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
179
+ const projects = o.projects ?? projectGutter({ root: d.root })
180
+ // the answers a page waits on when a repo shows, kept warm in memory: a
181
+ // request answers from here and never walks the repo (warm-cell.ts)
182
+ const warmOpts = { stamp: d.stamp, maxAgeMs: WARM_MAX_AGE_MS }
183
+ const rows = new WarmCell<Rows>(async () => {
184
+ await d.refresh?.()
185
+ const cols = d.collections()
186
+ return { collections: cols.map((c) => ({ id: c.id, title: c.title, media: c.media })), resources: await projectResources(cols, d.hubProblems?.collector) }
187
+ }, warmOpts)
188
+ // the run record and the config are all it reads, and the stamp sees both
189
+ const timeline = d.timeline == null ? null : new WarmCell(d.timeline, { stamp: d.stamp })
190
+ const graphs = new Map(d.graphs.map((g) => [g.id, new WarmCell(async () => g.load(), { maxAgeMs: WARM_MAX_AGE_MS })]))
191
+ const fresh = (req: Request): boolean => new URL(req.url).searchParams.get('fresh') === '1'
192
+ return {
193
+ tools,
194
+ invalidate: () => {
195
+ rows.invalidate()
196
+ timeline?.invalidate()
197
+ for (const g of graphs.values()) g.invalidate()
198
+ },
199
+ warm: async () => {
200
+ await rows.warm()
201
+ await timeline?.warm()
202
+ for (const g of graphs.values()) await g.warm()
203
+ },
204
+ stop: () => { d.comfy?.stop() },
205
+ async handle(req, path, srv): Promise<Response> {
141
206
  if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the hub page', { status: 403 })
142
207
  // identity probe: lets a second launch tell "same repo, another
143
208
  // window" from "a DIFFERENT repo hashed onto my port"
144
209
  // `root` rides with it so a page can put a file link into his
145
210
  // 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 })
211
+ // `uses` tells the home which machine resources this repo needs,
212
+ // `base` where this hub's page sits on the server that answers
213
+ if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, root: d.root, base, tools, uses: d.uses ?? [] })
147
214
  if (path.startsWith('/api/projects')) return (await projects.handle(req, path)) ?? new Response('not found', { status: 404 })
215
+ // where the machine's home serves, and whether it does: a rail
216
+ // click opens the home on that repo, or VS Code while none runs
217
+ if (path === '/api/home-url') return Response.json({ port: homePort(), up: await homeUp() })
218
+ if (path === '/api/run' && req.method === 'POST' && d.runCli != null) {
219
+ const b = (await req.json().catch(() => ({}))) as { kind?: string; opts?: Record<string, unknown> }
220
+ if (b.kind !== 'ci' && b.kind !== 'check') return new Response('kind must be ci or check', { status: 400 })
221
+ const runCli = d.runCli
222
+ // a gate can stay silent for minutes (queued, or one long tsc):
223
+ // Bun would cut the connection after its idle timeout
224
+ srv.timeout(req, 0)
225
+ // wire lines as they come, then one EXIT line the client ends on
226
+ const stream = new ReadableStream<string>({
227
+ start(ctl) {
228
+ const put = (l: string): void => { try { ctl.enqueue(`${l}\n`) } catch { /* the terminal went away: the run finishes anyway */ } }
229
+ void runCli(b.kind as 'ci' | 'check', b.opts ?? {}, put)
230
+ .then((code) => put(`${RUN_EXIT}${code}`), (e: Error) => { put(`FAIL: ${e.message}`); put(`${RUN_EXIT}1`) })
231
+ .finally(() => { try { ctl.close() } catch { /* closed */ } })
232
+ },
233
+ })
234
+ return new Response(stream, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
235
+ }
236
+ if (path === '/api/processes.json') return Response.json({ processes: d.processes?.views() ?? [] })
237
+ if (path === '/api/processes/act' && req.method === 'POST') {
238
+ const b = (await req.json().catch(() => ({}))) as { id?: string; act?: string }
239
+ if (b.act !== 'start' && b.act !== 'stop' && b.act !== 'restart') return new Response('act must be start, stop or restart', { status: 400 })
240
+ const v = d.processes?.act(b.id ?? '', b.act)
241
+ return v == null ? new Response(`no process '${b.id ?? ''}' in serve.processes`, { status: 404 }) : Response.json(v)
242
+ }
148
243
  // the three surfaces built on the config read the config on disk
149
244
  if (path === '/api/needs.json' || path === '/api/resources.json' || path === '/api/checks.json' || path === '/api/collections.json') await d.refresh?.()
150
245
  if (path === '/api/needs.json') {
@@ -155,11 +250,11 @@ export function serveHub(d: HubDeps): Hub {
155
250
  // ONE flat list for the whole repo: the page groups it by
156
251
  // path and by type, and a second endpoint would be a second
157
252
  // answer to drift from this one
158
- const cols = d.collections()
253
+ const r = await rows.get({ fresh: fresh(req) })
159
254
  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,
255
+ collections: r.collections,
256
+ resources: r.resources,
257
+ planned: d.planned?.() ?? null,
163
258
  hub: d.hubProblems?.list() ?? [],
164
259
  })
165
260
  }
@@ -236,9 +331,9 @@ export function serveHub(d: HubDeps): Hub {
236
331
  return Response.json({ url: d.repoHub == null ? null : await d.repoHub(repo) })
237
332
  }
238
333
  if (path === '/api/timeline.json') {
239
- if (d.timeline == null) return new Response('this hub keeps no run record', { status: 404 })
334
+ if (timeline == null) return new Response('this hub keeps no run record', { status: 404 })
240
335
  try {
241
- return Response.json(await d.timeline())
336
+ return Response.json(await timeline.get({ fresh: fresh(req) }))
242
337
  } catch (e) {
243
338
  return new Response(`the run record could not be shaped: ${(e as Error).message}`, { status: 500 })
244
339
  }
@@ -319,30 +414,33 @@ export function serveHub(d: HubDeps): Hub {
319
414
  })
320
415
  return new Response(stream, { headers: { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store' } })
321
416
  }
322
- const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
417
+ const page = (s: string): Response => new Response(withHome(s, base), { headers: { 'content-type': 'text/html; charset=utf-8' } })
323
418
  for (const g of d.graphs) {
324
419
  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` })
420
+ // the graph as the warm cell holds it: a page open never rescans the wiki
421
+ const held = await graphs.get(g.id)!.get()
422
+ const routes = graphRoutes({ load: () => held, title: g.title, file: positionsFile(d.root, g.id), saveUrl: `${base}/graph/${g.id}/positions` })
326
423
  if (path === `/graph/${g.id}`) return page(routes.page())
327
- if (path === `/graph/${g.id}/graph.json`) return Response.json(g.load())
424
+ if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
328
425
  if (path === `/graph/${g.id}/positions`) return routes.save(req)
329
426
  }
330
427
  // the media API namespace — the studio route table, ONE prefix strip
331
428
  if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
332
429
  // stale bookmarks (/assets/, /status, …) land here — name the way home
333
- return new Response('not found — the hub page is at /', { status: 404 })
430
+ return new Response(`not found — the hub page is at ${base}/`, { status: 404 })
334
431
  },
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
432
  }
433
+ }
434
+
435
+ export function serveHub(d: HubDeps, h: HubHandler = hubHandler(d)): Hub {
436
+ const server: Server = Bun.serve({
437
+ hostname: '127.0.0.1',
438
+ port: d.port,
439
+ // `/` IS the page — the react app; there is no other surface
440
+ routes: { '/': hubIndex },
441
+ fetch: (req, srv) => h.handle(req, new URL(req.url).pathname, srv),
442
+ })
345
443
  // the panel process belongs to this hub: leaving it behind means the next
346
444
  // 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) } }
445
+ return { server, tools: h.tools, liveReload: canLiveReload(), stop: () => { h.stop(); server.stop(true) } }
348
446
  }
@@ -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,12 @@
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.
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.
9
5
  import { watch, type FSWatcher } from 'fs'
10
6
  import { GENERATED_IGNORE_LINES } from '../rules/untrack-generated.ts'
11
7
  import { gitBin } from '../core/git-bin.ts'
12
8
  import { LAYOUT } from '../core/layout.ts'
13
9
 
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
10
  /** every path a run of the gate or the hub itself writes, whatever the repo
71
11
  * ignores: the status files, the per run logs under the scratch dir, an atomic write's
72
12
  * temp file, the comfy state the hub writes at boot. A save there is the
@@ -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
+ }
@@ -0,0 +1,114 @@
1
+ // THE `<name>.shipkit.ts` file: a repo drops one anywhere and default-exports
2
+ // what it declares, and the kind says who reads it.
3
+ //
4
+ // check({...}) a gate check, read by discovery like a `.check.ts`
5
+ // globalProcess({...}) a long running process for the whole machine: pm
6
+ // supervises it, the home page switches it
7
+ // widget({...}) html the daemon renders every few seconds, mounted
8
+ // on the home page
9
+ //
10
+ // the one constraint a reader breaks first: every kind is IMPORTED by the
11
+ // process that reads it (discovery, pm's config child, the daemon), so a file
12
+ // keeps its top level cheap and puts heavy work inside a function.
13
+ import { check, type CheckDef } from './check.ts'
14
+
15
+ export { check }
16
+
17
+ /** one button beside a global process: a command, or a function of the file */
18
+ export type ProcessAction = {
19
+ /** the words on the button */
20
+ label: string
21
+ /** argv, run from the repo root, output appended to the process log */
22
+ cmd?: string[]
23
+ /** a function run in its own bun process that imports this file, from
24
+ * the repo root. Its output lands in the process log too */
25
+ run?: () => void | Promise<void>
26
+ }
27
+
28
+ export type GlobalProcessDef = {
29
+ /** kebab-case, unique on the machine: what a repo names in `serve.uses` */
30
+ id: string
31
+ /** the name on the switch (default the id) */
32
+ label?: string
33
+ /** one line: what it is for */
34
+ description?: string
35
+ /** the machine it runs on, as a hint beside the switch */
36
+ where?: string
37
+ /** argv, run from the repo root (or `cwd`). Without `down` it IS the
38
+ * service: pm keeps it running, restarts it on a crash, signals it on
39
+ * stop. With `down` it is a bring-up that exits once the service answers */
40
+ cmd: string[]
41
+ /** argv that takes a bring-up down again. Absent: `cmd` is the service */
42
+ down?: string[]
43
+ /** where both run, repo-relative (default the repo root) */
44
+ cwd?: string
45
+ /** the url it serves: shown as a link, probed when `probe` is absent */
46
+ url?: string
47
+ /** an http url that answers 2xx while it is up (default `url`). A
48
+ * bring-up needs one of the two: its probe is the only truth */
49
+ probe?: string
50
+ /** a button each beside the switch, by key */
51
+ actions?: Record<string, ProcessAction>
52
+ /** with `url`: open it as a shipkit app window. Starting it from the
53
+ * page opens that window once it answers, in one go */
54
+ app?: boolean
55
+ }
56
+
57
+ export type WidgetDef = {
58
+ /** kebab-case, unique in its repo */
59
+ id: string
60
+ /** the heading over it (default the id) */
61
+ title?: string
62
+ /** how often the daemon renders it again (default 5000, at least 1000) */
63
+ everyMs?: number
64
+ /** the html it shows, rendered in the daemon. A throw shows as its error */
65
+ render: () => string | Promise<string>
66
+ }
67
+
68
+ export type GlobalProcessUnit = GlobalProcessDef & { kind: 'globalProcess' }
69
+ export type WidgetUnit = WidgetDef & { kind: 'widget' }
70
+
71
+ export const UNIT_KINDS = ['globalProcess', 'widget'] as const
72
+
73
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/
74
+ const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
75
+ const httpUrl = (v: unknown): boolean => typeof v === 'string' && /^https?:\/\//.test(v)
76
+
77
+ /** why a global process declaration cannot run, or null */
78
+ export function globalProcessProblem(d: GlobalProcessDef): string | null {
79
+ const at = `globalProcess '${String(d.id)}'`
80
+ if (!KEBAB.test(d.id ?? '')) return `${at}: id must be kebab-case`
81
+ if (!words(d.cmd)) return `${at}: cmd is a list of words`
82
+ if (d.down != null && !words(d.down)) return `${at}: down is a list of words`
83
+ if (d.url != null && !httpUrl(d.url)) return `${at}: url must be an http url`
84
+ if (d.probe != null && !httpUrl(d.probe)) return `${at}: probe must be an http url`
85
+ if (d.down != null && d.probe == null && d.url == null) return `${at}: a bring-up (one with down) needs probe or url, the only truth of whether it is up`
86
+ if (d.app === true && d.url == null) return `${at}: app needs a url to open`
87
+ for (const [k, a] of Object.entries(d.actions ?? {})) {
88
+ if (!KEBAB.test(k)) return `${at}: action '${k}' must be kebab-case`
89
+ if ((a.cmd != null) === (a.run != null)) return `${at}: action '${k}' declares exactly one of cmd or run`
90
+ if (a.cmd != null && !words(a.cmd)) return `${at}: action '${k}'.cmd is a list of words`
91
+ }
92
+ return null
93
+ }
94
+
95
+ export function globalProcess(d: GlobalProcessDef): GlobalProcessUnit {
96
+ const why = globalProcessProblem(d)
97
+ if (why != null) throw new Error(why)
98
+ return { ...d, kind: 'globalProcess' }
99
+ }
100
+
101
+ export function widget(d: WidgetDef): WidgetUnit {
102
+ if (!KEBAB.test(d.id ?? '')) throw new Error(`widget '${String(d.id)}': id must be kebab-case`)
103
+ if (typeof d.render !== 'function') throw new Error(`widget '${d.id}': render is a function returning html`)
104
+ if (d.everyMs != null && !(d.everyMs >= 1000)) throw new Error(`widget '${d.id}': everyMs is at least 1000`)
105
+ return { ...d, kind: 'widget' }
106
+ }
107
+
108
+ /** the kind a default export says it is: a unit, else a check */
109
+ export function unitKind(d: unknown): 'globalProcess' | 'widget' | 'check' {
110
+ const k = (d as { kind?: unknown } | null)?.kind
111
+ return k === 'globalProcess' || k === 'widget' ? k : 'check'
112
+ }
113
+
114
+ export type { CheckDef }
package/src/shipkit.ts CHANGED
@@ -15,7 +15,8 @@ export { contractsConfigFrom, activeContracts, applyContractsResult } from './co
15
15
  export { serveHub, hubTools } from './serve/serve-hub.ts'
16
16
  export type { Hub, HubDeps, HubTool } from './serve/serve-hub.ts'
17
17
  export type { GraphMount } from './wiki/graph-mount.ts'
18
- export { runServe, resolveHubDeps, defaultHubPort, baseWikiRoot } from './serve/run-serve.ts'
18
+ export { runServe } from './serve/run-serve.ts'
19
+ export { resolveHubDeps, defaultHubPort, baseWikiRoot, loadHub } from './serve/hub-runtime.ts'
19
20
  export { projectNeeds, redCount } from './collections/collection-types.ts'
20
21
  export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
21
22
  export { buildCollections } from './collections/providers.ts'
@@ -15,7 +15,7 @@
15
15
  import { copyFileSync, mkdirSync, readFileSync } from 'fs'
16
16
  import { dirname, join } from 'path'
17
17
  import { atomicWrite } from '../core/locked-write.ts'
18
- import { shipkitRoot } from '../ledger/store.ts'
18
+ import { shipkitRoot } from '../ledger/db.ts'
19
19
 
20
20
  /** where a unit's durations live, beside everything else the repo remembers */
21
21
  export const timingsHome = (root: string, unit: string): string => join(shipkitRoot(root), 'suite-timings', `${unit === '' ? 'root' : unit}.json`)
@@ -4,7 +4,7 @@
4
4
  import { mkdirSync, readFileSync } from 'fs'
5
5
  import { dirname, join } from 'path'
6
6
  import { atomicWrite } from '../core/locked-write.ts'
7
- import { shipkitRoot } from '../ledger/store.ts'
7
+ import { shipkitRoot } from '../ledger/db.ts'
8
8
  import type { MdGraph } from './md-graph.ts'
9
9
 
10
10
  export type Positions = Record<string, [number, number]>