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.
- package/CHANGELOG.md +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +208 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- 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
|
+
}
|