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
package/src/ledger/git-scan.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { isAbsolute, join, normalize, resolve } from 'path'
|
|
|
35
35
|
import { probeGitRoot } from '../core/git-root.ts'
|
|
36
36
|
import { treePrefix } from '../core/nesting.ts'
|
|
37
37
|
import { gitBin } from '../core/git-bin.ts'
|
|
38
|
+
import { blobOid } from '../core/read-set.ts'
|
|
38
39
|
|
|
39
40
|
/** what a scan measured. Every OID is a git object id of the CURRENT disk
|
|
40
41
|
* content, tracked or not. */
|
|
@@ -59,6 +60,14 @@ export type ScanResult = {
|
|
|
59
60
|
|
|
60
61
|
export class GitScanError extends Error {}
|
|
61
62
|
|
|
63
|
+
export type ScanOpts = {
|
|
64
|
+
/** the caller reads the whole tree's id and nothing else, so git is never
|
|
65
|
+
* asked what it ignores: that answer only feeds force-adds and the
|
|
66
|
+
* excluded list, and `git add -- .` leaves ignored files out by itself.
|
|
67
|
+
* `excluded` and `ignored` come back empty */
|
|
68
|
+
treeOnly?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
/** the scratch index FORMAT. Bump it whenever what the scan stages changes.
|
|
63
72
|
* The index is persistent, and anything staged into it stays there until
|
|
64
73
|
* something stages over it: a run that force-added a path a later version no
|
|
@@ -208,10 +217,15 @@ const underNodeModules = (p: string): boolean => p.split('/').includes('node_mod
|
|
|
208
217
|
* stale describes files nobody has, and a key built on it is a false green
|
|
209
218
|
* waiting for its first hit.
|
|
210
219
|
*/
|
|
211
|
-
export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult): ScanResult {
|
|
220
|
+
export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult, o: ScanOpts = {}): ScanResult {
|
|
212
221
|
const started = performance.now()
|
|
222
|
+
if (o.treeOnly === true && (rawPaths.length !== 1 || normalizeInput(rawPaths[0]!) !== WHOLE_TREE))
|
|
223
|
+
throw new GitScanError(`treeOnly scans the whole tree and nothing else, asked for: ${rawPaths.join(', ')}`)
|
|
213
224
|
const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
|
|
214
225
|
const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
|
|
226
|
+
// nothing asked for: the repo is still validated above, and no tree is
|
|
227
|
+
// written that nobody reads (a run of read checks declares no path)
|
|
228
|
+
if (wanted.length === 0) return { treeOid: '', oidByPath: new Map(), missing: [], excluded: new Map(), ignored: [], ignoredTreeOid: null, ms: performance.now() - started }
|
|
215
229
|
const missing = wanted.filter((p) => !existsSync(join(root, p)))
|
|
216
230
|
const present = wanted.filter((p) => existsSync(join(root, p)))
|
|
217
231
|
const toAdd = dropCovered(present)
|
|
@@ -227,7 +241,9 @@ export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult):
|
|
|
227
241
|
// A path that became ignored in between makes its plain add fail, which
|
|
228
242
|
// throws, and a throw stores nothing: the one failure mode stays loud.
|
|
229
243
|
const sameMissing = prior != null && prior.missing.join('\n') === [...new Set(missing)].sort().join('\n')
|
|
230
|
-
const ignored =
|
|
244
|
+
const ignored = o.treeOnly === true
|
|
245
|
+
? []
|
|
246
|
+
: prior != null && sameMissing
|
|
231
247
|
? prior.ignored
|
|
232
248
|
: toAdd.length === 0
|
|
233
249
|
? []
|
|
@@ -363,14 +379,16 @@ function excludedBy(wanted: string[], ignored: string[], forced: Set<string>): M
|
|
|
363
379
|
}
|
|
364
380
|
|
|
365
381
|
/**
|
|
366
|
-
* blob ids for a list of FILES,
|
|
382
|
+
* blob ids for a list of FILES, hashed in process: no git, no index, no lock.
|
|
367
383
|
*
|
|
368
384
|
* The scratch index is the right tool for a declared input, which is usually a
|
|
369
385
|
* directory and usually unchanged (git's stat cache answers without reading).
|
|
370
386
|
* It is the wrong tool for the module-graph closure: those are individual
|
|
371
|
-
* files, they belong to no declared path, and
|
|
372
|
-
*
|
|
373
|
-
*
|
|
387
|
+
* files, they belong to no declared path, and a check's code is a handful of
|
|
388
|
+
* small sources that cost less to read than one spawn. The id is the blob id
|
|
389
|
+
* of the bytes on disk, the same one the read recorder uses (`blobOid`). Git
|
|
390
|
+
* would apply clean filters (eol, lfs) first, so this id is only ever STRICTER:
|
|
391
|
+
* a filter-only change is a miss, never a false hit.
|
|
374
392
|
*
|
|
375
393
|
* A file that is not on disk is skipped and reported, never guessed at.
|
|
376
394
|
*/
|
|
@@ -379,16 +397,10 @@ export function hashFiles(root: string, files: string[]): { oids: Map<string, st
|
|
|
379
397
|
const wanted = [...new Set(files.map(normalizeInput))].sort()
|
|
380
398
|
const missing = wanted.filter((f) => !existsSync(join(root, f)))
|
|
381
399
|
const present = wanted.filter((f) => existsSync(join(root, f)))
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
stderr: 'pipe',
|
|
388
|
-
})
|
|
389
|
-
if (p.exitCode !== 0) throw new GitScanError(`git hash-object exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n').slice(0, 3).join(' / ')}`)
|
|
390
|
-
const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
|
|
391
|
-
if (lines.length !== present.length) throw new GitScanError(`git hash-object answered ${lines.length} of ${present.length} files`)
|
|
392
|
-
present.forEach((f, i) => oids.set(f, lines[i]!.trim()))
|
|
400
|
+
for (const f of present) {
|
|
401
|
+
try { oids.set(f, blobOid(readFileSync(join(root, f)))) } catch (e) {
|
|
402
|
+
throw new GitScanError(`cannot read ${f} to hash it: ${e instanceof Error ? e.message : String(e)}`)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
393
405
|
return { oids, missing }
|
|
394
406
|
}
|
package/src/ledger/ledger.ts
CHANGED
|
@@ -92,6 +92,8 @@ export type LedgerOpts = {
|
|
|
92
92
|
fresh?: boolean
|
|
93
93
|
/** module-graph config, loaded from the repo when omitted */
|
|
94
94
|
cfg?: RvtConfig
|
|
95
|
+
/** the checks this run executes (KeyLayerOpts.scope) */
|
|
96
|
+
scope?: ReadonlySet<string>
|
|
95
97
|
now?: () => number
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -133,7 +135,7 @@ export class Ledger {
|
|
|
133
135
|
) {}
|
|
134
136
|
|
|
135
137
|
static open(root: string, decls: CheckDecl[], o: LedgerOpts): Ledger {
|
|
136
|
-
const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
|
|
138
|
+
const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg, scope: o.scope })
|
|
137
139
|
const fresh = o.fresh === true || process.env[FRESH_ENV] != null
|
|
138
140
|
const reading = (o.read ?? ledgerReadEnabled()) && !fresh
|
|
139
141
|
return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
|
|
@@ -166,7 +168,7 @@ export class Ledger {
|
|
|
166
168
|
if (!this.reading) { this.misses++; return null }
|
|
167
169
|
const e = readStored<Stored>(this.path(k), k.key)
|
|
168
170
|
if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
|
|
169
|
-
if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, this.view()) != null)) { this.misses++; return null }
|
|
171
|
+
if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, () => this.view()) != null)) { this.misses++; return null }
|
|
170
172
|
this.hits++
|
|
171
173
|
return e.payload
|
|
172
174
|
}
|
|
@@ -178,8 +180,14 @@ export class Ledger {
|
|
|
178
180
|
return readStored<Stored>(this.path(k), k.key)?.payload.reads ?? null
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
/** the view is asked for only when a glob needs it: a read set with no
|
|
184
|
+
* listing is validated on file content alone, and a whole tree git scan
|
|
185
|
+
* per replay would buy nothing */
|
|
186
|
+
private driftOf(reads: ReadSet, view: () => RepoView): string | null {
|
|
187
|
+
return readSetDrift(this.layer.root, reads, (g) => {
|
|
188
|
+
const v = view()
|
|
189
|
+
return v.fallback != null ? null : v.list(g)
|
|
190
|
+
})
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
/** where askers of this check's exact question meet while one of them is
|
|
@@ -230,11 +238,11 @@ export class Ledger {
|
|
|
230
238
|
// a fresh list, never this run's: the question is whether the tree
|
|
231
239
|
// still is what the body read
|
|
232
240
|
let fresh: RepoView | null = null
|
|
241
|
+
const freshView = (): RepoView => (fresh ??= RepoView.open(this.layer.root, this.viewMemo))
|
|
233
242
|
for (const [id, outcome] of this.buffered) {
|
|
234
243
|
if (touched.has(id)) { discarded.push(id); continue }
|
|
235
244
|
if (outcome.reads != null) {
|
|
236
|
-
|
|
237
|
-
const moved = this.driftOf(outcome.reads, fresh)
|
|
245
|
+
const moved = this.driftOf(outcome.reads, freshView)
|
|
238
246
|
if (moved != null) { discarded.push(id); drifted.push(moved); continue }
|
|
239
247
|
}
|
|
240
248
|
const k = this.key(id)
|
package/src/ledger/repo-view.ts
CHANGED
|
@@ -23,12 +23,12 @@ export class RepoView {
|
|
|
23
23
|
readonly scan: ScanResult | null = null,
|
|
24
24
|
) {}
|
|
25
25
|
|
|
26
|
-
/** `prior`: an earlier view of the same root in this run.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
/** `prior`: an earlier view of the same root in this run. The tree is
|
|
27
|
+
* still staged and hashed fresh, so the list is today's, and an unchanged
|
|
28
|
+
* tree reuses the prior's answer */
|
|
29
29
|
static open(root: string, prior?: RepoView | null): RepoView {
|
|
30
30
|
try {
|
|
31
|
-
const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined)
|
|
31
|
+
const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined, { treeOnly: true })
|
|
32
32
|
const tree = scan.oidByPath.get(WHOLE_TREE)
|
|
33
33
|
if (tree == null) throw new GitScanError('git could not name the work tree')
|
|
34
34
|
return new RepoView(filesOf(root, tree), null, scan)
|
|
@@ -45,14 +45,16 @@ export class RepoView {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** a tree id names its content, so its file list is a pure function of the
|
|
48
|
-
* id and one listing serves every view of that tree
|
|
48
|
+
* id and one listing serves every view of that tree. `--full-tree` keeps it
|
|
49
|
+
* so: without it git filters the listing by the cwd's path under the work
|
|
50
|
+
* tree, and a nested folder's own subtree lists nothing */
|
|
49
51
|
const listed = new Map<string, string[]>()
|
|
50
52
|
const LISTED_MAX = 64
|
|
51
53
|
|
|
52
54
|
function filesOf(root: string, tree: string): string[] {
|
|
53
55
|
const hit = listed.get(tree)
|
|
54
56
|
if (hit != null) return hit
|
|
55
|
-
const p = Bun.spawnSync([gitBin(), 'ls-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
57
|
+
const p = Bun.spawnSync([gitBin(), 'ls-tree', '--full-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
56
58
|
if (p.exitCode !== 0) throw new GitScanError(`git ls-tree exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
|
|
57
59
|
const files = p.stdout.toString().split('\0').filter((f) => f !== '').sort()
|
|
58
60
|
if (listed.size >= LISTED_MAX) listed.delete(listed.keys().next().value!)
|
package/src/ledger/run-log.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { Database } from 'bun:sqlite'
|
|
22
22
|
import { existsSync } from 'fs'
|
|
23
23
|
import type { Line } from '../core/wire.ts'
|
|
24
|
-
import { dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
|
|
24
|
+
import { BUSY_MS, dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
|
|
25
25
|
import { LIVE_BEAT_MS, liveRowDead, type LiveRow, type LiveState } from './live-runs.ts'
|
|
26
26
|
import { KEEP_MS } from './store.ts'
|
|
27
27
|
|
|
@@ -187,10 +187,16 @@ export class RunLog {
|
|
|
187
187
|
const hadLive = this.liveSent !== ''
|
|
188
188
|
this.quiet()
|
|
189
189
|
this.liveSent = ''
|
|
190
|
-
|
|
190
|
+
// the live row's handle writes the run too: an open and a close are
|
|
191
|
+
// most of what a write costs. It waits like a fresh one would
|
|
192
|
+
const live = this.liveDb
|
|
191
193
|
this.liveDb = null
|
|
192
|
-
if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) return []
|
|
193
|
-
|
|
194
|
+
if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) { try { live?.close() } catch { /* already gone */ } return [] }
|
|
195
|
+
let opened: { db: Database } | null = null
|
|
196
|
+
if (live != null) {
|
|
197
|
+
try { live.exec(`pragma busy_timeout = ${Math.max(0, Math.round(this.busyMs ?? BUSY_MS))}`); opened = { db: live } } catch { try { live.close() } catch { /* already gone */ } }
|
|
198
|
+
}
|
|
199
|
+
opened ??= openDb(this.path, { busyMs: this.busyMs })
|
|
194
200
|
if (opened == null) return unwritable(this.path)
|
|
195
201
|
try {
|
|
196
202
|
const insert = opened.db.query(INSERT_RUN)
|
|
@@ -45,7 +45,9 @@ export type TimelineInvocation = {
|
|
|
45
45
|
/** an invocation in a list: its numbers without its lanes */
|
|
46
46
|
export type InvocationSummary = Omit<TimelineInvocation, 'bars'> & { checks: number }
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
/** `over` its ceiling, `near` it (past half), or over its `budget` on the
|
|
49
|
+
* mean of its quick tier runs */
|
|
50
|
+
export type SlowCheck = { id: string; meanMs: number; worstMs: number; samples: number; ceilingMs: number | null; budgetMs: number | null; flag: 'over' | 'near' | 'budget' | null }
|
|
49
51
|
|
|
50
52
|
export type SuiteToFix = {
|
|
51
53
|
check: string; unit: string; file: string; at: number; ms: number; budgetMs: number; tier: 'quick' | 'full'; red: boolean; slow: boolean
|
|
@@ -72,6 +74,8 @@ export type TimelineInput = {
|
|
|
72
74
|
suites: SuiteRow[]
|
|
73
75
|
/** each check's kill time, ms */
|
|
74
76
|
ceilings: Record<string, number>
|
|
77
|
+
/** each check's resolved budget (core/budget.ts), for the slowest list */
|
|
78
|
+
budgets?: Record<string, number>
|
|
75
79
|
/** the checks the repo declares today. Given, `slowest` lists only these:
|
|
76
80
|
* a deleted check's rows stay in the run history, and no edit can make it
|
|
77
81
|
* faster */
|
|
@@ -120,23 +124,31 @@ export function invocationsOf(runs: RunRow[], invocations: InvocationRow[]): Tim
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/** the checks that cost the most when they really run: mean and worst of the
|
|
123
|
-
* MEASURED runs, flagged when the worst reached its ceiling or half of it
|
|
124
|
-
|
|
127
|
+
* MEASURED runs, flagged when the worst reached its ceiling or half of it,
|
|
128
|
+
* else when the mean of its QUICK tier runs is over its budget (the full
|
|
129
|
+
* tier runs every slow suite on purpose, and is never judged) */
|
|
130
|
+
export function slowestChecks(runs: RunRow[], ceilings: Record<string, number>, size = TIMELINE_LIST, inventory?: readonly string[], budgets: Record<string, number> = {}): SlowCheck[] {
|
|
125
131
|
const live = inventory == null ? null : new Set(inventory)
|
|
126
132
|
const ms = new Map<string, number[]>()
|
|
133
|
+
const quick = new Map<string, number[]>()
|
|
134
|
+
const add = (m: Map<string, number[]>, id: string, v: number): void => { m.set(id, [...(m.get(id) ?? []), v]) }
|
|
127
135
|
for (const r of runs) {
|
|
128
136
|
if (r.replayed || r.status === 'skip') continue
|
|
129
137
|
if (live != null && !live.has(r.id)) continue
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
ms.set(r.id, list)
|
|
138
|
+
add(ms, r.id, r.ms)
|
|
139
|
+
if (r.full !== true) add(quick, r.id, r.ms)
|
|
133
140
|
}
|
|
141
|
+
const mean = (list: number[]): number => Math.round(list.reduce((a, b) => a + b, 0) / list.length)
|
|
134
142
|
const out: SlowCheck[] = []
|
|
135
143
|
for (const [id, list] of ms) {
|
|
136
144
|
const worstMs = Math.max(...list)
|
|
137
145
|
const ceilingMs = ceilings[id] ?? null
|
|
138
|
-
const
|
|
139
|
-
|
|
146
|
+
const budgetMs = budgets[id] ?? null
|
|
147
|
+
const q = quick.get(id) ?? []
|
|
148
|
+
const flag = ceilingMs != null && worstMs >= ceilingMs ? 'over'
|
|
149
|
+
: ceilingMs != null && worstMs > ceilingMs * NEAR_CEILING_SHARE ? 'near'
|
|
150
|
+
: budgetMs != null && q.length > 0 && mean(q) > budgetMs ? 'budget' : null
|
|
151
|
+
out.push({ id, meanMs: mean(list), worstMs, samples: list.length, ceilingMs, budgetMs, flag })
|
|
140
152
|
}
|
|
141
153
|
return out.sort((a, b) => b.meanMs - a.meanMs || a.id.localeCompare(b.id)).slice(0, size)
|
|
142
154
|
}
|
|
@@ -164,7 +176,7 @@ export function shapeTimeline(o: TimelineInput): Timeline {
|
|
|
164
176
|
const size = o.listSize ?? TIMELINE_LIST
|
|
165
177
|
return {
|
|
166
178
|
invocations: all.slice(0, o.keep ?? TIMELINE_INVOCATIONS),
|
|
167
|
-
slowest: slowestChecks(o.runs, o.ceilings, size, o.inventory),
|
|
179
|
+
slowest: slowestChecks(o.runs, o.ceilings, size, o.inventory, o.budgets),
|
|
168
180
|
longest: [...all].sort((a, b) => b.wallMs - a.wallMs || b.started - a.started).slice(0, size).map(({ bars, ...rest }) => ({ ...rest, checks: bars.length })),
|
|
169
181
|
testFiles: testFilesToFix(o.suites, o.declaredSlow),
|
|
170
182
|
suitesRecorded: o.suites.length > 0,
|
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
// asked about three checks replaces those three and leaves the rest standing:
|
|
3
3
|
// vscode clears the whole pane at every frame, so a narrowed run that printed
|
|
4
4
|
// only its own findings would erase every other check's.
|
|
5
|
-
import {
|
|
5
|
+
import type { HubProblem } from '../serve/hub-problems.ts'
|
|
6
|
+
import { frameLines, hubProblemLine, type Problem } from './problem-line.ts'
|
|
6
7
|
|
|
7
8
|
export class ProblemBoard {
|
|
8
9
|
private readonly byCheck = new Map<string, Problem[]>()
|
|
10
|
+
private hub: Problem[] = []
|
|
11
|
+
|
|
12
|
+
/** what is wrong with the hub itself (serve/hub-problems.ts), which no
|
|
13
|
+
* check owns. Returns whether the board moved */
|
|
14
|
+
setHub(problems: HubProblem[]): boolean {
|
|
15
|
+
const next = problems.map(hubProblemLine)
|
|
16
|
+
const moved = JSON.stringify(next) !== JSON.stringify(this.hub)
|
|
17
|
+
this.hub = next
|
|
18
|
+
return moved
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
/** a check reached a verdict. What it carries has already passed the pane
|
|
11
22
|
* rule (problem-line.ts feedsProblemsPane), so an empty list clears it */
|
|
@@ -17,7 +28,7 @@ export class ProblemBoard {
|
|
|
17
28
|
/** every finding standing, sorted by file then line, so a frame reads the
|
|
18
29
|
* same whatever order the checks finished in */
|
|
19
30
|
all(): Problem[] {
|
|
20
|
-
return [...this.byCheck.values()].flat().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col || a.check.localeCompare(b.check))
|
|
31
|
+
return [...this.hub, ...this.byCheck.values()].flat().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col || a.check.localeCompare(b.check))
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/** one check's verdict, as the runner reports it. A skip is no verdict
|
|
@@ -39,6 +39,15 @@ export const ENDS_PATTERN = `^${FRAME_END_HEAD}\\b`
|
|
|
39
39
|
* (`imports:check`) both split right */
|
|
40
40
|
export const PROBLEM_PATTERN = '^(.+?):(\\d+):(\\d+): (error|warning) (\\S+?): (.*)$'
|
|
41
41
|
|
|
42
|
+
/** where a problem of the hub itself is anchored: it has no file, and the
|
|
43
|
+
* matcher needs one, so it sits on line 1 of the file that declares the
|
|
44
|
+
* hub. Check id `hub` */
|
|
45
|
+
export const HUB_ANCHOR = 'repo.config.ts'
|
|
46
|
+
|
|
47
|
+
export function hubProblemLine(p: { text: string; severity: 'red' | 'yellow' }): Problem {
|
|
48
|
+
return { file: HUB_ANCHOR, line: 1, col: 1, severity: p.severity === 'red' ? 'error' : 'warning', check: 'hub', message: p.text }
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
export function formatProblem(p: Problem): string {
|
|
43
52
|
const message = p.message.replace(/\s*\n\s*/g, ' ').trim()
|
|
44
53
|
return `${p.file}:${p.line}:${p.col}: ${p.severity} ${p.check}: ${message}`
|
package/src/repo-config.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
// a slot that can't fill emits a visible reason, disabling via
|
|
9
9
|
// `defaults: { x: false }` prints a skipped-by-config line — never silence.
|
|
10
10
|
// The `contracts` task is always present when any contract applies.
|
|
11
|
-
import { existsSync
|
|
11
|
+
import { existsSync } from 'fs'
|
|
12
12
|
import { isAbsolute, join, normalize } from 'path'
|
|
13
|
-
import { fileURLToPath } from 'url'
|
|
14
13
|
import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
|
|
15
|
-
import { FLOOR_IDS
|
|
14
|
+
import { FLOOR_IDS } from './core/floor.ts'
|
|
16
15
|
export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
|
|
17
16
|
import type { ContractManifest } from './contracts/contract-types.ts'
|
|
18
17
|
import type { ProseConfig } from './prose/run-prose.ts'
|
|
@@ -21,6 +20,7 @@ import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
|
|
|
21
20
|
import { RULE_ID_RE, validateRules } from './rules/rule.ts'
|
|
22
21
|
import { builtinCheckIds } from './core/discover-checks.ts'
|
|
23
22
|
import { parseWhen } from './core/when.ts'
|
|
23
|
+
import { budgetValueProblem } from './core/budget.ts'
|
|
24
24
|
import type { Rule } from './check.ts'
|
|
25
25
|
import { leakAllowProblems } from './hooks/leak-guard.ts'
|
|
26
26
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
@@ -105,6 +105,14 @@ export type RepoConfig = {
|
|
|
105
105
|
* Its last recorded run, from a gate or a health pass, is the secret's
|
|
106
106
|
* verdict. Absent: no check probes it, and the row says so */
|
|
107
107
|
probedBy?: string
|
|
108
|
+
/** how much breaks without it, which decides its colour when it is
|
|
109
|
+
* missing or its probe fails. `core`: the toolkit's own features stop
|
|
110
|
+
* (a push, creating a repo, a release), so it is red, and a missing
|
|
111
|
+
* one reaches `shipkit needs` as red. `used`: a feature this repo uses today, so
|
|
112
|
+
* it is orange, a needs line that never pings. `minor`: proactive,
|
|
113
|
+
* for something nothing uses yet, so it is dim and stays out of
|
|
114
|
+
* `shipkit needs`. Default `used` */
|
|
115
|
+
level?: 'core' | 'used' | 'minor'
|
|
108
116
|
}[]
|
|
109
117
|
/** store-command template for a missing secret tile, `{name}` substituted, as in your secret manager's set command */
|
|
110
118
|
credsCommand?: string
|
|
@@ -145,6 +153,12 @@ export type RepoConfig = {
|
|
|
145
153
|
* reason, and on an id no check carries. The hub writes this
|
|
146
154
|
* map through the managed block in this file (core/when-block.ts). */
|
|
147
155
|
when?: Record<string, string>
|
|
156
|
+
/** WHAT A HEALTHY RUN OF EACH CHECK TAKES, in ms, keyed by check id. A
|
|
157
|
+
* check that passes over its budget prints a WARN and reads yellow, and
|
|
158
|
+
* never blocks. It wins over the check's own `budgetMs` and the default
|
|
159
|
+
* for its kind (core/budget.ts). An id no check carries fails the run,
|
|
160
|
+
* the same as a `when` entry. */
|
|
161
|
+
budgets?: Record<string, number>
|
|
148
162
|
/** DEPRECATED, read into `when` as `off:<reason>`: rule id → the reason
|
|
149
163
|
* this repo breaks that invariant on purpose. An exempted rule does not
|
|
150
164
|
* lint and prints a skip line carrying the reason. `defineRepo` throws on
|
|
@@ -242,7 +256,7 @@ export type RepoConfig = {
|
|
|
242
256
|
logDir?: string
|
|
243
257
|
}
|
|
244
258
|
|
|
245
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
|
|
259
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
|
|
246
260
|
|
|
247
261
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
248
262
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
@@ -320,6 +334,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
320
334
|
if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
|
|
321
335
|
throw new Error(`defineRepo: when.${id} names no rule, the ids are in \`shipkit checks\``)
|
|
322
336
|
}
|
|
337
|
+
// a budget turns a slow pass yellow, so a value it cannot compare with dies here
|
|
338
|
+
for (const [id, v] of Object.entries(c.budgets ?? {})) {
|
|
339
|
+
if (id.trim() === '') throw new Error(`defineRepo: budgets has an entry with no check id`)
|
|
340
|
+
const bad = budgetValueProblem(v)
|
|
341
|
+
if (bad != null) throw new Error(`defineRepo: budgets.${id} ${bad}`)
|
|
342
|
+
}
|
|
323
343
|
// an exemption silences a gate, so it names a rule that exists and says why
|
|
324
344
|
for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
|
|
325
345
|
if (!builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
|
|
@@ -47,10 +47,10 @@ const VERDICT_WORD: Record<'pass' | 'warn' | 'fail' | 'skip', string> = {
|
|
|
47
47
|
// ── the pieces ───────────────────────────────────────────────────────────
|
|
48
48
|
|
|
49
49
|
/** one duration, in the one unit, in the one tone */
|
|
50
|
-
const Dur = ({ ms, ceiling }: { ms: number | null | undefined; ceiling?: number }) =>
|
|
50
|
+
const Dur = ({ ms, ceiling, budget }: { ms: number | null | undefined; ceiling?: number; budget?: number }) =>
|
|
51
51
|
ms == null
|
|
52
52
|
? <span className="dim">not timed yet</span>
|
|
53
|
-
: <span className={`dnum ${durationTone(ms, ceiling) ?? ''}`}>{fmtMs(ms)}</span>
|
|
53
|
+
: <span className={`dnum ${durationTone(ms, ceiling, budget) ?? ''}`}>{fmtMs(ms)}</span>
|
|
54
54
|
|
|
55
55
|
const MdHelp = ({ text }: { text: string }) => <Help text={<span className="md" dangerouslySetInnerHTML={{ __html: renderMd(text) }} />} />
|
|
56
56
|
|
|
@@ -177,14 +177,14 @@ const RunWhen = observer(({ app, u }: { app: HubSt; u: CheckFacts }) => {
|
|
|
177
177
|
|
|
178
178
|
/** the last N outcomes as a bar row: an average hides the shape, and the
|
|
179
179
|
* reds it cannot even see */
|
|
180
|
-
const Bars = ({ runs, ceiling }: { runs: RecentRun[]; ceiling: number }) => {
|
|
180
|
+
const Bars = ({ runs, ceiling, budget }: { runs: RecentRun[]; ceiling: number; budget: number }) => {
|
|
181
181
|
if (runs.length === 0) return <div className="dim">no run on record yet</div>
|
|
182
182
|
const top = Math.max(...runs.map((r) => r.ms), 1)
|
|
183
183
|
return (
|
|
184
184
|
<div className="bars">
|
|
185
185
|
{runs.map((r, n) => (
|
|
186
186
|
<i key={`${r.at}-${n}`}
|
|
187
|
-
className={`bar ${r.replayed ? 'rep' : r.status === 'fail' ? 'red' : r.status === 'warn' ? 'warn' : r.status === 'skip' ? 'rep' : durationTone(r.ms, ceiling) ?? 'plain'}`}
|
|
187
|
+
className={`bar ${r.replayed ? 'rep' : r.status === 'fail' ? 'red' : r.status === 'warn' ? 'warn' : r.status === 'skip' ? 'rep' : durationTone(r.ms, ceiling, budget) ?? 'plain'}`}
|
|
188
188
|
style={{ height: `${r.replayed ? 8 : Math.max(10, Math.round((r.ms / top) * 100))}%` }}
|
|
189
189
|
title={`${stampWords(r.at)}: ${r.replayed ? CACHED : `${VERDICT_WORD[r.status]} in ${fmtMs(r.ms)}`}`} />
|
|
190
190
|
))}
|
|
@@ -200,22 +200,23 @@ export const LastRunValue = observer(({ app, u }: { app: HubSt; u: CheckFacts })
|
|
|
200
200
|
<>
|
|
201
201
|
<span className={`tl ${l.tone}`}>{l.word}</span>
|
|
202
202
|
{u.lastAt == null ? null : <> <TimeAgo clock={app.clock} at={u.lastAt} /></>}
|
|
203
|
-
{u.lastMs == null || u.replayed ? null : <> in <Dur ms={u.lastMs} ceiling={u.timeoutMs} /></>}
|
|
203
|
+
{u.lastMs == null || u.replayed ? null : <> in <Dur ms={u.lastMs} ceiling={u.timeoutMs} budget={u.budgetMs} /></>}
|
|
204
204
|
{l.verdict == null ? null : <span className={`tl ${l.tone}`}>, {l.verdict}</span>}
|
|
205
205
|
</>
|
|
206
206
|
)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
209
|
export const Timings = ({ u }: { u: CheckFacts }) => (
|
|
210
|
-
<Row k="timings" wide help="what this check costs: its last run, the mean of the runs that really ran, the worst one on record, and the limit it is killed at">
|
|
210
|
+
<Row k="timings" wide help="what this check costs: its last run, the mean of the runs that really ran, the worst one on record, what a healthy run takes, and the limit it is killed at">
|
|
211
211
|
<span className="ctimes">
|
|
212
|
-
<span><span className="ck">last</span> <Dur ms={u.lastMs} ceiling={u.timeoutMs} /></span>
|
|
213
|
-
<span><span className="ck">average</span> {u.samples === 0 ? <span className="dim">not timed yet</span> : <><Dur ms={u.avgMs} ceiling={u.timeoutMs} /> <span className="dim">over {u.samples}</span></>}</span>
|
|
214
|
-
<span><span className="ck">slowest</span> <Dur ms={u.slowestMs} ceiling={u.timeoutMs} />{u.failures > 0 ? <span className="dim">, {u.failures} red</span> : null}</span>
|
|
215
|
-
{/* a limit, never
|
|
212
|
+
<span><span className="ck">last</span> <Dur ms={u.lastMs} ceiling={u.timeoutMs} budget={u.budgetMs} /></span>
|
|
213
|
+
<span><span className="ck">average</span> {u.samples === 0 ? <span className="dim">not timed yet</span> : <><Dur ms={u.avgMs} ceiling={u.timeoutMs} budget={u.budgetMs} /> <span className="dim">over {u.samples}</span></>}</span>
|
|
214
|
+
<span><span className="ck">slowest</span> <Dur ms={u.slowestMs} ceiling={u.timeoutMs} budget={u.budgetMs} />{u.failures > 0 ? <span className="dim">, {u.failures} red</span> : null}</span>
|
|
215
|
+
{/* a budget and a limit, never measurements: they wear no tone of their own */}
|
|
216
|
+
<span><span className="ck">budget</span> <span className="dnum dim">{fmtMs(u.budgetMs)}</span> <Help term="budget" /></span>
|
|
216
217
|
<span><span className="ck">limit</span> <span className="dnum dim">{fmtMs(u.timeoutMs)}</span> <Help term="ceiling" /></span>
|
|
217
218
|
</span>
|
|
218
|
-
<Bars runs={u.recent ?? []} ceiling={u.timeoutMs} />
|
|
219
|
+
<Bars runs={u.recent ?? []} ceiling={u.timeoutMs} budget={u.budgetMs} />
|
|
219
220
|
</Row>
|
|
220
221
|
)
|
|
221
222
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// the card.
|
|
6
6
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
7
|
import type { HubSt } from './hub-st.ts'
|
|
8
|
-
import type { Col, ColMeta, Resource, Schedule } from './hub-types.ts'
|
|
8
|
+
import type { Col, ColMeta, HubProblem, Resource, Schedule } from './hub-types.ts'
|
|
9
9
|
|
|
10
10
|
export class ColsSvc {
|
|
11
11
|
/** exactly what the server last sent; `resources` is this plus the live
|
|
@@ -14,6 +14,10 @@ export class ColsSvc {
|
|
|
14
14
|
colMeta: ColMeta[] = []
|
|
15
15
|
/** the hub's daily health pass timer, as the last poll saw it */
|
|
16
16
|
schedule: Schedule | null = null
|
|
17
|
+
/** what the server says is wrong with the hub itself */
|
|
18
|
+
hubProblems: HubProblem[] = []
|
|
19
|
+
/** the last poll failed: the page cannot reach its own server */
|
|
20
|
+
fetchErr: string | null = null
|
|
17
21
|
actErr = ''
|
|
18
22
|
remarkDraft = ''
|
|
19
23
|
proofDraft = ''
|
|
@@ -37,20 +41,18 @@ export class ColsSvc {
|
|
|
37
41
|
try {
|
|
38
42
|
const r = await fetch('/api/resources.json')
|
|
39
43
|
if (!r.ok) throw new Error(await r.text())
|
|
40
|
-
const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null }
|
|
44
|
+
const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null; hub?: HubProblem[] }
|
|
41
45
|
runInAction(() => {
|
|
42
46
|
this.colMeta = j.collections
|
|
43
47
|
this.fetched = j.resources
|
|
44
48
|
this.schedule = j.schedule ?? null
|
|
49
|
+
this.hubProblems = j.hub ?? []
|
|
50
|
+
this.fetchErr = null
|
|
45
51
|
})
|
|
46
52
|
} catch (e) {
|
|
47
|
-
// a dead server must SAY so
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
runInAction(() => {
|
|
51
|
-
this.colMeta = [{ id: 'hub', title: 'hub', media: 'health' }]
|
|
52
|
-
this.fetched = [{ id: 'hub:resources-broken', collection: 'hub', type: 'health', path: 'health/hub-fetch', virtual: true, title: text, text, state: 'red', severity: 'red' }]
|
|
53
|
-
})
|
|
53
|
+
// a dead server must SAY so, in the header, never leave a tree
|
|
54
|
+
// that reads like a healthy repo. The last rows stay, dated by it
|
|
55
|
+
runInAction(() => { this.fetchErr = `the page cannot reach this hub: ${(e as Error).message}` })
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -62,6 +64,11 @@ export class ColsSvc {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** the collections, as a grouping of the one array */
|
|
67
|
+
/** the header's red: the server's own problems, then an unreachable server */
|
|
68
|
+
get hubStatus(): HubProblem[] {
|
|
69
|
+
return [...this.hubProblems, ...(this.fetchErr == null ? [] : [{ id: 'fetch', text: this.fetchErr, severity: 'red' as const }])]
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
get cols(): Col[] {
|
|
66
73
|
return this.colMeta.map((m) => ({ ...m, instances: this.resources.filter((r) => r.collection === m.id) }))
|
|
67
74
|
}
|
package/src/serve/hub-app/dur.ts
CHANGED
|
@@ -31,9 +31,18 @@ const group = (n: number): string => {
|
|
|
31
31
|
|
|
32
32
|
export const fmtMs = (ms: number | null | undefined): string => (ms == null ? '' : `${group(Math.round(ms))}ms`)
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/** the tone of one duration. A check's own BUDGET, when the caller knows it,
|
|
35
|
+
* replaces the table above it: over the budget is red, past half of it
|
|
36
|
+
* orange, and a suite well inside a generous budget never reads red because
|
|
37
|
+
* a rule would be slow at that length (core/budget.ts) */
|
|
38
|
+
export function durationTone(ms: number | null | undefined, ceilingMs?: number | null, budgetMs?: number | null): DurTone | null {
|
|
35
39
|
if (ms == null) return null
|
|
36
40
|
if (ceilingMs != null && ceilingMs > 0 && ms > ceilingMs * CEILING_SHARE) return 'bad'
|
|
41
|
+
if (budgetMs != null && budgetMs > 0) {
|
|
42
|
+
if (ms > budgetMs) return 'bad'
|
|
43
|
+
if (ms > budgetMs / 2) return 'slow'
|
|
44
|
+
return ms < FAST_MS ? 'fast' : 'plain'
|
|
45
|
+
}
|
|
37
46
|
if (ms < FAST_MS) return 'fast'
|
|
38
47
|
if (ms < PLAIN_MS) return 'plain'
|
|
39
48
|
if (ms < SLOW_MS) return 'slow'
|
|
@@ -46,6 +46,16 @@ const RunState = observer(({ app }: { app: HubSt }) => {
|
|
|
46
46
|
)
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
+
/** what is wrong with the hub itself, one chip per problem. Nothing when all
|
|
50
|
+
* is well: the header states trouble, never health */
|
|
51
|
+
const HubStatus = observer(({ app }: { app: HubSt }) => (
|
|
52
|
+
<>
|
|
53
|
+
{app.cols.hubStatus.map((p) => (
|
|
54
|
+
<span key={p.id} className={`hstatus ${p.severity === 'red' ? 'red' : 'warn'}`} title={p.text}>{p.text}</span>
|
|
55
|
+
))}
|
|
56
|
+
</>
|
|
57
|
+
))
|
|
58
|
+
|
|
49
59
|
export const HeaderBar = observer(({ app }: { app: HubSt }) => {
|
|
50
60
|
const u = app.checks
|
|
51
61
|
return (
|
|
@@ -57,6 +67,7 @@ export const HeaderBar = observer(({ app }: { app: HubSt }) => {
|
|
|
57
67
|
))}
|
|
58
68
|
</nav>
|
|
59
69
|
<span className="sp" />
|
|
70
|
+
<HubStatus app={app} />
|
|
60
71
|
<RunState app={app} />
|
|
61
72
|
<button className="primary" disabled={u.busy} onClick={() => void u.run()}>run the health pass</button>
|
|
62
73
|
<button disabled={u.busy} onClick={() => void u.run(undefined, { gate: true, label: 'the gate' })}>run the gate</button>
|
|
@@ -14,6 +14,7 @@ export const TERMS: Record<string, string> = {
|
|
|
14
14
|
advisory: 'it can fail without stopping anything: the gate reports it and goes on',
|
|
15
15
|
blocking: 'its failure stops the gate, and stops you',
|
|
16
16
|
ceiling: 'the time it is killed at: a check creeping toward its ceiling is visible before it trips',
|
|
17
|
+
budget: 'what a healthy run takes: a run that passes over it warns and reads yellow, and never blocks. Set it with budgetMs in the check file, or budgets in repo.config.ts',
|
|
17
18
|
cached: 'it did not run: the answer came from the cache, because nothing it depends on moved since it last ran',
|
|
18
19
|
ran: 'it ran and was timed, so the number is what it costs today',
|
|
19
20
|
slowest: 'the worst run on record, reds included',
|
|
@@ -134,10 +134,9 @@ export class HubSt {
|
|
|
134
134
|
// the runs page is a page, not a resource, so no collection lists it
|
|
135
135
|
out.push({ id: 'page:runs', label: 'runs', section: 'page', run: () => this.cols.select('runs', RUNS_ID) })
|
|
136
136
|
for (const col of this.cols.sidebarCols) {
|
|
137
|
-
|
|
138
|
-
out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => (health ? this.showStatus() : this.showCol(col.id)) })
|
|
137
|
+
out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
|
|
139
138
|
for (const i of col.instances)
|
|
140
|
-
out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () =>
|
|
139
|
+
out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => this.cols.select(col.id, i.id) })
|
|
141
140
|
}
|
|
142
141
|
return out
|
|
143
142
|
}
|
|
@@ -203,7 +202,7 @@ export class HubSt {
|
|
|
203
202
|
this.cols.select(r.collection, r.id)
|
|
204
203
|
}
|
|
205
204
|
|
|
206
|
-
/** collection view — one card per instance
|
|
205
|
+
/** collection view — one card per instance */
|
|
207
206
|
showCol(id: string): void {
|
|
208
207
|
this.setSel({ t: 'col', id })
|
|
209
208
|
}
|
|
@@ -105,6 +105,7 @@ export type CheckFacts = {
|
|
|
105
105
|
ttl?: string
|
|
106
106
|
never?: string
|
|
107
107
|
timeoutMs: number
|
|
108
|
+
budgetMs: number
|
|
108
109
|
status: CheckStatus | null
|
|
109
110
|
replayed: boolean
|
|
110
111
|
lastMs: number | null
|
|
@@ -121,6 +122,10 @@ export type CheckFacts = {
|
|
|
121
122
|
recent?: RecentRun[]
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/** what is wrong with the hub itself, as the resources feed sends it
|
|
126
|
+
* (serve/hub-problems.ts): the header shows it, never the tree */
|
|
127
|
+
export type HubProblem = { id: string; text: string; severity: 'red' | 'yellow' }
|
|
128
|
+
|
|
124
129
|
/** the hub's daily health pass timer, as the resources feed sends it
|
|
125
130
|
* (serve/health-schedule.ts). null = this hub runs no timer */
|
|
126
131
|
export type Schedule = { everyMs: number; lastPassAt: number | null; nextAt: number; running: boolean }
|