rvlib-shipkit 1.1.1 → 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 +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- 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 +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -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_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -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 +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- 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 +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- 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/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +235 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- 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/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +92 -0
- 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 +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- 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 +340 -0
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// WHEN A CHECK RUNS, as a check declares it: `every` (a span, a range, or a
|
|
2
|
+
// pure function of its last result), `cron`, and `whenInactive`. Pure: no
|
|
3
|
+
// clock, no disk, so the planner (planner.ts) and every test read the same
|
|
4
|
+
// answer from the same facts.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: a lambda must stay pure. It is
|
|
7
|
+
// called on every plan look, and one that throws is an error row, never a
|
|
8
|
+
// silence: the check falls back to running on change only.
|
|
9
|
+
import { parseDuration } from '../core/wire.ts'
|
|
10
|
+
|
|
11
|
+
export type LastStatus = 'pass' | 'warn' | 'fail' | 'skip'
|
|
12
|
+
|
|
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
|
+
}
|
|
25
|
+
|
|
26
|
+
/** what a lambda sees beside the last result */
|
|
27
|
+
export type CadenceState = { active: boolean }
|
|
28
|
+
|
|
29
|
+
/** a span (`'24h'`), a range (`['10h', '12h']`), a cron expression
|
|
30
|
+
* (`'0 9 * * 1'`), `'once'` (only while it has no answer: what a
|
|
31
|
+
* `ttl: 'forever'` reads as), or null for on change only */
|
|
32
|
+
export type EveryValue = string | readonly [string, string] | null
|
|
33
|
+
|
|
34
|
+
export type Every = EveryValue | ((last: LastResult | null, s: CadenceState) => EveryValue)
|
|
35
|
+
|
|
36
|
+
export type WhenInactive = 'run' | 'wait'
|
|
37
|
+
|
|
38
|
+
export type WhenInactiveDecl = WhenInactive | ((last: LastResult | null, s: CadenceState) => WhenInactive)
|
|
39
|
+
|
|
40
|
+
/** what a check declared about its timing, as the planner reads it */
|
|
41
|
+
export type PlanDecl = { every?: Every; cron?: string; whenInactive?: WhenInactiveDecl }
|
|
42
|
+
|
|
43
|
+
/** one resolved cadence */
|
|
44
|
+
export type Cadence =
|
|
45
|
+
| { kind: 'change' }
|
|
46
|
+
| { kind: 'once' }
|
|
47
|
+
| { kind: 'every'; ms: number }
|
|
48
|
+
| { kind: 'range'; min: number; max: number; point: number }
|
|
49
|
+
| { kind: 'cron'; expr: string; cron: Cron }
|
|
50
|
+
|
|
51
|
+
/** the cadence a check runs on, and the words saying what chose it */
|
|
52
|
+
export type Resolved = { cadence: Cadence; rule: string; error: string | null }
|
|
53
|
+
|
|
54
|
+
/** runs while it has no answer, never on a clock */
|
|
55
|
+
export const ONCE = 'once'
|
|
56
|
+
|
|
57
|
+
const isCron = (s: string): boolean => s.trim().split(/\s+/).length === 5
|
|
58
|
+
|
|
59
|
+
/** a stable fraction in [0, 1) for one repo and check: the same pair always
|
|
60
|
+
* lands on the same point of its range, and pairs spread over it */
|
|
61
|
+
export function stablePoint(seed: string): number {
|
|
62
|
+
let h = 0x811c9dc5
|
|
63
|
+
for (let i = 0; i < seed.length; i++) {
|
|
64
|
+
h ^= seed.charCodeAt(i)
|
|
65
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
66
|
+
}
|
|
67
|
+
return h / 0x1_0000_0000
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** one value to a cadence, or the reason it is not one */
|
|
71
|
+
export function cadenceOf(v: EveryValue, seed: string): Cadence | string {
|
|
72
|
+
if (v == null) return { kind: 'change' }
|
|
73
|
+
if (v === ONCE) return { kind: 'once' }
|
|
74
|
+
if (Array.isArray(v)) {
|
|
75
|
+
if (v.length !== 2) return `a range is two spans, as in ['10h', '12h']`
|
|
76
|
+
try {
|
|
77
|
+
const min = parseDuration(v[0]!)
|
|
78
|
+
const max = parseDuration(v[1]!)
|
|
79
|
+
if (!(max > min) || min <= 0) return `range ${JSON.stringify(v)}: the second span must be longer than the first, and both above zero`
|
|
80
|
+
return { kind: 'range', min, max, point: Math.round(min + stablePoint(seed) * (max - min)) }
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return (e as Error).message
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (typeof v !== 'string') return `every takes a span, a range, a cron expression or null, got ${typeof v}`
|
|
86
|
+
if (isCron(v)) {
|
|
87
|
+
const c = parseCron(v)
|
|
88
|
+
return typeof c === 'string' ? c : { kind: 'cron', expr: v.trim(), cron: c }
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const ms = parseDuration(v)
|
|
92
|
+
if (ms <= 0) return `every '${v}' must be above zero`
|
|
93
|
+
return { kind: 'every', ms }
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return (e as Error).message
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** the problem with a static declaration, or null. A lambda is judged when it
|
|
100
|
+
* runs, so it passes here */
|
|
101
|
+
export function planDeclProblem(d: PlanDecl): string | null {
|
|
102
|
+
if (d.every != null && d.cron != null) return 'every and cron contradict each other: declare one'
|
|
103
|
+
if (d.every != null && typeof d.every !== 'function') {
|
|
104
|
+
const c = cadenceOf(d.every, '')
|
|
105
|
+
if (typeof c === 'string') return `every: ${c}`
|
|
106
|
+
}
|
|
107
|
+
if (d.cron != null) {
|
|
108
|
+
if (typeof d.cron !== 'string' || !isCron(d.cron)) return `cron takes five fields, as in '0 9 * * 1'`
|
|
109
|
+
const c = parseCron(d.cron)
|
|
110
|
+
if (typeof c === 'string') return `cron: ${c}`
|
|
111
|
+
}
|
|
112
|
+
if (d.whenInactive != null && typeof d.whenInactive !== 'function' && d.whenInactive !== 'run' && d.whenInactive !== 'wait')
|
|
113
|
+
return `whenInactive takes 'run', 'wait' or a function returning one`
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
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}`)
|
|
118
|
+
|
|
119
|
+
const stateWords = (last: LastResult | null, s: CadenceState): string =>
|
|
120
|
+
`${last == null ? 'never ran' : `last ${last.status} x${last.streak}`}, ${s.active ? 'active' : 'inactive'}`
|
|
121
|
+
|
|
122
|
+
/** the cadence one check runs on now. `fallback` is what applies when the
|
|
123
|
+
* check declared nothing: its ttl, or the daily default */
|
|
124
|
+
export function resolveCadence(d: PlanDecl, fallback: { value: EveryValue; why: string }, last: LastResult | null, s: CadenceState, seed: string): Resolved {
|
|
125
|
+
let value: EveryValue
|
|
126
|
+
let rule: string
|
|
127
|
+
if (d.cron != null) {
|
|
128
|
+
value = d.cron
|
|
129
|
+
rule = `cron ${d.cron}`
|
|
130
|
+
} else if (typeof d.every === 'function') {
|
|
131
|
+
try {
|
|
132
|
+
value = d.every(last, s)
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { cadence: { kind: 'change' }, rule: `every lambda threw (${stateWords(last, s)}), on change only`, error: `every lambda threw: ${(e as Error).message}` }
|
|
135
|
+
}
|
|
136
|
+
rule = `every lambda returned ${JSON.stringify(value)} (${stateWords(last, s)})`
|
|
137
|
+
} else if (d.every !== undefined) {
|
|
138
|
+
value = d.every
|
|
139
|
+
rule = words(value)
|
|
140
|
+
} else {
|
|
141
|
+
value = fallback.value
|
|
142
|
+
rule = `${words(value)} (${fallback.why})`
|
|
143
|
+
}
|
|
144
|
+
const c = cadenceOf(value, seed)
|
|
145
|
+
if (typeof c === 'string') return { cadence: { kind: 'change' }, rule: `${rule}, on change only`, error: c }
|
|
146
|
+
return { cadence: c, rule, error: null }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** what the check does while its repo is inactive, and the error when a
|
|
150
|
+
* lambda threw (then it waits) */
|
|
151
|
+
export function resolveWhenInactive(d: PlanDecl, last: LastResult | null, s: CadenceState): { value: WhenInactive; rule: string; error: string | null } {
|
|
152
|
+
const w = d.whenInactive
|
|
153
|
+
if (w == null) return { value: 'wait', rule: 'whenInactive wait (default)', error: null }
|
|
154
|
+
if (typeof w !== 'function') return { value: w, rule: `whenInactive ${w}`, error: null }
|
|
155
|
+
try {
|
|
156
|
+
const v = w(last, s)
|
|
157
|
+
if (v !== 'run' && v !== 'wait') return { value: 'wait', rule: `whenInactive lambda returned ${JSON.stringify(v)}, read as wait`, error: `whenInactive lambda returned ${JSON.stringify(v)}: it takes 'run' or 'wait'` }
|
|
158
|
+
return { value: v, rule: `whenInactive lambda returned '${v}' (${stateWords(last, s)})`, error: null }
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { value: 'wait', rule: 'whenInactive lambda threw, read as wait', error: `whenInactive lambda threw: ${(e as Error).message}` }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** when a cadence is next due after a run at `last`, null for on change
|
|
165
|
+
* only. A check that never ran is due now, except a cron one, which waits
|
|
166
|
+
* for its next slot */
|
|
167
|
+
export function nextDue(c: Cadence, last: number | null, now: number): number | null {
|
|
168
|
+
if (c.kind === 'change') return null
|
|
169
|
+
if (c.kind === 'once') return last == null ? now : null
|
|
170
|
+
if (c.kind === 'every') return last == null ? now : last + c.ms
|
|
171
|
+
if (c.kind === 'range') return last == null ? now : last + c.point
|
|
172
|
+
return cronNext(c.cron, last ?? now)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── a five field cron: minute hour day-of-month month day-of-week ─────────
|
|
176
|
+
|
|
177
|
+
export type Cron = { min: Set<number>; hour: Set<number>; dom: Set<number>; mon: Set<number>; dow: Set<number>; domAny: boolean; dowAny: boolean }
|
|
178
|
+
|
|
179
|
+
const FIELDS: [string, number, number][] = [['minute', 0, 59], ['hour', 0, 23], ['day of month', 1, 31], ['month', 1, 12], ['day of week', 0, 7]]
|
|
180
|
+
|
|
181
|
+
function field(raw: string, name: string, lo: number, hi: number): Set<number> | string {
|
|
182
|
+
const out = new Set<number>()
|
|
183
|
+
for (const part of raw.split(',')) {
|
|
184
|
+
const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part)
|
|
185
|
+
if (m == null) return `${name} '${part}' is not a number, a range, * or a step`
|
|
186
|
+
const step = m[2] == null ? 1 : Number(m[2])
|
|
187
|
+
if (step < 1) return `${name} step must be at least 1`
|
|
188
|
+
let a = lo
|
|
189
|
+
let b = hi
|
|
190
|
+
if (m[1] !== '*') {
|
|
191
|
+
const [x, y] = m[1]!.split('-').map(Number)
|
|
192
|
+
a = x!
|
|
193
|
+
b = y ?? (m[2] == null ? x! : hi)
|
|
194
|
+
}
|
|
195
|
+
if (a < lo || b > hi || a > b) return `${name} '${part}' is outside ${lo}..${hi}`
|
|
196
|
+
for (let v = a; v <= b; v += step) out.add(v)
|
|
197
|
+
}
|
|
198
|
+
return out
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseCron(expr: string): Cron | string {
|
|
202
|
+
const parts = expr.trim().split(/\s+/)
|
|
203
|
+
if (parts.length !== 5) return `cron takes five fields (minute hour day month weekday), got ${parts.length}`
|
|
204
|
+
const sets: Set<number>[] = []
|
|
205
|
+
for (let i = 0; i < 5; i++) {
|
|
206
|
+
const [name, lo, hi] = FIELDS[i]!
|
|
207
|
+
const s = field(parts[i]!, name, lo, hi)
|
|
208
|
+
if (typeof s === 'string') return s
|
|
209
|
+
sets.push(s)
|
|
210
|
+
}
|
|
211
|
+
const dow = sets[4]!
|
|
212
|
+
if (dow.has(7)) dow.add(0)
|
|
213
|
+
return { min: sets[0]!, hour: sets[1]!, dom: sets[2]!, mon: sets[3]!, dow, domAny: parts[2] === '*', dowAny: parts[4] === '*' }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** the first minute strictly after `after` that the cron matches, in local
|
|
217
|
+
* time. Walks months, days and hours before minutes, so a yearly slot costs
|
|
218
|
+
* a few hundred steps */
|
|
219
|
+
export function cronNext(c: Cron, after: number): number {
|
|
220
|
+
const d = new Date(after)
|
|
221
|
+
d.setSeconds(0, 0)
|
|
222
|
+
d.setMinutes(d.getMinutes() + 1)
|
|
223
|
+
const dayOk = (x: Date): boolean => {
|
|
224
|
+
const dom = c.dom.has(x.getDate())
|
|
225
|
+
const dow = c.dow.has(x.getDay())
|
|
226
|
+
// the classic rule: both restricted means either one matches
|
|
227
|
+
if (c.domAny) return dow
|
|
228
|
+
if (c.dowAny) return dom
|
|
229
|
+
return dom || dow
|
|
230
|
+
}
|
|
231
|
+
for (let guard = 0; guard < 100_000; guard++) {
|
|
232
|
+
if (!c.mon.has(d.getMonth() + 1)) {
|
|
233
|
+
d.setMonth(d.getMonth() + 1, 1)
|
|
234
|
+
d.setHours(0, 0, 0, 0)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (!dayOk(d)) {
|
|
238
|
+
d.setDate(d.getDate() + 1)
|
|
239
|
+
d.setHours(0, 0, 0, 0)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (!c.hour.has(d.getHours())) {
|
|
243
|
+
d.setHours(d.getHours() + 1, 0, 0, 0)
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (!c.min.has(d.getMinutes())) {
|
|
247
|
+
d.setMinutes(d.getMinutes() + 1, 0, 0)
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
return d.getTime()
|
|
251
|
+
}
|
|
252
|
+
throw new Error('cron has no matching minute within the search window')
|
|
253
|
+
}
|
|
@@ -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
|
+
}
|