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
package/src/serve/serve-hub.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// the hub's routes: ONE page (the hub app, a Bun HTML import: media studio,
|
|
2
|
+
// asks, creds, posts, health, wikis as sections), the studio route table as
|
|
3
|
+
// the media API under /media/*, graph canvases as drill-down pages. hubHandler
|
|
4
|
+
// is the routing alone, under a base path; serveHub puts it behind a
|
|
5
|
+
// Bun.serve of its own. Resolving WHAT to mount from repo.config is
|
|
6
|
+
// hub-runtime.ts's job.
|
|
6
7
|
import { existsSync } from 'fs'
|
|
7
8
|
import { join } from 'path'
|
|
8
9
|
import { positionsFile } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
|
|
@@ -21,19 +22,23 @@ import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
|
|
|
21
22
|
import type { LiveView } from '../ledger/live-timeline.ts'
|
|
22
23
|
|
|
23
24
|
export type { LiveView }
|
|
24
|
-
import type {
|
|
25
|
+
import type { PlanRow } from '../plan/planner.ts'
|
|
25
26
|
import type { HubProblems } from './hub-problems.ts'
|
|
26
27
|
import hubIndex from './hub-app/index.html'
|
|
28
|
+
import { homePort, homeUp } from '../home/home-paths.ts'
|
|
29
|
+
import { RUN_EXIT } from '../home/home-words.ts'
|
|
30
|
+
import type { RepoProcesses } from './repo-processes.ts'
|
|
27
31
|
import { projectGutter } from './project-gutter-route.ts'
|
|
28
32
|
import { sameOrigin } from './same-origin.ts'
|
|
29
33
|
import { LAYOUT } from '../core/layout.ts'
|
|
34
|
+
import { WarmCell } from './warm-cell.ts'
|
|
30
35
|
|
|
31
36
|
export { sameOrigin }
|
|
32
37
|
|
|
33
38
|
export type HubTool = { path: string; label: string; desc: string }
|
|
34
39
|
|
|
35
40
|
/** what a running gate streams back to the page */
|
|
36
|
-
export type CheckTap = { line: (l: string) => void; check: (e: CheckEvent) => void }
|
|
41
|
+
export type CheckTap = { line: (l: string) => void; check: (e: CheckEvent) => void; job?: (id: string) => void }
|
|
37
42
|
/** the comfy panel, as the hub needs it: the child process it embeds and the
|
|
38
43
|
* box those workflows generate on (src/comfy/comfy-panel.ts owns both) */
|
|
39
44
|
export type ComfyDeps = {
|
|
@@ -45,6 +50,12 @@ export type ComfyDeps = {
|
|
|
45
50
|
export type HubDeps = {
|
|
46
51
|
root: string
|
|
47
52
|
name: string
|
|
53
|
+
/** the machine resources the repo declares in serve.uses */
|
|
54
|
+
uses?: string[]
|
|
55
|
+
/** the long running processes the repo declares in serve.processes */
|
|
56
|
+
processes?: RepoProcesses
|
|
57
|
+
/** a terminal's `shipkit ci` or `check`, run in this process; resolves to its exit code */
|
|
58
|
+
runCli?: (kind: 'ci' | 'check', opts: Record<string, unknown>, print: (l: string) => void) => Promise<number>
|
|
48
59
|
/** 0 = random (tests); real launches pass repo.config/default */
|
|
49
60
|
port: number
|
|
50
61
|
/** every md graph this hub serves, in card order: /graph/<id>. run-serve
|
|
@@ -62,9 +73,12 @@ export type HubDeps = {
|
|
|
62
73
|
* into a tree someone is editing. run-serve owns the routing between the
|
|
63
74
|
* two engines. */
|
|
64
75
|
runChecks: (tap: CheckTap, o?: { only?: string[]; gate?: boolean; by?: InvokedBy; auto?: boolean }) => Promise<number>
|
|
65
|
-
/**
|
|
66
|
-
*
|
|
67
|
-
|
|
76
|
+
/** this repo's rows of the plan (plan/planner.ts): the page reads them to
|
|
77
|
+
* say when a check runs next and why; absent = no plan */
|
|
78
|
+
planned?: () => PlanRow[]
|
|
79
|
+
/** a click: this check runs next, through the plan. false = the plan
|
|
80
|
+
* does not know it */
|
|
81
|
+
ask?: (check: string) => boolean
|
|
68
82
|
/** every run on record, shaped for the timeline page. Absent = the page
|
|
69
83
|
* says there is no record rather than drawing an empty one */
|
|
70
84
|
timeline?: () => Promise<Timeline>
|
|
@@ -99,11 +113,40 @@ export type HubDeps = {
|
|
|
99
113
|
* file, so every surface built on the config asks for this first, or a
|
|
100
114
|
* toggle reads as a toggle that did nothing until the next restart */
|
|
101
115
|
refresh?: () => Promise<void>
|
|
102
|
-
/**
|
|
116
|
+
/** a cheap fingerprint of what the rows and the run record read (file
|
|
117
|
+
* stats): a new one marks the warm answers stale. Absent = only a save
|
|
118
|
+
* and their age refresh them */
|
|
119
|
+
stamp?: () => string
|
|
120
|
+
/** RED-only ping tick, called on an interval by the hub runtime (null = off) */
|
|
103
121
|
pingTick?: ((needs: NeedItem[]) => void) | null
|
|
104
122
|
}
|
|
105
123
|
export type Hub = { server: ReturnType<typeof Bun.serve>; tools: HubTool[]; liveReload: boolean; stop: () => void }
|
|
106
124
|
|
|
125
|
+
/** one repo's request handling, apart from any server: `shipkit serve` puts it
|
|
126
|
+
* behind a Bun.serve of its own, the home behind /r/<repo>/ of its one */
|
|
127
|
+
export type HubHandler = {
|
|
128
|
+
tools: HubTool[]
|
|
129
|
+
/** a request whose path is relative to the hub's base ('/api/…', '/hub.json') */
|
|
130
|
+
handle: (req: Request, path: string, srv: Server) => Promise<Response>
|
|
131
|
+
/** a tracked file of the repo moved: every warm answer recomputes behind */
|
|
132
|
+
invalidate: () => void
|
|
133
|
+
/** compute the warm answers now, the rows first: the hub's load */
|
|
134
|
+
warm: () => Promise<void>
|
|
135
|
+
stop: () => void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** past this age a warm answer recomputes in the background even when no
|
|
139
|
+
* save and no stamp said it moved: what no watch sees (a clock, a wiki in
|
|
140
|
+
* another repo, a file git ignores). Long, because a recompute holds the
|
|
141
|
+
* one process every repo answers from */
|
|
142
|
+
export const WARM_MAX_AGE_MS = 120_000
|
|
143
|
+
|
|
144
|
+
/** the one flat list of the repo, as /api/resources.json carries it before
|
|
145
|
+
* the plan rows and the hub problems, which are read live */
|
|
146
|
+
type Rows = { collections: { id: string; title: string; media: string }[]; resources: Awaited<ReturnType<typeof projectResources>> }
|
|
147
|
+
type Server = ReturnType<typeof Bun.serve>
|
|
148
|
+
type Gutter = ReturnType<typeof projectGutter>
|
|
149
|
+
|
|
107
150
|
/** the tab refreshes ITSELF through bun's hot reload client, which rides in
|
|
108
151
|
* the served page: it swaps a hub-app edit in, and reconnects across the
|
|
109
152
|
* process restart a source edit triggers under `bun --watch`. Bun puts that
|
|
@@ -124,27 +167,79 @@ export function hubTools(d: Pick<HubDeps, 'graphs'>): HubTool[] {
|
|
|
124
167
|
}
|
|
125
168
|
|
|
126
169
|
// drill-down pages (graph canvases) get one fixed home link back to THE page
|
|
127
|
-
const
|
|
128
|
-
background:#141619;border:1px solid #282c31;border-radius:3px;padding:5px 9px}</style><a class=skhome href="/" title="hub — THE page">⌂</a>`
|
|
129
|
-
const withHome = (html: string): string => html.replace(/<body([^>]*)>/, `<body$1>${
|
|
170
|
+
const homeLink = (base: string): string => `<style>.skhome{position:fixed;left:10px;top:10px;z-index:9999;font-size:17px;text-decoration:none;
|
|
171
|
+
background:#141619;border:1px solid #282c31;border-radius:3px;padding:5px 9px}</style><a class=skhome href="${base}/" title="hub — THE page">⌂</a>`
|
|
172
|
+
const withHome = (html: string, base: string): string => html.replace(/<body([^>]*)>/, `<body$1>${homeLink(base)}`)
|
|
130
173
|
|
|
131
|
-
|
|
174
|
+
/** the hub's routes under `base` ('' on a server of its own, '/r/<repo>' in
|
|
175
|
+
* the home). `projects` is the rail, shared when one process serves many */
|
|
176
|
+
export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } = {}): HubHandler {
|
|
177
|
+
const base = o.base ?? ''
|
|
132
178
|
const tools = hubTools(d)
|
|
133
|
-
const projects = projectGutter({ root: d.root })
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
179
|
+
const projects = o.projects ?? projectGutter({ root: d.root })
|
|
180
|
+
// the answers a page waits on when a repo shows, kept warm in memory: a
|
|
181
|
+
// request answers from here and never walks the repo (warm-cell.ts)
|
|
182
|
+
const warmOpts = { stamp: d.stamp, maxAgeMs: WARM_MAX_AGE_MS }
|
|
183
|
+
const rows = new WarmCell<Rows>(async () => {
|
|
184
|
+
await d.refresh?.()
|
|
185
|
+
const cols = d.collections()
|
|
186
|
+
return { collections: cols.map((c) => ({ id: c.id, title: c.title, media: c.media })), resources: await projectResources(cols, d.hubProblems?.collector) }
|
|
187
|
+
}, warmOpts)
|
|
188
|
+
// the run record and the config are all it reads, and the stamp sees both
|
|
189
|
+
const timeline = d.timeline == null ? null : new WarmCell(d.timeline, { stamp: d.stamp })
|
|
190
|
+
const graphs = new Map(d.graphs.map((g) => [g.id, new WarmCell(async () => g.load(), { maxAgeMs: WARM_MAX_AGE_MS })]))
|
|
191
|
+
const fresh = (req: Request): boolean => new URL(req.url).searchParams.get('fresh') === '1'
|
|
192
|
+
return {
|
|
193
|
+
tools,
|
|
194
|
+
invalidate: () => {
|
|
195
|
+
rows.invalidate()
|
|
196
|
+
timeline?.invalidate()
|
|
197
|
+
for (const g of graphs.values()) g.invalidate()
|
|
198
|
+
},
|
|
199
|
+
warm: async () => {
|
|
200
|
+
await rows.warm()
|
|
201
|
+
await timeline?.warm()
|
|
202
|
+
for (const g of graphs.values()) await g.warm()
|
|
203
|
+
},
|
|
204
|
+
stop: () => { d.comfy?.stop() },
|
|
205
|
+
async handle(req, path, srv): Promise<Response> {
|
|
141
206
|
if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the hub page', { status: 403 })
|
|
142
207
|
// identity probe: lets a second launch tell "same repo, another
|
|
143
208
|
// window" from "a DIFFERENT repo hashed onto my port"
|
|
144
209
|
// `root` rides with it so a page can put a file link into his
|
|
145
210
|
// editor: only the server knows where this repo sits on disk
|
|
146
|
-
|
|
211
|
+
// `uses` tells the home which machine resources this repo needs,
|
|
212
|
+
// `base` where this hub's page sits on the server that answers
|
|
213
|
+
if (path === '/hub.json') return Response.json({ hub: 'shipkit', name: d.name, root: d.root, base, tools, uses: d.uses ?? [] })
|
|
147
214
|
if (path.startsWith('/api/projects')) return (await projects.handle(req, path)) ?? new Response('not found', { status: 404 })
|
|
215
|
+
// where the machine's home serves, and whether it does: a rail
|
|
216
|
+
// click opens the home on that repo, or VS Code while none runs
|
|
217
|
+
if (path === '/api/home-url') return Response.json({ port: homePort(), up: await homeUp() })
|
|
218
|
+
if (path === '/api/run' && req.method === 'POST' && d.runCli != null) {
|
|
219
|
+
const b = (await req.json().catch(() => ({}))) as { kind?: string; opts?: Record<string, unknown> }
|
|
220
|
+
if (b.kind !== 'ci' && b.kind !== 'check') return new Response('kind must be ci or check', { status: 400 })
|
|
221
|
+
const runCli = d.runCli
|
|
222
|
+
// a gate can stay silent for minutes (queued, or one long tsc):
|
|
223
|
+
// Bun would cut the connection after its idle timeout
|
|
224
|
+
srv.timeout(req, 0)
|
|
225
|
+
// wire lines as they come, then one EXIT line the client ends on
|
|
226
|
+
const stream = new ReadableStream<string>({
|
|
227
|
+
start(ctl) {
|
|
228
|
+
const put = (l: string): void => { try { ctl.enqueue(`${l}\n`) } catch { /* the terminal went away: the run finishes anyway */ } }
|
|
229
|
+
void runCli(b.kind as 'ci' | 'check', b.opts ?? {}, put)
|
|
230
|
+
.then((code) => put(`${RUN_EXIT}${code}`), (e: Error) => { put(`FAIL: ${e.message}`); put(`${RUN_EXIT}1`) })
|
|
231
|
+
.finally(() => { try { ctl.close() } catch { /* closed */ } })
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
return new Response(stream, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
|
|
235
|
+
}
|
|
236
|
+
if (path === '/api/processes.json') return Response.json({ processes: d.processes?.views() ?? [] })
|
|
237
|
+
if (path === '/api/processes/act' && req.method === 'POST') {
|
|
238
|
+
const b = (await req.json().catch(() => ({}))) as { id?: string; act?: string }
|
|
239
|
+
if (b.act !== 'start' && b.act !== 'stop' && b.act !== 'restart') return new Response('act must be start, stop or restart', { status: 400 })
|
|
240
|
+
const v = d.processes?.act(b.id ?? '', b.act)
|
|
241
|
+
return v == null ? new Response(`no process '${b.id ?? ''}' in serve.processes`, { status: 404 }) : Response.json(v)
|
|
242
|
+
}
|
|
148
243
|
// the three surfaces built on the config read the config on disk
|
|
149
244
|
if (path === '/api/needs.json' || path === '/api/resources.json' || path === '/api/checks.json' || path === '/api/collections.json') await d.refresh?.()
|
|
150
245
|
if (path === '/api/needs.json') {
|
|
@@ -155,11 +250,11 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
155
250
|
// ONE flat list for the whole repo: the page groups it by
|
|
156
251
|
// path and by type, and a second endpoint would be a second
|
|
157
252
|
// answer to drift from this one
|
|
158
|
-
const
|
|
253
|
+
const r = await rows.get({ fresh: fresh(req) })
|
|
159
254
|
return Response.json({
|
|
160
|
-
collections:
|
|
161
|
-
resources:
|
|
162
|
-
|
|
255
|
+
collections: r.collections,
|
|
256
|
+
resources: r.resources,
|
|
257
|
+
planned: d.planned?.() ?? null,
|
|
163
258
|
hub: d.hubProblems?.list() ?? [],
|
|
164
259
|
})
|
|
165
260
|
}
|
|
@@ -236,9 +331,9 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
236
331
|
return Response.json({ url: d.repoHub == null ? null : await d.repoHub(repo) })
|
|
237
332
|
}
|
|
238
333
|
if (path === '/api/timeline.json') {
|
|
239
|
-
if (
|
|
334
|
+
if (timeline == null) return new Response('this hub keeps no run record', { status: 404 })
|
|
240
335
|
try {
|
|
241
|
-
return Response.json(await
|
|
336
|
+
return Response.json(await timeline.get({ fresh: fresh(req) }))
|
|
242
337
|
} catch (e) {
|
|
243
338
|
return new Response(`the run record could not be shaped: ${(e as Error).message}`, { status: 500 })
|
|
244
339
|
}
|
|
@@ -319,30 +414,33 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
319
414
|
})
|
|
320
415
|
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store' } })
|
|
321
416
|
}
|
|
322
|
-
const page = (s: string): Response => new Response(withHome(s), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
417
|
+
const page = (s: string): Response => new Response(withHome(s, base), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
323
418
|
for (const g of d.graphs) {
|
|
324
419
|
if (!path.startsWith(`/graph/${g.id}`)) continue
|
|
325
|
-
|
|
420
|
+
// the graph as the warm cell holds it: a page open never rescans the wiki
|
|
421
|
+
const held = await graphs.get(g.id)!.get()
|
|
422
|
+
const routes = graphRoutes({ load: () => held, title: g.title, file: positionsFile(d.root, g.id), saveUrl: `${base}/graph/${g.id}/positions` })
|
|
326
423
|
if (path === `/graph/${g.id}`) return page(routes.page())
|
|
327
|
-
if (path === `/graph/${g.id}/graph.json`) return Response.json(
|
|
424
|
+
if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
|
|
328
425
|
if (path === `/graph/${g.id}/positions`) return routes.save(req)
|
|
329
426
|
}
|
|
330
427
|
// the media API namespace — the studio route table, ONE prefix strip
|
|
331
428
|
if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
|
|
332
429
|
// stale bookmarks (/assets/, /status, …) land here — name the way home
|
|
333
|
-
return new Response(
|
|
430
|
+
return new Response(`not found — the hub page is at ${base}/`, { status: 404 })
|
|
334
431
|
},
|
|
335
|
-
})
|
|
336
|
-
// RED-only pings ride the serving process: one tick now (state file
|
|
337
|
-
// guarantees a restart never re-fires), then every minute
|
|
338
|
-
let pingTimer: ReturnType<typeof setInterval> | null = null
|
|
339
|
-
if (d.pingTick != null) {
|
|
340
|
-
// tick failures are LOUD (console = the serve terminal), never swallowed
|
|
341
|
-
const tick = (): void => void d.needs().then((n) => d.pingTick?.(n)).catch((e: Error) => console.error(`WARN ping tick broke: ${e.message}`))
|
|
342
|
-
tick()
|
|
343
|
-
pingTimer = setInterval(tick, 60_000)
|
|
344
432
|
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function serveHub(d: HubDeps, h: HubHandler = hubHandler(d)): Hub {
|
|
436
|
+
const server: Server = Bun.serve({
|
|
437
|
+
hostname: '127.0.0.1',
|
|
438
|
+
port: d.port,
|
|
439
|
+
// `/` IS the page — the react app; there is no other surface
|
|
440
|
+
routes: { '/': hubIndex },
|
|
441
|
+
fetch: (req, srv) => h.handle(req, new URL(req.url).pathname, srv),
|
|
442
|
+
})
|
|
345
443
|
// the panel process belongs to this hub: leaving it behind means the next
|
|
346
444
|
// launch adopts a server holding the previous run's modules
|
|
347
|
-
return { server, tools, liveReload: canLiveReload(), stop: () => {
|
|
445
|
+
return { server, tools: h.tools, liveReload: canLiveReload(), stop: () => { h.stop(); server.stop(true) } }
|
|
348
446
|
}
|
|
@@ -20,6 +20,9 @@ export type StaleRestartOpts = {
|
|
|
20
20
|
/** why the file table leaked, null while it did not. Default: this
|
|
21
21
|
* process's own table (core/fd-table.ts) */
|
|
22
22
|
leak?: () => string | null
|
|
23
|
+
/** one line on source that moved under a process with no bun watch, null
|
|
24
|
+
* while nothing moved */
|
|
25
|
+
moved?: () => string | null
|
|
23
26
|
/** a run is in flight: a restart now would kill it */
|
|
24
27
|
busy: () => boolean
|
|
25
28
|
/** never returns when it works. `why` is one line on what went stale */
|
|
@@ -62,6 +65,8 @@ export class StaleRestart {
|
|
|
62
65
|
const disk = this.o.disk()
|
|
63
66
|
// a package.json half written by a bump reads as `?`, and is no verdict
|
|
64
67
|
if (disk !== '?' && disk !== this.o.boot) return { why: `this hub runs shipkit ${this.o.boot} but ${disk} is on disk`, leak: false }
|
|
68
|
+
const moved = this.o.moved?.() ?? null
|
|
69
|
+
if (moved != null) return { why: moved, leak: false }
|
|
65
70
|
const leak = (this.o.leak ?? fdTableLeak)()
|
|
66
71
|
return leak == null ? null : { why: leak, leak: true }
|
|
67
72
|
}
|
|
@@ -1,72 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// file event costs a stale problems pane until the next save, never a green.
|
|
6
|
-
//
|
|
7
|
-
// never two runs at once: a request during a run queues exactly one more, run
|
|
8
|
-
// after it ends, however many saves landed meanwhile.
|
|
1
|
+
// the save watch: which files moving under a repo count as the owner's edit.
|
|
2
|
+
// It only says THAT something moved; the plan (plan/planner.ts) then asks the
|
|
3
|
+
// ledger which checks that moved, so a missed file event costs a stale
|
|
4
|
+
// problems pane until the next save, never a green.
|
|
9
5
|
import { watch, type FSWatcher } from 'fs'
|
|
10
6
|
import { GENERATED_IGNORE_LINES } from '../rules/untrack-generated.ts'
|
|
11
7
|
import { gitBin } from '../core/git-bin.ts'
|
|
12
8
|
import { LAYOUT } from '../core/layout.ts'
|
|
13
9
|
|
|
14
|
-
export const QUIET_MS = 2000
|
|
15
|
-
|
|
16
|
-
export type Timers = {
|
|
17
|
-
set: (fn: () => void, ms: number) => unknown
|
|
18
|
-
clear: (handle: unknown) => void
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const realTimers: Timers = {
|
|
22
|
-
set: (fn, ms) => setTimeout(fn, ms),
|
|
23
|
-
clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export class GateTrigger {
|
|
27
|
-
private timer: unknown = null
|
|
28
|
-
private running: Promise<void> | null = null
|
|
29
|
-
private pending = false
|
|
30
|
-
private readonly timers: Timers
|
|
31
|
-
private readonly quietMs: number
|
|
32
|
-
|
|
33
|
-
constructor(private readonly o: { run: () => Promise<unknown>; quietMs?: number; timers?: Timers; onError?: (e: Error) => void }) {
|
|
34
|
-
this.timers = o.timers ?? realTimers
|
|
35
|
-
this.quietMs = o.quietMs ?? QUIET_MS
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** a tracked file moved: the run starts once nothing has moved for the
|
|
39
|
-
* quiet window, so a save burst is one run */
|
|
40
|
-
touched(): void {
|
|
41
|
-
if (this.timer != null) this.timers.clear(this.timer)
|
|
42
|
-
this.timer = this.timers.set(() => {
|
|
43
|
-
this.timer = null
|
|
44
|
-
this.request()
|
|
45
|
-
}, this.quietMs)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** run now, or once more after the run in flight */
|
|
49
|
-
request(): void {
|
|
50
|
-
if (this.running != null) { this.pending = true; return }
|
|
51
|
-
this.running = (async () => {
|
|
52
|
-
do {
|
|
53
|
-
this.pending = false
|
|
54
|
-
try { await this.o.run() } catch (e) { this.o.onError?.(e instanceof Error ? e : new Error(String(e))) }
|
|
55
|
-
} while (this.pending)
|
|
56
|
-
})().finally(() => { this.running = null })
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** resolves when no run is in flight or queued */
|
|
60
|
-
async idle(): Promise<void> {
|
|
61
|
-
while (this.running != null) await this.running
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
stop(): void {
|
|
65
|
-
if (this.timer != null) this.timers.clear(this.timer)
|
|
66
|
-
this.timer = null
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
10
|
/** every path a run of the gate or the hub itself writes, whatever the repo
|
|
71
11
|
* ignores: the status files, the per run logs under the scratch dir, an atomic write's
|
|
72
12
|
* temp file, the comfy state the hub writes at boot. A save there is the
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// one answer of a hub kept warm in memory: computed when the hub loads,
|
|
2
|
+
// recomputed in the background when something says it moved (a save, a
|
|
3
|
+
// stamp, its age), and served from memory. A request never pays for the
|
|
4
|
+
// walk behind it, except the very first one and one that asks for `fresh`.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: `get()` answers what is in
|
|
7
|
+
// memory even when it is known stale. Whoever just changed the truth (an
|
|
8
|
+
// act, the end of a run) asks for `fresh`, which waits on a compute that
|
|
9
|
+
// started after its call.
|
|
10
|
+
|
|
11
|
+
export type WarmCellOpts = {
|
|
12
|
+
/** a cheap fingerprint of what the answer reads (file stats): a new one
|
|
13
|
+
* marks the answer stale */
|
|
14
|
+
stamp?: () => string
|
|
15
|
+
/** past this age the answer is recomputed in the background even when
|
|
16
|
+
* nothing said it moved: what no watch sees (a clock, another repo) */
|
|
17
|
+
maxAgeMs?: number
|
|
18
|
+
now?: () => number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class WarmCell<T> {
|
|
22
|
+
private value: { v: T; at: number; stamp: string } | null = null
|
|
23
|
+
private running: Promise<T> | null = null
|
|
24
|
+
/** bumped by invalidate: a compute that started before it is stale */
|
|
25
|
+
private gen = 0
|
|
26
|
+
private runGen = -1
|
|
27
|
+
|
|
28
|
+
constructor(private readonly compute: () => Promise<T>, private readonly o: WarmCellOpts = {}) {}
|
|
29
|
+
|
|
30
|
+
private now(): number {
|
|
31
|
+
return this.o.now?.() ?? Date.now()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** a compute, shared by every caller while it runs */
|
|
35
|
+
private run(): Promise<T> {
|
|
36
|
+
if (this.running != null) return this.running
|
|
37
|
+
const gen = this.gen
|
|
38
|
+
const stamp = this.o.stamp?.() ?? ''
|
|
39
|
+
this.runGen = gen
|
|
40
|
+
const p = (async (): Promise<T> => {
|
|
41
|
+
try {
|
|
42
|
+
// never synchronously: `running` is set before this settles
|
|
43
|
+
const v = await Promise.resolve().then(() => this.compute())
|
|
44
|
+
this.value = { v, at: this.now(), stamp }
|
|
45
|
+
return v
|
|
46
|
+
} finally {
|
|
47
|
+
this.running = null
|
|
48
|
+
// what said it moved while this ran is not in this answer
|
|
49
|
+
if (this.gen !== gen) this.kick()
|
|
50
|
+
}
|
|
51
|
+
})()
|
|
52
|
+
this.running = p
|
|
53
|
+
return p
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** a compute in the background: on a failure the last answer stays */
|
|
57
|
+
private kick(): void {
|
|
58
|
+
void this.run().catch(() => {})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get stale(): boolean {
|
|
62
|
+
const v = this.value
|
|
63
|
+
if (v == null) return true
|
|
64
|
+
if (this.o.maxAgeMs != null && this.now() - v.at > this.o.maxAgeMs) return true
|
|
65
|
+
return this.o.stamp != null && this.o.stamp() !== v.stamp
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** the answer in memory, a stale one kicking a recompute behind it. With
|
|
69
|
+
* nothing in memory yet, or `fresh`, it waits on a compute */
|
|
70
|
+
async get(o: { fresh?: boolean } = {}): Promise<T> {
|
|
71
|
+
if (o.fresh === true) {
|
|
72
|
+
// a compute that started before this call may miss what the
|
|
73
|
+
// caller just changed: wait for it, then for one of our own
|
|
74
|
+
if (this.running != null) await this.running.catch(() => {})
|
|
75
|
+
return this.run()
|
|
76
|
+
}
|
|
77
|
+
const v = this.value
|
|
78
|
+
// nothing in memory, a failed compute included: wait on a new one
|
|
79
|
+
if (v == null) return this.run()
|
|
80
|
+
if (this.running == null && (this.stale || this.gen !== this.runGen)) this.kick()
|
|
81
|
+
return v.v
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** something this answer reads moved: recompute in the background */
|
|
85
|
+
invalidate(): void {
|
|
86
|
+
this.gen++
|
|
87
|
+
if (this.running == null) this.kick()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** compute now unless an answer is already in memory: the hub's load */
|
|
91
|
+
async warm(): Promise<void> {
|
|
92
|
+
if (this.value != null) return
|
|
93
|
+
await this.run().catch(() => {})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// THE `<name>.shipkit.ts` file: a repo drops one anywhere and default-exports
|
|
2
|
+
// what it declares, and the kind says who reads it.
|
|
3
|
+
//
|
|
4
|
+
// check({...}) a gate check, read by discovery like a `.check.ts`
|
|
5
|
+
// globalProcess({...}) a long running process for the whole machine: pm
|
|
6
|
+
// supervises it, the home page switches it
|
|
7
|
+
// widget({...}) html the daemon renders every few seconds, mounted
|
|
8
|
+
// on the home page
|
|
9
|
+
//
|
|
10
|
+
// the one constraint a reader breaks first: every kind is IMPORTED by the
|
|
11
|
+
// process that reads it (discovery, pm's config child, the daemon), so a file
|
|
12
|
+
// keeps its top level cheap and puts heavy work inside a function.
|
|
13
|
+
import { check, type CheckDef } from './check.ts'
|
|
14
|
+
|
|
15
|
+
export { check }
|
|
16
|
+
|
|
17
|
+
/** one button beside a global process: a command, or a function of the file */
|
|
18
|
+
export type ProcessAction = {
|
|
19
|
+
/** the words on the button */
|
|
20
|
+
label: string
|
|
21
|
+
/** argv, run from the repo root, output appended to the process log */
|
|
22
|
+
cmd?: string[]
|
|
23
|
+
/** a function run in its own bun process that imports this file, from
|
|
24
|
+
* the repo root. Its output lands in the process log too */
|
|
25
|
+
run?: () => void | Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type GlobalProcessDef = {
|
|
29
|
+
/** kebab-case, unique on the machine: what a repo names in `serve.uses` */
|
|
30
|
+
id: string
|
|
31
|
+
/** the name on the switch (default the id) */
|
|
32
|
+
label?: string
|
|
33
|
+
/** one line: what it is for */
|
|
34
|
+
description?: string
|
|
35
|
+
/** the machine it runs on, as a hint beside the switch */
|
|
36
|
+
where?: string
|
|
37
|
+
/** argv, run from the repo root (or `cwd`). Without `down` it IS the
|
|
38
|
+
* service: pm keeps it running, restarts it on a crash, signals it on
|
|
39
|
+
* stop. With `down` it is a bring-up that exits once the service answers */
|
|
40
|
+
cmd: string[]
|
|
41
|
+
/** argv that takes a bring-up down again. Absent: `cmd` is the service */
|
|
42
|
+
down?: string[]
|
|
43
|
+
/** where both run, repo-relative (default the repo root) */
|
|
44
|
+
cwd?: string
|
|
45
|
+
/** the url it serves: shown as a link, probed when `probe` is absent */
|
|
46
|
+
url?: string
|
|
47
|
+
/** an http url that answers 2xx while it is up (default `url`). A
|
|
48
|
+
* bring-up needs one of the two: its probe is the only truth */
|
|
49
|
+
probe?: string
|
|
50
|
+
/** a button each beside the switch, by key */
|
|
51
|
+
actions?: Record<string, ProcessAction>
|
|
52
|
+
/** with `url`: open it as a shipkit app window. Starting it from the
|
|
53
|
+
* page opens that window once it answers, in one go */
|
|
54
|
+
app?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WidgetDef = {
|
|
58
|
+
/** kebab-case, unique in its repo */
|
|
59
|
+
id: string
|
|
60
|
+
/** the heading over it (default the id) */
|
|
61
|
+
title?: string
|
|
62
|
+
/** how often the daemon renders it again (default 5000, at least 1000) */
|
|
63
|
+
everyMs?: number
|
|
64
|
+
/** the html it shows, rendered in the daemon. A throw shows as its error */
|
|
65
|
+
render: () => string | Promise<string>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type GlobalProcessUnit = GlobalProcessDef & { kind: 'globalProcess' }
|
|
69
|
+
export type WidgetUnit = WidgetDef & { kind: 'widget' }
|
|
70
|
+
|
|
71
|
+
export const UNIT_KINDS = ['globalProcess', 'widget'] as const
|
|
72
|
+
|
|
73
|
+
const KEBAB = /^[a-z0-9][a-z0-9-]*$/
|
|
74
|
+
const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
|
|
75
|
+
const httpUrl = (v: unknown): boolean => typeof v === 'string' && /^https?:\/\//.test(v)
|
|
76
|
+
|
|
77
|
+
/** why a global process declaration cannot run, or null */
|
|
78
|
+
export function globalProcessProblem(d: GlobalProcessDef): string | null {
|
|
79
|
+
const at = `globalProcess '${String(d.id)}'`
|
|
80
|
+
if (!KEBAB.test(d.id ?? '')) return `${at}: id must be kebab-case`
|
|
81
|
+
if (!words(d.cmd)) return `${at}: cmd is a list of words`
|
|
82
|
+
if (d.down != null && !words(d.down)) return `${at}: down is a list of words`
|
|
83
|
+
if (d.url != null && !httpUrl(d.url)) return `${at}: url must be an http url`
|
|
84
|
+
if (d.probe != null && !httpUrl(d.probe)) return `${at}: probe must be an http url`
|
|
85
|
+
if (d.down != null && d.probe == null && d.url == null) return `${at}: a bring-up (one with down) needs probe or url, the only truth of whether it is up`
|
|
86
|
+
if (d.app === true && d.url == null) return `${at}: app needs a url to open`
|
|
87
|
+
for (const [k, a] of Object.entries(d.actions ?? {})) {
|
|
88
|
+
if (!KEBAB.test(k)) return `${at}: action '${k}' must be kebab-case`
|
|
89
|
+
if ((a.cmd != null) === (a.run != null)) return `${at}: action '${k}' declares exactly one of cmd or run`
|
|
90
|
+
if (a.cmd != null && !words(a.cmd)) return `${at}: action '${k}'.cmd is a list of words`
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function globalProcess(d: GlobalProcessDef): GlobalProcessUnit {
|
|
96
|
+
const why = globalProcessProblem(d)
|
|
97
|
+
if (why != null) throw new Error(why)
|
|
98
|
+
return { ...d, kind: 'globalProcess' }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function widget(d: WidgetDef): WidgetUnit {
|
|
102
|
+
if (!KEBAB.test(d.id ?? '')) throw new Error(`widget '${String(d.id)}': id must be kebab-case`)
|
|
103
|
+
if (typeof d.render !== 'function') throw new Error(`widget '${d.id}': render is a function returning html`)
|
|
104
|
+
if (d.everyMs != null && !(d.everyMs >= 1000)) throw new Error(`widget '${d.id}': everyMs is at least 1000`)
|
|
105
|
+
return { ...d, kind: 'widget' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** the kind a default export says it is: a unit, else a check */
|
|
109
|
+
export function unitKind(d: unknown): 'globalProcess' | 'widget' | 'check' {
|
|
110
|
+
const k = (d as { kind?: unknown } | null)?.kind
|
|
111
|
+
return k === 'globalProcess' || k === 'widget' ? k : 'check'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type { CheckDef }
|
package/src/shipkit.ts
CHANGED
|
@@ -15,7 +15,8 @@ export { contractsConfigFrom, activeContracts, applyContractsResult } from './co
|
|
|
15
15
|
export { serveHub, hubTools } from './serve/serve-hub.ts'
|
|
16
16
|
export type { Hub, HubDeps, HubTool } from './serve/serve-hub.ts'
|
|
17
17
|
export type { GraphMount } from './wiki/graph-mount.ts'
|
|
18
|
-
export { runServe
|
|
18
|
+
export { runServe } from './serve/run-serve.ts'
|
|
19
|
+
export { resolveHubDeps, defaultHubPort, baseWikiRoot, loadHub } from './serve/hub-runtime.ts'
|
|
19
20
|
export { projectNeeds, redCount } from './collections/collection-types.ts'
|
|
20
21
|
export type { Collection, Instance, NeedItem, NeedSeverity, CurationVerb } from './collections/collection-types.ts'
|
|
21
22
|
export { buildCollections } from './collections/providers.ts'
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { copyFileSync, mkdirSync, readFileSync } from 'fs'
|
|
16
16
|
import { dirname, join } from 'path'
|
|
17
17
|
import { atomicWrite } from '../core/locked-write.ts'
|
|
18
|
-
import { shipkitRoot } from '../ledger/
|
|
18
|
+
import { shipkitRoot } from '../ledger/db.ts'
|
|
19
19
|
|
|
20
20
|
/** where a unit's durations live, beside everything else the repo remembers */
|
|
21
21
|
export const timingsHome = (root: string, unit: string): string => join(shipkitRoot(root), 'suite-timings', `${unit === '' ? 'root' : unit}.json`)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { mkdirSync, readFileSync } from 'fs'
|
|
5
5
|
import { dirname, join } from 'path'
|
|
6
6
|
import { atomicWrite } from '../core/locked-write.ts'
|
|
7
|
-
import { shipkitRoot } from '../ledger/
|
|
7
|
+
import { shipkitRoot } from '../ledger/db.ts'
|
|
8
8
|
import type { MdGraph } from './md-graph.ts'
|
|
9
9
|
|
|
10
10
|
export type Positions = Record<string, [number, number]>
|