rvlib-shipkit 1.2.0 → 1.3.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +20 -6
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  18. package/src/checks/SK024-identity-colors.check.ts +5 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Ci.ts +5 -0
  25. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  26. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  27. package/src/cli/cmd_Keys.ts +17 -3
  28. package/src/cli/cmd_Overview.ts +98 -0
  29. package/src/cli/cmd_Planned.ts +117 -0
  30. package/src/cli.ts +9 -1
  31. package/src/collections/checks.ts +3 -2
  32. package/src/collections/providers.ts +1 -1
  33. package/src/collections/test-time.ts +10 -1
  34. package/src/core/agent-spawn.ts +46 -0
  35. package/src/core/archived.ts +82 -0
  36. package/src/core/canonical-scripts.ts +12 -3
  37. package/src/core/check-runner.ts +40 -12
  38. package/src/core/discover-checks.ts +2 -0
  39. package/src/core/file-stamp.ts +20 -0
  40. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  41. package/src/core/identity-color.ts +1 -1
  42. package/src/core/repo-facts.ts +14 -0
  43. package/src/core/retention.ts +14 -5
  44. package/src/core/retired-checks.ts +7 -0
  45. package/src/core/run-check.ts +6 -2
  46. package/src/core/run-ci.ts +43 -12
  47. package/src/core/run-fix.ts +7 -2
  48. package/src/core/run-fixers.ts +10 -6
  49. package/src/core/wire.ts +11 -0
  50. package/src/fleet/scan-fleet.ts +2 -1
  51. package/src/home/activity-shape.ts +135 -0
  52. package/src/home/activity.ts +249 -0
  53. package/src/home/global-units.ts +3 -2
  54. package/src/home/home-routes.ts +32 -5
  55. package/src/home/pm.ts +37 -4
  56. package/src/home/process-rows.ts +205 -0
  57. package/src/home/resources.ts +35 -8
  58. package/src/home/run-daemon.ts +6 -6
  59. package/src/home/run-pm.ts +1 -0
  60. package/src/home/serve-web.ts +6 -3
  61. package/src/home/supervise.ts +16 -1
  62. package/src/home/widget-text.ts +119 -0
  63. package/src/hooks/run-hooks.ts +20 -3
  64. package/src/ledger/db.ts +1 -1
  65. package/src/ledger/gate-checks.ts +17 -3
  66. package/src/ledger/house-runs.ts +43 -0
  67. package/src/ledger/live-timeline.ts +4 -19
  68. package/src/ledger/run-log.ts +39 -10
  69. package/src/ledger/store.ts +19 -2
  70. package/src/ledger/world-cache.ts +51 -4
  71. package/src/plan/cadence.ts +21 -5
  72. package/src/plan/catch-up.ts +37 -0
  73. package/src/plan/plan-timeline.ts +99 -0
  74. package/src/plan/planner.ts +138 -13
  75. package/src/plan/repo-source.ts +120 -8
  76. package/src/release/run-release.ts +9 -2
  77. package/src/release/unit-resolve.ts +8 -12
  78. package/src/repo-config.ts +11 -3
  79. package/src/serve/house-runs.ts +53 -0
  80. package/src/serve/hub-app/activity-st.ts +168 -0
  81. package/src/serve/hub-app/activity-view.tsx +218 -0
  82. package/src/serve/hub-app/activity.css +78 -0
  83. package/src/serve/hub-app/admin-svc.ts +41 -0
  84. package/src/serve/hub-app/admin-view.tsx +42 -0
  85. package/src/serve/hub-app/chart-view.ts +12 -5
  86. package/src/serve/hub-app/check-words.ts +2 -0
  87. package/src/serve/hub-app/controls.css +32 -0
  88. package/src/serve/hub-app/cycle-button.tsx +36 -0
  89. package/src/serve/hub-app/dock-anim.ts +25 -0
  90. package/src/serve/hub-app/dock-st.ts +329 -0
  91. package/src/serve/hub-app/dock-view.tsx +68 -0
  92. package/src/serve/hub-app/dock.css +35 -0
  93. package/src/serve/hub-app/hash-route.ts +7 -1
  94. package/src/serve/hub-app/header-bar.tsx +79 -104
  95. package/src/serve/hub-app/home-st.ts +49 -12
  96. package/src/serve/hub-app/home-strip.css +65 -22
  97. package/src/serve/hub-app/home-strip.tsx +228 -97
  98. package/src/serve/hub-app/home.html +4 -0
  99. package/src/serve/hub-app/hub-app.tsx +96 -160
  100. package/src/serve/hub-app/hub.css +77 -37
  101. package/src/serve/hub-app/icons.tsx +23 -3
  102. package/src/serve/hub-app/index.html +4 -0
  103. package/src/serve/hub-app/keys.ts +42 -7
  104. package/src/serve/hub-app/live-svc.ts +24 -64
  105. package/src/serve/hub-app/machine-st.ts +64 -1
  106. package/src/serve/hub-app/md.ts +38 -5
  107. package/src/serve/hub-app/next-run.ts +1 -1
  108. package/src/serve/hub-app/palette-st.ts +11 -7
  109. package/src/serve/hub-app/palette-view.tsx +47 -0
  110. package/src/serve/hub-app/pill-row.tsx +65 -0
  111. package/src/serve/hub-app/planned-st.ts +21 -85
  112. package/src/serve/hub-app/planned-view.tsx +12 -7
  113. package/src/serve/hub-app/planned.css +5 -2
  114. package/src/serve/hub-app/project-list.css +43 -21
  115. package/src/serve/hub-app/project-list.tsx +122 -23
  116. package/src/serve/hub-app/projects-svc.ts +120 -12
  117. package/src/serve/hub-app/readme-svc.ts +38 -0
  118. package/src/serve/hub-app/readme-view.tsx +19 -0
  119. package/src/serve/hub-app/readme.css +11 -0
  120. package/src/serve/hub-app/repo-sections.tsx +61 -0
  121. package/src/serve/hub-app/repo-st.ts +25 -6
  122. package/src/serve/hub-app/runs-tabs.ts +1 -1
  123. package/src/serve/hub-app/sidebar.tsx +84 -0
  124. package/src/serve/hub-app/tests-view.tsx +31 -4
  125. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  126. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  127. package/src/serve/hub-app/timeline-svc.ts +2 -2
  128. package/src/serve/hub-app/timeline-view.tsx +20 -3
  129. package/src/serve/hub-runtime.ts +17 -9
  130. package/src/serve/other-repos.ts +8 -45
  131. package/src/serve/project-gutter-route.ts +100 -20
  132. package/src/serve/project-gutter.ts +33 -11
  133. package/src/serve/readme-route.ts +57 -0
  134. package/src/serve/serve-hub.ts +13 -8
  135. package/src/serve/tracked-watch.ts +33 -1
  136. package/src/shipkit-files.ts +20 -4
  137. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  138. package/src/checks/contracts.check.ts +0 -20
@@ -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 { parseDuration } from '../core/wire.ts'
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 */
@@ -345,6 +358,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
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 }),
@@ -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: what the strip under the hub header draws. The same
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, plus one collapsed
4
- // lane per OTHER repo that has something in flight.
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 unfolded strip's widest window */
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) ?? [],
@@ -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
- const rows = opened.db.query('select run_id as runId, kind, label, by, narrowed, started, ended from invocations where ended >= ? order by started asc').all(since) as {
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('select id, status from check_last').all() as { id: string; status: string }[]
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[] {
@@ -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
@@ -137,6 +138,21 @@ export class Answers {
137
138
  )
138
139
  }
139
140
 
141
+ /** every stored answer of one check, newest first: what adopting an
142
+ * answer written under an older key shape reads */
143
+ readAll<T>(ns: string, name: string): Stored<T>[] {
144
+ 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 })[]) ?? []
145
+ return rows.flatMap((row) => {
146
+ try {
147
+ const payload = JSON.parse(row.payload) as T
148
+ if (Number(row.v) !== STORE_VERSION || typeof row.at !== 'number' || payload == null) return []
149
+ return [{ v: Number(row.v), key: row.key, at: row.at, keeps: row.keeps ?? undefined, payload }]
150
+ } catch {
151
+ return []
152
+ }
153
+ })
154
+ }
155
+
140
156
  drop(ns: string, name: string, key: string): void {
141
157
  this.with(BUSY_MS, (h) => h.query('delete from answers where ns = ? and name = ? and key = ?').run(ns, name, key))
142
158
  }
@@ -162,8 +178,9 @@ export class Answers {
162
178
  * runs is kept. How many went, null when the db could not be opened. */
163
179
  sweep(now: number, o: { keepMs?: number; max?: number } = {}): number | null {
164
180
  return this.with(BUSY_MS, (h) => {
165
- let n = h.query('delete from answers where used < ?').run(now - (o.keepMs ?? KEEP_MS)).changes
166
- n += h.query('delete from answers where rowid in (select rowid from answers order by used desc limit -1 offset ?)').run(o.max ?? MAX_ANSWERS).changes
181
+ // a forever answer is a decided fact: dropping it re-asks the world
182
+ let n = h.query('delete from answers where used < ? and coalesce(keeps, 0) < ?').run(now - (o.keepMs ?? KEEP_MS), FOREVER_MS).changes
183
+ 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
184
  return n
168
185
  })
169
186
  }
@@ -31,8 +31,8 @@
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 { fmtStamp, type Line } from '../core/wire.ts'
35
- import { FRESH_ENV, withAnswers } from './store.ts'
34
+ import { FOREVER_MS, fmtStamp, type Line } from '../core/wire.ts'
35
+ import { FRESH_ENV, withAnswers, type Answers, type Stored } from './store.ts'
36
36
  import { CACHED, RAN } from '../core/outcome-words.ts'
37
37
 
38
38
  /** the longest a MEASURED NEGATIVE is remembered, whatever TTL was declared.
@@ -40,6 +40,15 @@ import { CACHED, RAN } from '../core/outcome-words.ts'
40
40
  * enough that the world being fixed is noticed the same day. */
41
41
  export const NEGATIVE_TTL_CAP_MS = 60 * 60 * 1000
42
42
 
43
+ /** what a world answer is stored under. A timed answer follows the program
44
+ * that measured it (toolchain, probe code); a forever one is the declared
45
+ * value alone, or a shipkit release would ask every decided fact again */
46
+ export function worldKeyOf(k: { ttlMs: number; toolchain: string; code: string; rotation: string; frozen: boolean | null }): string {
47
+ const frozen = k.frozen == null ? '' : `\0frozen=${k.frozen}`
48
+ if (k.ttlMs >= FOREVER_MS) return `forever\0${k.rotation}${frozen}`
49
+ return `tc=${k.toolchain}\0code=${k.code}\0${k.rotation}${frozen}`
50
+ }
51
+
43
52
  export type WorldCache = {
44
53
  /** the repo's db file (ledger/store.ts answersDb) */
45
54
  db: string
@@ -54,6 +63,31 @@ export type WorldCache = {
54
63
  }
55
64
 
56
65
 
66
+ const FOREVER_PREFIX = 'forever\0'
67
+ /** the timed key a check wrote before it declared `ttl: 'forever'`:
68
+ * `tc=<toolchain>\0code=<code>\0` then the same declared value */
69
+ const TIMED_HEAD = /^tc=[^\0]*\0code=[^\0]*\0/
70
+
71
+ /** a decided fact with no row under its forever key takes the newest CLEAN
72
+ * answer its timed key shape stored for the same declared value, once: the
73
+ * value it checks did not move, so the fact is still known. Written under
74
+ * the forever key; the timed rows age out with the store's sweep */
75
+ function adoptTimed(a: Answers, ns: string, name: string, key: string): Stored<Line[]> | null {
76
+ if (!key.startsWith(FOREVER_PREFIX)) return null
77
+ const rest = key.slice(FOREVER_PREFIX.length)
78
+ const old = a.readAll<Line[]>(ns, name).find((e) => {
79
+ const head = TIMED_HEAD.exec(e.key)
80
+ 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')
81
+ })
82
+ if (old == null) return null
83
+ const payload = old.payload.map((l, i): Line => (i !== 0 ? l : { ...l, text: l.text.replace(new RegExp(`\\((${CACHED} at [^)]*?) until [^)]*\\)$`), '($1 until its declaration changes)') }))
84
+ a.write([{ ns, name, key, payload, at: old.at, keeps: FOREVER_MS }], { now: old.at })
85
+ return { ...old, key, keeps: FOREVER_MS, payload }
86
+ }
87
+
88
+ /** the answer for this key, or the one a forever key adopts */
89
+ const readOrAdopt = (a: Answers, ns: string, name: string, key: string): Stored<Line[]> | null => a.read<Line[]>(ns, name, key) ?? adoptTimed(a, ns, name, key)
90
+
57
91
  /** the stored answer for this exact question, or null when it must be
58
92
  * measured. An entry may keep for LESS than the declared TTL: a measured
59
93
  * negative wrote its own shorter window, so the run after a fix sees the fix
@@ -63,7 +97,7 @@ export function replayWorld(c: WorldCache, name: string, key: string, ttlMs: num
63
97
  if (process.env[c.freshEnv ?? FRESH_ENV] != null) return null
64
98
  const now = c.now()
65
99
  const entry = withAnswers(c.db, (a) => {
66
- const e = a.read<Line[]>(c.ns, name, key)
100
+ const e = readOrAdopt(a, c.ns, name, key)
67
101
  if (e != null) a.write([], { touched: [{ ns: c.ns, name, key }], now })
68
102
  return e
69
103
  })
@@ -88,6 +122,17 @@ export type Measured = {
88
122
  keepNegative: boolean
89
123
  }
90
124
 
125
+ /** is an answer stored for this exact question, without stamping it used:
126
+ * what the plan asks of a forever check */
127
+ export function hasWorld(c: WorldCache, name: string, key: string, ttlMs: number): boolean {
128
+ if (ttlMs <= 0) return false
129
+ const now = c.now()
130
+ const entry = withAnswers(c.db, (a) => readOrAdopt(a, c.ns, name, key))
131
+ if (entry == null || !Array.isArray(entry.payload)) return false
132
+ const keeps = entry.keeps ?? ttlMs
133
+ return now - entry.at < keeps && now >= entry.at
134
+ }
135
+
91
136
  /** what a measured answer leaves behind, and the lines to report for it. This
92
137
  * check writes its OWN row and only its own, so a racing runner can lose
93
138
  * nothing. */
@@ -106,7 +151,9 @@ export function rememberWorld(c: WorldCache, name: string, key: string, ttlMs: n
106
151
  }
107
152
  const now = c.now()
108
153
  const lines = m.lines.map((l, i): Line =>
109
- i === 0 ? { ...l, text: `${l.text} (${m.clean ? `${CACHED} at ${fmtStamp(now)} until` : `${RAN} at ${fmtStamp(now)}, kept until`} ${fmtStamp(now + keepsFor)})` } : l,
154
+ i !== 0 ? l
155
+ : keepsFor >= FOREVER_MS ? { ...l, text: `${l.text} (${CACHED} at ${fmtStamp(now)} until its declaration changes)` }
156
+ : { ...l, text: `${l.text} (${m.clean ? `${CACHED} at ${fmtStamp(now)} until` : `${RAN} at ${fmtStamp(now)}, kept until`} ${fmtStamp(now + keepsFor)})` },
110
157
  )
111
158
  withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: lines, at: now, keeps: keepsFor }], { now }))
112
159
  return lines
@@ -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, and how
14
- * many runs in a row ended on that same status */
15
- export type LastResult = { status: LastStatus; at: number; streak: number }
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'`), or null for on change only */
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)
@@ -0,0 +1,37 @@
1
+ // when the plan's catch up window started, kept across restarts in place: work
2
+ // already overdue when the plan starts is spread over CATCH_UP_MS from this
3
+ // start (planner.ts), so a daemon restarted every few minutes never pushes it
4
+ // back into a fresh window.
5
+ //
6
+ // the one constraint a reader breaks first: only a real pause opens a new
7
+ // window. The daemon beats while it runs, and a boot less than
8
+ // CATCH_UP_GAP_MS after the last beat keeps the start it had.
9
+ import { machineGet, machineSet } from '../core/machine-db.ts'
10
+
11
+ /** a boot this long after the plan's last beat is a pause (a machine asleep,
12
+ * pm stopped), and opens a new window. A restart in place takes seconds */
13
+ export const CATCH_UP_GAP_MS = 10 * 60_000
14
+
15
+ /** how often a running plan says it is alive */
16
+ export const CATCH_UP_BEAT_MS = 60_000
17
+
18
+ export type CatchUpMark = { start: number; seen: number }
19
+
20
+ export type CatchUpStore = { get: () => CatchUpMark | null; set: (v: CatchUpMark) => void }
21
+
22
+ const KEY = 'plan.catch-up'
23
+
24
+ /** the machine db row (core/machine-db.ts): one per machine, like the plan */
25
+ export const machineCatchUp: CatchUpStore = {
26
+ get: () => machineGet<CatchUpMark>(KEY),
27
+ set: (v) => void machineSet(KEY, v),
28
+ }
29
+
30
+ /** the start of the window this boot belongs to, written back with `now` as
31
+ * its last beat */
32
+ export function resumeCatchUp(now: number, store: CatchUpStore): number {
33
+ const m = store.get()
34
+ const start = m != null && Number.isFinite(m.start) && Number.isFinite(m.seen) && now - m.seen < CATCH_UP_GAP_MS && m.start <= now ? m.start : now
35
+ store.set({ start, seen: now })
36
+ return start
37
+ }
@@ -0,0 +1,99 @@
1
+ // the planned page's words, pure: the one timeline (now, next), the checks
2
+ // waiting for their repo to be active, the filter, and every word a row says. The page (planned-st.ts) and `shipkit
3
+ // planned` both draw from these, so the two can never disagree.
4
+ import type { LastStatus } from './cadence.ts'
5
+ import type { PlanReason, PlanRow } from './planner.ts'
6
+
7
+ export type Section = 'now' | 'next'
8
+
9
+ /** the section a row sits in; null for a check with no time of its own, and
10
+ * for one waiting for its repo to be active: it waits by design, so it is
11
+ * counted on one line (waitingWords), never listed as late */
12
+ export function sectionOf(r: PlanRow): Section | null {
13
+ if (r.state === 'running' || r.state === 'due') return 'now'
14
+ if (r.state === 'next') return 'next'
15
+ return null
16
+ }
17
+
18
+ /** the checks a `wait` rule holds until their repo is active, by repo then check */
19
+ export function waitingRows(rows: PlanRow[], query = ''): PlanRow[] {
20
+ return rows.filter((r) => r.state === 'late' && matches(r, query)).sort((a, b) => a.repo.localeCompare(b.repo) || a.check.localeCompare(b.check))
21
+ }
22
+
23
+ /** their one line, or '' when none waits */
24
+ export function waitingWords(rows: PlanRow[], query = ''): string {
25
+ const w = waitingRows(rows, query)
26
+ if (w.length === 0) return ''
27
+ const repos = new Set(w.map((r) => r.repo)).size
28
+ return `waiting for their repo to be active: ${w.length} check${w.length === 1 ? '' : 's'} in ${repos} repo${repos === 1 ? '' : 's'}`
29
+ }
30
+
31
+ /** a row matches a text filter when its project or check holds every word
32
+ * of it, in any case */
33
+ export function matches(r: PlanRow, query: string): boolean {
34
+ const words = query.toLowerCase().split(/\s+/).filter((w) => w !== '')
35
+ const hay = `${r.repo} ${r.check}`.toLowerCase()
36
+ return words.every((w) => hay.includes(w))
37
+ }
38
+
39
+ /** the one list: running first, then due, then the next ones soonest
40
+ * first. Each row carries its section */
41
+ export function timeline(rows: PlanRow[], query = ''): { section: Section; row: PlanRow }[] {
42
+ const rank = (r: PlanRow): number => (r.state === 'running' ? 0 : r.state === 'due' ? 1 : 2)
43
+ return rows
44
+ .filter((r) => sectionOf(r) != null && matches(r, query))
45
+ .sort((a, b) => rank(a) - rank(b) || (a.dueAt ?? Infinity) - (b.dueAt ?? Infinity) || a.repo.localeCompare(b.repo) || a.check.localeCompare(b.check))
46
+ .map((row) => ({ section: sectionOf(row)!, row }))
47
+ }
48
+
49
+ const MIN = 60_000
50
+ const HOUR = 60 * MIN
51
+ const DAY = 24 * HOUR
52
+
53
+ /** a span in its largest unit: `<1m`, `25m`, `2h`, `3d` */
54
+ export function spanShort(ms: number): string {
55
+ if (ms < MIN) return '<1m'
56
+ if (ms < HOUR) return `${Math.round(ms / MIN)}m`
57
+ if (ms < DAY) return `${Math.round(ms / HOUR)}h`
58
+ return `${Math.round(ms / DAY)}d`
59
+ }
60
+
61
+ /** when the row runs, relative to now: `now`, `in 4m`, or `when active`
62
+ * for one waiting for its repo */
63
+ export function whenWords(r: PlanRow, now: number): string {
64
+ if (r.state === 'late') return 'when active'
65
+ if (r.dueAt == null || r.state === 'running' || r.state === 'due') return 'now'
66
+ const d = r.dueAt - now
67
+ return d > 0 ? `in ${spanShort(d)}` : 'now'
68
+ }
69
+
70
+ export type Mark = 'running' | 'failed' | 'waiting' | 'due' | 'next'
71
+
72
+ /** the one mark a row shows and its few words: running wins, then a failed
73
+ * last result, then waiting for its repo */
74
+ export function markOf(r: PlanRow): { mark: Mark; words: string } {
75
+ if (r.state === 'running') return { mark: 'running', words: 'running' }
76
+ if (r.last?.status === 'fail') return { mark: 'failed', words: 'last failed' }
77
+ if (r.state === 'late') return { mark: 'waiting', words: 'waits: repo inactive' }
78
+ if (r.state === 'due') return { mark: 'due', words: 'due' }
79
+ return { mark: 'next', words: '' }
80
+ }
81
+
82
+ /** why it is due or planned, in words */
83
+ export const REASON_WORDS: Record<PlanReason, string> = {
84
+ changed: 'files changed',
85
+ scheduled: 'scheduled',
86
+ late: 'waits for its repo to be active',
87
+ asked: 'asked by hand',
88
+ }
89
+
90
+ export const reasonWords = (r: PlanRow): string => (r.reason == null ? '' : REASON_WORDS[r.reason])
91
+
92
+ /** the tone of a last result: red failing, warn warning, ok passing */
93
+ export const LAST_TONE: Record<LastStatus, 'red' | 'warn' | 'ok' | 'dim'> = { fail: 'red', warn: 'warn', pass: 'ok', skip: 'dim' }
94
+
95
+ /** a title worth showing beside the id: a title that repeats the id loses it */
96
+ export function titleWords(title: string | null, check: string): string | null {
97
+ const t = title?.startsWith(check) === true ? title.slice(check.length).replace(/^[\s:·]+/, '') : title
98
+ return t == null || t === '' ? null : t
99
+ }