rvlib-shipkit 1.0.0 → 1.1.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 +65 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +30 -4
- package/src/assets/gen/derived.ts +1 -1
- package/src/check.ts +6 -0
- package/src/checks/SK004-gitignore-policy.check.ts +4 -4
- package/src/checks/SK005-rvlib-versions.check.ts +25 -7
- package/src/checks/SK021-github-login.check.ts +123 -0
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Check.ts +1 -1
- package/src/cli/cmd_Ci.ts +1 -1
- package/src/cli/cmd_Fleet.ts +4 -47
- package/src/cli.ts +2 -3
- package/src/collections/checks.ts +12 -3
- package/src/collections/collection-types.ts +34 -18
- package/src/collections/comfy.ts +1 -0
- package/src/collections/posts.ts +1 -0
- package/src/collections/providers.ts +71 -43
- package/src/collections/reflections.ts +1 -0
- package/src/collections/state-rules.ts +29 -13
- package/src/collections/tests.ts +1 -0
- package/src/collections/wikis.ts +1 -0
- package/src/core/budget.ts +60 -0
- package/src/core/check-ctx.ts +2 -1
- package/src/core/check-runner.ts +43 -6
- package/src/core/fd-table.ts +37 -0
- package/src/core/floor-resolve.ts +2 -2
- package/src/core/git-root.ts +26 -2
- package/src/core/lib-version.ts +16 -0
- package/src/core/migrate-layout.ts +69 -9
- package/src/core/motherhome.ts +2 -4
- package/src/core/package-manager.ts +20 -16
- package/src/core/raw-access.ts +11 -3
- package/src/core/reexec.ts +26 -0
- package/src/core/run-check.ts +36 -16
- package/src/core/run-ci.ts +31 -27
- package/src/core/run-fix.ts +4 -1
- package/src/core/run-fixers.ts +19 -11
- package/src/core/self-update.ts +63 -26
- package/src/core/update-policy.ts +25 -0
- package/src/fleet/scan-fleet.ts +20 -15
- package/src/fleet/update-fleet.ts +85 -0
- package/src/hooks/leak-guard.ts +10 -1
- package/src/hooks/run-hooks.ts +3 -3
- package/src/ledger/gate-checks.ts +19 -5
- package/src/ledger/git-key.ts +10 -5
- package/src/ledger/git-scan.ts +29 -17
- package/src/ledger/ledger.ts +14 -6
- package/src/ledger/repo-view.ts +8 -6
- package/src/ledger/run-log.ts +10 -4
- package/src/ledger/run-timeline.ts +21 -9
- package/src/problems/problem-board.ts +13 -2
- package/src/problems/problem-line.ts +9 -0
- package/src/repo-config.ts +24 -4
- package/src/serve/hub-app/check-view.tsx +12 -11
- package/src/serve/hub-app/cols-svc.ts +16 -9
- package/src/serve/hub-app/dur.ts +10 -1
- package/src/serve/hub-app/header-bar.tsx +11 -0
- package/src/serve/hub-app/help.tsx +1 -0
- package/src/serve/hub-app/hub-st.ts +3 -4
- package/src/serve/hub-app/hub-types.ts +5 -0
- package/src/serve/hub-app/hub.css +3 -0
- package/src/serve/hub-app/timeline-view.tsx +4 -4
- package/src/serve/hub-app/tree-build.ts +1 -2
- package/src/serve/hub-problems.ts +35 -0
- package/src/serve/run-serve.ts +51 -12
- package/src/serve/serve-hub.ts +10 -3
- package/src/serve/stale-restart.ts +77 -0
- package/src/smoke/run-smoke.ts +6 -2
- package/src/suite/suite-cli.ts +14 -10
- package/src/ts/source-graph.ts +3 -2
- package/src/wiki/scan-md.ts +1 -1
|
@@ -37,6 +37,9 @@ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans
|
|
|
37
37
|
.hnav button:hover{color:var(--ink);border-color:transparent;border-bottom-color:var(--line)}
|
|
38
38
|
.hnav button.on{color:var(--accent);border-bottom-color:var(--accent)}
|
|
39
39
|
/* the run state holds its width whatever it says, so a run never moves the buttons beside it */
|
|
40
|
+
.hstatus{max-width:420px;flex:0 1 auto;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
41
|
+
.hstatus.red{color:var(--red)}
|
|
42
|
+
.hstatus.warn{color:var(--warn)}
|
|
40
43
|
.hstate{display:inline-flex;gap:var(--s1);align-items:center;width:260px;min-width:260px;flex:none;justify-content:flex-end;font-size:11.5px;color:var(--dim);overflow:hidden}
|
|
41
44
|
.hstate.on{color:var(--ink)}
|
|
42
45
|
.hstate .htext{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -163,7 +163,7 @@ const TestFiles = ({ app, rows, recorded }: { app: HubSt; rows: SuiteToFix[]; re
|
|
|
163
163
|
)
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
const FLAG_WORD = { over: 'at its ceiling', near: 'past half its ceiling' } as const
|
|
166
|
+
const FLAG_WORD = { over: 'at its ceiling', near: 'past half its ceiling', budget: 'over its budget' } as const
|
|
167
167
|
|
|
168
168
|
/** two numbers side by side need their names once, above the column */
|
|
169
169
|
const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
|
|
@@ -174,9 +174,9 @@ const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
|
|
|
174
174
|
{rows.map((r) => (
|
|
175
175
|
<div key={r.id} className="rline rslow" onClick={() => app.timeline.openCheck(r.id)}>
|
|
176
176
|
<span className="rtag">{r.id}</span>
|
|
177
|
-
<span className={`rnum ${durationTone(r.meanMs, r.ceilingMs) ?? ''}`} title="mean of the runs that ran">{fmtMs(r.meanMs)}</span>
|
|
178
|
-
<span className={`rnum ${durationTone(r.worstMs, r.ceilingMs) ?? ''}`} title="the worst run that ran">{fmtMs(r.worstMs)}</span>
|
|
179
|
-
<span className={r.flag === 'over' ? 'rflag red' : 'rflag warn'} title={r.ceilingMs == null ?
|
|
177
|
+
<span className={`rnum ${durationTone(r.meanMs, r.ceilingMs, r.budgetMs) ?? ''}`} title="mean of the runs that ran">{fmtMs(r.meanMs)}</span>
|
|
178
|
+
<span className={`rnum ${durationTone(r.worstMs, r.ceilingMs, r.budgetMs) ?? ''}`} title="the worst run that ran">{fmtMs(r.worstMs)}</span>
|
|
179
|
+
<span className={r.flag === 'over' ? 'rflag red' : 'rflag warn'} title={[r.budgetMs == null ? null : `budget ${fmtMs(r.budgetMs)}`, r.ceilingMs == null ? null : `ceiling ${fmtMs(r.ceilingMs)}`].filter((x) => x != null).join(', ') || undefined}>{r.flag == null ? '' : FLAG_WORD[r.flag]}</span>
|
|
180
180
|
</div>
|
|
181
181
|
))}
|
|
182
182
|
</>
|
|
@@ -335,7 +335,7 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
|
|
|
335
335
|
state: verdict[0],
|
|
336
336
|
tone: verdict[1],
|
|
337
337
|
dur: fmtMs(u.lastMs),
|
|
338
|
-
durTone: durationTone(u.lastMs, u.timeoutMs),
|
|
338
|
+
durTone: durationTone(u.lastMs, u.timeoutMs, u.budgetMs),
|
|
339
339
|
note: WHEN_NOTE[u.when],
|
|
340
340
|
count: null,
|
|
341
341
|
hint: [
|
|
@@ -391,7 +391,6 @@ export const TYPE_MARK: Record<string, TypeMark> = {
|
|
|
391
391
|
workflow: { glyph: '\u29c9', hue: 'k6', word: 'a comfy workflow module' },
|
|
392
392
|
wiki: { glyph: '\u2b21', hue: 'k3', word: 'a markdown wiki, opened as its link graph' },
|
|
393
393
|
contract: { glyph: '\u00a7', hue: 'k4', word: 'a release contract of a venue' },
|
|
394
|
-
health: { glyph: '\u229e', hue: 'k5', word: 'a health row that belongs to no other kind' },
|
|
395
394
|
check: { glyph: '\u2299', hue: 'k6', word: 'a gate check: one question `shipkit ci` or `shipkit check` asks, whether it builds, guards a repo shape or probes the world. same page and same switch for all of them' },
|
|
396
395
|
test: { glyph: '\u2263', hue: 'k5', word: 'a test file: the check that runs it, its last runs against its budget, the command that runs it alone' },
|
|
397
396
|
reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// what is wrong with the hub ITSELF, as opposed to the repo it serves: a
|
|
2
|
+
// collection it could not list, a restart it could not do. No row owns
|
|
3
|
+
// these, so they ride the hub's status: the header on the page, and the
|
|
4
|
+
// problems frame the hub task prints (problems/problem-board.ts), so the
|
|
5
|
+
// pane and the page count the same reds.
|
|
6
|
+
|
|
7
|
+
export type HubProblem = { id: string; text: string; severity: 'red' | 'yellow' }
|
|
8
|
+
|
|
9
|
+
export class HubProblems {
|
|
10
|
+
private readonly byId = new Map<string, HubProblem>()
|
|
11
|
+
|
|
12
|
+
constructor(private readonly onChange: (all: HubProblem[]) => void = () => {}) {}
|
|
13
|
+
|
|
14
|
+
/** a problem under this id, or none (null). Tells onChange only when the
|
|
15
|
+
* list moved */
|
|
16
|
+
set(id: string, p: Omit<HubProblem, 'id'> | null): void {
|
|
17
|
+
const before = this.byId.get(id)
|
|
18
|
+
if (p == null) {
|
|
19
|
+
if (before == null) return
|
|
20
|
+
this.byId.delete(id)
|
|
21
|
+
} else {
|
|
22
|
+
if (before != null && before.text === p.text && before.severity === p.severity) return
|
|
23
|
+
this.byId.set(id, { id, ...p })
|
|
24
|
+
}
|
|
25
|
+
this.onChange(this.list())
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
list(): HubProblem[] {
|
|
29
|
+
return [...this.byId.values()].sort((a, b) => a.id.localeCompare(b.id))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** what projectResources / projectNeeds report per collection */
|
|
33
|
+
collector = (collection: string, error: string | null): void =>
|
|
34
|
+
this.set(`collector:${collection}`, error == null ? null : { text: `the hub could not list ${collection}: ${error}`, severity: 'red' })
|
|
35
|
+
}
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
import { existsSync, readFileSync, statSync } from 'fs'
|
|
10
10
|
import { homedir } from 'os'
|
|
11
11
|
import { basename, dirname, join, resolve, sep } from 'path'
|
|
12
|
-
import { fileURLToPath } from 'url'
|
|
13
12
|
import { loadKit, type ResolvedKit } from '../assets/kit-config.ts'
|
|
14
13
|
import { studioRoutes, type StudioRoutes } from '../assets/server/studio-server.ts'
|
|
15
14
|
import { projectNeeds } from '../collections/collection-types.ts'
|
|
@@ -42,9 +41,13 @@ import { BUILD_TIMEOUT_MS, CHECK_TIMEOUT_MS } from '../collections/checks.ts'
|
|
|
42
41
|
import { HealthSchedule } from './health-schedule.ts'
|
|
43
42
|
import { GateTrigger, watchTracked } from './gate-trigger.ts'
|
|
44
43
|
import { ProblemBoard } from '../problems/problem-board.ts'
|
|
44
|
+
import { HubProblems } from './hub-problems.ts'
|
|
45
|
+
import { StaleRestart } from './stale-restart.ts'
|
|
46
|
+
import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
|
|
45
47
|
import { serveHub, type HubDeps } from './serve-hub.ts'
|
|
46
48
|
import { defaultHubPort } from './hub-port.ts'
|
|
47
49
|
import { LAYOUT } from '../core/layout.ts'
|
|
50
|
+
import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
|
|
48
51
|
export { defaultHubPort }
|
|
49
52
|
|
|
50
53
|
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
@@ -153,7 +156,7 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
153
156
|
const hasComfy = scanComfyWorkflows(root).length > 0
|
|
154
157
|
const panel = hasComfy ? new ComfyPanel({ root, port: c.serve?.comfyPort ?? defaultComfyPanelPort(c.name) }) : null
|
|
155
158
|
const allCollections = () => [
|
|
156
|
-
...buildCollections(root, c, kit
|
|
159
|
+
...buildCollections(root, c, kit),
|
|
157
160
|
...(hasComfy ? [comfyCollection(root)] : []),
|
|
158
161
|
wikisCollection(graphs, root),
|
|
159
162
|
]
|
|
@@ -167,6 +170,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
167
170
|
// the latest verdict of every check, printed as one problem frame after
|
|
168
171
|
// each run for the vscode problems pane (problems/problem-line.ts)
|
|
169
172
|
const board = new ProblemBoard()
|
|
173
|
+
// what is wrong with the hub itself rides the same frame, the moment it
|
|
174
|
+
// moves, so the pane never reads 0 while the page shows a hub red
|
|
175
|
+
const hubProblems = new HubProblems((all) => {
|
|
176
|
+
if (board.setHub(all)) for (const l of board.frame()) io.print(l)
|
|
177
|
+
})
|
|
170
178
|
const runChecks: HubDeps['runChecks'] = (tap, o) => {
|
|
171
179
|
const run = chain.then(async () => {
|
|
172
180
|
active++
|
|
@@ -267,13 +275,18 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
267
275
|
repoHub: (repo) => others.hubOf(repo),
|
|
268
276
|
timeline: async () => {
|
|
269
277
|
const ceilings: Record<string, number> = {}
|
|
278
|
+
const budgets: Record<string, number> = {}
|
|
270
279
|
const inventory = await gateInventory(root, c)
|
|
271
|
-
for (const row of inventory)
|
|
280
|
+
for (const row of inventory) {
|
|
281
|
+
ceilings[row.decl.id] = row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS)
|
|
282
|
+
budgets[row.decl.id] = row.budgetMs
|
|
283
|
+
}
|
|
272
284
|
return shapeTimeline({
|
|
273
285
|
runs: readRuns(record),
|
|
274
286
|
invocations: readInvocations(record),
|
|
275
287
|
suites: readSuites(record),
|
|
276
288
|
ceilings,
|
|
289
|
+
budgets,
|
|
277
290
|
inventory: inventory.map((row) => row.decl.id),
|
|
278
291
|
declaredSlow: declaredSlowFiles(c),
|
|
279
292
|
cap: parallelCap(),
|
|
@@ -287,7 +300,9 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
287
300
|
return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
|
|
288
301
|
},
|
|
289
302
|
prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
|
|
290
|
-
needs: () => projectNeeds(allCollections()),
|
|
303
|
+
needs: () => projectNeeds(allCollections(), hubProblems.collector),
|
|
304
|
+
hubProblems,
|
|
305
|
+
busy: () => active > 0,
|
|
291
306
|
collections: allCollections,
|
|
292
307
|
asksDir: c.asks?.dir ?? LAYOUT.asks,
|
|
293
308
|
comfy: panel == null ? null : { ensure: () => panel.ensure(), restart: () => panel.restart(), box: () => comfyBox(root), stop: () => panel.stop() },
|
|
@@ -319,14 +334,6 @@ export function hubGateOpts(o: { only?: string[]; auto: boolean }): Pick<CiOpts,
|
|
|
319
334
|
return { onlyIds: o.only, fix: whole, status: whole }
|
|
320
335
|
}
|
|
321
336
|
|
|
322
|
-
// the hub must KNOW when it is outdated — a stale morning
|
|
323
|
-
// process serves broken-looking picks all day: version read at
|
|
324
|
-
// boot vs on disk per attention load; mismatch = red line telling him to
|
|
325
|
-
// restart the task
|
|
326
|
-
const libVersion = (): string => {
|
|
327
|
-
try { return (JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
|
|
328
|
-
}
|
|
329
|
-
const BOOT_VERSION = libVersion()
|
|
330
337
|
|
|
331
338
|
// the / dashboard's data now comes from the collection providers
|
|
332
339
|
// (src/collections/providers.ts) — never a parallel projection.
|
|
@@ -368,12 +375,44 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
368
375
|
})
|
|
369
376
|
const watcher = watchTracked(root, () => trigger.touched(), { onError: (e) => io.printErr(`WARN the file watch stopped, so the problems pane refreshes only on a hub run: ${e.message}`) })
|
|
370
377
|
if (watcher.error != null) io.printErr(`WARN no file watch (${watcher.error}), so the problems pane refreshes only on a hub run`)
|
|
378
|
+
// a lib that moved under this process (under bun --watch the version
|
|
379
|
+
// import already restarted it, so what is left is a plain serve), or
|
|
380
|
+
// a file table the watch restarts leaked: it re-executes itself in
|
|
381
|
+
// place (same pid, argv, port) once no run is in flight. Only a
|
|
382
|
+
// restart that throws is said, in the hub's own status
|
|
383
|
+
const stale = new StaleRestart({
|
|
384
|
+
boot: LIB_VERSION,
|
|
385
|
+
disk: diskLibVersion,
|
|
386
|
+
busy: () => deps.busy?.() === true,
|
|
387
|
+
restart: (why) => {
|
|
388
|
+
// a refusal throws while the hub still serves, so the page can say it
|
|
389
|
+
const blocked = reexecBlocker()
|
|
390
|
+
if (blocked != null) throw new Error(blocked)
|
|
391
|
+
io.print(`ok: ${why}: restarting in place`)
|
|
392
|
+
stale.stop()
|
|
393
|
+
trigger.stop()
|
|
394
|
+
watcher.close()
|
|
395
|
+
hub.stop()
|
|
396
|
+
try {
|
|
397
|
+
reexecSelf()
|
|
398
|
+
} catch (e) {
|
|
399
|
+
// the server is already down: nothing is left to show a status
|
|
400
|
+
io.printErr(`FAIL: this hub stopped to restart and could not: ${(e as Error).message}. Restart the hub task`)
|
|
401
|
+
process.exit(1)
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
|
|
405
|
+
})
|
|
406
|
+
stale.start()
|
|
407
|
+
// a watch restart that leaked sheds it before the first run spawns anything
|
|
408
|
+
stale.tick()
|
|
371
409
|
trigger.request()
|
|
372
410
|
// a child process (the comfy panel) outlives a parent nobody stopped,
|
|
373
411
|
// and the next launch then adopts one holding the PREVIOUS run's
|
|
374
412
|
// modules. A hard kill still leaks one, which is why adoption exists
|
|
375
413
|
for (const sig of ['SIGINT', 'SIGTERM'] as const)
|
|
376
414
|
process.on(sig, () => {
|
|
415
|
+
stale.stop()
|
|
377
416
|
trigger.stop()
|
|
378
417
|
watcher.close()
|
|
379
418
|
hub.stop()
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { graphRoutes } from 'rvlib-shipkit/src/wiki/serve-graph.ts'
|
|
|
10
10
|
import type { StudioRoutes } from '../assets/server/studio-server.ts'
|
|
11
11
|
import { comfyBoxUp, type ComfyBox, type ComfyPanelUp } from '../comfy/comfy-panel.ts'
|
|
12
12
|
import { appendAskRemark, appendAssetNote, appendPostFeedback, askDone, askProof, markPosted, pickPostVariant } from '../collections/actions.ts'
|
|
13
|
-
import { projectResources, type Collection, type Instance, type NeedItem } from '../collections/collection-types.ts'
|
|
13
|
+
import { brokenRow, projectResources, type Collection, type Instance, type NeedItem } from '../collections/collection-types.ts'
|
|
14
14
|
import type { CheckEvent } from '../core/run-check.ts'
|
|
15
15
|
import { configDirty, readGateBlock, writeGateBlock, type GateEdit } from '../core/when-block.ts'
|
|
16
16
|
import type { InvokedBy } from '../ledger/run-log.ts'
|
|
@@ -22,6 +22,7 @@ import type { LiveView } from '../ledger/live-timeline.ts'
|
|
|
22
22
|
|
|
23
23
|
export type { LiveView }
|
|
24
24
|
import type { HealthSchedule } from './health-schedule.ts'
|
|
25
|
+
import type { HubProblems } from './hub-problems.ts'
|
|
25
26
|
import hubIndex from './hub-app/index.html'
|
|
26
27
|
import { projectGutter } from './project-gutter-route.ts'
|
|
27
28
|
import { sameOrigin } from './same-origin.ts'
|
|
@@ -78,6 +79,11 @@ export type HubDeps = {
|
|
|
78
79
|
/** every human-action item, red/yellow, gathered per request from the
|
|
79
80
|
* collection providers (run-serve builds it) */
|
|
80
81
|
needs: () => Promise<NeedItem[]>
|
|
82
|
+
/** a run is in flight (a restart now would kill it) */
|
|
83
|
+
busy?: () => boolean
|
|
84
|
+
/** what is wrong with the hub itself: the page header shows it, and the
|
|
85
|
+
* hub task's problems frame counts it. Absent = nothing is tracked */
|
|
86
|
+
hubProblems?: HubProblems
|
|
81
87
|
/** BROWSE-ALL: the collections with every instance incl. healthy —
|
|
82
88
|
* the page shows everything that exists, dimmed never hidden */
|
|
83
89
|
collections: () => Collection[]
|
|
@@ -152,8 +158,9 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
152
158
|
const cols = d.collections()
|
|
153
159
|
return Response.json({
|
|
154
160
|
collections: cols.map((c) => ({ id: c.id, title: c.title, media: c.media })),
|
|
155
|
-
resources: await projectResources(cols),
|
|
161
|
+
resources: await projectResources(cols, d.hubProblems?.collector),
|
|
156
162
|
schedule: d.schedule?.state() ?? null,
|
|
163
|
+
hub: d.hubProblems?.list() ?? [],
|
|
157
164
|
})
|
|
158
165
|
}
|
|
159
166
|
// the previous address, answered while pages opened before the
|
|
@@ -166,7 +173,7 @@ export function serveHub(d: HubDeps): Hub {
|
|
|
166
173
|
try {
|
|
167
174
|
out.push({ id: col.id, title: col.title, media: col.media, instances: await col.list() })
|
|
168
175
|
} catch (e) {
|
|
169
|
-
out.push({ id: col.id, title: col.title, media: col.media, instances: [
|
|
176
|
+
out.push({ id: col.id, title: col.title, media: col.media, instances: [brokenRow(col, e)] })
|
|
170
177
|
}
|
|
171
178
|
}
|
|
172
179
|
return Response.json({ collections: out })
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// a hub that went stale restarts itself. Stale is two things. Its lib moved
|
|
2
|
+
// under it: under `bun --watch` the version is a module import
|
|
3
|
+
// (core/lib-version.ts), so a bump restarts the hub before this ever sees a
|
|
4
|
+
// difference, and a hub NOT under a watch sees the package.json on disk move
|
|
5
|
+
// away from the version it booted with. Or its file table leaked
|
|
6
|
+
// (core/fd-table.ts): every `bun --watch` restart keeps the previous image's
|
|
7
|
+
// watch fds, and a full table fails every spawn with EBADF. Either way it
|
|
8
|
+
// re-executes in place (core/reexec.ts) once no run is in flight, which is a
|
|
9
|
+
// clean table too. Only a restart that throws reaches a human, once, through
|
|
10
|
+
// `say`.
|
|
11
|
+
import { assertLeakHealable, FD_HEALED_ENV, fdTableLeak } from '../core/fd-table.ts'
|
|
12
|
+
|
|
13
|
+
export type StaleTick = 'fresh' | 'busy' | 'restarted' | 'impossible'
|
|
14
|
+
|
|
15
|
+
export type StaleRestartOpts = {
|
|
16
|
+
/** the version this process booted with */
|
|
17
|
+
boot: string
|
|
18
|
+
/** the version on disk now, `?` when unreadable */
|
|
19
|
+
disk: () => string
|
|
20
|
+
/** why the file table leaked, null while it did not. Default: this
|
|
21
|
+
* process's own table (core/fd-table.ts) */
|
|
22
|
+
leak?: () => string | null
|
|
23
|
+
/** a run is in flight: a restart now would kill it */
|
|
24
|
+
busy: () => boolean
|
|
25
|
+
/** never returns when it works. `why` is one line on what went stale */
|
|
26
|
+
restart: (why: string) => void
|
|
27
|
+
/** the hub's status line: a text when the restart is impossible, null
|
|
28
|
+
* once there is nothing to say */
|
|
29
|
+
say: (text: string | null) => void
|
|
30
|
+
everyMs?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class StaleRestart {
|
|
34
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
35
|
+
private failed: string | null = null
|
|
36
|
+
|
|
37
|
+
constructor(private readonly o: StaleRestartOpts) {}
|
|
38
|
+
|
|
39
|
+
tick(): StaleTick {
|
|
40
|
+
const stale = this.why()
|
|
41
|
+
if (stale == null) {
|
|
42
|
+
if (this.failed != null) { this.failed = null; this.o.say(null) }
|
|
43
|
+
return 'fresh'
|
|
44
|
+
}
|
|
45
|
+
if (this.failed != null) return 'impossible'
|
|
46
|
+
if (this.o.busy()) return 'busy'
|
|
47
|
+
try {
|
|
48
|
+
if (stale.leak) {
|
|
49
|
+
assertLeakHealable()
|
|
50
|
+
process.env[FD_HEALED_ENV] = String(Date.now())
|
|
51
|
+
}
|
|
52
|
+
this.o.restart(stale.why)
|
|
53
|
+
return 'restarted'
|
|
54
|
+
} catch (e) {
|
|
55
|
+
this.failed = `${stale.why}, and this hub could not restart itself: ${(e as Error).message}. Restart the hub task`
|
|
56
|
+
this.o.say(this.failed)
|
|
57
|
+
return 'impossible'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private why(): { why: string; leak: boolean } | null {
|
|
62
|
+
const disk = this.o.disk()
|
|
63
|
+
// a package.json half written by a bump reads as `?`, and is no verdict
|
|
64
|
+
if (disk !== '?' && disk !== this.o.boot) return { why: `this hub runs shipkit ${this.o.boot} but ${disk} is on disk`, leak: false }
|
|
65
|
+
const leak = (this.o.leak ?? fdTableLeak)()
|
|
66
|
+
return leak == null ? null : { why: leak, leak: true }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
start(): void {
|
|
70
|
+
this.timer ??= setInterval(() => this.tick(), this.o.everyMs ?? 2_000)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stop(): void {
|
|
74
|
+
if (this.timer != null) clearInterval(this.timer)
|
|
75
|
+
this.timer = null
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/smoke/run-smoke.ts
CHANGED
|
@@ -379,8 +379,12 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
379
379
|
const chars = (await page.locator('body').innerText().catch(() => '')).trim().length
|
|
380
380
|
const matches = waitFor == null ? 0 : await page.locator(waitFor).count().catch(() => 0)
|
|
381
381
|
const overflowPx = await page.evaluate(() => {
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
// runs in the browser, typed locally: a consumer compiling this
|
|
383
|
+
// file may have no dom lib
|
|
384
|
+
type Box = { scrollWidth: number; clientWidth: number }
|
|
385
|
+
const doc = (globalThis as unknown as { document: { documentElement: Box; body: Box } }).document
|
|
386
|
+
const de = doc.documentElement
|
|
387
|
+
return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
|
|
384
388
|
}).catch(() => 0)
|
|
385
389
|
await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
|
|
386
390
|
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
|
package/src/suite/suite-cli.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// `Ran N tests` so the gate can still report what ran.
|
|
7
7
|
//
|
|
8
8
|
// usage: suite-cli [--budget-ms N] [--slow file]... [--serial file]... [--declared-in <where>] -- <bun test args>
|
|
9
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
10
|
-
import { availableParallelism,
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
10
|
+
import { availableParallelism, tmpdir } from 'os'
|
|
11
|
+
import { machineLoad } from '../core/budget.ts'
|
|
11
12
|
import { join } from 'path'
|
|
12
13
|
import { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
|
|
13
14
|
import { CHECK_ID_ENV, recordSuites, type SuitePhase } from '../ledger/suite-log.ts'
|
|
@@ -15,16 +16,19 @@ import { killTree } from '../core/spawn-timeout.ts'
|
|
|
15
16
|
import { parallelCap } from '../core/parallel.ts'
|
|
16
17
|
import { poolDir, poolOccupancy } from '../core/slots.ts'
|
|
17
18
|
import { keepTimings, seedTimings, timingsHome } from './kept-timings.ts'
|
|
18
|
-
import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers,
|
|
19
|
+
import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, WAIT_FACTOR, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
|
|
19
20
|
|
|
20
21
|
type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
|
|
21
22
|
|
|
22
|
-
async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, echo = true, cpuFile: string | null = null): Promise<Phase> {
|
|
23
|
+
async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, tmp: string, echo = true, cpuFile: string | null = null): Promise<Phase> {
|
|
23
24
|
const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
|
|
24
25
|
// the per suite CPU figure rides in a preload of the runner's own, beside
|
|
25
26
|
// the repo's (a command line preload adds to bunfig's, never replaces it)
|
|
26
27
|
const cpu = cpuFile != null ? ['--preload', CPU_PRELOAD] : []
|
|
27
|
-
|
|
28
|
+
// TMPDIR is the run's own dir, deleted with it: whatever a suite forgets to
|
|
29
|
+
// clean dies here, never in the machine temp dir, whose listing bun reads on
|
|
30
|
+
// the first import from a fixture there (90k leaked dirs cost ~100ms each time)
|
|
31
|
+
const env = { ...process.env, TMPDIR: tmp, ...(cpuFile != null ? { [SUITE_CPU_ENV]: cpuFile } : {}) }
|
|
28
32
|
const proc = Bun.spawn(['bun', 'test', ...cpu, ...timing, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env })
|
|
29
33
|
// the workers are children of `bun test`, and a stuck one is the process
|
|
30
34
|
// to reach, so the whole tree goes
|
|
@@ -59,8 +63,6 @@ const CPU_PRELOAD = join(import.meta.dir, 'suite-cpu-preload.ts')
|
|
|
59
63
|
|
|
60
64
|
const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
61
65
|
|
|
62
|
-
const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
|
|
63
|
-
|
|
64
66
|
const overWords = (c: Confirmed): string =>
|
|
65
67
|
c.over === 'cpu' ? `${c.file} ${secs(c.ms)} of CPU (${secs(c.wallMs)} wall)`
|
|
66
68
|
: c.over === 'wait' ? `${c.file} ${secs(c.wallMs)} wall on an idle machine, more than ${WAIT_FACTOR}x the budget, with ${secs(c.cpuMs ?? 0)} of CPU: it waits (a timer, a sleep, a poll)`
|
|
@@ -103,6 +105,8 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
103
105
|
const workers = parallel ? poolWorkers({ cores, size: parallelCap(), held }) : undefined
|
|
104
106
|
const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers })
|
|
105
107
|
const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
|
|
108
|
+
const tmp = join(dir, 'temp')
|
|
109
|
+
mkdirSync(tmp)
|
|
106
110
|
const notes: string[] = []
|
|
107
111
|
if (!helpText.includes('--parallel')) notes.push(`skip: parallel suites (bun ${Bun.version} has no --parallel)`)
|
|
108
112
|
if (!timed) notes.push(`skip: suite budget (bun ${Bun.version} writes no --timings)`)
|
|
@@ -114,7 +118,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
114
118
|
if (kept != null) seedTimings(kept, join(dir, 'parallel.json'))
|
|
115
119
|
const cpuFile = timed && parallel ? join(dir, 'cpu.tsv') : null
|
|
116
120
|
const p1At = Date.now()
|
|
117
|
-
const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, true, cpuFile)
|
|
121
|
+
const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, tmp, true, cpuFile)
|
|
118
122
|
const p1Ms = Date.now() - p1At
|
|
119
123
|
// bun merges what it read into what it writes, so a suite deleted since
|
|
120
124
|
// the last run is judged and kept only if the workers say it ran here
|
|
@@ -130,7 +134,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
136
|
const p2At = Date.now()
|
|
133
|
-
const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null, ceilingMs) : null
|
|
137
|
+
const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null, ceilingMs, tmp) : null
|
|
134
138
|
const p2Ms = p2 == null ? null : Date.now() - p2At
|
|
135
139
|
const phases = [p1, ...(p2 != null ? [p2] : [])]
|
|
136
140
|
const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
|
|
@@ -149,7 +153,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
149
153
|
const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
|
|
150
154
|
const aloneAt = Date.now()
|
|
151
155
|
for (const [i, t] of rerun.entries()) {
|
|
152
|
-
const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, false)
|
|
156
|
+
const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false)
|
|
153
157
|
const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
|
|
154
158
|
if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
|
|
155
159
|
}
|
package/src/ts/source-graph.ts
CHANGED
|
@@ -146,7 +146,8 @@ export function buildSourceGraph(cfg: RvtConfig = loadConfig()): SourceGraph {
|
|
|
146
146
|
* knows a missing declared path makes a check uncacheable, and a throw here
|
|
147
147
|
* would turn a stale declaration into a dead gate.
|
|
148
148
|
*/
|
|
149
|
-
export function importClosure(cfg: RvtConfig, entries: string[], o: { dynamic?: boolean } = {}): string[] {
|
|
149
|
+
export function importClosure(cfg: RvtConfig, entries: string[], o: { dynamic?: boolean; read?: (rel: string) => string } = {}): string[] {
|
|
150
|
+
const read = o.read ?? ((rel: string): string => readFileSync(join(cfg.root, rel), 'utf8'))
|
|
150
151
|
const probe = new Map<string, boolean>()
|
|
151
152
|
const has = (p: string): boolean => {
|
|
152
153
|
let r = probe.get(p)
|
|
@@ -163,7 +164,7 @@ export function importClosure(cfg: RvtConfig, entries: string[], o: { dynamic?:
|
|
|
163
164
|
const file = queue.shift()!
|
|
164
165
|
let content: string
|
|
165
166
|
try {
|
|
166
|
-
content =
|
|
167
|
+
content = read(file)
|
|
167
168
|
} catch {
|
|
168
169
|
continue
|
|
169
170
|
}
|
package/src/wiki/scan-md.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
|
|
8
8
|
// unreachable files stay in the graph with no depth (lint-md.ts turns those,
|
|
9
9
|
// plus dangling refs, into failures — the graph itself is a viewer).
|
|
10
|
-
import {
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
11
11
|
import { basename, dirname, join, relative, resolve } from 'path'
|
|
12
12
|
import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
13
13
|
|