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.
Files changed (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
@@ -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.filter((f) => h.blockedPaths!.some((p) => f.startsWith(p)))
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
- `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
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 SK023`
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 { 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 */
@@ -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: 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
@@ -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
- 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
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 { fmtStamp, type Line } from '../core/wire.ts'
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.read<Line[]>(c.ns, name, key)
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 stamp was baked in when the answer was MEASURED (below): a replay
74
- // prints the stored lines VERBATIM, so the live run and every later replay
75
- // render the same string. Annotating replays but not live runs diffed on
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
- 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,
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
  }
@@ -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)