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,338 @@
1
+ // the machine's global processes (ComfyUI, a TTS server, an app panel), each
2
+ // declared once in a repo's `<name>.shipkit.ts` as `globalProcess({...})`
3
+ // (shipkit-files.ts) and owned by pm: a switch, a restart, its log, its
4
+ // action buttons, and for an app the window that opens on its url.
5
+ //
6
+ // two kinds: with no `down`, the command IS the service, supervised like a
7
+ // hub (restarted on a crash, signalled on stop). With `down`, the command is
8
+ // a bring-up that exits (an idempotent ladder script), `down` its reverse,
9
+ // and the probe is the only truth.
10
+ import { mkdirSync, openSync, closeSync } from 'fs'
11
+ import { dirname, join } from 'path'
12
+ import { fileURLToPath } from 'url'
13
+ import type { GlobalProcessDecl } from './global-units.ts'
14
+ import { Proc, realDeps, tailLines, type ProcStatus, type SuperviseDeps } from './supervise.ts'
15
+ import { resourceLog, resourcePid } from './home-paths.ts'
16
+ import { groupOf, localPort, portHolders } from '../core/port-holders.ts'
17
+
18
+ export type ResourceView = {
19
+ id: string
20
+ label: string
21
+ description: string | null
22
+ where: string | null
23
+ status: ProcStatus
24
+ /** what the owner last asked for */
25
+ want: 'up' | 'down'
26
+ note: string | null
27
+ /** a bring-up can always be taken down; a kept process not started by pm cannot */
28
+ canStop: boolean
29
+ /** it runs, started outside the supervisor: whether pm can stop that copy
30
+ * and start its own (and why not). null while there is nothing to take over */
31
+ takeOver: { ok: true } | { ok: false; why: string } | null
32
+ /** the last lines of its log, what the page shows on hover */
33
+ tail: string[]
34
+ /** the repo and the file that declare it */
35
+ repo: string
36
+ file: string
37
+ url: string | null
38
+ /** opens as a shipkit app window on its url */
39
+ app: boolean
40
+ actions: { id: string; label: string }[]
41
+ /** the action running now, or null */
42
+ running: string | null
43
+ }
44
+
45
+ export type HttpProbe = (url: string) => Promise<boolean>
46
+
47
+ export const probeUrl: HttpProbe = async (url) => {
48
+ try {
49
+ const r = await fetch(url, { signal: AbortSignal.timeout(2_000) })
50
+ return r.ok
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ /** runs a command to its end, output appended to the log; the exit code */
57
+ export type RunOnce = (cmd: string[], cwd: string, log: string) => Promise<number>
58
+
59
+ export const runOnce: RunOnce = async (cmd, cwd, log) => {
60
+ mkdirSync(dirname(log), { recursive: true })
61
+ const fd = openSync(log, 'a')
62
+ try {
63
+ const p = Bun.spawn(cmd, { cwd, stdio: ['ignore', fd, fd] })
64
+ return await p.exited
65
+ } catch (e) {
66
+ throw new Error(`could not run ${cmd.join(' ')}: ${(e as Error).message}`)
67
+ } finally {
68
+ closeSync(fd)
69
+ }
70
+ }
71
+
72
+ /** opens a shipkit app window on a url; what it did lands in the log */
73
+ export type OpenApp = (url: string, log: string) => void
74
+
75
+ /** its own process: what building the app needs never loads in pm */
76
+ const OPEN_APP = fileURLToPath(new URL('./open-app.ts', import.meta.url))
77
+
78
+ const openApp: OpenApp = (url, log) => {
79
+ mkdirSync(dirname(log), { recursive: true })
80
+ const fd = openSync(log, 'a')
81
+ try {
82
+ Bun.spawn([process.execPath, OPEN_APP, url], { stdio: ['ignore', fd, fd] })
83
+ } finally {
84
+ closeSync(fd)
85
+ }
86
+ }
87
+
88
+ /** opens a url in the default browser; what it did lands in the log */
89
+ export type Browse = (url: string, log: string) => void
90
+
91
+ const browse: Browse = (url, log) => {
92
+ mkdirSync(dirname(log), { recursive: true })
93
+ const fd = openSync(log, 'a')
94
+ try {
95
+ const cmd = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]
96
+ Bun.spawn(cmd, { stdio: ['ignore', fd, fd] })
97
+ } finally {
98
+ closeSync(fd)
99
+ }
100
+ }
101
+
102
+ /** the function action runner: imports the unit file, calls the action */
103
+ const ACTION_RUNNER = fileURLToPath(new URL('./run-unit-action.ts', import.meta.url))
104
+
105
+ export type ResourceOpts = {
106
+ probe?: HttpProbe
107
+ run?: RunOnce
108
+ deps?: SuperviseDeps
109
+ /** the app window */
110
+ open?: OpenApp
111
+ /** the default browser */
112
+ browse?: Browse
113
+ /** the pids listening on a local port, and the process group of a pid */
114
+ holders?: (port: number) => number[]
115
+ groupOf?: (pid: number) => number | null
116
+ /** pm's own process group: never signalled by a take over */
117
+ ownGroup?: number
118
+ sleep?: (ms: number) => Promise<void>
119
+ }
120
+
121
+ /** where a process started elsewhere is found: the port of its url, else of its probe */
122
+ const ownerPort = (d: GlobalProcessDecl): number | null => localPort(d.url) ?? localPort(d.probe)
123
+
124
+ /** what pm's own group is, read once */
125
+ let pmGroup: number | null | undefined
126
+
127
+ /** one switchable process */
128
+ export class Resource {
129
+ private proc: Proc | null = null
130
+ status: ProcStatus = 'down'
131
+ want: 'up' | 'down' = 'down'
132
+ note: string | null = null
133
+ running: string | null = null
134
+ /** how its last action ended, kept beside what the supervisor says */
135
+ private actionNote: string | null = null
136
+ private busy = false
137
+ /** the last bring-up exited non zero */
138
+ private failed = false
139
+ /** open it once it answers: in the default browser, or as an app window */
140
+ private openWhenUp: 'browser' | 'app' | null = null
141
+ readonly log: string
142
+
143
+ constructor(readonly decl: GlobalProcessDecl, dir: string, private o: ResourceOpts = {}) {
144
+ this.log = resourceLog(dir, decl.id)
145
+ if (decl.down == null)
146
+ this.proc = new Proc({ id: decl.id, cmd: decl.cmd, cwd: decl.cwd, log: this.log, pidKey: resourcePid(dir, decl.id), probe: decl.probe == null ? undefined : () => this.probe() }, o.deps ?? realDeps)
147
+ }
148
+
149
+ private probe(): Promise<boolean> {
150
+ return this.decl.probe == null ? Promise.resolve(true) : (this.o.probe ?? probeUrl)(this.decl.probe)
151
+ }
152
+
153
+ get canStop(): boolean {
154
+ return this.proc == null || !this.proc.external
155
+ }
156
+
157
+ /** null while it is ours or not running; else whether it can be taken over */
158
+ get takeOverView(): ResourceView['takeOver'] {
159
+ if (this.proc == null || !this.proc.external) return null
160
+ if (ownerPort(this.decl) == null) return { ok: false, why: 'it declares no local url or probe with a port, so the process that runs it cannot be found: stop it where it was started' }
161
+ return { ok: true }
162
+ }
163
+
164
+ /** it runs, started elsewhere: stop that copy (the process group that
165
+ * listens on its port, SIGTERM then SIGKILL after the grace) and start
166
+ * the supervised one, in one go */
167
+ async takeOver(): Promise<void> {
168
+ const p = this.proc
169
+ const v = this.takeOverView
170
+ if (p == null || v == null) throw new Error(`${this.decl.id} does not run outside the supervisor: nothing to take over`)
171
+ if (!v.ok) throw new Error(`${this.decl.id}: ${v.why}`)
172
+ const port = ownerPort(this.decl)!
173
+ const owners = (this.o.holders ?? portHolders)(port).filter((pid) => pid !== process.pid)
174
+ if (owners.length === 0) throw new Error(`${this.decl.id} answers, but no process listens on port ${port}: nothing to stop`)
175
+ const own = this.o.ownGroup ?? (pmGroup === undefined ? (pmGroup = groupOf(process.pid)) : pmGroup)
176
+ const targets = [...new Set(owners.map((pid) => {
177
+ const g = (this.o.groupOf ?? groupOf)(pid)
178
+ // a group of one's own (pm's, a shell's login group) is never signalled whole
179
+ return g != null && g > 1 && g !== own ? g : pid
180
+ }))]
181
+ this.want = 'up'
182
+ this.openWhenUp = null
183
+ await p.takeOver(owners, targets, this.o.sleep ?? ((ms) => Bun.sleep(ms)))
184
+ await this.tick()
185
+ }
186
+
187
+ /** switch it on or off. The bring-up kind reports its command's failure */
188
+ async set(want: 'up' | 'down'): Promise<void> {
189
+ this.want = want
190
+ if (want === 'down') this.openWhenUp = null
191
+ if (this.proc != null) {
192
+ if (want === 'up') await this.proc.ensure()
193
+ else this.proc.stop()
194
+ await this.tick()
195
+ return
196
+ }
197
+ if (this.busy) return
198
+ const cmd = want === 'up' ? this.decl.cmd : this.decl.down!
199
+ this.busy = true
200
+ this.status = want === 'up' ? 'starting' : 'stopping'
201
+ this.note = null
202
+ this.failed = false
203
+ try {
204
+ const code = await (this.o.run ?? runOnce)(cmd, this.decl.cwd, this.log)
205
+ if (code !== 0) {
206
+ this.failed = true
207
+ this.note = `\`${cmd.join(' ')}\` exited ${code}: see the log`
208
+ }
209
+ } catch (e) {
210
+ this.failed = true
211
+ this.note = (e as Error).message
212
+ } finally {
213
+ this.busy = false
214
+ }
215
+ await this.tick()
216
+ }
217
+
218
+ async restart(): Promise<void> {
219
+ if (this.proc != null) {
220
+ this.want = 'up'
221
+ this.proc.restart()
222
+ await this.tick()
223
+ return
224
+ }
225
+ await this.set('down')
226
+ await this.set('up')
227
+ }
228
+
229
+ /** its url in the default browser, or as a shipkit app window (app:
230
+ * true only): at once while it answers, else it starts and opens once
231
+ * it does */
232
+ async open(how: 'browser' | 'app'): Promise<void> {
233
+ if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
234
+ if (how === 'app' && !this.decl.app) throw new Error(`${this.decl.id} does not declare app: true, so it has no app window: open it in the browser`)
235
+ if (this.status === 'up') {
236
+ this.show(how, this.decl.url)
237
+ return
238
+ }
239
+ this.openWhenUp = how
240
+ await this.set('up')
241
+ }
242
+
243
+ private show(how: 'browser' | 'app', url: string): void {
244
+ if (how === 'app') (this.o.open ?? openApp)(url, this.log)
245
+ else (this.o.browse ?? browse)(url, this.log)
246
+ }
247
+
248
+ /** one declared button. A function action runs in its own bun process
249
+ * that imports the unit file */
250
+ async action(id: string): Promise<void> {
251
+ const a = this.decl.actions.find((x) => x.id === id)
252
+ if (a == null) throw new Error(`${this.decl.id} declares no action '${id}'`)
253
+ if (this.running != null) throw new Error(`${this.decl.id} is already running its action '${this.running}'`)
254
+ const cmd = a.cmd ?? [process.execPath, ACTION_RUNNER, join(this.decl.root, this.decl.file), id]
255
+ this.running = id
256
+ this.actionNote = null
257
+ try {
258
+ const code = await (this.o.run ?? runOnce)(cmd, this.decl.root, this.log)
259
+ this.actionNote = code === 0 ? `${a.label}: done` : `${a.label} exited ${code}: see the log`
260
+ } catch (e) {
261
+ this.actionNote = `${a.label}: ${(e as Error).message}`
262
+ } finally {
263
+ this.running = null
264
+ }
265
+ this.note = this.actionNote
266
+ }
267
+
268
+ async tick(): Promise<void> {
269
+ if (this.proc != null) {
270
+ await this.proc.tick()
271
+ this.status = this.proc.status
272
+ this.note = this.proc.note ?? this.actionNote
273
+ this.want = this.proc.want
274
+ } else if (!this.busy) {
275
+ const up = await this.probe()
276
+ this.status = up ? 'up' : this.failed && this.want === 'up' ? 'crashed' : 'down'
277
+ }
278
+ if (this.openWhenUp != null && this.status === 'up' && this.decl.url != null) {
279
+ const how = this.openWhenUp
280
+ this.openWhenUp = null
281
+ this.show(how, this.decl.url)
282
+ }
283
+ }
284
+
285
+ view(): ResourceView {
286
+ const d = this.decl
287
+ return {
288
+ id: d.id,
289
+ label: d.label,
290
+ description: d.description,
291
+ where: d.where,
292
+ status: this.status,
293
+ want: this.want,
294
+ note: this.note,
295
+ canStop: this.canStop,
296
+ takeOver: this.takeOverView,
297
+ tail: tailLines(this.log, 20),
298
+ repo: d.repo,
299
+ file: d.file,
300
+ url: d.url,
301
+ app: d.app,
302
+ actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
303
+ running: this.running,
304
+ }
305
+ }
306
+ }
307
+
308
+ export class Resources {
309
+ list: Resource[]
310
+ /** the unit files that would not load, as pm last read them */
311
+ errors: string[] = []
312
+
313
+ constructor(decls: GlobalProcessDecl[], private dir: string, private o: ResourceOpts = {}) {
314
+ this.list = decls.map((d) => new Resource(d, dir, o))
315
+ }
316
+
317
+ /** the declarations as they are now: an unchanged one keeps its state, a
318
+ * changed or new one starts from its pid record, a dropped one leaves
319
+ * its process running for whoever declares it next */
320
+ sync(decls: GlobalProcessDecl[], errors: string[] = []): void {
321
+ const key = (d: GlobalProcessDecl): string => JSON.stringify(d)
322
+ const had = new Map(this.list.map((r) => [key(r.decl), r]))
323
+ this.list = decls.map((d) => had.get(key(d)) ?? new Resource(d, this.dir, this.o))
324
+ this.errors = errors
325
+ }
326
+
327
+ get(id: string): Resource | null {
328
+ return this.list.find((r) => r.decl.id === id) ?? null
329
+ }
330
+
331
+ async tick(): Promise<void> {
332
+ await Promise.all(this.list.map((r) => r.tick()))
333
+ }
334
+
335
+ views(): ResourceView[] {
336
+ return this.list.map((r) => r.view())
337
+ }
338
+ }
@@ -0,0 +1,137 @@
1
+ // `shipkit daemon`: resolve what the home needs from the repo it runs in (the
2
+ // house repos around it), and serve its api on the
3
+ // internal port. A second launch sees the first and leaves. Every house repo's
4
+ // hub runs in this process (hubs.ts), on this process's copy of the lib
5
+ // (one-lib.ts). Every gate runs as a detached job it follows (core/jobs.ts), so
6
+ // it runs with no bun watch and an edit of its own code restarts it in place
7
+ // at once (lib-watch.ts): the new image follows the jobs still running. The
8
+ // machine resources are pm's.
9
+ import { fileURLToPath } from 'url'
10
+ import { useOneLib } from '../core/one-lib.ts'
11
+ import { reclaimPreviousImage } from '../core/instance.ts'
12
+ import { reloadOnOwnCode } from './lib-watch.ts'
13
+ import { loadHub } from '../serve/hub-runtime.ts'
14
+ import { houseRepos } from '../serve/project-gutter.ts'
15
+ import { projectGutter } from '../serve/project-gutter-route.ts'
16
+ import { loadRepoConfig } from '../repo-config.ts'
17
+ import { ensureIdentity } from '../core/identity.ts'
18
+ import { homedir } from 'os'
19
+ import { daemonPort, homeDir, homePort, homeUp, hubBase, WORKER_ENV } from './home-paths.ts'
20
+ import { HomeState } from './home-state.ts'
21
+ import { Hubs } from './hubs.ts'
22
+ import { Planner } from '../plan/planner.ts'
23
+ import { serveDaemon } from './serve-daemon.ts'
24
+ import { startSweeps } from './machine-stores.ts'
25
+ import { Widgets } from './widgets.ts'
26
+
27
+ const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
28
+
29
+ /** SHIPKIT_DAEMON_ONLY=a,b loads those house repos alone */
30
+ const onlyRepos = (): Set<string> | undefined => {
31
+ const v = process.env.SHIPKIT_DAEMON_ONLY
32
+ return v == null || v.trim() === '' ? undefined : new Set(v.split(',').map((x) => x.trim()).filter((x) => x !== ''))
33
+ }
34
+
35
+ export type HomeIo = { print: (l: string) => void; printErr: (l: string) => void }
36
+
37
+ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } = {}): Promise<number> {
38
+ const own = o.port ?? daemonPort()
39
+ // the port a page reaches every hub on: the web's
40
+ const port = homePort()
41
+ const house = houseRepos(root, homedir())
42
+ if (house.index == null) {
43
+ io.printErr(`FAIL: ${root} includes no house wiki index in its CLAUDE.md, so the daemon has no repos to load. Run it from the repo that hosts the house wiki`)
44
+ return 1
45
+ }
46
+ // before the first repo config loads: every one of them, and every check
47
+ // file, then imports this copy of the lib
48
+ useOneLib()
49
+ // a process this one starts runs its gate and its hub in process
50
+ process.env[WORKER_ENV] = '1'
51
+ // a restart in place keeps the pid: the check children its previous
52
+ // image spawned are still running, and die here
53
+ // a bun watch over this process follows every repo config it loads, so
54
+ // one repo's edit would restart every hub and kill every gate
55
+ if (process.execArgv.includes('--watch') || process.execArgv.includes('--hot'))
56
+ io.printErr(`WARN the daemon runs under bun --watch, so an edit of any repo config restarts every hub. Run \`shipkit pm install\` again for an agent without it`)
57
+ const reclaimed = reclaimPreviousImage()
58
+ if (reclaimed.killed.length > 0) io.print(`ok: the daemon restarted in place: killed ${reclaimed.killed.length} check process(es) its previous image left running`)
59
+ const c = await loadRepoConfig(root)
60
+ const dir = homeDir()
61
+ const gutter = projectGutter({ root, warn: (l) => io.printErr(l) })
62
+ const names = new Set(house.repos.map((r) => r.name))
63
+ // the machine's one plan: every repo and check pair, ticked by the daemon
64
+ const plan = new Planner({ onError: (repo, e) => io.printErr(`WARN the plan of ${repo} broke: ${e.message}`) })
65
+ const hubs = new Hubs({
66
+ dir,
67
+ port,
68
+ // every repo carries its identity colours: the first load writes the
69
+ // missing key, one line, and leaves the commit to the repo (SK024)
70
+ prepare: async (repo, hubIo) => {
71
+ const r = await ensureIdentity(repo.path)
72
+ if (r.changed && r.id != null) hubIo.print(`ok: ${repo.name}'s repo.config.ts gets identity ${r.id.colorLight} / ${r.id.colorDark}, from ${r.from}`)
73
+ else if (r.refused != null) hubIo.print(`note: ${repo.name} has no identity colours: ${r.refused}`)
74
+ },
75
+ load: async (repo, hubIo, o) => {
76
+ const rt = await loadHub(repo.path, hubIo, {
77
+ base: hubBase(repo.name),
78
+ port,
79
+ projects: gutter,
80
+ plan,
81
+ planName: repo.name,
82
+ gateAtStart: false,
83
+ ownTimers: false,
84
+ fresh: o.fresh,
85
+ // every gate is a detached job: a reload of this process never kills one
86
+ jobs: { cli: CLI },
87
+ // a lane click of the live strip opens that repo's page, on the page's own origin
88
+ repoHub: async (other) => (names.has(other) ? `${hubBase(other)}/` : null),
89
+ })
90
+ hubIo.print(`${rt.name} hub → http://localhost:${port}${hubBase(repo.name)}/`)
91
+ return { ...rt, uses: rt.deps.uses ?? [] }
92
+ },
93
+ })
94
+ try {
95
+ const home = serveDaemon({
96
+ root,
97
+ name: c.name,
98
+ port,
99
+ daemonPort: own,
100
+ dir,
101
+ hubs,
102
+ state: new HomeState(dir),
103
+ gutter,
104
+ plan,
105
+ only: onlyRepos(),
106
+ widgets: new Widgets(),
107
+ })
108
+ io.print(`daemon → http://127.0.0.1:${home.server.port} (the page: http://localhost:${port})`)
109
+ // nothing grows unbound: every store of every repo it loads, and the
110
+ // machine's, swept on boot and hourly (core/retention.ts)
111
+ const only = onlyRepos()
112
+ const sweeps = startSweeps({ roots: () => house.repos.filter((r) => only == null || only.has(r.name)).map((r) => r.path), dir, printErr: io.printErr })
113
+ const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, io })
114
+ io.print(` ${house.repos.length} repos, state in ${dir}, reloads on ${code.files} lib files`)
115
+ for (const sig of ['SIGINT', 'SIGTERM'] as const)
116
+ process.on(sig, () => {
117
+ // the repo processes it started keep running: they are their
118
+ // own process groups, and the next daemon adopts them
119
+ code.stop()
120
+ sweeps.stop()
121
+ home.stop()
122
+ process.exit(0)
123
+ })
124
+ await new Promise(() => {})
125
+ return 0
126
+ } catch (e) {
127
+ if (e instanceof Error && /in use|EADDRINUSE/.test(e.message)) {
128
+ if (await homeUp(own)) {
129
+ io.print(`daemon → http://127.0.0.1:${own} (already running)`)
130
+ return 0
131
+ }
132
+ io.printErr(`FAIL: port ${own} is taken by another process. Set SHIPKIT_DAEMON_PORT to a free one`)
133
+ return 1
134
+ }
135
+ throw e
136
+ }
137
+ }
@@ -0,0 +1,149 @@
1
+ // `shipkit pm`: the one process launchd keeps alive. It runs the daemon and
2
+ // the web as detached children, starts one again when it dies, owns the
3
+ // machine's global processes, and serves its api on its own port. It restarts itself
4
+ // in place on an edit of its own code, and adopts its children when it does.
5
+ // It never loads the repo config itself (pm-config.ts), and it runs from this
6
+ // file, never from cli.ts (execPm): cli.ts loads every command, about 170 MB
7
+ // against pm's own 20.
8
+ import { fileURLToPath } from 'url'
9
+ import { daemonPort, homeDir, homePort, homeUp, homeUrl, pmPort, pmUp, WORKER_ENV } from './home-paths.ts'
10
+ import { reloadOnOwnCode } from './lib-watch.ts'
11
+ import { Pm, PM_TICK_MS, type Role } from './pm.ts'
12
+ import { Resources } from './resources.ts'
13
+ import type { HomeIo } from './run-daemon.ts'
14
+ import type { PmConfig } from './pm-config.ts'
15
+
16
+ const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
17
+ const PM_CONFIG = fileURLToPath(new URL('./pm-config.ts', import.meta.url))
18
+
19
+ const RUN_PM = fileURLToPath(import.meta.url)
20
+
21
+ /** how often pm reads the house repos' global processes again */
22
+ export const PM_RESCAN_MS = 15_000
23
+
24
+ /** replace this process's image with pm run from this file: same pid, same
25
+ * cwd, same env, none of cli.ts loaded. False when the runtime has no
26
+ * execve, and the caller runs pm in process */
27
+ export function execPm(o: { port?: number } = {}): boolean {
28
+ const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
29
+ if (typeof execve !== 'function') return false
30
+ const env = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] != null))
31
+ return execve.call(process, process.execPath, [process.execPath, RUN_PM, ...(o.port != null ? ['--port', String(o.port)] : [])], env)
32
+ }
33
+
34
+ /** the house repo's side of pm, read by a child process */
35
+ export async function readPmConfig(root: string): Promise<PmConfig> {
36
+ const p = Bun.spawn([process.execPath, PM_CONFIG, root], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
37
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
38
+ if (code !== 0) throw new Error(`could not read the repo config of ${root} (exit ${code}): ${err.trim() || out.trim()}`)
39
+ return JSON.parse(out) as PmConfig
40
+ }
41
+
42
+ /** true when the role serves: the daemon as a home on its port, the web as
43
+ * any http answer on the home port (it answers 502 while the daemon is down,
44
+ * and still holds its port) */
45
+ export async function roleAnswers(role: Role): Promise<boolean> {
46
+ if (role === 'daemon') return homeUp(daemonPort())
47
+ try {
48
+ await fetch(`${homeUrl(homePort())}/`, { signal: AbortSignal.timeout(400) })
49
+ return true
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {}): Promise<number> {
56
+ const port = o.port ?? pmPort()
57
+ // every process pm starts runs its shipkit in process, and never starts pm
58
+ process.env[WORKER_ENV] = '1'
59
+ let c: PmConfig
60
+ try {
61
+ c = await readPmConfig(root)
62
+ } catch (e) {
63
+ io.printErr(`FAIL: ${(e as Error).message}`)
64
+ return 1
65
+ }
66
+ if (c.index == null) {
67
+ io.printErr(`FAIL: ${root} includes no house wiki index in its CLAUDE.md, so pm has no daemon to run. Run it from the repo that hosts the house wiki`)
68
+ return 1
69
+ }
70
+ if (await pmUp(port)) {
71
+ io.print(`pm → http://127.0.0.1:${port} (already running)`)
72
+ return 0
73
+ }
74
+ const dir = homeDir()
75
+ const pm = new Pm({
76
+ dir,
77
+ root,
78
+ cmd: (role) => [process.execPath, CLI, role],
79
+ probe: roleAnswers,
80
+ resources: new Resources(c.processes, dir),
81
+ })
82
+ let server: Bun.Server<unknown>
83
+ try {
84
+ server = Bun.serve({ port, hostname: '127.0.0.1', fetch: (req) => pm.handle(req) })
85
+ } catch (e) {
86
+ if (e instanceof Error && /in use|EADDRINUSE/.test(e.message)) {
87
+ io.printErr(`FAIL: port ${port} is taken by another process. Set SHIPKIT_PM_PORT to a free one`)
88
+ return 1
89
+ }
90
+ throw e
91
+ }
92
+ await pm.boot()
93
+ for (const p of pm.children.values()) {
94
+ const v = p.view()
95
+ io.print(` ${v.id}: ${p.adopted ? 'adopted' : v.status}${v.pid != null ? ` pid ${v.pid}` : ''}${v.note != null ? ` (${v.note})` : ''}`)
96
+ }
97
+ pm.resources.errors = c.errors
98
+ for (const e of c.errors) io.printErr(`WARN ${e}`)
99
+ // a unit file added, edited or removed in any house repo: read again,
100
+ // in its child process, and the unchanged processes keep their state
101
+ let reading = false
102
+ const reread = setInterval(() => {
103
+ if (reading) return
104
+ reading = true
105
+ void readPmConfig(root)
106
+ .then((n) => pm.resources.sync(n.processes, n.errors))
107
+ .catch((e) => io.printErr(`WARN pm could not read the global processes again: ${(e as Error).message}`))
108
+ .finally(() => { reading = false })
109
+ }, PM_RESCAN_MS)
110
+ let ticking = false
111
+ const timer = setInterval(() => {
112
+ if (ticking) return
113
+ ticking = true
114
+ void pm.tick().catch((e) => io.printErr(`WARN the pm tick broke: ${(e as Error).message}`)).finally(() => { ticking = false })
115
+ }, PM_TICK_MS)
116
+ const code = reloadOnOwnCode({
117
+ role: 'pm',
118
+ entry: fileURLToPath(import.meta.url),
119
+ // the children keep running, and the next image adopts them
120
+ before: () => {
121
+ clearInterval(timer)
122
+ clearInterval(reread)
123
+ server.stop(true)
124
+ },
125
+ io,
126
+ })
127
+ io.print(`pm → http://127.0.0.1:${server.port} (the page: http://localhost:${homePort()}, the daemon: :${daemonPort()}), ${c.processes.length} global processes, logs in ${dir}, reloads on ${code.files} lib files`)
128
+ let stopping = false
129
+ for (const sig of ['SIGINT', 'SIGTERM'] as const)
130
+ process.on(sig, () => {
131
+ // a stop asked of pm stops its children; the global processes keep running,
132
+ // their own process groups, for the next pm to adopt
133
+ if (stopping) return
134
+ stopping = true
135
+ code.stop()
136
+ clearInterval(timer)
137
+ clearInterval(reread)
138
+ server.stop(true)
139
+ void pm.stopAll().finally(() => process.exit(0))
140
+ })
141
+ await new Promise(() => {})
142
+ return 0
143
+ }
144
+
145
+ if (import.meta.main) {
146
+ const i = process.argv.indexOf('--port')
147
+ const port = i < 0 ? undefined : Number(process.argv[i + 1])
148
+ process.exitCode = await runPm(process.cwd(), { print: (l) => console.log(l), printErr: (l) => console.error(l) }, { port })
149
+ }
@@ -0,0 +1,25 @@
1
+ // runs one function action of a global process, in its own bun process so a
2
+ // slow or crashing action never touches pm:
3
+ //
4
+ // bun run-unit-action.ts <unit file> <action id>
5
+ //
6
+ // the unit file imports the lib by name, so this copy is registered first.
7
+ import type { GlobalProcessUnit } from '../shipkit-files.ts'
8
+
9
+ if (import.meta.main) {
10
+ const [file, id] = process.argv.slice(2)
11
+ if (file == null || id == null) {
12
+ console.error('usage: bun run-unit-action.ts <unit file> <action id>')
13
+ process.exit(2)
14
+ }
15
+ const { useOneLib } = await import('../core/one-lib.ts')
16
+ useOneLib()
17
+ const u = ((await import(file)) as { default?: GlobalProcessUnit }).default
18
+ const run = u?.actions?.[id]?.run
19
+ if (run == null) {
20
+ console.error(`FAIL: ${file} has no function action '${id}'`)
21
+ process.exit(1)
22
+ }
23
+ console.log(`▶ ${u!.id}: ${u!.actions![id]!.label}`)
24
+ await run()
25
+ }