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
@@ -0,0 +1,208 @@
1
+ // the daemon's api, apart from the page so a test reaches it without a bundle:
2
+ // its own /api/home/* and every repo's hub under /r/<repo>/. The daemon
3
+ // decides WHEN a hub loads and never what is true: every verdict still comes
4
+ // from each repo's own runner and ledger. The machine resources are pm's
5
+ // (run-pm.ts), and the page reaches them through the web at /api/pm/*.
6
+ import { existsSync } from 'fs'
7
+ import { dirname, join } from 'path'
8
+ import { fileURLToPath } from 'url'
9
+ import { installedVersion, isBehind } from '../fleet/scan-fleet.ts'
10
+ import { LIB_VERSION } from '../core/lib-version.ts'
11
+ import { projectGutter, type ProjectRow } from '../serve/project-gutter-route.ts'
12
+ import type { WiredRepo } from '../serve/project-gutter.ts'
13
+ import { sameOrigin } from '../serve/same-origin.ts'
14
+ import type { HomeState } from './home-state.ts'
15
+ import { tailLines } from './supervise.ts'
16
+ import { Hubs, type WorkerView } from './hubs.ts'
17
+ import type { Planner, PlanRow } from '../plan/planner.ts'
18
+ import type { Widgets, WidgetView } from './widgets.ts'
19
+
20
+ /** how often the daemon looks at every repo */
21
+ export const TICK_MS = 2_000
22
+
23
+ export type ModernizeView = { status: 'running' | 'done' | 'failed'; code: number | null; tail: string[] }
24
+
25
+ export type HomeAnswer = {
26
+ version: string
27
+ workers: WorkerView[]
28
+ /** the repos flagged active by hand */
29
+ active: string[]
30
+ /** repo → its installed shipkit, for every repo behind this home's */
31
+ behind: Record<string, string>
32
+ modernize: Record<string, ModernizeView>
33
+ }
34
+
35
+ export type HomeDeps = {
36
+ root: string
37
+ name: string
38
+ port: number
39
+ dir: string
40
+ hubs: Hubs
41
+ state: HomeState
42
+ /** the rail rows, the same measure a hub's rail reads */
43
+ gutter: ReturnType<typeof projectGutter>
44
+ /** the machine's one plan, which this tick drives. Absent = nothing runs
45
+ * on its own (a test of the routes alone) */
46
+ plan?: Planner
47
+ /** the repos it loads, by name; absent = every house repo. A smoke on a
48
+ * real machine keeps the others out, so no save there starts a gate */
49
+ only?: Set<string>
50
+ /** every house repo's widgets, rendered on the daemon's tick. Absent =
51
+ * none (a test of the routes alone) */
52
+ widgets?: Widgets
53
+ /** brings a repo current (modernizeCmd); resolves to its exit code */
54
+ modernize?: (repo: WiredRepo, log: string) => Promise<number>
55
+ }
56
+
57
+ const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
58
+
59
+ /** what bringing a repo current runs: its first install when it has none,
60
+ * else the fleet update, which goes through the repo's own gate */
61
+ export function modernizeCmd(repo: WiredRepo, root: string): { cmd: string[]; cwd: string } {
62
+ if (!existsSync(join(repo.path, 'node_modules'))) return { cmd: ['bun', 'install'], cwd: repo.path }
63
+ return { cmd: ['bun', CLI, 'fleet', '--update', '--only', repo.name], cwd: root }
64
+ }
65
+
66
+ export const runModernize = async (repo: WiredRepo, root: string, log: string): Promise<number> => {
67
+ const { mkdirSync, openSync, closeSync } = await import('fs')
68
+ mkdirSync(dirname(log), { recursive: true })
69
+ const fd = openSync(log, 'w')
70
+ try {
71
+ const { cmd, cwd } = modernizeCmd(repo, root)
72
+ const p = Bun.spawn(cmd, { cwd, stdio: ['ignore', fd, fd] })
73
+ return await p.exited
74
+ } finally {
75
+ closeSync(fd)
76
+ }
77
+ }
78
+
79
+ /** active = an agent works in it, VS Code has it open, or it is flagged by hand */
80
+ export function activeSet(rows: ProjectRow[], flagged: string[]): Set<string> {
81
+ const out = new Set(flagged)
82
+ for (const r of rows) if ((r.sessions != null && r.sessions > 0) || r.vscode === true) out.add(r.name)
83
+ return out
84
+ }
85
+
86
+ /** the monitor area's data: every widget's last html */
87
+ export type WidgetsAnswer = { widgets: WidgetView[]; errors: string[] }
88
+
89
+ /** the planned page's data: every row of the machine's plan */
90
+ export type PlannedAnswer = { now: number; rows: PlanRow[]; errors: { repo: string; error: string }[]; running: boolean }
91
+
92
+ export function homeRoutes(d: HomeDeps) {
93
+ let repos: WiredRepo[] = []
94
+ let rows: ProjectRow[] = []
95
+ const modernizing = new Map<string, ModernizeView & { log: string }>()
96
+ const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
97
+
98
+ async function refresh(): Promise<void> {
99
+ const a = await d.gutter.answer()
100
+ rows = a.projects
101
+ repos = a.projects.filter((p) => d.only == null || d.only.has(p.name)).map((p) => ({ name: p.name, path: p.path, icon: null }))
102
+ d.hubs.sync(repos)
103
+ }
104
+
105
+ let active = new Set<string>()
106
+ if (d.plan != null) d.plan.active = (name) => active.has(name)
107
+
108
+ async function tick(): Promise<void> {
109
+ await refresh()
110
+ active = activeSet(rows, d.state.data.active)
111
+ // the plan's clock: one look per tick, and it starts at most one batch
112
+ d.plan?.tick()
113
+ // a widget render never holds the tick back
114
+ void d.widgets?.tick(repos.map((r) => ({ name: r.name, path: r.path }))).catch((e) => console.error(`WARN the widgets broke: ${(e as Error).message}`))
115
+ await d.hubs.tick(active)
116
+ }
117
+
118
+ function answer(): HomeAnswer {
119
+ const behind: Record<string, string> = {}
120
+ for (const r of repos) {
121
+ const v = installedVersion(r.path, 'rvlib-shipkit')
122
+ if (v != null && isBehind(v, LIB_VERSION)) behind[r.name] = v
123
+ }
124
+ const modernize: Record<string, ModernizeView> = {}
125
+ for (const [k, m] of modernizing) modernize[k] = { status: m.status, code: m.code, tail: tailLines(m.log, 40) }
126
+ return { version: LIB_VERSION, workers: d.hubs.views(repos), active: d.state.data.active, behind, modernize }
127
+ }
128
+
129
+ const find = (name: unknown): WiredRepo | null => repos.find((r) => r.name === name) ?? null
130
+ const body = async (req: Request): Promise<Record<string, unknown>> => (await req.json().catch(() => ({}))) as Record<string, unknown>
131
+
132
+ async function handle(req: Request, srv?: Parameters<Hubs['dispatch']>[2]): Promise<Response | null> {
133
+ const path = new URL(req.url).pathname
134
+ // every repo's hub, which makes its own same origin check
135
+ if (path.startsWith('/r/')) {
136
+ if (repos.length === 0) await refresh()
137
+ return d.hubs.dispatch(req, path, srv as Parameters<Hubs['dispatch']>[2])
138
+ }
139
+ if (path === '/hub.json')
140
+ // the identity a client probes (homeUp), through the web or here
141
+ return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION }, { headers: { 'access-control-allow-origin': '*' } })
142
+ if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
143
+ if (path.startsWith('/api/projects')) return d.gutter.handle(req, path)
144
+ // the web asks which page routes to serve: one per house repo
145
+ if (path === '/api/home/repos.json') {
146
+ if (repos.length === 0) await refresh()
147
+ return Response.json({ repos: d.hubs.names() })
148
+ }
149
+ if (path === '/api/home/planned.json') {
150
+ if (repos.length === 0) await refresh()
151
+ const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true }
152
+ return Response.json(out)
153
+ }
154
+ if (path === '/api/home/widgets.json') {
155
+ const out: WidgetsAnswer = { widgets: d.widgets?.views() ?? [], errors: d.widgets?.errors ?? [] }
156
+ return Response.json(out)
157
+ }
158
+ if (path === '/api/home.json') {
159
+ if (repos.length === 0) await refresh()
160
+ return Response.json(answer())
161
+ }
162
+ if (req.method !== 'POST') return null
163
+ if (path === '/api/home/hub') {
164
+ const b = await body(req)
165
+ if (repos.length === 0) await refresh()
166
+ const r = find(b.name)
167
+ if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
168
+ if (b.act === 'ensure') return Response.json(d.hubs.ensure(r))
169
+ if (b.act === 'stop') return Response.json(d.hubs.stop(r))
170
+ if (b.act === 'restart') return Response.json(await d.hubs.restart(r))
171
+ return new Response(`act must be ensure, stop or restart`, { status: 400 })
172
+ }
173
+ if (path === '/api/home/planned/run') {
174
+ const b = await body(req)
175
+ if (d.plan == null || !d.plan.ask(String(b.repo), String(b.check))) return new Response(`no planned check ${String(b.check)} in ${String(b.repo)}`, { status: 404 })
176
+ d.plan.tick()
177
+ return Response.json({ ok: true })
178
+ }
179
+ if (path === '/api/home/active') {
180
+ const b = await body(req)
181
+ if (find(b.name) == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
182
+ d.state.setActive(String(b.name), b.on === true)
183
+ return Response.json({ active: d.state.data.active })
184
+ }
185
+ if (path === '/api/home/modernize') {
186
+ const b = await body(req)
187
+ const r = find(b.name)
188
+ if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
189
+ if (modernizing.get(r.name)?.status === 'running') return new Response(`already modernizing ${r.name}`, { status: 409 })
190
+ const log = modernizeLog(r.name)
191
+ const m = { status: 'running' as ModernizeView['status'], code: null as number | null, tail: [], log }
192
+ modernizing.set(r.name, m)
193
+ void (d.modernize ?? ((repo, l) => runModernize(repo, d.root, l)))(r, log)
194
+ .then(async (code) => {
195
+ m.status = code === 0 ? 'done' : 'failed'
196
+ m.code = code
197
+ // the hub loads again on what the update installed
198
+ if (code === 0) await d.hubs.restart(r)
199
+ })
200
+ .catch(() => { m.status = 'failed' })
201
+ return Response.json({ ok: true })
202
+ }
203
+ return null
204
+ }
205
+
206
+ return { handle, tick, answer, refresh }
207
+ }
208
+
@@ -0,0 +1,105 @@
1
+ // getting to a running home from anywhere: is pm installed, start it, find
2
+ // the house repo it runs from, open a window on it. `shipkit gui` and the
3
+ // serve handoff both come through here.
4
+ import { existsSync, mkdirSync, readFileSync } from 'fs'
5
+ import { homedir } from 'os'
6
+ import { dirname, join, resolve } from 'path'
7
+ import { gitRoot } from '../core/git-root.ts'
8
+ import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
9
+ import { BUNDLE_ID, canBuildGui, ensureGuiApp } from './gui-app.ts'
10
+ import { LABEL, plistPath } from './home-launchd.ts'
11
+ import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
12
+
13
+ /** the pm LaunchAgent is installed: on this machine no hub runs outside the home */
14
+ export const homeInstalled = (): boolean => existsSync(plistPath())
15
+
16
+ /** set, a machine with no LaunchAgent still sends its hubs and gates to the
17
+ * home that answers on SHIPKIT_HOME_PORT (a home started by hand, a test) */
18
+ export const USE_HOME_ENV = 'SHIPKIT_USE_HOME'
19
+
20
+ /** this machine runs its hubs and gates in the home */
21
+ export const homeWanted = (env: Record<string, string | undefined> = process.env, installed: () => boolean = homeInstalled): boolean =>
22
+ (env[USE_HOME_ENV] != null && env[USE_HOME_ENV] !== '') || installed()
23
+
24
+ /** how long a start waits for the home to answer */
25
+ export const START_WAIT_MS = 15_000
26
+
27
+ /** kick the installed pm agent and wait until the role answers: the daemon
28
+ * for a terminal client, the web for a window. Throws, loud, when it does
29
+ * not: a caller must never fall back to a hub of its own */
30
+ export async function startHome(o: { wait?: number; need?: 'daemon' | 'web' } = {}): Promise<void> {
31
+ const need = o.need ?? 'daemon'
32
+ const up = (): Promise<boolean> => (need === 'daemon' ? daemonUp() : homeUp(homePort()))
33
+ if (await up()) return
34
+ const p = Bun.spawn(['launchctl', 'kickstart', `gui/${process.getuid?.() ?? 501}/${LABEL}`], { stdout: 'ignore', stderr: 'pipe' })
35
+ const err = (await new Response(p.stderr).text()).trim()
36
+ if ((await p.exited) !== 0) throw new Error(`launchctl kickstart ${LABEL} failed: ${err}. Reinstall it with \`shipkit pm install\` from the house repo`)
37
+ const until = Date.now() + (o.wait ?? START_WAIT_MS)
38
+ while (Date.now() < until) {
39
+ if (await up()) return
40
+ await Bun.sleep(250)
41
+ }
42
+ const port = need === 'daemon' ? daemonPort() : homePort()
43
+ throw new Error(`the ${need} did not answer on :${port} within ${Math.round((o.wait ?? START_WAIT_MS) / 1000)}s: see ${childLog(homeDir(), need)} and ${pmLog()}`)
44
+ }
45
+
46
+ /** the cli the home runs from its repo: the one that repo's own `hub` script
47
+ * runs (an installed copy, or the lib source in the repo that hosts it),
48
+ * else this one */
49
+ export function houseCli(root: string, own: string): string {
50
+ try {
51
+ const hub = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts?.['hub'] ?? ''
52
+ const cli = /(\S*cli\.ts)\b/.exec(hub)?.[1]
53
+ if (cli != null) {
54
+ const abs = resolve(root, cli)
55
+ if (existsSync(abs)) return abs
56
+ }
57
+ } catch { /* no package.json: this cli */ }
58
+ return own
59
+ }
60
+
61
+ /** the repo that hosts the house wiki, seen from any house repo, or null */
62
+ export function houseRootOf(cwd: string): string | null {
63
+ const index = houseIndex(homeRepo(cwd), homedir())
64
+ if (index == null) return null
65
+ return gitRoot(dirname(index))?.top ?? null
66
+ }
67
+
68
+ export const CHROME = process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
69
+ /** the window's own Chrome profile: its own dock icon and cookies, apart from the everyday browser */
70
+ export const guiProfile = (): string => join(homeDir(), 'gui-profile')
71
+ const GUI_DISK_CACHE = 32 * 1024 * 1024
72
+
73
+ /** the command that opens one more home window: a Chrome app window (no tabs,
74
+ * no address bar) on its own profile, else the default browser */
75
+ export function windowCmd(url: string, o: { chrome?: string; platform?: string } = {}): string[] {
76
+ const chrome = o.chrome ?? CHROME
77
+ const platform = o.platform ?? process.platform
78
+ // no component downloads and a small disk cache: the profile shows one
79
+ // page, and those two are most of what grows it (retention.ts caps it)
80
+ if (existsSync(chrome)) return [chrome, `--app=${url}`, `--user-data-dir=${guiProfile()}`, '--no-first-run', '--no-default-browser-check', '--disable-component-update', `--disk-cache-size=${GUI_DISK_CACHE}`]
81
+ return platform === 'darwin' ? ['open', url] : ['xdg-open', url]
82
+ }
83
+
84
+ /** one more home window: the native app where it can be built (its own
85
+ * dock icon, the house mark), else a Chrome app window. `warn` hears why the
86
+ * native one was not used */
87
+ export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
88
+ if (canBuildGui()) {
89
+ try {
90
+ const { app, rebuilt } = ensureGuiApp({ root: o.root ?? houseRootOf(process.cwd()) ?? process.cwd(), version: o.version ?? '0' })
91
+ // a running copy is the old build: it quits, and the new one opens the window
92
+ if (rebuilt) Bun.spawnSync(['osascript', '-e', `quit app id "${BUNDLE_ID}"`], { stdout: 'ignore', stderr: 'ignore' })
93
+ const cmd = ['open', '-a', app, url]
94
+ Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'] })
95
+ return cmd
96
+ } catch (e) {
97
+ o.warn?.(`WARN the shipkit app could not be built, so this window is a Chrome one: ${(e as Error).message}`)
98
+ }
99
+ }
100
+ mkdirSync(guiProfile(), { recursive: true })
101
+ const cmd = windowCmd(url)
102
+ // detached: the window outlives the command that opened it
103
+ Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'], detached: true }).unref()
104
+ return cmd
105
+ }
@@ -0,0 +1,43 @@
1
+ // what the home remembers across restarts, and only that: the repos flagged
2
+ // active by hand. One row of the machine db (core/machine-db.ts), edited by
3
+ // the page.
4
+ import { importMachineFile, machineGet, machineSet } from '../core/machine-db.ts'
5
+ import { stateFile } from './home-paths.ts'
6
+
7
+ export type HomeStateData = {
8
+ /** repos whose hub runs whether or not an agent works in them */
9
+ active: string[]
10
+ }
11
+
12
+ const KEY = 'home.active'
13
+
14
+ export class HomeState {
15
+ data: HomeStateData
16
+
17
+ /** `dir` is the home dir, where the json file an older copy wrote is
18
+ * read once into the db */
19
+ constructor(dir: string) {
20
+ importMachineFile<string[]>(KEY, stateFile(dir), (t) => HomeState.parse(t).active)
21
+ this.data = { active: HomeState.clean(machineGet<unknown>(KEY)) }
22
+ }
23
+
24
+ static parse(text: string): HomeStateData {
25
+ try {
26
+ return { active: HomeState.clean((JSON.parse(text) as Partial<HomeStateData>).active) }
27
+ } catch {
28
+ return { active: [] }
29
+ }
30
+ }
31
+
32
+ private static clean(v: unknown): string[] {
33
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
34
+ }
35
+
36
+ setActive(name: string, on: boolean): void {
37
+ const set = new Set(this.data.active)
38
+ if (on) set.add(name)
39
+ else set.delete(name)
40
+ this.data = { ...this.data, active: [...set].sort() }
41
+ if (!machineSet(KEY, this.data.active)) throw new Error('the machine db could not be written, so the active flag is not saved')
42
+ }
43
+ }
@@ -0,0 +1,12 @@
1
+ // the words the home's server and page both need, in a module with no node
2
+ // import, so the page bundle can take it.
3
+
4
+ /** the blocker the page answers with an install button */
5
+ export const NOT_INSTALLED = 'rvlib-shipkit is not installed here: run `bun install`'
6
+
7
+ /** the last line of a run a hub streams to a terminal: its exit code follows */
8
+ export const RUN_EXIT = '\u0000shipkit-exit '
9
+
10
+ /** a line of a run a hub streams to a terminal: the gate job it started, so a
11
+ * terminal whose stream is cut follows the rest from the job folder */
12
+ export const RUN_JOB = '\u0000shipkit-job '
@@ -0,0 +1,286 @@
1
+ // every house repo's hub, IN the home process: one runtime per repo
2
+ // (serve/hub-runtime.ts), loaded at boot one after the other, served under
3
+ // /r/<repo>/ of the home's one server. A repo whose config or deps fail to
4
+ // load reads crashed with the error, and the others keep serving.
5
+ //
6
+ // the one constraint a reader breaks first: nothing here starts a gate. Every
7
+ // run nobody asked for comes from the machine's one plan (plan/planner.ts),
8
+ // which each loaded hub joins. An edit of a repo config reloads that repo's
9
+ // hub alone, once its gate is done: the daemon runs with no bun watch, so one
10
+ // repo's edit never restarts the others (lib-watch.ts).
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
12
+ import { dirname, join } from 'path'
13
+ import type { WiredRepo } from '../serve/project-gutter.ts'
14
+ import type { HubRuntime, ServeIo } from '../serve/hub-runtime.ts'
15
+ import { configStamp, PING_EVERY_MS } from '../serve/hub-runtime.ts'
16
+ import { capLog, tailLines, type ProcStatus } from './supervise.ts'
17
+ import { hubBase, hubLog } from './home-paths.ts'
18
+ import { NOT_INSTALLED } from './home-words.ts'
19
+
20
+ /** what the home needs of a loaded hub */
21
+ export type LoadedHub = Pick<HubRuntime, 'handler' | 'start' | 'stop' | 'ping' | 'busy'> & { uses: string[] }
22
+
23
+ export type HubLoader = (repo: WiredRepo, io: ServeIo, o: { fresh: boolean }) => Promise<LoadedHub>
24
+
25
+ export type WorkerView = {
26
+ id: string
27
+ repo: string
28
+ status: ProcStatus
29
+ /** the home's pid while the hub is loaded: it runs in the home */
30
+ pid: number | null
31
+ /** epoch ms of the last status change */
32
+ since: number
33
+ /** why it is not up, when that is known and `blocked` does not say it */
34
+ note: string | null
35
+ /** the home's port, which serves every hub */
36
+ port: number
37
+ /** the hub page, on the home's origin */
38
+ url: string
39
+ /** why the home cannot load it at all */
40
+ blocked: string | null
41
+ /** the last lines of its log, while it is not up */
42
+ tail: string[]
43
+ /** the machine resources its config names in serve.uses */
44
+ uses: string[]
45
+ }
46
+
47
+ type Entry = {
48
+ repo: WiredRepo
49
+ status: ProcStatus
50
+ since: number
51
+ note: string | null
52
+ rt: LoadedHub | null
53
+ loading: Promise<void> | null
54
+ /** loads done, so a view tells a reload from the first one */
55
+ loads: number
56
+ /** stopped by hand: nothing but a restart loads it again */
57
+ held: boolean
58
+ /** its repo.config.ts as the last load read it */
59
+ stamp: string
60
+ }
61
+
62
+ /** why the home cannot load this repo's hub, or null when it can */
63
+ export function hubBlocker(path: string): string | null {
64
+ if (!existsSync(join(path, 'repo.config.ts'))) return 'no repo.config.ts: this repo is not wired to shipkit'
65
+ return null
66
+ }
67
+
68
+ /** a load that failed on a fresh clone: its dependencies are not installed */
69
+ function notInstalled(path: string): boolean {
70
+ if (existsSync(join(path, 'node_modules'))) return false
71
+ try {
72
+ const pkg = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) as { dependencies?: object; devDependencies?: object }
73
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).length > 0
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ export class Hubs {
80
+ private entries = new Map<string, Entry>()
81
+ private queue: Promise<void> = Promise.resolve()
82
+ private now: () => number
83
+ private lastPing = -Infinity
84
+
85
+ /** `prepare` runs before each load and may edit the repo's config (the
86
+ * identity fill): the stamp is taken after it, so its edit is no reload */
87
+ constructor(private o: { dir: string; port: number; load: HubLoader; prepare?: (repo: WiredRepo, io: ServeIo) => Promise<void>; now?: () => number }) {
88
+ this.now = o.now ?? Date.now
89
+ }
90
+
91
+ private io(e: Entry): ServeIo {
92
+ const file = hubLog(this.o.dir, e.repo.name)
93
+ const put = (l: string): void => {
94
+ try {
95
+ mkdirSync(dirname(file), { recursive: true })
96
+ appendFileSync(file, `${l}\n`)
97
+ } catch { /* a full disk loses a log line, never the hub */ }
98
+ }
99
+ return { print: put, printErr: put }
100
+ }
101
+
102
+ private entry(repo: WiredRepo): Entry {
103
+ let e = this.entries.get(repo.name)
104
+ if (e == null) {
105
+ e = { repo, status: 'down', since: this.now(), note: null, rt: null, loading: null, loads: 0, held: false, stamp: '' }
106
+ this.entries.set(repo.name, e)
107
+ }
108
+ e.repo = repo
109
+ return e
110
+ }
111
+
112
+ private set(e: Entry, status: ProcStatus, note: string | null = null): void {
113
+ e.status = status
114
+ e.note = note
115
+ e.since = this.now()
116
+ }
117
+
118
+ /** queue a load: one at a time for the machine, in the order asked */
119
+ private schedule(e: Entry, fresh: boolean): Promise<void> {
120
+ if (e.loading != null) return e.loading
121
+ this.set(e, 'starting')
122
+ const run = this.queue.then(async () => {
123
+ const blocked = hubBlocker(e.repo.path)
124
+ if (blocked != null) {
125
+ this.set(e, 'down', blocked)
126
+ return
127
+ }
128
+ const io = this.io(e)
129
+ try {
130
+ if (this.o.prepare != null) await this.o.prepare(e.repo, io).catch((err) => io.printErr(`WARN ${e.repo.name}: ${(err as Error).message}`))
131
+ e.stamp = configStamp(e.repo.path)
132
+ const rt = await this.o.load(e.repo, io, { fresh: fresh || e.loads > 0 })
133
+ // a stop by hand came while it loaded, and wins
134
+ if (e.held) {
135
+ rt.stop({ procs: true })
136
+ this.set(e, 'down', 'stopped by hand')
137
+ return
138
+ }
139
+ rt.start()
140
+ e.rt = rt
141
+ e.loads++
142
+ this.set(e, 'up')
143
+ } catch (err) {
144
+ if (e.held) return
145
+ const msg = (err as Error).message
146
+ io.printErr(`FAIL: ${e.repo.name}'s hub did not load: ${msg}`)
147
+ this.set(e, 'crashed', msg)
148
+ }
149
+ }).finally(() => { e.loading = null })
150
+ e.loading = run
151
+ this.queue = run.catch(() => {})
152
+ return run
153
+ }
154
+
155
+ /** the list of house repos moved: a new one loads, a gone one stops. The
156
+ * first call is the boot: every repo queues, in rail order */
157
+ sync(repos: WiredRepo[]): void {
158
+ const names = new Set(repos.map((r) => r.name))
159
+ for (const [name, e] of this.entries) {
160
+ if (names.has(name)) continue
161
+ e.rt?.stop({ procs: true })
162
+ this.entries.delete(name)
163
+ }
164
+ for (const r of repos) {
165
+ const known = this.entries.has(r.name)
166
+ const e = this.entry(r)
167
+ if (!known) void this.schedule(e, false)
168
+ }
169
+ }
170
+
171
+ /** load it when it is down, and say where it stands */
172
+ ensure(repo: WiredRepo): WorkerView {
173
+ const e = this.entry(repo)
174
+ if (e.status === 'down' && e.rt == null && !e.held && hubBlocker(repo.path) == null) void this.schedule(e, false)
175
+ return this.view(e)
176
+ }
177
+
178
+ /** held down until a restart; a load in flight is dropped when it lands */
179
+ stop(repo: WiredRepo): WorkerView {
180
+ const e = this.entry(repo)
181
+ e.held = true
182
+ e.rt?.stop({ procs: true })
183
+ e.rt = null
184
+ this.set(e, 'down', 'stopped by hand')
185
+ return this.view(e)
186
+ }
187
+
188
+ /** stop, then load again with the config read fresh */
189
+ async restart(repo: WiredRepo): Promise<WorkerView> {
190
+ const e = this.entry(repo)
191
+ await e.loading
192
+ e.held = false
193
+ e.rt?.stop({ procs: true })
194
+ e.rt = null
195
+ await this.schedule(e, true)
196
+ return this.view(e)
197
+ }
198
+
199
+ /** a request under /r/<repo>/: waits for a load in flight, then the
200
+ * repo's own handler answers with the path below its base */
201
+ async dispatch(req: Request, path: string, srv: Parameters<LoadedHub['handler']['handle']>[2]): Promise<Response> {
202
+ const m = /^\/r\/([^/]+)(\/.*)?$/.exec(path)
203
+ if (m == null) return new Response('not found', { status: 404 })
204
+ const name = decodeURIComponent(m[1]!)
205
+ const rest = m[2]
206
+ if (rest == null) return Response.redirect(`${hubBase(name)}/${new URL(req.url).search}`, 308)
207
+ const e = this.entries.get(name)
208
+ if (e == null) return new Response(`not a house repo: ${name}`, { status: 404 })
209
+ await e.loading
210
+ if (e.rt == null) return new Response(`${name}'s hub is ${e.status}${e.note != null ? `: ${e.note}` : ''}`, { status: 409 })
211
+ return e.rt.handler.handle(req, rest, srv)
212
+ }
213
+
214
+ /** one pass: logs stay capped, an edited config reloads its hub, and the
215
+ * ACTIVE repos get their RED ping every PING_EVERY_MS */
216
+ async tick(active: Set<string>): Promise<void> {
217
+ for (const e of this.entries.values()) {
218
+ capLog(hubLog(this.o.dir, e.repo.name))
219
+ this.reloadIfEdited(e)
220
+ }
221
+ const now = this.now()
222
+ const up = [...this.entries.values()].filter((e) => e.rt != null && active.has(e.repo.name))
223
+ if (now - this.lastPing >= PING_EVERY_MS) {
224
+ this.lastPing = now
225
+ for (const e of up) await e.rt?.ping()
226
+ }
227
+ }
228
+
229
+ /** its config moved since it loaded: reload it with the config read
230
+ * fresh, its processes left running for the new load to adopt. A gate in
231
+ * flight defers it to a later tick */
232
+ private reloadIfEdited(e: Entry): void {
233
+ if (e.loading != null || e.held) return
234
+ const now = configStamp(e.repo.path)
235
+ if (now === e.stamp || now === '') return
236
+ // a hub that crashed on its config gets its next try from the edit
237
+ if (e.rt == null) {
238
+ if (e.status !== 'crashed') return
239
+ this.io(e).print(`ok: ${e.repo.name}'s repo.config.ts changed: loading its hub again`)
240
+ void this.schedule(e, true)
241
+ return
242
+ }
243
+ if (e.rt.busy()) return
244
+ this.io(e).print(`ok: ${e.repo.name}'s repo.config.ts changed: reloading its hub`)
245
+ e.rt.stop()
246
+ e.rt = null
247
+ void this.schedule(e, true)
248
+ }
249
+
250
+ /** a run is in flight in some hub */
251
+ busy(): boolean {
252
+ for (const e of this.entries.values()) if (e.rt?.busy() === true) return true
253
+ return false
254
+ }
255
+
256
+ /** the repos it knows, loaded or not */
257
+ names(): string[] {
258
+ return [...this.entries.keys()]
259
+ }
260
+
261
+ private view(e: Entry): WorkerView {
262
+ const blocked = hubBlocker(e.repo.path) ?? (e.status === 'crashed' && notInstalled(e.repo.path) ? NOT_INSTALLED : null)
263
+ return {
264
+ id: e.repo.name,
265
+ repo: e.repo.name,
266
+ status: e.status,
267
+ pid: e.rt != null ? process.pid : null,
268
+ since: e.since,
269
+ note: blocked != null && e.note === blocked ? null : e.note,
270
+ port: this.o.port,
271
+ url: `${hubBase(e.repo.name)}/`,
272
+ blocked,
273
+ tail: e.status === 'up' ? [] : tailLines(hubLog(this.o.dir, e.repo.name), 30),
274
+ uses: e.rt?.uses ?? [],
275
+ }
276
+ }
277
+
278
+ views(repos: WiredRepo[]): WorkerView[] {
279
+ return repos.map((r) => this.view(this.entry(r)))
280
+ }
281
+
282
+ /** every hub stops, its processes left running for the next home to adopt */
283
+ stopAll(): void {
284
+ for (const e of this.entries.values()) e.rt?.stop()
285
+ }
286
+ }