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.
- package/CHANGELOG.md +67 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +20 -6
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- 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 +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- 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 +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +116 -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/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +40 -12
- package/src/core/discover-checks.ts +2 -0
- 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/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 +6 -2
- package/src/core/run-ci.ts +43 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/home-routes.ts +32 -5
- package/src/home/pm.ts +37 -4
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +35 -8
- package/src/home/run-daemon.ts +6 -6
- package/src/home/run-pm.ts +1 -0
- 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/hooks/run-hooks.ts +20 -3
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +17 -3
- 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 +19 -2
- package/src/ledger/world-cache.ts +51 -4
- 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 +11 -3
- 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/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 +49 -12
- package/src/serve/hub-app/home-strip.css +65 -22
- package/src/serve/hub-app/home-strip.tsx +228 -97
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +96 -160
- package/src/serve/hub-app/hub.css +77 -37
- package/src/serve/hub-app/icons.tsx +23 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +42 -7
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +64 -1
- 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 +11 -7
- 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 +25 -6
- 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 +20 -3
- 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 +33 -11
- 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/shipkit-files.ts +20 -4
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- 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 {
|
|
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:
|
|
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
|
|
@@ -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
|
-
|
|
166
|
-
n
|
|
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
|
|
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
|
|
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
|
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)
|
|
@@ -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
|
+
}
|