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,208 @@
|
|
|
1
|
+
// the daemon's api, apart from the page so a test reaches it without a bundle:
|
|
2
|
+
// its own /api/home/* and every repo's hub under /r/<repo>/. The daemon
|
|
3
|
+
// decides WHEN a hub loads and never what is true: every verdict still comes
|
|
4
|
+
// from each repo's own runner and ledger. The machine resources are pm's
|
|
5
|
+
// (run-pm.ts), and the page reaches them through the web at /api/pm/*.
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
import { dirname, join } from 'path'
|
|
8
|
+
import { fileURLToPath } from 'url'
|
|
9
|
+
import { installedVersion, isBehind } from '../fleet/scan-fleet.ts'
|
|
10
|
+
import { LIB_VERSION } from '../core/lib-version.ts'
|
|
11
|
+
import { projectGutter, type ProjectRow } from '../serve/project-gutter-route.ts'
|
|
12
|
+
import type { WiredRepo } from '../serve/project-gutter.ts'
|
|
13
|
+
import { sameOrigin } from '../serve/same-origin.ts'
|
|
14
|
+
import type { HomeState } from './home-state.ts'
|
|
15
|
+
import { tailLines } from './supervise.ts'
|
|
16
|
+
import { Hubs, type WorkerView } from './hubs.ts'
|
|
17
|
+
import type { Planner, PlanRow } from '../plan/planner.ts'
|
|
18
|
+
import type { Widgets, WidgetView } from './widgets.ts'
|
|
19
|
+
|
|
20
|
+
/** how often the daemon looks at every repo */
|
|
21
|
+
export const TICK_MS = 2_000
|
|
22
|
+
|
|
23
|
+
export type ModernizeView = { status: 'running' | 'done' | 'failed'; code: number | null; tail: string[] }
|
|
24
|
+
|
|
25
|
+
export type HomeAnswer = {
|
|
26
|
+
version: string
|
|
27
|
+
workers: WorkerView[]
|
|
28
|
+
/** the repos flagged active by hand */
|
|
29
|
+
active: string[]
|
|
30
|
+
/** repo → its installed shipkit, for every repo behind this home's */
|
|
31
|
+
behind: Record<string, string>
|
|
32
|
+
modernize: Record<string, ModernizeView>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type HomeDeps = {
|
|
36
|
+
root: string
|
|
37
|
+
name: string
|
|
38
|
+
port: number
|
|
39
|
+
dir: string
|
|
40
|
+
hubs: Hubs
|
|
41
|
+
state: HomeState
|
|
42
|
+
/** the rail rows, the same measure a hub's rail reads */
|
|
43
|
+
gutter: ReturnType<typeof projectGutter>
|
|
44
|
+
/** the machine's one plan, which this tick drives. Absent = nothing runs
|
|
45
|
+
* on its own (a test of the routes alone) */
|
|
46
|
+
plan?: Planner
|
|
47
|
+
/** the repos it loads, by name; absent = every house repo. A smoke on a
|
|
48
|
+
* real machine keeps the others out, so no save there starts a gate */
|
|
49
|
+
only?: Set<string>
|
|
50
|
+
/** every house repo's widgets, rendered on the daemon's tick. Absent =
|
|
51
|
+
* none (a test of the routes alone) */
|
|
52
|
+
widgets?: Widgets
|
|
53
|
+
/** brings a repo current (modernizeCmd); resolves to its exit code */
|
|
54
|
+
modernize?: (repo: WiredRepo, log: string) => Promise<number>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
58
|
+
|
|
59
|
+
/** what bringing a repo current runs: its first install when it has none,
|
|
60
|
+
* else the fleet update, which goes through the repo's own gate */
|
|
61
|
+
export function modernizeCmd(repo: WiredRepo, root: string): { cmd: string[]; cwd: string } {
|
|
62
|
+
if (!existsSync(join(repo.path, 'node_modules'))) return { cmd: ['bun', 'install'], cwd: repo.path }
|
|
63
|
+
return { cmd: ['bun', CLI, 'fleet', '--update', '--only', repo.name], cwd: root }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const runModernize = async (repo: WiredRepo, root: string, log: string): Promise<number> => {
|
|
67
|
+
const { mkdirSync, openSync, closeSync } = await import('fs')
|
|
68
|
+
mkdirSync(dirname(log), { recursive: true })
|
|
69
|
+
const fd = openSync(log, 'w')
|
|
70
|
+
try {
|
|
71
|
+
const { cmd, cwd } = modernizeCmd(repo, root)
|
|
72
|
+
const p = Bun.spawn(cmd, { cwd, stdio: ['ignore', fd, fd] })
|
|
73
|
+
return await p.exited
|
|
74
|
+
} finally {
|
|
75
|
+
closeSync(fd)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** active = an agent works in it, VS Code has it open, or it is flagged by hand */
|
|
80
|
+
export function activeSet(rows: ProjectRow[], flagged: string[]): Set<string> {
|
|
81
|
+
const out = new Set(flagged)
|
|
82
|
+
for (const r of rows) if ((r.sessions != null && r.sessions > 0) || r.vscode === true) out.add(r.name)
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** the monitor area's data: every widget's last html */
|
|
87
|
+
export type WidgetsAnswer = { widgets: WidgetView[]; errors: string[] }
|
|
88
|
+
|
|
89
|
+
/** the planned page's data: every row of the machine's plan */
|
|
90
|
+
export type PlannedAnswer = { now: number; rows: PlanRow[]; errors: { repo: string; error: string }[]; running: boolean }
|
|
91
|
+
|
|
92
|
+
export function homeRoutes(d: HomeDeps) {
|
|
93
|
+
let repos: WiredRepo[] = []
|
|
94
|
+
let rows: ProjectRow[] = []
|
|
95
|
+
const modernizing = new Map<string, ModernizeView & { log: string }>()
|
|
96
|
+
const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
|
|
97
|
+
|
|
98
|
+
async function refresh(): Promise<void> {
|
|
99
|
+
const a = await d.gutter.answer()
|
|
100
|
+
rows = a.projects
|
|
101
|
+
repos = a.projects.filter((p) => d.only == null || d.only.has(p.name)).map((p) => ({ name: p.name, path: p.path, icon: null }))
|
|
102
|
+
d.hubs.sync(repos)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let active = new Set<string>()
|
|
106
|
+
if (d.plan != null) d.plan.active = (name) => active.has(name)
|
|
107
|
+
|
|
108
|
+
async function tick(): Promise<void> {
|
|
109
|
+
await refresh()
|
|
110
|
+
active = activeSet(rows, d.state.data.active)
|
|
111
|
+
// the plan's clock: one look per tick, and it starts at most one batch
|
|
112
|
+
d.plan?.tick()
|
|
113
|
+
// a widget render never holds the tick back
|
|
114
|
+
void d.widgets?.tick(repos.map((r) => ({ name: r.name, path: r.path }))).catch((e) => console.error(`WARN the widgets broke: ${(e as Error).message}`))
|
|
115
|
+
await d.hubs.tick(active)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function answer(): HomeAnswer {
|
|
119
|
+
const behind: Record<string, string> = {}
|
|
120
|
+
for (const r of repos) {
|
|
121
|
+
const v = installedVersion(r.path, 'rvlib-shipkit')
|
|
122
|
+
if (v != null && isBehind(v, LIB_VERSION)) behind[r.name] = v
|
|
123
|
+
}
|
|
124
|
+
const modernize: Record<string, ModernizeView> = {}
|
|
125
|
+
for (const [k, m] of modernizing) modernize[k] = { status: m.status, code: m.code, tail: tailLines(m.log, 40) }
|
|
126
|
+
return { version: LIB_VERSION, workers: d.hubs.views(repos), active: d.state.data.active, behind, modernize }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const find = (name: unknown): WiredRepo | null => repos.find((r) => r.name === name) ?? null
|
|
130
|
+
const body = async (req: Request): Promise<Record<string, unknown>> => (await req.json().catch(() => ({}))) as Record<string, unknown>
|
|
131
|
+
|
|
132
|
+
async function handle(req: Request, srv?: Parameters<Hubs['dispatch']>[2]): Promise<Response | null> {
|
|
133
|
+
const path = new URL(req.url).pathname
|
|
134
|
+
// every repo's hub, which makes its own same origin check
|
|
135
|
+
if (path.startsWith('/r/')) {
|
|
136
|
+
if (repos.length === 0) await refresh()
|
|
137
|
+
return d.hubs.dispatch(req, path, srv as Parameters<Hubs['dispatch']>[2])
|
|
138
|
+
}
|
|
139
|
+
if (path === '/hub.json')
|
|
140
|
+
// the identity a client probes (homeUp), through the web or here
|
|
141
|
+
return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION }, { headers: { 'access-control-allow-origin': '*' } })
|
|
142
|
+
if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
|
|
143
|
+
if (path.startsWith('/api/projects')) return d.gutter.handle(req, path)
|
|
144
|
+
// the web asks which page routes to serve: one per house repo
|
|
145
|
+
if (path === '/api/home/repos.json') {
|
|
146
|
+
if (repos.length === 0) await refresh()
|
|
147
|
+
return Response.json({ repos: d.hubs.names() })
|
|
148
|
+
}
|
|
149
|
+
if (path === '/api/home/planned.json') {
|
|
150
|
+
if (repos.length === 0) await refresh()
|
|
151
|
+
const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true }
|
|
152
|
+
return Response.json(out)
|
|
153
|
+
}
|
|
154
|
+
if (path === '/api/home/widgets.json') {
|
|
155
|
+
const out: WidgetsAnswer = { widgets: d.widgets?.views() ?? [], errors: d.widgets?.errors ?? [] }
|
|
156
|
+
return Response.json(out)
|
|
157
|
+
}
|
|
158
|
+
if (path === '/api/home.json') {
|
|
159
|
+
if (repos.length === 0) await refresh()
|
|
160
|
+
return Response.json(answer())
|
|
161
|
+
}
|
|
162
|
+
if (req.method !== 'POST') return null
|
|
163
|
+
if (path === '/api/home/hub') {
|
|
164
|
+
const b = await body(req)
|
|
165
|
+
if (repos.length === 0) await refresh()
|
|
166
|
+
const r = find(b.name)
|
|
167
|
+
if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
|
|
168
|
+
if (b.act === 'ensure') return Response.json(d.hubs.ensure(r))
|
|
169
|
+
if (b.act === 'stop') return Response.json(d.hubs.stop(r))
|
|
170
|
+
if (b.act === 'restart') return Response.json(await d.hubs.restart(r))
|
|
171
|
+
return new Response(`act must be ensure, stop or restart`, { status: 400 })
|
|
172
|
+
}
|
|
173
|
+
if (path === '/api/home/planned/run') {
|
|
174
|
+
const b = await body(req)
|
|
175
|
+
if (d.plan == null || !d.plan.ask(String(b.repo), String(b.check))) return new Response(`no planned check ${String(b.check)} in ${String(b.repo)}`, { status: 404 })
|
|
176
|
+
d.plan.tick()
|
|
177
|
+
return Response.json({ ok: true })
|
|
178
|
+
}
|
|
179
|
+
if (path === '/api/home/active') {
|
|
180
|
+
const b = await body(req)
|
|
181
|
+
if (find(b.name) == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
|
|
182
|
+
d.state.setActive(String(b.name), b.on === true)
|
|
183
|
+
return Response.json({ active: d.state.data.active })
|
|
184
|
+
}
|
|
185
|
+
if (path === '/api/home/modernize') {
|
|
186
|
+
const b = await body(req)
|
|
187
|
+
const r = find(b.name)
|
|
188
|
+
if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
|
|
189
|
+
if (modernizing.get(r.name)?.status === 'running') return new Response(`already modernizing ${r.name}`, { status: 409 })
|
|
190
|
+
const log = modernizeLog(r.name)
|
|
191
|
+
const m = { status: 'running' as ModernizeView['status'], code: null as number | null, tail: [], log }
|
|
192
|
+
modernizing.set(r.name, m)
|
|
193
|
+
void (d.modernize ?? ((repo, l) => runModernize(repo, d.root, l)))(r, log)
|
|
194
|
+
.then(async (code) => {
|
|
195
|
+
m.status = code === 0 ? 'done' : 'failed'
|
|
196
|
+
m.code = code
|
|
197
|
+
// the hub loads again on what the update installed
|
|
198
|
+
if (code === 0) await d.hubs.restart(r)
|
|
199
|
+
})
|
|
200
|
+
.catch(() => { m.status = 'failed' })
|
|
201
|
+
return Response.json({ ok: true })
|
|
202
|
+
}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { handle, tick, answer, refresh }
|
|
207
|
+
}
|
|
208
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// getting to a running home from anywhere: is pm installed, start it, find
|
|
2
|
+
// the house repo it runs from, open a window on it. `shipkit gui` and the
|
|
3
|
+
// serve handoff both come through here.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
|
5
|
+
import { homedir } from 'os'
|
|
6
|
+
import { dirname, join, resolve } from 'path'
|
|
7
|
+
import { gitRoot } from '../core/git-root.ts'
|
|
8
|
+
import { homeRepo, houseIndex } from '../serve/project-gutter.ts'
|
|
9
|
+
import { BUNDLE_ID, canBuildGui, ensureGuiApp } from './gui-app.ts'
|
|
10
|
+
import { LABEL, plistPath } from './home-launchd.ts'
|
|
11
|
+
import { childLog, daemonPort, daemonUp, homeDir, homePort, homeUp, pmLog } from './home-paths.ts'
|
|
12
|
+
|
|
13
|
+
/** the pm LaunchAgent is installed: on this machine no hub runs outside the home */
|
|
14
|
+
export const homeInstalled = (): boolean => existsSync(plistPath())
|
|
15
|
+
|
|
16
|
+
/** set, a machine with no LaunchAgent still sends its hubs and gates to the
|
|
17
|
+
* home that answers on SHIPKIT_HOME_PORT (a home started by hand, a test) */
|
|
18
|
+
export const USE_HOME_ENV = 'SHIPKIT_USE_HOME'
|
|
19
|
+
|
|
20
|
+
/** this machine runs its hubs and gates in the home */
|
|
21
|
+
export const homeWanted = (env: Record<string, string | undefined> = process.env, installed: () => boolean = homeInstalled): boolean =>
|
|
22
|
+
(env[USE_HOME_ENV] != null && env[USE_HOME_ENV] !== '') || installed()
|
|
23
|
+
|
|
24
|
+
/** how long a start waits for the home to answer */
|
|
25
|
+
export const START_WAIT_MS = 15_000
|
|
26
|
+
|
|
27
|
+
/** kick the installed pm agent and wait until the role answers: the daemon
|
|
28
|
+
* for a terminal client, the web for a window. Throws, loud, when it does
|
|
29
|
+
* not: a caller must never fall back to a hub of its own */
|
|
30
|
+
export async function startHome(o: { wait?: number; need?: 'daemon' | 'web' } = {}): Promise<void> {
|
|
31
|
+
const need = o.need ?? 'daemon'
|
|
32
|
+
const up = (): Promise<boolean> => (need === 'daemon' ? daemonUp() : homeUp(homePort()))
|
|
33
|
+
if (await up()) return
|
|
34
|
+
const p = Bun.spawn(['launchctl', 'kickstart', `gui/${process.getuid?.() ?? 501}/${LABEL}`], { stdout: 'ignore', stderr: 'pipe' })
|
|
35
|
+
const err = (await new Response(p.stderr).text()).trim()
|
|
36
|
+
if ((await p.exited) !== 0) throw new Error(`launchctl kickstart ${LABEL} failed: ${err}. Reinstall it with \`shipkit pm install\` from the house repo`)
|
|
37
|
+
const until = Date.now() + (o.wait ?? START_WAIT_MS)
|
|
38
|
+
while (Date.now() < until) {
|
|
39
|
+
if (await up()) return
|
|
40
|
+
await Bun.sleep(250)
|
|
41
|
+
}
|
|
42
|
+
const port = need === 'daemon' ? daemonPort() : homePort()
|
|
43
|
+
throw new Error(`the ${need} did not answer on :${port} within ${Math.round((o.wait ?? START_WAIT_MS) / 1000)}s: see ${childLog(homeDir(), need)} and ${pmLog()}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** the cli the home runs from its repo: the one that repo's own `hub` script
|
|
47
|
+
* runs (an installed copy, or the lib source in the repo that hosts it),
|
|
48
|
+
* else this one */
|
|
49
|
+
export function houseCli(root: string, own: string): string {
|
|
50
|
+
try {
|
|
51
|
+
const hub = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts?.['hub'] ?? ''
|
|
52
|
+
const cli = /(\S*cli\.ts)\b/.exec(hub)?.[1]
|
|
53
|
+
if (cli != null) {
|
|
54
|
+
const abs = resolve(root, cli)
|
|
55
|
+
if (existsSync(abs)) return abs
|
|
56
|
+
}
|
|
57
|
+
} catch { /* no package.json: this cli */ }
|
|
58
|
+
return own
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** the repo that hosts the house wiki, seen from any house repo, or null */
|
|
62
|
+
export function houseRootOf(cwd: string): string | null {
|
|
63
|
+
const index = houseIndex(homeRepo(cwd), homedir())
|
|
64
|
+
if (index == null) return null
|
|
65
|
+
return gitRoot(dirname(index))?.top ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const CHROME = process.env.CHROME ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
69
|
+
/** the window's own Chrome profile: its own dock icon and cookies, apart from the everyday browser */
|
|
70
|
+
export const guiProfile = (): string => join(homeDir(), 'gui-profile')
|
|
71
|
+
const GUI_DISK_CACHE = 32 * 1024 * 1024
|
|
72
|
+
|
|
73
|
+
/** the command that opens one more home window: a Chrome app window (no tabs,
|
|
74
|
+
* no address bar) on its own profile, else the default browser */
|
|
75
|
+
export function windowCmd(url: string, o: { chrome?: string; platform?: string } = {}): string[] {
|
|
76
|
+
const chrome = o.chrome ?? CHROME
|
|
77
|
+
const platform = o.platform ?? process.platform
|
|
78
|
+
// no component downloads and a small disk cache: the profile shows one
|
|
79
|
+
// page, and those two are most of what grows it (retention.ts caps it)
|
|
80
|
+
if (existsSync(chrome)) return [chrome, `--app=${url}`, `--user-data-dir=${guiProfile()}`, '--no-first-run', '--no-default-browser-check', '--disable-component-update', `--disk-cache-size=${GUI_DISK_CACHE}`]
|
|
81
|
+
return platform === 'darwin' ? ['open', url] : ['xdg-open', url]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** one more home window: the native app where it can be built (its own
|
|
85
|
+
* dock icon, the house mark), else a Chrome app window. `warn` hears why the
|
|
86
|
+
* native one was not used */
|
|
87
|
+
export function openWindow(url: string, o: { root?: string | null; version?: string; warn?: (l: string) => void } = {}): string[] {
|
|
88
|
+
if (canBuildGui()) {
|
|
89
|
+
try {
|
|
90
|
+
const { app, rebuilt } = ensureGuiApp({ root: o.root ?? houseRootOf(process.cwd()) ?? process.cwd(), version: o.version ?? '0' })
|
|
91
|
+
// a running copy is the old build: it quits, and the new one opens the window
|
|
92
|
+
if (rebuilt) Bun.spawnSync(['osascript', '-e', `quit app id "${BUNDLE_ID}"`], { stdout: 'ignore', stderr: 'ignore' })
|
|
93
|
+
const cmd = ['open', '-a', app, url]
|
|
94
|
+
Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
95
|
+
return cmd
|
|
96
|
+
} catch (e) {
|
|
97
|
+
o.warn?.(`WARN the shipkit app could not be built, so this window is a Chrome one: ${(e as Error).message}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
mkdirSync(guiProfile(), { recursive: true })
|
|
101
|
+
const cmd = windowCmd(url)
|
|
102
|
+
// detached: the window outlives the command that opened it
|
|
103
|
+
Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'ignore'], detached: true }).unref()
|
|
104
|
+
return cmd
|
|
105
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// what the home remembers across restarts, and only that: the repos flagged
|
|
2
|
+
// active by hand. One row of the machine db (core/machine-db.ts), edited by
|
|
3
|
+
// the page.
|
|
4
|
+
import { importMachineFile, machineGet, machineSet } from '../core/machine-db.ts'
|
|
5
|
+
import { stateFile } from './home-paths.ts'
|
|
6
|
+
|
|
7
|
+
export type HomeStateData = {
|
|
8
|
+
/** repos whose hub runs whether or not an agent works in them */
|
|
9
|
+
active: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const KEY = 'home.active'
|
|
13
|
+
|
|
14
|
+
export class HomeState {
|
|
15
|
+
data: HomeStateData
|
|
16
|
+
|
|
17
|
+
/** `dir` is the home dir, where the json file an older copy wrote is
|
|
18
|
+
* read once into the db */
|
|
19
|
+
constructor(dir: string) {
|
|
20
|
+
importMachineFile<string[]>(KEY, stateFile(dir), (t) => HomeState.parse(t).active)
|
|
21
|
+
this.data = { active: HomeState.clean(machineGet<unknown>(KEY)) }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static parse(text: string): HomeStateData {
|
|
25
|
+
try {
|
|
26
|
+
return { active: HomeState.clean((JSON.parse(text) as Partial<HomeStateData>).active) }
|
|
27
|
+
} catch {
|
|
28
|
+
return { active: [] }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private static clean(v: unknown): string[] {
|
|
33
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setActive(name: string, on: boolean): void {
|
|
37
|
+
const set = new Set(this.data.active)
|
|
38
|
+
if (on) set.add(name)
|
|
39
|
+
else set.delete(name)
|
|
40
|
+
this.data = { ...this.data, active: [...set].sort() }
|
|
41
|
+
if (!machineSet(KEY, this.data.active)) throw new Error('the machine db could not be written, so the active flag is not saved')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// the words the home's server and page both need, in a module with no node
|
|
2
|
+
// import, so the page bundle can take it.
|
|
3
|
+
|
|
4
|
+
/** the blocker the page answers with an install button */
|
|
5
|
+
export const NOT_INSTALLED = 'rvlib-shipkit is not installed here: run `bun install`'
|
|
6
|
+
|
|
7
|
+
/** the last line of a run a hub streams to a terminal: its exit code follows */
|
|
8
|
+
export const RUN_EXIT = '\u0000shipkit-exit '
|
|
9
|
+
|
|
10
|
+
/** a line of a run a hub streams to a terminal: the gate job it started, so a
|
|
11
|
+
* terminal whose stream is cut follows the rest from the job folder */
|
|
12
|
+
export const RUN_JOB = '\u0000shipkit-job '
|
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
|
+
}
|