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.
- package/CHANGELOG.md +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- 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/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -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 +235 -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 +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -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 +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -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/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- 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 +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- 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 +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- 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 +92 -0
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- 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/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +340 -0
- 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/sidebar.tsx +84 -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 +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- 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 +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -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/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
package/src/home/hubs.ts
ADDED
|
@@ -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
|
+
}
|