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,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
+ }
package/src/home/pm.ts ADDED
@@ -0,0 +1,141 @@
1
+ // pm, the machine's process manager: it keeps the daemon and the web running
2
+ // (supervise.ts: detached, pid records, crash restarts with backoff, adoption)
3
+ // and owns the machine's global processes (resources.ts). Its api answers on
4
+ // its own port, and the web proxies /api/pm/* to it, so the page switches a
5
+ // process and restarts a child through the one origin it knows.
6
+ //
7
+ // the one constraint a reader breaks first: pm never takes a child down with
8
+ // it. A reload of pm, or pm killed, leaves both running, and the next pm
9
+ // adopts them from their pid records. Only a stop asked of pm stops them.
10
+ import { LIB_VERSION } from '../core/lib-version.ts'
11
+ import { sameOrigin } from '../serve/same-origin.ts'
12
+ import { childLog, childPid } from './home-paths.ts'
13
+ import type { Resources, ResourceView } from './resources.ts'
14
+ import { capLog, Proc, realDeps, tailLines, type ProcView, type SuperviseDeps } from './supervise.ts'
15
+
16
+ export type Role = 'daemon' | 'web'
17
+ export const ROLES: Role[] = ['daemon', 'web']
18
+
19
+ /** how often pm looks at its children and the global processes */
20
+ export const PM_TICK_MS = 2_000
21
+
22
+ export type ChildView = ProcView & { tail: string[] }
23
+
24
+ export type PmAnswer = {
25
+ pm: 'shipkit-pm'
26
+ pid: number
27
+ version: string
28
+ children: ChildView[]
29
+ resources: ResourceView[]
30
+ /** the `.shipkit.ts` files that would not load, as pm last read them */
31
+ unitErrors: string[]
32
+ }
33
+
34
+ export type PmDeps = {
35
+ dir: string
36
+ /** the house repo: where the children run */
37
+ root: string
38
+ /** the command that runs one role, e.g. [bun, cli.ts, 'daemon'] */
39
+ cmd: (role: Role) => string[]
40
+ /** true once the role serves */
41
+ probe: (role: Role) => Promise<boolean>
42
+ resources: Resources
43
+ deps?: SuperviseDeps
44
+ /** runs a stop or restart of the web once its answer is out: the web
45
+ * proxies that very request (default: 200 ms later) */
46
+ later?: (f: () => void) => void
47
+ }
48
+
49
+ const WEB_ANSWER_MS = 200
50
+
51
+ export class Pm {
52
+ readonly children: Map<Role, Proc>
53
+
54
+ constructor(private d: PmDeps) {
55
+ this.children = new Map(ROLES.map((role) => [role, new Proc({
56
+ id: role,
57
+ cmd: d.cmd(role),
58
+ cwd: d.root,
59
+ log: childLog(d.dir, role),
60
+ pidKey: childPid(d.dir, role),
61
+ probe: () => d.probe(role),
62
+ // a crash loop while its code is being edited heals itself
63
+ neverGiveUp: true,
64
+ }, d.deps ?? realDeps)]))
65
+ }
66
+
67
+ get resources(): Resources {
68
+ return this.d.resources
69
+ }
70
+
71
+ /** every child up: adopted from its pid record, else started, unless
72
+ * something else already answers its port */
73
+ async boot(): Promise<void> {
74
+ await Promise.all([...this.children.values()].map((p) => p.ensure()))
75
+ }
76
+
77
+ async tick(): Promise<void> {
78
+ await Promise.all([...[...this.children.values()].map((p) => p.tick()), this.d.resources.tick()])
79
+ for (const role of ROLES) capLog(childLog(this.d.dir, role))
80
+ }
81
+
82
+ /** stop both children and wait until they are gone, SIGKILL included */
83
+ async stopAll(o: { waitMs?: number; stepMs?: number } = {}): Promise<void> {
84
+ for (const p of this.children.values()) p.stop()
85
+ const until = Date.now() + (o.waitMs ?? 12_000)
86
+ while (Date.now() < until && [...this.children.values()].some((p) => p.status === 'stopping')) {
87
+ await Bun.sleep(o.stepMs ?? 200)
88
+ await Promise.all([...this.children.values()].map((p) => p.tick()))
89
+ }
90
+ }
91
+
92
+ answer(): PmAnswer {
93
+ const children = [...this.children.values()].map((p) => ({ ...p.view(), tail: p.status === 'up' ? [] : tailLines(p.spec.log, 20) }))
94
+ return { pm: 'shipkit-pm', pid: process.pid, version: LIB_VERSION, children, resources: this.d.resources.views(), unitErrors: this.d.resources.errors }
95
+ }
96
+
97
+ async handle(req: Request): Promise<Response> {
98
+ const path = new URL(req.url).pathname
99
+ if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
100
+ if (path === '/api/pm/status.json') return Response.json(this.answer())
101
+ if (req.method !== 'POST') return new Response('not found', { status: 404 })
102
+ const b = (await req.json().catch(() => ({}))) as Record<string, unknown>
103
+ if (path === '/api/pm/child') {
104
+ const p = this.children.get(b.id as Role)
105
+ if (p == null) return new Response(`no child '${String(b.id)}': pm runs ${ROLES.join(' and ')}`, { status: 404 })
106
+ if (b.act === 'start') p.start()
107
+ else if (b.act === 'stop' || b.act === 'restart') {
108
+ const act = b.act === 'stop' ? () => p.stop() : () => p.restart()
109
+ if (b.id === 'web') (this.d.later ?? ((f) => { setTimeout(f, WEB_ANSWER_MS) }))(act)
110
+ else act()
111
+ } else return new Response('act must be start, stop or restart', { status: 400 })
112
+ return Response.json(p.view())
113
+ }
114
+ if (path === '/api/pm/resource') {
115
+ const r = this.d.resources.get(String(b.id))
116
+ if (r == null) return new Response(`no global process '${String(b.id)}': none of the house repos' .shipkit.ts files declares it`, { status: 404 })
117
+ const act = b.act ?? (b.on === true ? 'start' : b.on === false ? 'stop' : null)
118
+ // a bring-up or an action can take minutes: the page follows it on its poll
119
+ const report = (p: Promise<void>): void => { p.catch((e: Error) => { r.note = e.message }) }
120
+ if (act === 'start') report(r.set('up'))
121
+ else if (act === 'stop') report(r.set('down'))
122
+ else if (act === 'restart') report(r.restart())
123
+ else if (act === 'open' || act === 'app') {
124
+ if (r.decl.url == null) return new Response(`${r.decl.id} declares no url to open`, { status: 400 })
125
+ if (act === 'app' && !r.decl.app) return new Response(`${r.decl.id} does not declare app: true, so it has no app window`, { status: 400 })
126
+ report(r.open(act === 'app' ? 'app' : 'browser'))
127
+ } else if (act === 'takeover') {
128
+ const v = r.takeOverView
129
+ if (v == null) return new Response(`${r.decl.id} does not run outside the supervisor: nothing to take over`, { status: 409 })
130
+ if (!v.ok) return new Response(`${r.decl.id}: ${v.why}`, { status: 400 })
131
+ report(r.takeOver())
132
+ } else if (act === 'action') {
133
+ if (!r.decl.actions.some((a) => a.id === b.action)) return new Response(`${r.decl.id} declares no action '${String(b.action)}'`, { status: 404 })
134
+ if (r.running != null) return new Response(`${r.decl.id} is already running its action '${r.running}'`, { status: 409 })
135
+ report(r.action(String(b.action)))
136
+ } else return new Response('act must be start, stop, restart, open, app, takeover or action', { status: 400 })
137
+ return Response.json(r.view())
138
+ }
139
+ return new Response('not found', { status: 404 })
140
+ }
141
+ }