rvlib-shipkit 1.2.0 → 1.4.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 +104 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +15 -10
- package/src/check.ts +26 -10
- package/src/checks/SK001-package-json-shape.check.ts +34 -5
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
- package/src/checks/SK024-identity-colors.check.ts +5 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
- package/src/checks/SK028-readme.check.ts +83 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +119 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +9 -1
- package/src/collections/checks.ts +3 -2
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +7 -3
- package/src/collections/test-time.ts +10 -1
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/budget.ts +19 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +66 -17
- package/src/core/discover-checks.ts +10 -5
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +1 -1
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +14 -5
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +8 -7
- package/src/core/run-ci.ts +46 -15
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +49 -9
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +138 -0
- package/src/home/activity.ts +256 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +10 -2
- package/src/home/home-routes.ts +34 -6
- package/src/home/home-start.ts +33 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +40 -7
- package/src/home/process-rows.ts +222 -0
- package/src/home/resources.ts +99 -20
- package/src/home/run-daemon.ts +12 -7
- package/src/home/run-pm.ts +30 -5
- package/src/home/serve-web.ts +6 -3
- package/src/home/supervise.ts +16 -1
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +32 -5
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +20 -6
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +39 -10
- package/src/ledger/store.ts +30 -2
- package/src/ledger/world-cache.ts +63 -14
- package/src/plan/cadence.ts +21 -5
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +138 -13
- package/src/plan/repo-source.ts +120 -8
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +17 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-words.ts +2 -0
- package/src/serve/hub-app/checks-svc.ts +2 -1
- package/src/serve/hub-app/col-tables-view.tsx +124 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +28 -0
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/hash-route.ts +7 -1
- package/src/serve/hub-app/header-bar.tsx +79 -104
- package/src/serve/hub-app/home-st.ts +50 -13
- package/src/serve/hub-app/home-strip.css +81 -23
- package/src/serve/hub-app/home-strip.tsx +243 -102
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +103 -161
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +101 -39
- package/src/serve/hub-app/icons.tsx +27 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +44 -9
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +65 -13
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/next-run.ts +1 -1
- package/src/serve/hub-app/palette-st.ts +13 -9
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +21 -85
- package/src/serve/hub-app/planned-view.tsx +12 -7
- package/src/serve/hub-app/planned.css +5 -2
- package/src/serve/hub-app/project-list.css +43 -21
- package/src/serve/hub-app/project-list.tsx +122 -23
- package/src/serve/hub-app/projects-svc.ts +120 -12
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +26 -7
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/tests-view.tsx +31 -4
- package/src/serve/hub-app/timeline-chart.tsx +31 -27
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +2 -2
- package/src/serve/hub-app/timeline-view.tsx +29 -9
- package/src/serve/hub-runtime.ts +17 -9
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +100 -20
- package/src/serve/project-gutter.ts +34 -13
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/serve-hub.ts +13 -8
- package/src/serve/tracked-watch.ts +33 -1
- package/src/serve/warm-cell.ts +49 -16
- package/src/shipkit-files.ts +20 -4
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -43,14 +43,18 @@ export type HooksConfig = {
|
|
|
43
43
|
* hooks/leak-guard.ts */
|
|
44
44
|
leakAllow?: LeakAllowEntry[]
|
|
45
45
|
/** staged-path prefixes that can never enter git (local state like
|
|
46
|
-
* '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not.
|
|
46
|
+
* '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not.
|
|
47
|
+
* A repo declaring `visibility: 'private'` still commits
|
|
48
|
+
* '.comfy-ts/drafts/' through a broader prefix. Public and undeclared
|
|
49
|
+
* repos block it. */
|
|
47
50
|
blockedPaths?: string[]
|
|
48
51
|
/** words the commit-message language gate never counts as French
|
|
49
52
|
* (proper nouns that carry accents without being a language choice) */
|
|
50
53
|
languageAllow?: string[]
|
|
51
54
|
/** disable a built-in gate, visible in `hooks list`, never silent.
|
|
52
55
|
* 'commit-language' lifts ONLY the French check, keeping the dash and
|
|
53
|
-
* AI-attribution guards armed.
|
|
56
|
+
* AI-attribution guards armed. 'commit-message' lifts the dash and French
|
|
57
|
+
* checks; the AI-attribution guard is never lifted. */
|
|
54
58
|
disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
|
|
55
59
|
}
|
|
56
60
|
|
|
@@ -191,6 +195,20 @@ function waivedLines(waived: LeakWaiver[]): string[] {
|
|
|
191
195
|
)
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
const COMFY_DRAFTS = '.comfy-ts/drafts/'
|
|
199
|
+
|
|
200
|
+
/** the staged files the `blocked-paths` gate refuses. A repo DECLARED private
|
|
201
|
+
* (never by default) commits its comfy drafts through a
|
|
202
|
+
* broader block: a draft names private loras, which a private tree may hold.
|
|
203
|
+
* A prefix naming the drafts folder itself keeps them blocked. */
|
|
204
|
+
export function blockedStaged(staged: string[], config: RepoConfig): string[] {
|
|
205
|
+
const prefixes = config.hooks?.blockedPaths ?? []
|
|
206
|
+
const open = draftsOpen(config)
|
|
207
|
+
return staged.filter((f) => prefixes.some((p) => f.startsWith(p) && !(open && f.startsWith(COMFY_DRAFTS) && !p.startsWith(COMFY_DRAFTS))))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
|
|
211
|
+
|
|
194
212
|
/** the pre-commit gate: built-in leak guard, then the repo's own commands.
|
|
195
213
|
* A commit takes ~1s (build/shipkit.md): the gate times itself, and going
|
|
196
214
|
* over budget WARNs naming the fix, so drift back to slow commits is loud. */
|
|
@@ -226,7 +244,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
226
244
|
if ((h.blockedPaths ?? []).length > 0) {
|
|
227
245
|
const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
|
|
228
246
|
const staged = names.stdout.toString().split('\n').filter(Boolean)
|
|
229
|
-
const blocked = staged
|
|
247
|
+
const blocked = blockedStaged(staged, config)
|
|
230
248
|
out.push(blocked.length === 0
|
|
231
249
|
? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
|
|
232
250
|
: {
|
|
@@ -292,6 +310,13 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
292
310
|
...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
|
|
293
311
|
],
|
|
294
312
|
})
|
|
313
|
+
} else {
|
|
314
|
+
// AI attribution is the one message rule no config lifts: this hook is
|
|
315
|
+
// the guard against it in every repo (identity/how-we-commit.md)
|
|
316
|
+
const offenses = commitMessageOffenses(message).filter((o) => o.kind === 'attribution')
|
|
317
|
+
out.push(offenses.length === 0
|
|
318
|
+
? { id: 'ai-attribution', ok: true, lines: ['ok: no AI attribution in the commit message'] }
|
|
319
|
+
: { id: 'ai-attribution', ok: false, lines: [`FAIL: commit message carries AI attribution`, ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`)] })
|
|
295
320
|
}
|
|
296
321
|
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
297
322
|
// commit body is as public as one pasted into a file
|
|
@@ -327,10 +352,12 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
327
352
|
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
|
|
328
353
|
return [
|
|
329
354
|
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
|
|
330
|
-
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
355
|
+
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
|
|
331
356
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
332
357
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
333
|
-
|
|
358
|
+
off.has('commit-message')
|
|
359
|
+
? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
|
|
360
|
+
: `commit-msg commit-message — dash punctuation + AI attribution (never disabled) + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
|
|
334
361
|
`commit-msg leak-guard${leakState} — the message text`,
|
|
335
362
|
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
336
363
|
]
|
package/src/ledger/db.ts
CHANGED
|
@@ -287,7 +287,7 @@ function migrate(db: Database): void {
|
|
|
287
287
|
/** give the file back its free pages once more than half of it is free and
|
|
288
288
|
* that is worth it: a DELETE frees pages for reuse but never shrinks the
|
|
289
289
|
* file. VACUUM holds the write lock while it runs: a gate that asks then
|
|
290
|
-
* measures instead of waiting. The daemon's sweep and `check --fix
|
|
290
|
+
* measures instead of waiting. The daemon's sweep and `check --fix stores-under-cap` (house repo)
|
|
291
291
|
* call it. True when it ran */
|
|
292
292
|
export function compactDb(path: string, o: { minFreeBytes?: number } = {}): boolean {
|
|
293
293
|
const opened = openDb(path, { create: false })
|
|
@@ -19,9 +19,9 @@ import { discoverLegacyChecks } from '../core/discover-legacy-checks.ts'
|
|
|
19
19
|
import { checkFileRunBy } from '../core/legacy-shapes.ts'
|
|
20
20
|
import { ruleCacheExtra } from '../rules/rule.ts'
|
|
21
21
|
import { discoverChecks } from '../core/discover-checks.ts'
|
|
22
|
-
import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom } from '../check.ts'
|
|
22
|
+
import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom, type CheckEnv } from '../check.ts'
|
|
23
23
|
import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/check-runner.ts'
|
|
24
|
-
import {
|
|
24
|
+
import { ttlMsOf } from '../core/wire.ts'
|
|
25
25
|
import { budgetOf } from '../core/budget.ts'
|
|
26
26
|
import { whenOfLegacyCheck, whenOfDisabledSlot, whenOfRule, whenOfTask, whenOfCheck, type WhenDecl } from '../core/when.ts'
|
|
27
27
|
import type { CheckDecl } from './git-key.ts'
|
|
@@ -74,6 +74,18 @@ export async function allTasks(c: RepoConfig, root: string): Promise<FloorResolu
|
|
|
74
74
|
/** one gate check with everything a reader asks about it, not only what the
|
|
75
75
|
* key layer asks. The cache half stays in `decl` so `CheckDecl` never grows
|
|
76
76
|
* fields the key layer must then decide to ignore. */
|
|
77
|
+
/** a check's `planOff`, read now; one that throws stays planned, and says so */
|
|
78
|
+
function planOffOf(id: string, fn: ((env: CheckEnv) => string | null) | undefined, env: CheckEnv): { planOff?: string } {
|
|
79
|
+
if (fn == null) return {}
|
|
80
|
+
try {
|
|
81
|
+
const why = fn(env)
|
|
82
|
+
return why == null ? {} : { planOff: why }
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error(`WARN ${id} planOff threw, so it stays planned: ${(e as Error).message}`)
|
|
85
|
+
return {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
export type CheckRow = {
|
|
78
90
|
decl: CheckDecl
|
|
79
91
|
/** the display name an operator reads (its title, else the id) */
|
|
@@ -122,6 +134,8 @@ export type CheckRow = {
|
|
|
122
134
|
/** when the planner runs it on its own (check.ts `every`, `cron`,
|
|
123
135
|
* `whenInactive`). Absent: nothing declared, the planner's defaults */
|
|
124
136
|
plan?: PlanDecl
|
|
137
|
+
/** why the plan leaves this check out in this repo (check.ts `planOff`) */
|
|
138
|
+
planOff?: string
|
|
125
139
|
/** whose check it is: `shipkit` = it ships with the toolkit (a built-in
|
|
126
140
|
* SK check, the contracts pass, a floor slot nothing in the repo filled),
|
|
127
141
|
* `repo` = this repo declared it (a package.json script behind a floor
|
|
@@ -154,7 +168,6 @@ function taskBody(t: TaskDef): CheckBody {
|
|
|
154
168
|
}
|
|
155
169
|
}
|
|
156
170
|
|
|
157
|
-
const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === '' ? 0 : parseDuration(ttl))
|
|
158
171
|
|
|
159
172
|
/** the runnable half of a row: everything the runner needs, and nothing it
|
|
160
173
|
* has to look up again */
|
|
@@ -338,13 +351,14 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
338
351
|
owner: u.owner,
|
|
339
352
|
group: u.def.group ?? null,
|
|
340
353
|
timeoutMs: u.def.timeoutMs ?? null,
|
|
341
|
-
budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null }, c.budgets),
|
|
354
|
+
budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null || u.def.never != null }, c.budgets),
|
|
342
355
|
optIn: false,
|
|
343
356
|
fixable: u.def.fix != null,
|
|
344
357
|
when: w,
|
|
345
358
|
deps: u.def.deps ?? null,
|
|
346
359
|
from: u.from,
|
|
347
360
|
...(u.def.every !== undefined || u.def.cron != null || u.def.whenInactive != null ? { plan: { every: u.def.every, cron: u.def.cron, whenInactive: u.def.whenInactive } } : {}),
|
|
361
|
+
...planOffOf(u.id, u.def.planOff, env),
|
|
348
362
|
}
|
|
349
363
|
const body: CheckBody = {
|
|
350
364
|
...(u.def.cmd == null ? {} : { cmd: u.def.cmd }),
|
|
@@ -375,7 +389,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
375
389
|
owner: r.owner,
|
|
376
390
|
group: null,
|
|
377
391
|
timeoutMs: null,
|
|
378
|
-
budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null }, c.budgets),
|
|
392
|
+
budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null || r.never != null }, c.budgets),
|
|
379
393
|
optIn: false,
|
|
380
394
|
fixable: r.fix != null,
|
|
381
395
|
when: whenOfRule(c, r.id),
|
|
@@ -419,7 +433,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
419
433
|
owner: rel,
|
|
420
434
|
group: task?.group ?? null,
|
|
421
435
|
timeoutMs: task?.timeoutMs ?? null,
|
|
422
|
-
budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null }, c.budgets),
|
|
436
|
+
budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null || d.never != null }, c.budgets),
|
|
423
437
|
optIn: false,
|
|
424
438
|
// the reader is a source scanner, so it cannot carry a function
|
|
425
439
|
fixable: false,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// THE HOUSE'S RUNS: what the dock at the bottom of the page draws, every
|
|
2
|
+
// house repo's recent runs side by side on one clock. Each repo is shaped the
|
|
3
|
+
// way its own live view is (live-timeline.ts), so a run looks the same
|
|
4
|
+
// wherever it is drawn, and a repo that ran nothing in the window has no group.
|
|
5
|
+
//
|
|
6
|
+
// Pure, on a clock the caller passes. The reads are the caller's
|
|
7
|
+
// (serve/house-runs.ts), which opens a record only when its files moved.
|
|
8
|
+
import type { LiveRow } from './live-runs.ts'
|
|
9
|
+
import type { InvocationRow, RunRow } from './run-log.ts'
|
|
10
|
+
import type { TimelineInvocation } from './run-timeline.ts'
|
|
11
|
+
import { LIVE_HISTORY_MS, shapeLive, type RunSummary } from './live-timeline.ts'
|
|
12
|
+
|
|
13
|
+
/** one repo's record, as read for the window */
|
|
14
|
+
export type HouseRecord = { repo: string; live: LiveRow[]; runs: RunRow[]; invocations: InvocationRow[] }
|
|
15
|
+
|
|
16
|
+
/** one repo that ran something in the window: its runs, newest first, and
|
|
17
|
+
* what it has in flight */
|
|
18
|
+
export type HouseGroup = { repo: string; invocations: TimelineInvocation[]; busy: number; runs: RunSummary[] }
|
|
19
|
+
|
|
20
|
+
export type HouseView = {
|
|
21
|
+
now: number
|
|
22
|
+
historyMs: number
|
|
23
|
+
/** the runner's cap, drawn over the concurrency band */
|
|
24
|
+
cap: number
|
|
25
|
+
/** checks in flight across every repo */
|
|
26
|
+
busy: number
|
|
27
|
+
/** by repo name, so a group keeps its place from one answer to the next */
|
|
28
|
+
groups: HouseGroup[]
|
|
29
|
+
/** what holds the machine's plan back, when the host has one (Planner.held) */
|
|
30
|
+
held?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shapeHouse(o: { now: number; cap: number; repos: HouseRecord[]; alive?: (pid: number) => boolean; historyMs?: number }): HouseView {
|
|
34
|
+
const historyMs = o.historyMs ?? LIVE_HISTORY_MS
|
|
35
|
+
const groups = o.repos
|
|
36
|
+
.map((r): HouseGroup => {
|
|
37
|
+
const v = shapeLive({ now: o.now, cap: o.cap, live: r.live, runs: r.runs, invocations: r.invocations, alive: o.alive, historyMs })
|
|
38
|
+
return { repo: r.repo, invocations: v.invocations, busy: v.busy, runs: v.runs }
|
|
39
|
+
})
|
|
40
|
+
.filter((g) => g.invocations.length > 0)
|
|
41
|
+
.sort((a, b) => a.repo.localeCompare(b.repo))
|
|
42
|
+
return { now: o.now, historyMs, cap: o.cap, busy: groups.reduce((a, g) => a + g.busy, 0), groups }
|
|
43
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// THE LIVE TIMELINE:
|
|
1
|
+
// THE LIVE TIMELINE: one repo's recent runs and its runs in flight. The same
|
|
2
2
|
// shape the runs page reads (ledger/run-timeline.ts), cut to a rolling
|
|
3
|
-
// window that ends now, with the runs still going in it
|
|
4
|
-
//
|
|
3
|
+
// window that ends now, with the runs still going in it. Every house repo's
|
|
4
|
+
// view side by side is the dock at the bottom of the page (house-runs.ts).
|
|
5
5
|
//
|
|
6
6
|
// Pure, on a clock the caller passes, so every rule is pinned on a fake one.
|
|
7
7
|
// A run still in the live table is drawn from there, its running checks
|
|
@@ -12,12 +12,9 @@ import { liveRowsOf, type LiveRow, type LiveWait } from './live-runs.ts'
|
|
|
12
12
|
import type { InvocationRow, RunRow } from './run-log.ts'
|
|
13
13
|
import { invocationsOf, type TimelineBar, type TimelineInvocation } from './run-timeline.ts'
|
|
14
14
|
|
|
15
|
-
/** how far back the server looks: the
|
|
15
|
+
/** how far back the server looks: the widest window a live chart draws */
|
|
16
16
|
export const LIVE_HISTORY_MS = 600_000
|
|
17
17
|
|
|
18
|
-
/** another repo with something in flight, collapsed to one lane */
|
|
19
|
-
export type RepoLane = { repo: string; label: string; by: string; start: number; running: number; checks: string[] }
|
|
20
|
-
|
|
21
18
|
/** a run in flight here, for the header's one line */
|
|
22
19
|
export type RunSummary = { runId: string; label: string; by: string; done: number; total: number; reds: number }
|
|
23
20
|
|
|
@@ -31,7 +28,6 @@ export type LiveView = {
|
|
|
31
28
|
/** every invocation touching the window, newest first, the ones in flight
|
|
32
29
|
* marked running */
|
|
33
30
|
invocations: TimelineInvocation[]
|
|
34
|
-
repos: RepoLane[]
|
|
35
31
|
runs: RunSummary[]
|
|
36
32
|
/** of the newest run in flight: checks ready for a slot, and checks
|
|
37
33
|
* waiting on another one */
|
|
@@ -53,15 +49,6 @@ export function invocationOfLive(r: LiveRow, now: number): TimelineInvocation {
|
|
|
53
49
|
}
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
/** another repo, collapsed: one lane while it has anything running, from
|
|
57
|
-
* its earliest running check to now. null when nothing runs there */
|
|
58
|
-
export function repoLane(repo: string, rows: LiveRow[], now: number, alive?: (pid: number) => boolean): RepoLane | null {
|
|
59
|
-
const going = liveRowsOf(rows, now, alive).filter((r) => r.ended == null && r.running.length > 0)
|
|
60
|
-
if (going.length === 0) return null
|
|
61
|
-
const running = going.flatMap((r) => r.running)
|
|
62
|
-
return { repo, label: going[0]!.label, by: going[0]!.by, start: Math.min(...running.map((c) => c.started)), running: running.length, checks: running.map((c) => c.id) }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
52
|
export function shapeLive(o: {
|
|
66
53
|
now: number
|
|
67
54
|
cap: number
|
|
@@ -69,7 +56,6 @@ export function shapeLive(o: {
|
|
|
69
56
|
/** the run record's rows that finished inside the window */
|
|
70
57
|
runs: RunRow[]
|
|
71
58
|
invocations: InvocationRow[]
|
|
72
|
-
others?: { repo: string; rows: LiveRow[] }[]
|
|
73
59
|
alive?: (pid: number) => boolean
|
|
74
60
|
historyMs?: number
|
|
75
61
|
}): LiveView {
|
|
@@ -89,7 +75,6 @@ export function shapeLive(o: {
|
|
|
89
75
|
cap: o.cap,
|
|
90
76
|
busy: inFlight.reduce((a, r) => a + r.running.length, 0),
|
|
91
77
|
invocations,
|
|
92
|
-
repos: (o.others ?? []).map((x) => repoLane(x.repo, x.rows, o.now, o.alive)).filter((x) => x != null).sort((a, b) => a.repo.localeCompare(b.repo)),
|
|
93
78
|
runs: inFlight.map((r) => ({ runId: r.runId, label: r.label, by: r.by, done: r.done, total: r.total, reds: r.reds })),
|
|
94
79
|
queued: newest?.waiting.filter((w) => w.deps.length === 0).length ?? 0,
|
|
95
80
|
blocked: newest?.waiting.filter((w) => w.deps.length > 0) ?? [],
|
package/src/ledger/run-log.ts
CHANGED
|
@@ -266,20 +266,23 @@ export function recordUse(path: string): { count: number; oldest: number | null
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
type RawInvocation = { runId: string; kind: string; label: string; by: string; narrowed: number; started: number; ended: number }
|
|
270
|
+
|
|
271
|
+
const INVOCATIONS_SINCE = 'select run_id as runId, kind, label, by, narrowed, started, ended from invocations where ended >= ? order by started asc'
|
|
272
|
+
|
|
273
|
+
const invocationRow = (r: RawInvocation): InvocationRow => ({
|
|
274
|
+
runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
|
|
275
|
+
by: r.by === 'hub' || r.by === 'schedule' ? r.by : 'cli', narrowed: Number(r.narrowed) === 1,
|
|
276
|
+
started: Number(r.started), ended: Number(r.ended),
|
|
277
|
+
})
|
|
278
|
+
|
|
269
279
|
/** every invocation on record that ended at or after `since`, oldest
|
|
270
280
|
* first. An unreadable db is none. */
|
|
271
281
|
export function readInvocations(path: string, since = 0): InvocationRow[] {
|
|
272
282
|
const opened = openDb(path, { create: false })
|
|
273
283
|
if (opened == null) return []
|
|
274
284
|
try {
|
|
275
|
-
|
|
276
|
-
runId: string; kind: string; label: string; by: string; narrowed: number; started: number; ended: number
|
|
277
|
-
}[]
|
|
278
|
-
return rows.map((r): InvocationRow => ({
|
|
279
|
-
runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
|
|
280
|
-
by: r.by === 'hub' || r.by === 'schedule' ? r.by : 'cli', narrowed: Number(r.narrowed) === 1,
|
|
281
|
-
started: Number(r.started), ended: Number(r.ended),
|
|
282
|
-
}))
|
|
285
|
+
return (opened.db.query(INVOCATIONS_SINCE).all(since) as RawInvocation[]).map(invocationRow)
|
|
283
286
|
} catch {
|
|
284
287
|
return []
|
|
285
288
|
} finally {
|
|
@@ -339,14 +342,17 @@ export function readLiveReadonly(path: string): LiveRow[] {
|
|
|
339
342
|
|
|
340
343
|
/** every check's last outcome in a record, read only, for a hub that shows
|
|
341
344
|
* ANOTHER repo's gate: `[]` when the record or the table is missing */
|
|
342
|
-
export function readLastStatusReadonly(path: string): { id: string; status: string }[]
|
|
345
|
+
export function readLastStatusReadonly(path: string): { id: string; status: string; at: number }[]
|
|
346
|
+
/** with `lines`: what each check last printed too, as its raw json */
|
|
347
|
+
export function readLastStatusReadonly(path: string, o: { lines: true }): { id: string; status: string; at: number; lines: string }[]
|
|
348
|
+
export function readLastStatusReadonly(path: string, o: { lines?: boolean } = {}): { id: string; status: string; at: number; lines?: string }[] {
|
|
343
349
|
if (!existsSync(path)) return []
|
|
344
350
|
let db: Database | null = null
|
|
345
351
|
try {
|
|
346
352
|
db = new Database(path, { readonly: true })
|
|
347
353
|
db.exec('pragma busy_timeout = 50')
|
|
348
354
|
if (db.query("select 1 from sqlite_master where type = 'table' and name = 'check_last'").get() == null) return []
|
|
349
|
-
return db.query(
|
|
355
|
+
return db.query(`select id, status, at${o.lines === true ? ', lines' : ''} from check_last`).all() as { id: string; status: string; at: number; lines?: string }[]
|
|
350
356
|
} catch {
|
|
351
357
|
return []
|
|
352
358
|
} finally {
|
|
@@ -389,6 +395,29 @@ export function readRunsSince(path: string, since: number): RunRow[] {
|
|
|
389
395
|
}
|
|
390
396
|
}
|
|
391
397
|
|
|
398
|
+
/** what a live chart of ANOTHER repo draws from its record, strictly read
|
|
399
|
+
* only and in one open: its live rows, and the runs and invocations that
|
|
400
|
+
* finished at or after `since`. A table the record does not have yet is
|
|
401
|
+
* none, and so is a record that cannot be opened */
|
|
402
|
+
export function readWindowReadonly(path: string, since: number): { live: LiveRow[]; runs: RunRow[]; invocations: InvocationRow[] } {
|
|
403
|
+
const out = { live: [] as LiveRow[], runs: [] as RunRow[], invocations: [] as InvocationRow[] }
|
|
404
|
+
if (!existsSync(path)) return out
|
|
405
|
+
let db: Database | null = null
|
|
406
|
+
try {
|
|
407
|
+
db = new Database(path, { readonly: true })
|
|
408
|
+
db.exec('pragma busy_timeout = 50')
|
|
409
|
+
const has = (t: string): boolean => db!.query("select 1 from sqlite_master where type = 'table' and name = ?").get(t) != null
|
|
410
|
+
if (has('live')) out.live = (db.query(LIVE_SELECT).all() as RawLive[]).map(liveRowOf).filter((r) => r != null)
|
|
411
|
+
if (has('invocations')) out.invocations = (db.query(INVOCATIONS_SINCE).all(since) as RawInvocation[]).map(invocationRow)
|
|
412
|
+
if (has('runs') && has('invocations')) out.runs = (db.query(`${RUN_ROWS} where r.at >= ? order by r.at asc, r.rowid asc`).all(since) as RawRow[]).map(runRow)
|
|
413
|
+
return out
|
|
414
|
+
} catch {
|
|
415
|
+
return out
|
|
416
|
+
} finally {
|
|
417
|
+
try { db?.close() } catch { /* already gone */ }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
392
421
|
/** every row the record holds, oldest first. A db that cannot be read is no
|
|
393
422
|
* rows at all: it feeds a dashboard, and an empty history beats a crash. */
|
|
394
423
|
export function readRuns(path: string): RunRow[] {
|
package/src/ledger/store.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { Database } from 'bun:sqlite'
|
|
18
18
|
import { readdirSync, readFileSync, rmSync, statSync } from 'fs'
|
|
19
19
|
import { join } from 'path'
|
|
20
|
+
import { FOREVER_MS } from '../core/wire.ts'
|
|
20
21
|
import { BUSY_MS, dbPath, openDb, shipkitRoot } from './db.ts'
|
|
21
22
|
|
|
22
23
|
/** the envelope FORMAT, stored per row. Bumped when the envelope changes, so
|
|
@@ -104,6 +105,17 @@ export class Answers {
|
|
|
104
105
|
this.h = null
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** the busy timeout sqlite holds on the open handle, in ms: how long its
|
|
109
|
+
* last statement could wait on a writer. Null with no handle */
|
|
110
|
+
busyTimeout(): number | null {
|
|
111
|
+
try {
|
|
112
|
+
const r = this.h?.query('pragma busy_timeout').get() as Record<string, number> | null | undefined
|
|
113
|
+
return r == null ? null : (Object.values(r)[0] ?? null)
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
/** the stored answer, or null when the check must run */
|
|
108
120
|
read<T>(ns: string, name: string, key: string): Stored<T> | null {
|
|
109
121
|
const row = this.with(READ_BUSY_MS, (h) => h.query('select v, at, keeps, payload from answers where ns = ? and name = ? and key = ?').get(ns, name, key) as Row | null)
|
|
@@ -137,6 +149,21 @@ export class Answers {
|
|
|
137
149
|
)
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
/** every stored answer of one check, newest first: what adopting an
|
|
153
|
+
* answer written under an older key shape reads */
|
|
154
|
+
readAll<T>(ns: string, name: string): Stored<T>[] {
|
|
155
|
+
const rows = this.with(READ_BUSY_MS, (h) => h.query('select v, key, at, keeps, payload from answers where ns = ? and name = ? order by at desc').all(ns, name) as (Row & { key: string })[]) ?? []
|
|
156
|
+
return rows.flatMap((row) => {
|
|
157
|
+
try {
|
|
158
|
+
const payload = JSON.parse(row.payload) as T
|
|
159
|
+
if (Number(row.v) !== STORE_VERSION || typeof row.at !== 'number' || payload == null) return []
|
|
160
|
+
return [{ v: Number(row.v), key: row.key, at: row.at, keeps: row.keeps ?? undefined, payload }]
|
|
161
|
+
} catch {
|
|
162
|
+
return []
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
140
167
|
drop(ns: string, name: string, key: string): void {
|
|
141
168
|
this.with(BUSY_MS, (h) => h.query('delete from answers where ns = ? and name = ? and key = ?').run(ns, name, key))
|
|
142
169
|
}
|
|
@@ -162,8 +189,9 @@ export class Answers {
|
|
|
162
189
|
* runs is kept. How many went, null when the db could not be opened. */
|
|
163
190
|
sweep(now: number, o: { keepMs?: number; max?: number } = {}): number | null {
|
|
164
191
|
return this.with(BUSY_MS, (h) => {
|
|
165
|
-
|
|
166
|
-
n
|
|
192
|
+
// a forever answer is a decided fact: dropping it re-asks the world
|
|
193
|
+
let n = h.query('delete from answers where used < ? and coalesce(keeps, 0) < ?').run(now - (o.keepMs ?? KEEP_MS), FOREVER_MS).changes
|
|
194
|
+
n += h.query('delete from answers where rowid in (select rowid from answers where coalesce(keeps, 0) < ? order by used desc limit -1 offset ?)').run(FOREVER_MS, o.max ?? MAX_ANSWERS).changes
|
|
167
195
|
return n
|
|
168
196
|
})
|
|
169
197
|
}
|
|
@@ -31,15 +31,23 @@
|
|
|
31
31
|
// A measured negative may be kept only for a check about the WORLD. One about
|
|
32
32
|
// THIS REPO is fixed by a human and the next run goes green, so replaying its
|
|
33
33
|
// red would be the cache lying about work already done.
|
|
34
|
-
import {
|
|
35
|
-
import { FRESH_ENV, withAnswers } from './store.ts'
|
|
36
|
-
import { CACHED, RAN } from '../core/outcome-words.ts'
|
|
34
|
+
import { FOREVER_MS, type Line, type Stamp } from '../core/wire.ts'
|
|
35
|
+
import { FRESH_ENV, withAnswers, type Answers, type Stored } from './store.ts'
|
|
37
36
|
|
|
38
37
|
/** the longest a MEASURED NEGATIVE is remembered, whatever TTL was declared.
|
|
39
38
|
* Long enough to stop a re-ask storm inside one working session, short
|
|
40
39
|
* enough that the world being fixed is noticed the same day. */
|
|
41
40
|
export const NEGATIVE_TTL_CAP_MS = 60 * 60 * 1000
|
|
42
41
|
|
|
42
|
+
/** what a world answer is stored under. A timed answer follows the program
|
|
43
|
+
* that measured it (toolchain, probe code); a forever one is the declared
|
|
44
|
+
* value alone, or a shipkit release would ask every decided fact again */
|
|
45
|
+
export function worldKeyOf(k: { ttlMs: number; toolchain: string; code: string; rotation: string; frozen: boolean | null }): string {
|
|
46
|
+
const frozen = k.frozen == null ? '' : `\0frozen=${k.frozen}`
|
|
47
|
+
if (k.ttlMs >= FOREVER_MS) return `forever\0${k.rotation}${frozen}`
|
|
48
|
+
return `tc=${k.toolchain}\0code=${k.code}\0${k.rotation}${frozen}`
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
export type WorldCache = {
|
|
44
52
|
/** the repo's db file (ledger/store.ts answersDb) */
|
|
45
53
|
db: string
|
|
@@ -54,6 +62,41 @@ export type WorldCache = {
|
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
|
|
65
|
+
const FOREVER_PREFIX = 'forever\0'
|
|
66
|
+
/** the timed key a check wrote before it declared `ttl: 'forever'`:
|
|
67
|
+
* `tc=<toolchain>\0code=<code>\0` then the same declared value */
|
|
68
|
+
const TIMED_HEAD = /^tc=[^\0]*\0code=[^\0]*\0/
|
|
69
|
+
|
|
70
|
+
/** a decided fact with no row under its forever key takes the newest CLEAN
|
|
71
|
+
* answer its timed key shape stored for the same declared value, once: the
|
|
72
|
+
* value it checks did not move, so the fact is still known. Written under
|
|
73
|
+
* the forever key; the timed rows age out with the store's sweep */
|
|
74
|
+
function adoptTimed(a: Answers, ns: string, name: string, key: string): Stored<Line[]> | null {
|
|
75
|
+
if (!key.startsWith(FOREVER_PREFIX)) return null
|
|
76
|
+
const rest = key.slice(FOREVER_PREFIX.length)
|
|
77
|
+
const old = a.readAll<Line[]>(ns, name).find((e) => {
|
|
78
|
+
const head = TIMED_HEAD.exec(e.key)
|
|
79
|
+
return head != null && e.key.slice(head[0].length) === rest && Array.isArray(e.payload) && e.payload.length > 0 && e.payload.every((l) => l.kind === 'ok' || l.kind === 'note')
|
|
80
|
+
})
|
|
81
|
+
if (old == null) return null
|
|
82
|
+
const payload = unbake(old.payload)
|
|
83
|
+
a.write([{ ns, name, key, payload, at: old.at, keeps: FOREVER_MS }], { now: old.at })
|
|
84
|
+
return { ...old, key, keeps: FOREVER_MS, payload }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** the times an older build wrote into the first line's text, as `(cached at
|
|
88
|
+
* <stamp> until <stamp>)` or `(ran at <stamp>, kept until <stamp>)`: the
|
|
89
|
+
* entry holds them as data, so the text drops them */
|
|
90
|
+
const BAKED = / \((?:cached|ran) at \d{4}-\d\d-\d\d \d\d:\d\d:\d\d,? (?:kept )?until [^)]*\)$/
|
|
91
|
+
const unbake = (lines: Line[]): Line[] => lines.map((l, i) => (i === 0 ? { ...l, text: l.text.replace(BAKED, '') } : l))
|
|
92
|
+
|
|
93
|
+
/** the first line, stamped with when its answer was measured and until when it keeps */
|
|
94
|
+
const stamped = (lines: Line[], stamp: Stamp): Line[] => lines.map((l, i) => (i === 0 ? { ...l, stamp } : l))
|
|
95
|
+
const untilOf = (at: number, keeps: number): number | null => (keeps >= FOREVER_MS ? null : at + keeps)
|
|
96
|
+
|
|
97
|
+
/** the answer for this key, or the one a forever key adopts */
|
|
98
|
+
const readOrAdopt = (a: Answers, ns: string, name: string, key: string): Stored<Line[]> | null => a.read<Line[]>(ns, name, key) ?? adoptTimed(a, ns, name, key)
|
|
99
|
+
|
|
57
100
|
/** the stored answer for this exact question, or null when it must be
|
|
58
101
|
* measured. An entry may keep for LESS than the declared TTL: a measured
|
|
59
102
|
* negative wrote its own shorter window, so the run after a fix sees the fix
|
|
@@ -63,18 +106,16 @@ export function replayWorld(c: WorldCache, name: string, key: string, ttlMs: num
|
|
|
63
106
|
if (process.env[c.freshEnv ?? FRESH_ENV] != null) return null
|
|
64
107
|
const now = c.now()
|
|
65
108
|
const entry = withAnswers(c.db, (a) => {
|
|
66
|
-
const e = a
|
|
109
|
+
const e = readOrAdopt(a, c.ns, name, key)
|
|
67
110
|
if (e != null) a.write([], { touched: [{ ns: c.ns, name, key }], now })
|
|
68
111
|
return e
|
|
69
112
|
})
|
|
70
113
|
if (entry == null || !Array.isArray(entry.payload)) return null
|
|
71
114
|
const keeps = entry.keeps ?? ttlMs
|
|
72
115
|
if (now - entry.at >= keeps || now < entry.at) return null
|
|
73
|
-
// the
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
// every fresh-vs-cached alternation, so neither side annotates.
|
|
77
|
-
return entry.payload
|
|
116
|
+
// the times ride as data on the first line, rendered by whoever prints it
|
|
117
|
+
// against its own clock, so a line read a day later says how old it is
|
|
118
|
+
return stamped(unbake(entry.payload), { at: entry.at, until: untilOf(entry.at, keeps), cached: true })
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
export type Measured = {
|
|
@@ -88,6 +129,17 @@ export type Measured = {
|
|
|
88
129
|
keepNegative: boolean
|
|
89
130
|
}
|
|
90
131
|
|
|
132
|
+
/** is an answer stored for this exact question, without stamping it used:
|
|
133
|
+
* what the plan asks of a forever check */
|
|
134
|
+
export function hasWorld(c: WorldCache, name: string, key: string, ttlMs: number): boolean {
|
|
135
|
+
if (ttlMs <= 0) return false
|
|
136
|
+
const now = c.now()
|
|
137
|
+
const entry = withAnswers(c.db, (a) => readOrAdopt(a, c.ns, name, key))
|
|
138
|
+
if (entry == null || !Array.isArray(entry.payload)) return false
|
|
139
|
+
const keeps = entry.keeps ?? ttlMs
|
|
140
|
+
return now - entry.at < keeps && now >= entry.at
|
|
141
|
+
}
|
|
142
|
+
|
|
91
143
|
/** what a measured answer leaves behind, and the lines to report for it. This
|
|
92
144
|
* check writes its OWN row and only its own, so a racing runner can lose
|
|
93
145
|
* nothing. */
|
|
@@ -105,9 +157,6 @@ export function rememberWorld(c: WorldCache, name: string, key: string, ttlMs: n
|
|
|
105
157
|
return m.lines
|
|
106
158
|
}
|
|
107
159
|
const now = c.now()
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
)
|
|
111
|
-
withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: lines, at: now, keeps: keepsFor }], { now }))
|
|
112
|
-
return lines
|
|
160
|
+
withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: m.lines, at: now, keeps: keepsFor }], { now }))
|
|
161
|
+
return stamped(m.lines, { at: now, until: untilOf(now, keepsFor) })
|
|
113
162
|
}
|
package/src/plan/cadence.ts
CHANGED
|
@@ -10,15 +10,25 @@ import { parseDuration } from '../core/wire.ts'
|
|
|
10
10
|
|
|
11
11
|
export type LastStatus = 'pass' | 'warn' | 'fail' | 'skip'
|
|
12
12
|
|
|
13
|
-
/** a check's last result on record: its status, when it finished,
|
|
14
|
-
*
|
|
15
|
-
|
|
13
|
+
/** a check's last result on record: its status, when it finished, how many
|
|
14
|
+
* runs in a row ended on that same status, and its last MEASURED run (a
|
|
15
|
+
* replay costs nothing, so it never counts) */
|
|
16
|
+
export type LastResult = {
|
|
17
|
+
status: LastStatus
|
|
18
|
+
at: number
|
|
19
|
+
streak: number
|
|
20
|
+
cost?: { at: number; ms: number }
|
|
21
|
+
/** a decided fact with no answer for its key, whose last run skipped (its
|
|
22
|
+
* probe could not answer): it retries on a backoff from `at` */
|
|
23
|
+
unknown?: boolean
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
/** what a lambda sees beside the last result */
|
|
18
27
|
export type CadenceState = { active: boolean }
|
|
19
28
|
|
|
20
29
|
/** a span (`'24h'`), a range (`['10h', '12h']`), a cron expression
|
|
21
|
-
* (`'0 9 * * 1'`),
|
|
30
|
+
* (`'0 9 * * 1'`), `'once'` (only while it has no answer: what a
|
|
31
|
+
* `ttl: 'forever'` reads as), or null for on change only */
|
|
22
32
|
export type EveryValue = string | readonly [string, string] | null
|
|
23
33
|
|
|
24
34
|
export type Every = EveryValue | ((last: LastResult | null, s: CadenceState) => EveryValue)
|
|
@@ -33,6 +43,7 @@ export type PlanDecl = { every?: Every; cron?: string; whenInactive?: WhenInacti
|
|
|
33
43
|
/** one resolved cadence */
|
|
34
44
|
export type Cadence =
|
|
35
45
|
| { kind: 'change' }
|
|
46
|
+
| { kind: 'once' }
|
|
36
47
|
| { kind: 'every'; ms: number }
|
|
37
48
|
| { kind: 'range'; min: number; max: number; point: number }
|
|
38
49
|
| { kind: 'cron'; expr: string; cron: Cron }
|
|
@@ -40,6 +51,9 @@ export type Cadence =
|
|
|
40
51
|
/** the cadence a check runs on, and the words saying what chose it */
|
|
41
52
|
export type Resolved = { cadence: Cadence; rule: string; error: string | null }
|
|
42
53
|
|
|
54
|
+
/** runs while it has no answer, never on a clock */
|
|
55
|
+
export const ONCE = 'once'
|
|
56
|
+
|
|
43
57
|
const isCron = (s: string): boolean => s.trim().split(/\s+/).length === 5
|
|
44
58
|
|
|
45
59
|
/** a stable fraction in [0, 1) for one repo and check: the same pair always
|
|
@@ -56,6 +70,7 @@ export function stablePoint(seed: string): number {
|
|
|
56
70
|
/** one value to a cadence, or the reason it is not one */
|
|
57
71
|
export function cadenceOf(v: EveryValue, seed: string): Cadence | string {
|
|
58
72
|
if (v == null) return { kind: 'change' }
|
|
73
|
+
if (v === ONCE) return { kind: 'once' }
|
|
59
74
|
if (Array.isArray(v)) {
|
|
60
75
|
if (v.length !== 2) return `a range is two spans, as in ['10h', '12h']`
|
|
61
76
|
try {
|
|
@@ -99,7 +114,7 @@ export function planDeclProblem(d: PlanDecl): string | null {
|
|
|
99
114
|
return null
|
|
100
115
|
}
|
|
101
116
|
|
|
102
|
-
const words = (v: EveryValue): string => (v == null ? 'on change' : Array.isArray(v) ? `every ${v[0]}..${v[1]}` : isCron(v as string) ? `cron ${v}` : `every ${v}`)
|
|
117
|
+
const words = (v: EveryValue): string => (v == null ? 'on change' : v === ONCE ? 'once, while its answer is unknown' : Array.isArray(v) ? `every ${v[0]}..${v[1]}` : isCron(v as string) ? `cron ${v}` : `every ${v}`)
|
|
103
118
|
|
|
104
119
|
const stateWords = (last: LastResult | null, s: CadenceState): string =>
|
|
105
120
|
`${last == null ? 'never ran' : `last ${last.status} x${last.streak}`}, ${s.active ? 'active' : 'inactive'}`
|
|
@@ -151,6 +166,7 @@ export function resolveWhenInactive(d: PlanDecl, last: LastResult | null, s: Cad
|
|
|
151
166
|
* for its next slot */
|
|
152
167
|
export function nextDue(c: Cadence, last: number | null, now: number): number | null {
|
|
153
168
|
if (c.kind === 'change') return null
|
|
169
|
+
if (c.kind === 'once') return last == null ? now : null
|
|
154
170
|
if (c.kind === 'every') return last == null ? now : last + c.ms
|
|
155
171
|
if (c.kind === 'range') return last == null ? now : last + c.point
|
|
156
172
|
return cronNext(c.cron, last ?? now)
|