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
@@ -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
+ }
@@ -0,0 +1,139 @@
1
+ // each machine process watches its OWN code and restarts in place on it: pm,
2
+ // the daemon and the web each run with no bun watch. A bun watch follows the
3
+ // whole module graph, which for the daemon holds every house repo config and
4
+ // check file (one repo's edit would restart every repo), and for any process
5
+ // started through cli.ts holds every command (one edit would restart all
6
+ // three). This watch covers the lib source, filtered to the files the role's
7
+ // own entry module reaches, and says a change once the burst of writes settles.
8
+ import { existsSync, readFileSync, watch, type FSWatcher } from 'fs'
9
+ import { dirname, extname, join, relative, resolve } from 'path'
10
+ import { LIB_ROOT } from '../core/lib-root.ts'
11
+ import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
12
+ import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
13
+ import { StaleRestart } from '../serve/stale-restart.ts'
14
+
15
+ const CODE = /\.(ts|tsx|js|jsx|json|css|html)$/
16
+ const LOADER: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.tsx': 'tsx', '.js': 'js', '.jsx': 'jsx' }
17
+ const TRY = ['', '.ts', '.tsx', '.js', '/index.ts', '/index.tsx']
18
+
19
+ function resolveRel(from: string, spec: string): string | null {
20
+ const base = resolve(dirname(from), spec)
21
+ for (const s of TRY) if (existsSync(base + s) && extname(base + s) !== '') return base + s
22
+ return null
23
+ }
24
+
25
+ /** every lib file `entry` reaches through its relative imports, type only
26
+ * imports left out (an edit there changes no running code). With `bundles`,
27
+ * an imported html page is followed into the scripts and styles it names:
28
+ * the web serves those, the daemon only holds the page object */
29
+ export function libGraph(entry: string, o: { bundles?: boolean } = {}): Set<string> {
30
+ const out = new Set<string>()
31
+ const todo = [resolve(entry)]
32
+ while (todo.length > 0) {
33
+ const f = todo.pop()!
34
+ if (out.has(f)) continue
35
+ out.add(f)
36
+ const ext = extname(f)
37
+ let text: string
38
+ try { text = readFileSync(f, 'utf8') } catch { continue }
39
+ const specs: string[] = []
40
+ if (LOADER[ext] != null) {
41
+ try {
42
+ for (const i of new Bun.Transpiler({ loader: LOADER[ext] }).scanImports(text)) specs.push(i.path)
43
+ } catch { /* a file that does not parse is still watched, its imports are not */ }
44
+ } else if (ext === '.html' && o.bundles === true) {
45
+ for (const m of text.matchAll(/(?:src|href)="(\.[^"]+)"/g)) specs.push(m[1]!)
46
+ }
47
+ for (const s of specs) {
48
+ if (!s.startsWith('.')) continue
49
+ const r = resolveRel(f, s)
50
+ if (r != null) todo.push(r)
51
+ }
52
+ }
53
+ return out
54
+ }
55
+
56
+ export class LibWatch {
57
+ private w: FSWatcher | null = null
58
+ private last: { file: string; at: number } | null = null
59
+ readonly error: string | null = null
60
+ private now: () => number
61
+ private settleMs: number
62
+
63
+ /** `only`: absolute paths; an edit of any other file is not a change */
64
+ constructor(private root: string, o: { settleMs?: number; now?: () => number; only?: Set<string> } = {}) {
65
+ this.now = o.now ?? Date.now
66
+ this.settleMs = o.settleMs ?? 1_000
67
+ try {
68
+ this.w = watch(join(root, 'src'), { recursive: true }, (_ev, file) => {
69
+ if (file == null || !CODE.test(String(file))) return
70
+ if (o.only != null && !o.only.has(join(root, 'src', String(file)))) return
71
+ this.last = { file: String(file), at: this.now() }
72
+ })
73
+ } catch (e) {
74
+ this.error = (e as Error).message
75
+ }
76
+ }
77
+
78
+ /** a change was seen, settled or not */
79
+ pending(): boolean {
80
+ return this.last != null
81
+ }
82
+
83
+ /** one line on what moved, once no write came for settleMs, else null */
84
+ why(): string | null {
85
+ if (this.last == null || this.now() - this.last.at < this.settleMs) return null
86
+ return `the shipkit source moved (src/${this.last.file})`
87
+ }
88
+
89
+ close(): void {
90
+ this.w?.close()
91
+ this.w = null
92
+ }
93
+ }
94
+
95
+ /** a machine process restarts itself in place, same pid, once its own code
96
+ * moved and nothing it runs is in flight. `before` tears down what must not
97
+ * outlive the image (its server); what it started detached keeps running */
98
+ export function reloadOnOwnCode(o: {
99
+ role: string
100
+ /** the role's runner module: its graph is what the role reloads on */
101
+ entry: string
102
+ bundles?: boolean
103
+ busy?: () => boolean
104
+ before: () => void
105
+ io: { print: (l: string) => void; printErr: (l: string) => void }
106
+ }): { stop: () => void; files: number } {
107
+ const graph = libGraph(o.entry, { bundles: o.bundles })
108
+ const lib = new LibWatch(LIB_ROOT, { only: graph })
109
+ if (lib.error != null) o.io.printErr(`WARN no watch on the shipkit source (${lib.error}), so an edit of the ${o.role} needs a restart of it`)
110
+ const stale = new StaleRestart({
111
+ boot: LIB_VERSION,
112
+ disk: diskLibVersion,
113
+ moved: () => lib.why(),
114
+ busy: o.busy ?? (() => false),
115
+ restart: (why) => {
116
+ const blocked = reexecBlocker()
117
+ if (blocked != null) throw new Error(blocked)
118
+ o.io.print(`ok: ${why}: the ${o.role} restarts in place`)
119
+ stale.stop()
120
+ lib.close()
121
+ o.before()
122
+ try {
123
+ reexecSelf()
124
+ } catch (e) {
125
+ o.io.printErr(`FAIL: the ${o.role} stopped to restart and could not: ${(e as Error).message}`)
126
+ process.exit(1)
127
+ }
128
+ },
129
+ say: (text) => { if (text != null) o.io.printErr(`WARN ${text}`) },
130
+ })
131
+ stale.start()
132
+ return {
133
+ files: [...graph].filter((f) => !relative(LIB_ROOT, f).startsWith('..')).length,
134
+ stop: () => {
135
+ stale.stop()
136
+ lib.close()
137
+ },
138
+ }
139
+ }
@@ -0,0 +1,115 @@
1
+ // the machine's own stores and their caps (core/retention.ts owns the model):
2
+ // every log under the home dir, and the gui window's Chrome profile. And the
3
+ // daemon's timer that sweeps every store, the repos' and these.
4
+ import { existsSync, lstatSync, readdirSync, rmSync, statSync } from 'fs'
5
+ import { join } from 'path'
6
+ import { repoStores, sweepStores, walkUse, type Store } from '../core/retention.ts'
7
+ import { capLog, LOG_MAX_BYTES } from './supervise.ts'
8
+
9
+ /** a log nothing wrote for this long belongs to a process that is gone (a
10
+ * repo removed, a resource dropped) */
11
+ export const LOG_KEEP_MS = 30 * 24 * 60 * 60 * 1000
12
+
13
+ /** the profile's size cap: one page's worth of cookies and storage */
14
+ export const GUI_PROFILE_MAX = 128 * 1024 * 1024
15
+
16
+ /** what Chrome rebuilds on its own, so trimming them loses nothing the window needs */
17
+ export const GUI_CACHE_DIRS = [
18
+ 'Default/Cache',
19
+ 'Default/Code Cache',
20
+ 'Default/GPUCache',
21
+ 'GraphiteDawnCache',
22
+ 'GrShaderCache',
23
+ 'ShaderCache',
24
+ 'component_crx_cache',
25
+ 'optimization_guide_model_store',
26
+ 'OnDeviceHeadSuggestModel',
27
+ 'WasmTtsEngine',
28
+ 'Safe Browsing',
29
+ ]
30
+
31
+ const isLog = (n: string): boolean => n.endsWith('.log')
32
+ const notProfile = (n: string): boolean => n === 'gui-profile'
33
+
34
+ /** every `*.log` under the home dir, at any depth, the profile aside */
35
+ function logFiles(dir: string): string[] {
36
+ const out: string[] = []
37
+ const walk = (d: string): void => {
38
+ let list
39
+ try { list = readdirSync(d, { withFileTypes: true }) } catch { return }
40
+ for (const e of list) {
41
+ if (e.isDirectory()) { if (!notProfile(e.name)) walk(join(d, e.name)); continue }
42
+ if (e.isFile() && isLog(e.name)) out.push(join(d, e.name))
43
+ }
44
+ }
45
+ walk(dir)
46
+ return out
47
+ }
48
+
49
+ /** Chrome holds the profile while a window runs: its lock is a symlink */
50
+ const profileInUse = (profile: string): boolean => {
51
+ try { return lstatSync(join(profile, 'SingletonLock')).isSymbolicLink() } catch { return false }
52
+ }
53
+
54
+ export function machineStores(dir: string): Store[] {
55
+ const profile = join(dir, 'gui-profile')
56
+ return [
57
+ {
58
+ id: 'home logs',
59
+ where: `${dir}/**/*.log`,
60
+ cap: { maxFileBytes: LOG_MAX_BYTES, maxAgeMs: LOG_KEEP_MS },
61
+ measure: () => walkUse(dir, { match: isLog, skip: notProfile }),
62
+ sweep: (now) => {
63
+ for (const f of logFiles(dir)) {
64
+ try {
65
+ if (statSync(f).mtimeMs < now - LOG_KEEP_MS) rmSync(f, { force: true })
66
+ else capLog(f)
67
+ } catch { /* raced: gone */ }
68
+ }
69
+ },
70
+ },
71
+ {
72
+ id: 'gui profile',
73
+ where: profile,
74
+ cap: { maxBytes: GUI_PROFILE_MAX },
75
+ measure: () => walkUse(profile),
76
+ // only while no window holds it: Chrome would rebuild a cache
77
+ // dir under a running window, and races the delete
78
+ sweep: () => {
79
+ if (!existsSync(profile) || profileInUse(profile)) return
80
+ for (const d of GUI_CACHE_DIRS) rmSync(join(profile, d), { recursive: true, force: true })
81
+ },
82
+ },
83
+ ]
84
+ }
85
+
86
+ /** how often the daemon sweeps, after the one on boot */
87
+ export const SWEEP_EVERY_MS = 60 * 60 * 1000
88
+ /** the boot sweep waits this long, so loading the repos goes first */
89
+ export const BOOT_SWEEP_DELAY_MS = 10_000
90
+
91
+ export type Sweeper = { stop: () => void; once: () => Promise<void> }
92
+
93
+ /** the daemon's one retention sweep: every repo it loads, then the machine,
94
+ * on boot and every hour. A store whose sweep throws is one printed line */
95
+ export function startSweeps(o: { roots: () => string[]; dir: string; printErr: (l: string) => void; everyMs?: number; bootDelayMs?: number; now?: () => number }): Sweeper {
96
+ let running = false
97
+ const once = async (): Promise<void> => {
98
+ if (running) return
99
+ running = true
100
+ try {
101
+ const now = (o.now ?? Date.now)()
102
+ for (const root of o.roots()) {
103
+ for (const r of await sweepStores(repoStores(root), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} in ${root} failed: ${r.error}`)
104
+ }
105
+ for (const r of await sweepStores(machineStores(o.dir), now)) if (r.error != null) o.printErr(`WARN retention sweep of ${r.id} failed: ${r.error}`)
106
+ } finally {
107
+ running = false
108
+ }
109
+ }
110
+ const boot = setTimeout(() => void once(), o.bootDelayMs ?? BOOT_SWEEP_DELAY_MS)
111
+ const every = setInterval(() => void once(), o.everyMs ?? SWEEP_EVERY_MS)
112
+ boot.unref()
113
+ every.unref()
114
+ return { stop: () => { clearTimeout(boot); clearInterval(every) }, once }
115
+ }
@@ -0,0 +1,16 @@
1
+ // opens one shipkit app window on a url, in its own short process, so pm
2
+ // never loads what building the app needs:
3
+ //
4
+ // bun open-app.ts <url>
5
+ import { LIB_VERSION } from '../core/lib-version.ts'
6
+ import { openWindow } from './home-start.ts'
7
+
8
+ if (import.meta.main) {
9
+ const url = process.argv[2]
10
+ if (url == null) {
11
+ console.error('usage: bun open-app.ts <url>')
12
+ process.exit(2)
13
+ }
14
+ const cmd = openWindow(url, { version: LIB_VERSION, warn: (l) => console.error(l) })
15
+ console.log(`shipkit: opened ${url} as an app window: ${cmd.join(' ')}`)
16
+ }
@@ -0,0 +1,37 @@
1
+ // what pm needs from the house repos (whether this one hosts the house wiki,
2
+ // and the global processes every house repo declares in its `.shipkit.ts`
3
+ // files), read in a short child process so pm itself never imports a repo
4
+ // file. That graph would make pm large and reload it on every edit there.
5
+ //
6
+ // bun pm-config.ts <root> prints { index, processes, errors } as json on stdout
7
+ import { homedir } from 'os'
8
+ import type { GlobalProcessDecl } from './global-units.ts'
9
+
10
+ export type PmConfig = {
11
+ /** the house wiki index the repo includes, null when it includes none */
12
+ index: string | null
13
+ processes: GlobalProcessDecl[]
14
+ /** the unit files that would not load */
15
+ errors: string[]
16
+ }
17
+
18
+ if (import.meta.main) {
19
+ const root = process.argv[2]
20
+ if (root == null) {
21
+ console.error('usage: bun pm-config.ts <root>')
22
+ process.exit(2)
23
+ }
24
+ const { houseRepos } = await import('../serve/project-gutter.ts')
25
+ const house = houseRepos(root, homedir())
26
+ const out: PmConfig = { index: house.index, processes: [], errors: [] }
27
+ if (house.index != null) {
28
+ // a unit file imports the lib by name: this copy, like the daemon's
29
+ const { useOneLib } = await import('../core/one-lib.ts')
30
+ const { scanGlobalUnits } = await import('./global-units.ts')
31
+ useOneLib()
32
+ const u = await scanGlobalUnits(house.repos.map((r) => ({ name: r.name, path: r.path })))
33
+ out.processes = u.processes
34
+ out.errors = u.errors
35
+ }
36
+ process.stdout.write(JSON.stringify(out))
37
+ }