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
|
@@ -26,9 +26,10 @@ export const testId = (file: string): string => `test:${file}`
|
|
|
26
26
|
export const NO_CHECK = 'in no check'
|
|
27
27
|
|
|
28
28
|
/** the resource KIND, never the collection it came from: one folder holds
|
|
29
|
-
* several kinds, which is what makes the path tree worth having.
|
|
30
|
-
*
|
|
31
|
-
|
|
29
|
+
* several kinds, which is what makes the path tree worth having. There is no
|
|
30
|
+
* catch all kind: a row belongs to an owner, and what belongs to the hub
|
|
31
|
+
* itself is its status (serve/hub-problems.ts), never a row */
|
|
32
|
+
export const RESOURCE_TYPES = ['ask', 'secret', 'asset', 'post', 'workflow', 'wiki', 'contract', 'check', 'test', 'reflection'] as const
|
|
32
33
|
|
|
33
34
|
export type ResourceType = (typeof RESOURCE_TYPES)[number]
|
|
34
35
|
|
|
@@ -97,6 +98,8 @@ export type CheckFacts = {
|
|
|
97
98
|
/** where it is killed: a check creeping toward its ceiling is visible
|
|
98
99
|
* before it trips */
|
|
99
100
|
timeoutMs: number
|
|
101
|
+
/** what a healthy run takes (core/budget.ts): a pass over it warns */
|
|
102
|
+
budgetMs: number
|
|
100
103
|
status: 'pass' | 'warn' | 'fail' | 'skip' | null
|
|
101
104
|
replayed: boolean
|
|
102
105
|
/** what the last run cost, ms. null on a replay: it measured nothing */
|
|
@@ -248,6 +251,9 @@ export type Instance = {
|
|
|
248
251
|
export type Collection = {
|
|
249
252
|
id: string
|
|
250
253
|
title: string
|
|
254
|
+
/** the kind of the rows it lists, which is also the kind of the one row
|
|
255
|
+
* that says its listing broke */
|
|
256
|
+
kind: ResourceType
|
|
251
257
|
media: 'image' | 'audio' | 'text' | 'data' | 'pointer' | 'health'
|
|
252
258
|
/** wire tone when any needing instance is only yellow */
|
|
253
259
|
list: () => Promise<Instance[]>
|
|
@@ -263,7 +269,7 @@ export type Resource = Instance & { collection: string; title: string; state: Re
|
|
|
263
269
|
|
|
264
270
|
/** PROACTIVE kinds: work waiting on a human, which is not the same thing as
|
|
265
271
|
* something being broken. Red is reserved for broken (a blocking check that
|
|
266
|
-
* failed, a secret
|
|
272
|
+
* failed, a `core` secret missing or refused, a collector that threw),
|
|
267
273
|
* so these read as taste pending on the tree. Their `severity` is untouched,
|
|
268
274
|
* because that is the axis `shipkit needs` and the once ever ping read */
|
|
269
275
|
const PROACTIVE: ReadonlySet<ResourceType> = new Set<ResourceType>(['ask'])
|
|
@@ -277,25 +283,33 @@ export async function listResources(c: Collection): Promise<Resource[]> {
|
|
|
277
283
|
return (await c.list()).map((i) => ({ ...i, collection: c.id, title: i.label ?? i.text, state: stateOf(i) }))
|
|
278
284
|
}
|
|
279
285
|
|
|
286
|
+
/** the ONE row a collection whose listing threw is reduced to: its own kind,
|
|
287
|
+
* filed in its own folder, red */
|
|
288
|
+
export const brokenRow = (c: Collection, e: unknown): Instance => ({
|
|
289
|
+
id: `${c.id}:broken`,
|
|
290
|
+
type: c.kind,
|
|
291
|
+
path: `${c.id}/collector`,
|
|
292
|
+
virtual: true,
|
|
293
|
+
text: `collector broke: ${(e as Error).message}`,
|
|
294
|
+
severity: 'red',
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
/** told, per collection, whether its listing broke (the message) or not
|
|
298
|
+
* (null), so the hub can carry it in its own status */
|
|
299
|
+
export type OnCollector = (collection: string, error: string | null) => void
|
|
300
|
+
|
|
280
301
|
/** every resource of every collection, failure-isolated per collection: a
|
|
281
302
|
* broken source reports itself as ONE red row, never blanks the tree */
|
|
282
|
-
export async function projectResources(collections: Collection[]): Promise<Resource[]> {
|
|
303
|
+
export async function projectResources(collections: Collection[], onCollector?: OnCollector): Promise<Resource[]> {
|
|
283
304
|
const out: Resource[] = []
|
|
284
305
|
for (const c of collections) {
|
|
285
306
|
try {
|
|
286
307
|
out.push(...(await listResources(c)))
|
|
308
|
+
onCollector?.(c.id, null)
|
|
287
309
|
} catch (e) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
type: 'health',
|
|
292
|
-
path: `health/${c.id}-collector`,
|
|
293
|
-
virtual: true,
|
|
294
|
-
title: `collector broke: ${(e as Error).message}`,
|
|
295
|
-
text: `collector broke: ${(e as Error).message}`,
|
|
296
|
-
severity: 'red',
|
|
297
|
-
state: 'red',
|
|
298
|
-
})
|
|
310
|
+
const row = brokenRow(c, e)
|
|
311
|
+
out.push({ ...row, collection: c.id, title: row.text, state: 'red' })
|
|
312
|
+
onCollector?.(c.id, (e as Error).message)
|
|
299
313
|
}
|
|
300
314
|
}
|
|
301
315
|
return out
|
|
@@ -303,13 +317,15 @@ export async function projectResources(collections: Collection[]): Promise<Resou
|
|
|
303
317
|
|
|
304
318
|
/** the flat needs projection — failure-isolated per collection: a broken
|
|
305
319
|
* source reports itself as ONE red item, never blanks the feed */
|
|
306
|
-
export async function projectNeeds(collections: Collection[]): Promise<NeedItem[]> {
|
|
320
|
+
export async function projectNeeds(collections: Collection[], onCollector?: OnCollector): Promise<NeedItem[]> {
|
|
307
321
|
const out: NeedItem[] = []
|
|
308
322
|
for (const c of collections) {
|
|
309
323
|
try {
|
|
310
324
|
for (const i of await c.list()) if (i.severity != null) out.push({ ...i, collection: c.id })
|
|
325
|
+
onCollector?.(c.id, null)
|
|
311
326
|
} catch (e) {
|
|
312
|
-
out.push({
|
|
327
|
+
out.push({ ...brokenRow(c, e), collection: c.id })
|
|
328
|
+
onCollector?.(c.id, (e as Error).message)
|
|
313
329
|
}
|
|
314
330
|
}
|
|
315
331
|
return out
|
package/src/collections/comfy.ts
CHANGED
package/src/collections/posts.ts
CHANGED
|
@@ -25,11 +25,13 @@ import { activeContracts, contractsConfigFrom } from '../contracts/apply-contrac
|
|
|
25
25
|
import type { CheckFacts, Collection, Instance } from './collection-types.ts'
|
|
26
26
|
import { mdBody } from './md-body.ts'
|
|
27
27
|
import { socialPostsCollection } from './posts.ts'
|
|
28
|
-
import { assetState, secretState, type SecretProbe } from './state-rules.ts'
|
|
28
|
+
import { assetState, secretState, type SecretLevel, type SecretProbe } from './state-rules.ts'
|
|
29
29
|
import { reflectionsCollection } from './reflections.ts'
|
|
30
30
|
import { testsCollection } from './tests.ts'
|
|
31
31
|
import { checksCollection } from './checks.ts'
|
|
32
32
|
import { LAYOUT } from '../core/layout.ts'
|
|
33
|
+
import { isBudgetLine } from '../core/budget.ts'
|
|
34
|
+
import { GH_LOGIN_CRED } from '../checks/SK021-github-login.check.ts'
|
|
33
35
|
|
|
34
36
|
const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? LAYOUT.asks)
|
|
35
37
|
// read path must not mint an asks/ dir (listAsks mkdirs on read)
|
|
@@ -101,10 +103,9 @@ export function probeOf(name: string, probedBy: string | undefined, verdicts: Ma
|
|
|
101
103
|
const why = lines.find((l) => l.kind === 'fail') ?? lines.find((l) => l.kind === 'warn')
|
|
102
104
|
return { probe: 'fail', words: `its probe ${probedBy} failed${why != null ? `: ${why.text}` : ''}`, md }
|
|
103
105
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
106
|
+
// a probe that only ran slow still proved the secret works
|
|
107
|
+
const why = lines.find((l) => l.kind === 'warn' && !isBudgetLine(l.text))
|
|
108
|
+
if (v.status === 'warn' && why != null) return { probe: 'warn', words: `its probe ${probedBy} warned: ${why.text}`, md }
|
|
108
109
|
const at = v.measuredAt ?? v.lastAt ?? now
|
|
109
110
|
if (v.keepsUntil != null && now > v.keepsUntil)
|
|
110
111
|
return { probe: 'stale', words: `last checked ${ago(now - at)} by ${probedBy}, older than its ${v.ttl} ttl`, md }
|
|
@@ -122,6 +123,7 @@ export function asksCollection(root: string, c: RepoConfig): Collection {
|
|
|
122
123
|
return {
|
|
123
124
|
id: 'asks',
|
|
124
125
|
title: 'asks',
|
|
126
|
+
kind: 'ask',
|
|
125
127
|
media: 'text',
|
|
126
128
|
list: async () => {
|
|
127
129
|
const rel = c.asks?.dir ?? LAYOUT.asks
|
|
@@ -183,9 +185,10 @@ const named = (name: string, service: string | undefined): string => (service !=
|
|
|
183
185
|
|
|
184
186
|
/** repo-declared secrets (repo.config `creds:`) plus every secret a check
|
|
185
187
|
* proved missing. The tile carries the mint artifact as data. EVERY declared
|
|
186
|
-
* cred is listed
|
|
187
|
-
*
|
|
188
|
-
*
|
|
188
|
+
* cred is listed, coloured by its level (state-rules.ts secretState).
|
|
189
|
+
* Missing + unclaimed = the set verb + artifact; missing + ask-covered =
|
|
190
|
+
* STILL listed (never hidden), no verbs: the text points at the ask that
|
|
191
|
+
* owns the action. The line that proved it missing
|
|
189
192
|
* (+ its fix: pointers) rides the instance's md, never a health line.
|
|
190
193
|
* A present secret wears the verdict of the check that probes it
|
|
191
194
|
* (`probedBy`), read from the run record (state-rules.ts secretState) */
|
|
@@ -193,6 +196,7 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
|
|
|
193
196
|
return {
|
|
194
197
|
id: 'creds',
|
|
195
198
|
title: 'secrets',
|
|
199
|
+
kind: 'secret',
|
|
196
200
|
media: 'pointer',
|
|
197
201
|
list: async () => {
|
|
198
202
|
const rel = c.asks?.dir ?? LAYOUT.asks
|
|
@@ -203,19 +207,27 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
|
|
|
203
207
|
const now = (o.now ?? Date.now)()
|
|
204
208
|
const missing = missingFrom(verdicts)
|
|
205
209
|
const byId = new Map(verdicts.map((v) => [v.checkId, v]))
|
|
210
|
+
// a toolkit credential joins the repo's own once its probe ran here
|
|
211
|
+
const builtins = BUILTIN_CREDS.filter((b) => byId.get(b.probedBy)?.status != null &&!(c.creds ?? []).some((d) => d.name === b.name))
|
|
212
|
+
const declared = [...builtins, ...(c.creds ?? [])]
|
|
206
213
|
const out: Instance[] = []
|
|
207
214
|
for (const [name, line] of missing) {
|
|
208
|
-
const
|
|
215
|
+
const builtin = builtins.find((b) => b.name === name)
|
|
216
|
+
if (builtin != null) {
|
|
217
|
+
out.push({ ...builtinMissing(builtin, line.fixes[0]), md: wireMd([`FAIL: ${line.text}`, ...line.fixes.map((f) => `fix: ${f}`)]) })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
const decl = declared.find((d) => d.name === name)
|
|
209
221
|
const md = wireMd([`FAIL: ${line.text}`, ...line.fixes.map((f) => `fix: ${f}`)])
|
|
210
222
|
const ask = coveringAsk(name)
|
|
211
223
|
if (ask != null)
|
|
212
224
|
// dedup = acted-on once, never hidden: the ask keeps the
|
|
213
|
-
// verbs and the ping; the cred still shows
|
|
225
|
+
// verbs and the ping; the cred still shows here
|
|
214
226
|
out.push({
|
|
215
227
|
id: `cred:${name}`,
|
|
216
|
-
text: `${named(name, decl?.service)} missing, ask ${rel}/${ask.file} owns this`,
|
|
217
|
-
...credPlace(name, true),
|
|
218
|
-
|
|
228
|
+
text: `${named(name, decl?.service)} missing, ask ${rel}/${ask.file} owns this${minorWord(decl?.level)}`,
|
|
229
|
+
...credPlace(name, true, decl?.level),
|
|
230
|
+
...secretState({ missing: true, probe: 'none', level: decl?.level }),
|
|
219
231
|
md,
|
|
220
232
|
askOwned: true,
|
|
221
233
|
})
|
|
@@ -224,14 +236,14 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
|
|
|
224
236
|
// every declared cred not proven missing is present: no copy
|
|
225
237
|
// command (nothing to run), the mint pointer when one is declared,
|
|
226
238
|
// and the colour of what its probe last proved
|
|
227
|
-
for (const d of
|
|
239
|
+
for (const d of declared) {
|
|
228
240
|
if (missing.has(d.name)) continue
|
|
229
241
|
const p = probeOf(d.name, d.probedBy, byId, now)
|
|
230
242
|
out.push({
|
|
231
243
|
id: `cred:${d.name}`,
|
|
232
|
-
text: `${named(d.name, d.service)} present, ${p.words}`,
|
|
233
|
-
...credPlace(d.name, false),
|
|
234
|
-
...secretState({ missing: false, probe: p.probe }),
|
|
244
|
+
text: `${named(d.name, d.service)} present, ${p.words}${minorWord(d.level)}`,
|
|
245
|
+
...credPlace(d.name, false, d.level),
|
|
246
|
+
...secretState({ missing: false, probe: p.probe, level: d.level }),
|
|
235
247
|
md: p.md,
|
|
236
248
|
artifact: d.mintUrl != null ? { url: d.mintUrl } : undefined,
|
|
237
249
|
})
|
|
@@ -245,15 +257,35 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
|
|
|
245
257
|
// never names any particular secret manager
|
|
246
258
|
// a secret has no file: the name is already slash shaped, so a virtual root
|
|
247
259
|
// groups it by vendor for free
|
|
248
|
-
const credPlace = (name: string, missing: boolean): Pick<Instance, 'type' | 'path' | 'virtual' | 'label' | 'slot'> =>
|
|
249
|
-
// the tree row is the NAME, and present or missing is the slot beside
|
|
250
|
-
|
|
260
|
+
const credPlace = (name: string, missing: boolean, level?: SecretLevel): Pick<Instance, 'type' | 'path' | 'virtual' | 'label' | 'slot'> =>
|
|
261
|
+
// the tree row is the NAME, and present or missing is the slot beside
|
|
262
|
+
// it. A minor secret nobody minted says minor there: missing is the
|
|
263
|
+
// expected state of a key nothing uses yet
|
|
264
|
+
({ type: 'secret', path: `secrets/${name}`, virtual: true, label: name, slot: missing ? (level === 'minor' ? 'minor' : 'missing') : 'present' })
|
|
265
|
+
|
|
266
|
+
/** a minor secret says so in its sentence, since its colour says nothing */
|
|
267
|
+
const minorWord = (level: SecretLevel | undefined): string => (level === 'minor' ? ', minor' : '')
|
|
268
|
+
|
|
269
|
+
/** credentials the toolkit itself runs on, which no secrets manager holds
|
|
270
|
+
* (gh keeps its own login). The repo's `creds` entry of the same name wins */
|
|
271
|
+
const BUILTIN_CREDS = [GH_LOGIN_CRED]
|
|
272
|
+
type BuiltinCred = (typeof BUILTIN_CREDS)[number]
|
|
251
273
|
|
|
252
|
-
|
|
274
|
+
/** a missing toolkit credential: its fix is a command to run, never a value
|
|
275
|
+
* to store, so it carries no `set` verb and copies the probe's own fix */
|
|
276
|
+
const builtinMissing = (b: BuiltinCred, fix: string | undefined): Instance => ({
|
|
277
|
+
id: `cred:${b.name}`,
|
|
278
|
+
text: `${named(b.name, b.service)} missing`,
|
|
279
|
+
...credPlace(b.name, true, b.level),
|
|
280
|
+
...secretState({ missing: true, probe: 'none', level: b.level }),
|
|
281
|
+
artifact: { copy: fix != null && !/\s(then|and)\s/.test(fix) ? fix : b.command, url: b.mintUrl },
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { service?: string; mintUrl?: string; steps?: string[]; level?: SecretLevel }): Instance => ({
|
|
253
285
|
id: `cred:${name}`,
|
|
254
|
-
text: `${named(name, decl?.service)} missing`,
|
|
255
|
-
...credPlace(name, true),
|
|
256
|
-
|
|
286
|
+
text: `${named(name, decl?.service)} missing${minorWord(decl?.level)}`,
|
|
287
|
+
...credPlace(name, true, decl?.level),
|
|
288
|
+
...secretState({ missing: true, probe: 'none', level: decl?.level }),
|
|
257
289
|
artifact: { copy: cmdTemplate?.replaceAll('{name}', name), url: decl?.mintUrl, steps: decl?.steps },
|
|
258
290
|
verbs: ['set'],
|
|
259
291
|
})
|
|
@@ -269,6 +301,7 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
|
|
|
269
301
|
return {
|
|
270
302
|
id: 'media',
|
|
271
303
|
title: 'assets',
|
|
304
|
+
kind: 'asset',
|
|
272
305
|
media: 'image',
|
|
273
306
|
list: async () => {
|
|
274
307
|
const idn = await heal(root)
|
|
@@ -332,30 +365,25 @@ const assetNotes = (root: string): Map<string, string[]> => {
|
|
|
332
365
|
return map
|
|
333
366
|
}
|
|
334
367
|
|
|
335
|
-
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
|
|
342
|
-
export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVersion?: string; libVersion?: () => string }): Collection {
|
|
368
|
+
/** the release contracts: one row per FAIL/WARN line of a venue contract.
|
|
369
|
+
* Every check, rule and task of the gate is its own resource
|
|
370
|
+
* (collections/checks.ts), so STATUS.md is never re-parsed into rows here.
|
|
371
|
+
* Cred probe FAIL lines (+ their fixes) stay routed to the secrets
|
|
372
|
+
* collection. The hub's own state (a stale process) is no row at all: the
|
|
373
|
+
* hub restarts itself (serve/stale-restart.ts) */
|
|
374
|
+
export function contractsCollection(root: string, c: RepoConfig): Collection {
|
|
343
375
|
return {
|
|
344
|
-
id: '
|
|
345
|
-
title: '
|
|
376
|
+
id: 'contracts',
|
|
377
|
+
title: 'release contracts',
|
|
378
|
+
kind: 'contract',
|
|
346
379
|
media: 'health',
|
|
347
380
|
list: async () => {
|
|
348
381
|
const out: Instance[] = []
|
|
349
|
-
if (o?.bootVersion != null && o.libVersion != null) {
|
|
350
|
-
const now = o.libVersion()
|
|
351
|
-
if (now !== o.bootVersion)
|
|
352
|
-
out.push({ id: 'health:stale-hub', type: 'health', path: 'health/stale-hub', virtual: true, severity: 'red', text: `hub is running shipkit ${o.bootVersion} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` })
|
|
353
|
-
}
|
|
354
382
|
const disk = CheckCtx.open({ root, config: c })
|
|
355
383
|
const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, disk), disk)
|
|
356
384
|
for (const l of r.lines) {
|
|
357
|
-
if (l.kind === 'fail') out.push({ id: `
|
|
358
|
-
else if (l.kind === 'warn') out.push({ id: `
|
|
385
|
+
if (l.kind === 'fail') out.push({ id: `contract:${l.text}`, type: 'contract', path: `contracts/${slug(l.text)}`, virtual: true, severity: 'red', text: l.text })
|
|
386
|
+
else if (l.kind === 'warn') out.push({ id: `contractwarn:${l.text}`, type: 'contract', path: `contracts/${slug(l.text)}`, virtual: true, severity: 'yellow', text: l.text })
|
|
359
387
|
}
|
|
360
388
|
return out
|
|
361
389
|
},
|
|
@@ -372,11 +400,11 @@ const sharedList = (col: Collection): Collection => {
|
|
|
372
400
|
}
|
|
373
401
|
}
|
|
374
402
|
|
|
375
|
-
export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit | null
|
|
403
|
+
export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit | null): Collection[] {
|
|
376
404
|
const checks = sharedList(checksCollection(root, c))
|
|
377
405
|
return [
|
|
378
406
|
checks,
|
|
379
|
-
|
|
407
|
+
contractsCollection(root, c),
|
|
380
408
|
asksCollection(root, c),
|
|
381
409
|
credsCollection(root, c, { verdicts: () => verdictsOf(checks) }),
|
|
382
410
|
mediaKitCollection(root, kit),
|
|
@@ -36,19 +36,35 @@ export const assetState = (f: AssetFacts): StateRule => {
|
|
|
36
36
|
* none no check probes this secret */
|
|
37
37
|
export type SecretProbe = 'pass' | 'fail' | 'warn' | 'stale' | 'unmeasured' | 'none'
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
39
|
+
/** how much breaks without a secret, declared once per secret like
|
|
40
|
+
* `blocking` for a check. `core`: the toolkit's own features stop (a push,
|
|
41
|
+
* creating a repo, a release). `used`: a feature this repo uses today.
|
|
42
|
+
* `minor`: proactive, for something nothing uses yet */
|
|
43
|
+
export type SecretLevel = 'core' | 'used' | 'minor'
|
|
44
|
+
|
|
45
|
+
/** a secret that declares no level */
|
|
46
|
+
export const DEFAULT_SECRET_LEVEL: SecretLevel = 'used'
|
|
47
|
+
|
|
48
|
+
/** THE secret rule. Red means this repo is broken, so only a `core` secret
|
|
49
|
+
* goes red: missing, or present with a probe that failed. Only a missing
|
|
50
|
+
* one is red in the feed, which pings once: a failed probe is often the
|
|
51
|
+
* service, and its own check row already carries the failure. A `used` one
|
|
52
|
+
* is orange in both cases, a feed line that never pings. A `minor` one is dim
|
|
53
|
+
* and stays out of the feed, whatever its probe said, and its row carries
|
|
54
|
+
* the words. Present plus a probe that passed is green at every level. A
|
|
55
|
+
* pass past its ttl, or a probe that never measured, is plain: nothing is
|
|
56
|
+
* known to be wrong. A secret no check probes is dim, because there is no
|
|
57
|
+
* verdict to wait for */
|
|
58
|
+
export const secretState = (f: { missing: boolean; probe: SecretProbe; level?: SecretLevel }): StateRule => {
|
|
59
|
+
const level = f.level ?? DEFAULT_SECRET_LEVEL
|
|
60
|
+
const broken = f.missing || f.probe === 'fail'
|
|
61
|
+
if (!f.missing && f.probe === 'pass') return { state: 'ok' }
|
|
62
|
+
if (broken || f.probe === 'warn') {
|
|
63
|
+
if (level === 'minor') return { state: 'quiet' }
|
|
64
|
+
if (level === 'core' && f.missing) return { severity: 'red' }
|
|
65
|
+
if (level === 'core' && broken) return { state: 'red', severity: 'yellow' }
|
|
66
|
+
return { severity: 'yellow' }
|
|
67
|
+
}
|
|
52
68
|
if (f.probe === 'none') return { state: 'quiet' }
|
|
53
69
|
return { state: 'idle' }
|
|
54
70
|
}
|
package/src/collections/tests.ts
CHANGED
package/src/collections/wikis.ts
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A CHECK'S TIME BUDGET: what a healthy run of it takes, distinct from its
|
|
2
|
+
// ceiling (`timeoutMs`), which is where it is killed. A check that PASSES over
|
|
3
|
+
// its budget is a warn, never a failure: slow is a defect to fix, not a broken
|
|
4
|
+
// repo. Judged on the wall time of a run that measured, at the quick tier
|
|
5
|
+
// only, and the one owner of the defaults, the line and the load reading.
|
|
6
|
+
import { availableParallelism, loadavg } from 'os'
|
|
7
|
+
import type { MachineLoad } from '../suite/judge-suites.ts'
|
|
8
|
+
|
|
9
|
+
/** an in process body, a rule or a page check. Read off a run record of 64
|
|
10
|
+
* such checks: the median rule answers in under 50ms, 55 of them stay under
|
|
11
|
+
* 2s at their 90th percentile, and the few past it shell out to a tool */
|
|
12
|
+
export const IN_PROCESS_BUDGET_MS = 2_000
|
|
13
|
+
|
|
14
|
+
/** a subprocess (a build step, a test suite, a `cmd` check) or a world probe.
|
|
15
|
+
* Read off the same record: every typecheck stays under 6s at its 90th
|
|
16
|
+
* percentile, and the quick test suites under 6s but one */
|
|
17
|
+
export const SUBPROCESS_BUDGET_MS = 10_000
|
|
18
|
+
|
|
19
|
+
/** what a check declares about itself that picks its default */
|
|
20
|
+
export type BudgetKind = { build: boolean; cmd: boolean; world: boolean }
|
|
21
|
+
|
|
22
|
+
export const defaultBudget = (k: BudgetKind): number => (k.build || k.cmd || k.world ? SUBPROCESS_BUDGET_MS : IN_PROCESS_BUDGET_MS)
|
|
23
|
+
|
|
24
|
+
/** the budget one check runs under: the repo's `budgets` entry, then what the
|
|
25
|
+
* check file declares, then the default for its kind */
|
|
26
|
+
export const budgetOf = (id: string, declared: number | undefined, kind: BudgetKind, budgets: Record<string, number> | undefined): number =>
|
|
27
|
+
budgets?.[id] ?? declared ?? defaultBudget(kind)
|
|
28
|
+
|
|
29
|
+
/** the `budgets` ids of a repo config that name no check it has */
|
|
30
|
+
export const unknownBudgetIds = (ids: readonly string[], budgets: Record<string, number> | undefined): string[] => {
|
|
31
|
+
const known = new Set(ids)
|
|
32
|
+
return Object.keys(budgets ?? {}).filter((id) => !known.has(id))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** what a budget value must be, or null when it is one */
|
|
36
|
+
export const budgetValueProblem = (v: unknown): string | null =>
|
|
37
|
+
typeof v === 'number' && Number.isInteger(v) && v > 0 ? null : `must be a whole number of milliseconds above 0, got ${JSON.stringify(v)}`
|
|
38
|
+
|
|
39
|
+
export const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
|
|
40
|
+
|
|
41
|
+
/** `10s` for a whole second count, `150ms` otherwise */
|
|
42
|
+
export const fmtBudget = (ms: number): string => (ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`)
|
|
43
|
+
|
|
44
|
+
/** a line budgetLine wrote, read back off the wire: a slow pass says
|
|
45
|
+
* nothing about what the check checks (a secret, a service), so a reader of
|
|
46
|
+
* the verdict tells the two apart */
|
|
47
|
+
export const isBudgetLine = (text: string): boolean => /^\S+ (passed|warned) in \d+\.\ds,(?: under load [^,]+,)? over its \d+m?s budget: /.test(text.replace(/^\[[^\]]+\] /, ''))
|
|
48
|
+
|
|
49
|
+
/** a test check sends the reader to the page that breaks its time down */
|
|
50
|
+
const isTestCheck = (id: string): boolean => id === 'test' || id.startsWith('test:')
|
|
51
|
+
|
|
52
|
+
/** the WARN a pass over its budget prints, or null inside it. A loaded
|
|
53
|
+
* machine is named, because wall time is what the reader waits on and load
|
|
54
|
+
* is the first thing to rule out */
|
|
55
|
+
export function budgetLine(id: string, ms: number, budgetMs: number, load: MachineLoad, verb: 'passed' | 'warned' = 'passed'): string | null {
|
|
56
|
+
if (ms <= budgetMs) return null
|
|
57
|
+
const loaded = load.load1 >= load.cores ? ` under load ${load.load1.toFixed(1)} on ${load.cores} cores,` : ''
|
|
58
|
+
const where = isTestCheck(id) ? 'see the tests page for where the time goes' : 'its page in the hub shows every run'
|
|
59
|
+
return `${id} ${verb} in ${(ms / 1000).toFixed(1)}s,${loaded} over its ${fmtBudget(budgetMs)} budget: ${where}`
|
|
60
|
+
}
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -230,7 +230,8 @@ export class CheckCtx {
|
|
|
230
230
|
* global never shadows the one a repo pins */
|
|
231
231
|
tool(name: string, candidates: string[] = []): string | null {
|
|
232
232
|
this.world('tool')
|
|
233
|
-
|
|
233
|
+
// the PATH as it stands NOW: Bun.which alone reads the one the process started with
|
|
234
|
+
return candidates.find((p) => existsSync(p)) ?? Bun.which(name, { PATH: process.env['PATH'] ?? '' })
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
// fetch with a default timeout; 429 → RateLimited → WARN upstream
|
package/src/core/check-runner.ts
CHANGED
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
// you failed" and a page can still show the red.
|
|
29
29
|
// - ONE tap fires per check, with what the wire lines cannot say: the real
|
|
30
30
|
// status, whether it replayed, whether it can block, what it cost.
|
|
31
|
-
import { closeSync, mkdirSync, openSync, readdirSync, rmSync, writeSync } from 'fs'
|
|
32
|
-
import { basename, join } from 'path'
|
|
31
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, rmSync, writeSync } from 'fs'
|
|
32
|
+
import { basename, isAbsolute, join, relative } from 'path'
|
|
33
33
|
import { shareRun } from './attach.ts'
|
|
34
34
|
import { CheckCtx } from './check-ctx.ts'
|
|
35
35
|
import { CheckPass } from './check-pass.ts'
|
|
@@ -37,6 +37,7 @@ import type { ReadSet } from './read-set.ts'
|
|
|
37
37
|
import { RepoView } from '../ledger/repo-view.ts'
|
|
38
38
|
import { parallelCap } from './parallel.ts'
|
|
39
39
|
import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
|
|
40
|
+
import { budgetLine, machineLoad } from './budget.ts'
|
|
40
41
|
import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
|
|
41
42
|
import { formatLine, parseWireOutput, type Line } from './wire.ts'
|
|
42
43
|
import { offLine, type WhenDecl } from './when.ts'
|
|
@@ -81,6 +82,10 @@ export type RunnableCheck = {
|
|
|
81
82
|
deps: string[]
|
|
82
83
|
group: string | null
|
|
83
84
|
timeoutMs: number
|
|
85
|
+
/** what a healthy run takes, in ms (core/budget.ts): a run that passes
|
|
86
|
+
* over it warns. Absent: never judged, which is what a build step gets
|
|
87
|
+
* at the full tier, where it runs every slow suite on purpose */
|
|
88
|
+
budgetMs?: number
|
|
84
89
|
/** the declared world TTL in ms, 0 when this check is not a world probe */
|
|
85
90
|
ttlMs: number
|
|
86
91
|
/** the rotation half of a world key (a secret hash, a config switch) */
|
|
@@ -638,6 +643,28 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
638
643
|
// unrelated edit moved the tree
|
|
639
644
|
const storable = (u: Live): u is Live & { status: 'pass' | 'warn' } => u.status === 'pass' || (u.status === 'warn' && u.blocking !== false)
|
|
640
645
|
|
|
646
|
+
/** a check that passed or warned past its budget says so in a warn line,
|
|
647
|
+
* judged on the wall time of the run that just measured */
|
|
648
|
+
const judgeBudget = (u: Live): void => {
|
|
649
|
+
if (u.budgetMs == null || (u.status !== 'pass' && u.status !== 'warn')) return
|
|
650
|
+
const text = budgetLine(u.id, u.endedAt - u.startedAt, u.budgetMs, machineLoad(), u.status === 'pass' ? 'passed' : 'warned')
|
|
651
|
+
if (text == null) return
|
|
652
|
+
u.lines = [...u.lines, { kind: 'warn', text }]
|
|
653
|
+
u.status = 'warn'
|
|
654
|
+
if (feeds(u)) u.problems = [...u.problems, { file: budgetAnchor(u), line: 1, col: 1, severity: 'warning', check: u.id, message: text }]
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** where a budget warning sits in the problems pane: the check's own
|
|
658
|
+
* file, else the package its command runs in, else the repo config that
|
|
659
|
+
* declares the repo's checks */
|
|
660
|
+
const budgetAnchor = (u: Live): string => {
|
|
661
|
+
const cwd = u.body.cwd == null ? null : relative(o.root, u.body.cwd)
|
|
662
|
+
const candidates = [u.owner, cwd == null || cwd === '' ? null : join(cwd, 'package.json')]
|
|
663
|
+
for (const f of candidates)
|
|
664
|
+
if (f != null && !isAbsolute(f) && !f.startsWith('..') && existsSync(join(o.root, f))) return f.replace(/\\/g, '/')
|
|
665
|
+
return 'repo.config.ts'
|
|
666
|
+
}
|
|
667
|
+
|
|
641
668
|
const adopt = (u: Live, a: Answer, pid: number): void => {
|
|
642
669
|
const stamp = ` ${attachedStamp(pid, a.measuredMs)}`
|
|
643
670
|
u.status = a.status
|
|
@@ -695,11 +722,16 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
695
722
|
// whose result is stored empty replays as a line nobody can tell
|
|
696
723
|
// from a measurement
|
|
697
724
|
if (u.lines.length === 0) headlineOf(u)
|
|
725
|
+
// what may be kept is decided on the verdict BEFORE the budget line:
|
|
726
|
+
// slow is not a negative about the world, so a slow pass keeps as long
|
|
727
|
+
// as a quick one, and replays with the warn it earned
|
|
728
|
+
const keep = storable(u)
|
|
729
|
+
const clean = u.status === 'pass'
|
|
730
|
+
judgeBudget(u)
|
|
698
731
|
// what may be remembered, and for how long, is ONE policy
|
|
699
732
|
// (ledger/world-cache.ts): a clean answer keeps for the declared
|
|
700
733
|
// TTL, a measured negative only long enough to stop a re-ask storm
|
|
701
734
|
if (o.cache != null && u.ttlMs > 0) {
|
|
702
|
-
const clean = u.status === 'pass'
|
|
703
735
|
u.lines = rememberWorld(o.cache, u.id, worldKey(u), u.ttlMs, {
|
|
704
736
|
lines: u.lines,
|
|
705
737
|
clean,
|
|
@@ -707,8 +739,8 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
707
739
|
keepNegative: u.blocking === false,
|
|
708
740
|
})
|
|
709
741
|
}
|
|
710
|
-
if (o.ledger != null &&
|
|
711
|
-
o.ledger.record(u.id, { status: u.status, lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
|
|
742
|
+
if (o.ledger != null && keep)
|
|
743
|
+
o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
|
|
712
744
|
}
|
|
713
745
|
|
|
714
746
|
const start = (u: Live): void => {
|
|
@@ -783,7 +815,12 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
783
815
|
const skipped = results.filter((r) => r.status === 'skip' && r.note !== '' && r.note !== 'off')
|
|
784
816
|
if (blocked.length > 0) emit({ kind: 'fail', text: `${blocked.length}/${results.length} failed: ${blocked.map((r) => r.id).join(', ')}` })
|
|
785
817
|
if (skipped.length > 0) emit({ kind: 'note', text: `skip: ${skipped.map((r) => `${r.id} (${r.note})`).join(', ')}` })
|
|
786
|
-
|
|
818
|
+
// a warn is not green: a gate whose every check passed but some warned
|
|
819
|
+
// says which, so the summary never reads cleaner than the lines above it
|
|
820
|
+
const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory))
|
|
821
|
+
const ran = results.filter((r) => r.status !== 'skip').length
|
|
822
|
+
if (blocked.length === 0 && !interrupted && results.length > 0)
|
|
823
|
+
emit({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` })
|
|
787
824
|
return {
|
|
788
825
|
ok: blocked.length === 0 && !interrupted,
|
|
789
826
|
interrupted,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// how full this process's file table is. macOS posix_spawn refuses any fd at
|
|
2
|
+
// or past OPEN_MAX (10240) with EBADF, and a new pipe takes the lowest free
|
|
3
|
+
// number, so a table that full fails every spawn that pipes. bun 1.4.2
|
|
4
|
+
// `--watch` opens each watched file without close on exec, so every in place
|
|
5
|
+
// restart keeps the previous image's copies (fixed in 1.4.3). A clean re-exec
|
|
6
|
+
// (process.execve) carries none of them, which is how a long lived process
|
|
7
|
+
// sheds the leak. This heal dies once every machine runs bun 1.4.3 or later.
|
|
8
|
+
import { readdirSync } from 'fs'
|
|
9
|
+
|
|
10
|
+
/** past this many open files a process is leaking, not working: a hub on a
|
|
11
|
+
* large repo holds about 1000 */
|
|
12
|
+
export const FD_LEAK_LIMIT = 4096
|
|
13
|
+
/** set by a restart that sheds a leak: when it comes back still over the
|
|
14
|
+
* limit, a restart is not what the table needs */
|
|
15
|
+
export const FD_HEALED_ENV = 'SHIPKIT_FD_HEALED_AT'
|
|
16
|
+
const HEAL_GRACE_MS = 60_000
|
|
17
|
+
|
|
18
|
+
/** open fds of this process, null where the platform lists none */
|
|
19
|
+
export const openFdCount = (): number | null => {
|
|
20
|
+
try { return readdirSync('/dev/fd').length } catch { return null }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** why this process's file table is leaking, null while it is not */
|
|
24
|
+
export function fdTableLeak(o: { count?: () => number | null; limit?: number } = {}): string | null {
|
|
25
|
+
const n = (o.count ?? openFdCount)()
|
|
26
|
+
const limit = o.limit ?? FD_LEAK_LIMIT
|
|
27
|
+
if (n == null || n <= limit) return null
|
|
28
|
+
return `this process holds ${n} open files, past the ${limit} it ever needs (a bun --watch restart leaks them), and at 10240 every spawn fails with EBADF`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** throws when a restart already shed a leak moments ago and the table is
|
|
32
|
+
* full again: that is a need, not a leak, and restarting would loop */
|
|
33
|
+
export function assertLeakHealable(env: Record<string, string | undefined> = process.env, now = Date.now()): void {
|
|
34
|
+
const at = Number(env[FD_HEALED_ENV])
|
|
35
|
+
if (Number.isFinite(at) && at > 0 && now - at < HEAL_GRACE_MS)
|
|
36
|
+
throw new Error(`a clean restart ${Math.round((now - at) / 1000)}s ago came back still over the limit, so a restart does not shed it`)
|
|
37
|
+
}
|