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,203 @@
|
|
|
1
|
+
// a repo's identity colours: `identity: { colorLight, colorDark }` in its
|
|
2
|
+
// repo.config.ts, one hex each. colorLight reads on a light background,
|
|
3
|
+
// colorDark on a dark one. This module is pure (the page imports it): hue
|
|
4
|
+
// maths, the fallback from the repo name, and the byte preserving edit of the
|
|
5
|
+
// config text. Reading an icon is core/identity.ts.
|
|
6
|
+
|
|
7
|
+
export type Identity = { colorLight: string; colorDark: string }
|
|
8
|
+
|
|
9
|
+
export const HEX_RE = /^#[0-9a-fA-F]{6}$/
|
|
10
|
+
|
|
11
|
+
/** why this value is not an identity, or null */
|
|
12
|
+
export function identityProblem(v: unknown): string | null {
|
|
13
|
+
if (v == null) return 'no identity colours'
|
|
14
|
+
if (typeof v !== 'object') return 'identity is not an object'
|
|
15
|
+
const o = v as Record<string, unknown>
|
|
16
|
+
for (const k of ['colorLight', 'colorDark'] as const)
|
|
17
|
+
if (typeof o[k] !== 'string' || !HEX_RE.test(o[k] as string)) return `identity.${k} is ${o[k] == null ? 'missing' : `'${String(o[k])}'`}, not a #rrggbb hex`
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
|
|
22
|
+
|
|
23
|
+
/** hsl (h in degrees, s and l in 0..1) as #rrggbb */
|
|
24
|
+
export function hslHex(h: number, s: number, l: number): string {
|
|
25
|
+
const a = s * Math.min(l, 1 - l)
|
|
26
|
+
const f = (n: number): string => {
|
|
27
|
+
const k = (n + h / 30) % 12
|
|
28
|
+
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
|
|
29
|
+
return Math.round(c * 255).toString(16).padStart(2, '0')
|
|
30
|
+
}
|
|
31
|
+
return `#${f(0)}${f(8)}${f(4)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** #rrggbb as hsl, h in degrees */
|
|
35
|
+
export function hexHsl(hex: string): { h: number; s: number; l: number } {
|
|
36
|
+
const n = Number.parseInt(hex.slice(1), 16)
|
|
37
|
+
return rgbHsl((n >> 16) & 255, (n >> 8) & 255, n & 255)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function rgbHsl(r8: number, g8: number, b8: number): { h: number; s: number; l: number } {
|
|
41
|
+
const r = r8 / 255
|
|
42
|
+
const g = g8 / 255
|
|
43
|
+
const b = b8 / 255
|
|
44
|
+
const max = Math.max(r, g, b)
|
|
45
|
+
const min = Math.min(r, g, b)
|
|
46
|
+
const l = (max + min) / 2
|
|
47
|
+
const d = max - min
|
|
48
|
+
if (d === 0) return { h: 0, s: 0, l }
|
|
49
|
+
const s = d / (1 - Math.abs(2 * l - 1))
|
|
50
|
+
let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
|
|
51
|
+
h *= 60
|
|
52
|
+
if (h < 0) h += 360
|
|
53
|
+
return { h, s, l }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** the pair for one hue: saturated enough to read as a colour, dark enough
|
|
57
|
+
* for white paper, light enough for a dark page */
|
|
58
|
+
export function identityOfHue(h: number, s = 0.7): Identity {
|
|
59
|
+
const sat = clamp(s, 0.5, 0.85)
|
|
60
|
+
return { colorLight: hslHex(h, sat, 0.38), colorDark: hslHex(h, sat, 0.68) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** a hue hashed from the name, stable from one run to the next */
|
|
64
|
+
export function nameHue(name: string): number {
|
|
65
|
+
let h = 0
|
|
66
|
+
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
|
|
67
|
+
return h % 360
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const identityOfName = (name: string): Identity => identityOfHue(nameHue(name))
|
|
71
|
+
|
|
72
|
+
/** the dominant saturated hue of rgba pixels, weighted by saturation, or
|
|
73
|
+
* null when the image is grey, transparent or empty */
|
|
74
|
+
export function dominantHue(rgba: Uint8Array | Buffer, channels = 4): { h: number; s: number } | null {
|
|
75
|
+
const BINS = 36
|
|
76
|
+
const weight = new Float64Array(BINS)
|
|
77
|
+
const sumS = new Float64Array(BINS)
|
|
78
|
+
const vecX = new Float64Array(BINS)
|
|
79
|
+
const vecY = new Float64Array(BINS)
|
|
80
|
+
for (let i = 0; i + channels - 1 < rgba.length; i += channels) {
|
|
81
|
+
if (channels === 4 && rgba[i + 3]! < 128) continue
|
|
82
|
+
const { h, s, l } = rgbHsl(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
|
|
83
|
+
if (s < 0.3 || l < 0.12 || l > 0.9) continue
|
|
84
|
+
const b = Math.floor(h / (360 / BINS)) % BINS
|
|
85
|
+
const w = s * (1 - Math.abs(2 * l - 1))
|
|
86
|
+
weight[b]! += w
|
|
87
|
+
sumS[b]! += s * w
|
|
88
|
+
vecX[b]! += Math.cos((h * Math.PI) / 180) * w
|
|
89
|
+
vecY[b]! += Math.sin((h * Math.PI) / 180) * w
|
|
90
|
+
}
|
|
91
|
+
let best = -1
|
|
92
|
+
let bestW = 0
|
|
93
|
+
// a bin and its two neighbours, so a hue on a bin edge is not split
|
|
94
|
+
for (let b = 0; b < BINS; b++) {
|
|
95
|
+
const w = weight[b]! + weight[(b + 1) % BINS]! + weight[(b + BINS - 1) % BINS]!
|
|
96
|
+
if (w > bestW) { bestW = w; best = b }
|
|
97
|
+
}
|
|
98
|
+
if (best < 0 || bestW === 0) return null
|
|
99
|
+
let x = 0, y = 0, s = 0, w = 0
|
|
100
|
+
for (const b of [(best + BINS - 1) % BINS, best, (best + 1) % BINS]) {
|
|
101
|
+
x += vecX[b]!
|
|
102
|
+
y += vecY[b]!
|
|
103
|
+
s += sumS[b]!
|
|
104
|
+
w += weight[b]!
|
|
105
|
+
}
|
|
106
|
+
let h = (Math.atan2(y, x) * 180) / Math.PI
|
|
107
|
+
if (h < 0) h += 360
|
|
108
|
+
return { h, s: s / w }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── the config text ─────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
// the key where a config writes it: at a line start, or inline after `{` or
|
|
114
|
+
// `,` (a one line config), bare or quoted
|
|
115
|
+
const KEY_RE = /(?:^|[{,])\s*(['"]?)identity\1\s*:\s*/gm
|
|
116
|
+
|
|
117
|
+
/** per char of the text: the object depth inside `defineRepo({` after that
|
|
118
|
+
* char (1 on the top level keys), or -1 inside a string or a comment */
|
|
119
|
+
export function configDepths(text: string): Int32Array {
|
|
120
|
+
const depth = new Int32Array(text.length).fill(0)
|
|
121
|
+
const open = /defineRepo\(\s*\{/.exec(text)
|
|
122
|
+
if (open == null) return depth
|
|
123
|
+
let d = 0
|
|
124
|
+
let quote: string | null = null
|
|
125
|
+
let comment: 'line' | 'block' | null = null
|
|
126
|
+
for (let i = open.index + open[0].length - 1; i < text.length; i++) {
|
|
127
|
+
const ch = text[i]!
|
|
128
|
+
if (comment === 'line') { if (ch === '\n') comment = null; else { depth[i] = -1; continue } }
|
|
129
|
+
else if (comment === 'block') { depth[i] = -1; if (ch === '/' && text[i - 1] === '*') comment = null; continue }
|
|
130
|
+
else if (quote != null) { depth[i] = -1; if (ch === '\\') { depth[++i] = -1 } else if (ch === quote) quote = null; continue }
|
|
131
|
+
else if (ch === '/' && text[i + 1] === '/') { comment = 'line'; depth[i] = -1; continue }
|
|
132
|
+
else if (ch === '/' && text[i + 1] === '*') { comment = 'block'; depth[i] = -1; continue }
|
|
133
|
+
else if (ch === "'" || ch === '"' || ch === '`') { quote = ch; depth[i] = -1; continue }
|
|
134
|
+
if (ch === '{' || ch === '[' || ch === '(') d++
|
|
135
|
+
else if (ch === '}' || ch === ']' || ch === ')') d--
|
|
136
|
+
depth[i] = d
|
|
137
|
+
}
|
|
138
|
+
return depth
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** every top level `identity` key of `defineRepo({…})`, as the offsets of
|
|
142
|
+
* its `{…}` value (end exclusive), null when the value is not an object
|
|
143
|
+
* literal. A key nested deeper, in a string or in a comment is not it */
|
|
144
|
+
function identityKeys(text: string): { value: { start: number; end: number } | null }[] {
|
|
145
|
+
const depth = configDepths(text)
|
|
146
|
+
const out: { value: { start: number; end: number } | null }[] = []
|
|
147
|
+
for (const m of text.matchAll(KEY_RE)) {
|
|
148
|
+
const sep = /^[{,]/.test(m[0]) ? m.index : m.index - 1
|
|
149
|
+
if (sep < 0 || depth[sep] !== 1) continue
|
|
150
|
+
const at = m.index + m[0].length
|
|
151
|
+
if (text[at] !== '{') { out.push({ value: null }); continue }
|
|
152
|
+
let end = -1
|
|
153
|
+
for (let j = at; j < text.length; j++) if (depth[j] === 1 && text[j] === '}') { end = j + 1; break }
|
|
154
|
+
out.push({ value: end === -1 ? null : { start: at, end } })
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** whether the text declares an `identity` key at all, readable or not */
|
|
160
|
+
export const declaresIdentity = (text: string): boolean => identityKeys(text).length > 0
|
|
161
|
+
|
|
162
|
+
/** the identity the config text declares, read without loading it: the page
|
|
163
|
+
* rows and the daemon read many configs per poll */
|
|
164
|
+
export function identityOfText(text: string): Identity | null {
|
|
165
|
+
const keys = identityKeys(text)
|
|
166
|
+
const v = keys.length === 1 ? keys[0]!.value : null
|
|
167
|
+
if (v == null) return null
|
|
168
|
+
const body = text.slice(v.start, v.end)
|
|
169
|
+
const light = /(['"]?)colorLight\1\s*:\s*['"`](#[0-9a-fA-F]{6})['"`]/.exec(body)?.[2]
|
|
170
|
+
const dark = /(['"]?)colorDark\1\s*:\s*['"`](#[0-9a-fA-F]{6})['"`]/.exec(body)?.[2]
|
|
171
|
+
return light == null || dark == null ? null : { colorLight: light.toLowerCase(), colorDark: dark.toLowerCase() }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** the config text with `identity` set to these colours. Only that key
|
|
175
|
+
* moves: an existing object value is replaced in place, a missing key lands
|
|
176
|
+
* as the first key of `defineRepo({`, and every other byte stays. Throws
|
|
177
|
+
* when the text holds the key twice, holds it with a value that is not an
|
|
178
|
+
* object literal, or has no `defineRepo({` to write into */
|
|
179
|
+
export function setIdentityText(text: string, id: Identity): string {
|
|
180
|
+
const bad = identityProblem(id)
|
|
181
|
+
if (bad != null) throw new Error(bad)
|
|
182
|
+
const q = /\bname\s*:\s*"/.test(text) && !/\bname\s*:\s*'/.test(text) ? '"' : "'"
|
|
183
|
+
const value = `{ colorLight: ${q}${id.colorLight.toLowerCase()}${q}, colorDark: ${q}${id.colorDark.toLowerCase()}${q} }`
|
|
184
|
+
const keys = identityKeys(text)
|
|
185
|
+
if (keys.length > 1) throw new Error('repo.config.ts declares `identity` more than once, set it by hand')
|
|
186
|
+
if (keys.length === 1) {
|
|
187
|
+
const v = keys[0]!.value
|
|
188
|
+
if (v == null) throw new Error('repo.config.ts declares `identity` as something other than `{ … }`, set it by hand')
|
|
189
|
+
return `${text.slice(0, v.start)}${value}${text.slice(v.end)}`
|
|
190
|
+
}
|
|
191
|
+
const lines = text.split('\n')
|
|
192
|
+
const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
|
|
193
|
+
if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `identity` into, set it by hand')
|
|
194
|
+
const line = lines[open]!
|
|
195
|
+
if (/defineRepo\(\s*\{\s*$/.test(line)) {
|
|
196
|
+
const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
|
|
197
|
+
const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
|
|
198
|
+
return [...lines.slice(0, open + 1), `${pad}identity: ${value},`, ...lines.slice(open + 1)].join('\n')
|
|
199
|
+
}
|
|
200
|
+
// a config on one line: the key goes right after the brace
|
|
201
|
+
lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}identity: ${value}, `)
|
|
202
|
+
return lines.join('\n')
|
|
203
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// a repo's identity colours, from disk: the dominant saturated colour of its
|
|
2
|
+
// rail icon (serve/project-gutter.ts ICON_FILES), else a hue hashed from its
|
|
3
|
+
// name, written into repo.config.ts with only the `identity` key moving. SK024
|
|
4
|
+
// warns when the key is missing, its fix and the daemon's load both call
|
|
5
|
+
// ensureIdentity, and the page's two swatches reach writeIdentity.
|
|
6
|
+
import { existsSync, readFileSync } from 'fs'
|
|
7
|
+
import { basename, join } from 'path'
|
|
8
|
+
import sharp from 'sharp'
|
|
9
|
+
import { atomicWrite } from './locked-write.ts'
|
|
10
|
+
import { dominantHue, identityOfHue, identityOfName, identityOfText, identityProblem, setIdentityText, type Identity } from './identity-color.ts'
|
|
11
|
+
import { ICON_FILES } from '../serve/project-gutter.ts'
|
|
12
|
+
|
|
13
|
+
export const CONFIG_FILE = 'repo.config.ts'
|
|
14
|
+
|
|
15
|
+
/** where a repo's own copy declares the config keys it knows */
|
|
16
|
+
export const INSTALLED_CONFIG_SRC = 'node_modules/rvlib-shipkit/src/repo-config.ts'
|
|
17
|
+
|
|
18
|
+
/** why the key cannot be written in this repo yet, or null. `source` is the
|
|
19
|
+
* text of its own copy's repo-config.ts, null when it has none. A copy that
|
|
20
|
+
* does not know the key would refuse it (its config load in ci, its
|
|
21
|
+
* typecheck), so nothing writes it there. Read by capability, not version:
|
|
22
|
+
* a linked copy is the source itself, whatever its package.json says */
|
|
23
|
+
export const identityRefusal = (source: string | null): string | null =>
|
|
24
|
+
source != null && !source.includes("'identity'")
|
|
25
|
+
? `its own rvlib-shipkit predates the identity key, so writing it would break that copy: update rvlib-shipkit first`
|
|
26
|
+
: null
|
|
27
|
+
|
|
28
|
+
const installedSource = (root: string): string | null => {
|
|
29
|
+
try {
|
|
30
|
+
return readFileSync(join(root, INSTALLED_CONFIG_SRC), 'utf8')
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** the name the config declares, else the folder name */
|
|
37
|
+
export const configName = (text: string, root: string): string => /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(text)?.[1] ?? basename(root)
|
|
38
|
+
|
|
39
|
+
/** the colours an image's pixels suggest, or null when it is grey, unreadable
|
|
40
|
+
* or a format sharp does not read (an .ico) */
|
|
41
|
+
export async function identityOfImage(bytes: Uint8Array): Promise<Identity | null> {
|
|
42
|
+
try {
|
|
43
|
+
const { data } = await sharp(bytes).resize(64, 64, { fit: 'inside' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
44
|
+
const d = dominantHue(data, 4)
|
|
45
|
+
return d == null ? null : identityOfHue(d.h, d.s)
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** the repo's icon file, first hit wins, repo-relative */
|
|
52
|
+
export const iconFileOf = (root: string): string | null => ICON_FILES.find((f) => existsSync(join(root, f))) ?? null
|
|
53
|
+
|
|
54
|
+
/** what the colours should be: from the icon when it has a colour, else from the name */
|
|
55
|
+
export async function deriveIdentity(root: string, name: string, read: (rel: string) => Uint8Array | null = (rel) => readFileSync(join(root, rel))): Promise<{ id: Identity; from: string }> {
|
|
56
|
+
const icon = iconFileOf(root)
|
|
57
|
+
if (icon != null) {
|
|
58
|
+
const bytes = read(icon)
|
|
59
|
+
const id = bytes == null ? null : await identityOfImage(bytes)
|
|
60
|
+
if (id != null) return { id, from: icon }
|
|
61
|
+
}
|
|
62
|
+
return { id: identityOfName(name), from: `the name '${name}'` }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** the config with valid colours: derived and written when the key is
|
|
66
|
+
* missing or malformed, the text left byte for byte otherwise */
|
|
67
|
+
export async function ensureIdentity(root: string): Promise<{ changed: boolean; id: Identity | null; from: string | null; refused?: string }> {
|
|
68
|
+
const path = join(root, CONFIG_FILE)
|
|
69
|
+
if (!existsSync(path)) return { changed: false, id: null, from: null }
|
|
70
|
+
const text = readFileSync(path, 'utf8')
|
|
71
|
+
const have = identityOfText(text)
|
|
72
|
+
if (have != null && identityProblem(have) == null) return { changed: false, id: have, from: null }
|
|
73
|
+
const refused = identityRefusal(installedSource(root))
|
|
74
|
+
if (refused != null) return { changed: false, id: identityOfName(configName(text, root)), from: null, refused }
|
|
75
|
+
const { id, from } = await deriveIdentity(root, configName(text, root))
|
|
76
|
+
atomicWrite(path, setIdentityText(text, id))
|
|
77
|
+
return { changed: true, id, from }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** set the colours by hand; a value left out keeps what the config has */
|
|
81
|
+
export function writeIdentity(root: string, next: Partial<Identity>): Identity {
|
|
82
|
+
const refused = identityRefusal(installedSource(root))
|
|
83
|
+
if (refused != null) throw new Error(refused)
|
|
84
|
+
const path = join(root, CONFIG_FILE)
|
|
85
|
+
const text = readFileSync(path, 'utf8')
|
|
86
|
+
const have = identityOfText(text) ?? identityOfName(configName(text, root))
|
|
87
|
+
const id = { colorLight: next.colorLight ?? have.colorLight, colorDark: next.colorDark ?? have.colorDark }
|
|
88
|
+
const out = setIdentityText(text, id)
|
|
89
|
+
if (out !== text) atomicWrite(path, out)
|
|
90
|
+
return { colorLight: id.colorLight.toLowerCase(), colorDark: id.colorDark.toLowerCase() }
|
|
91
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// following a detached gate job from its folder (jobs.ts): its wire lines as
|
|
2
|
+
// out.log grows, its verdicts as checks.ndjson grows, then its exit code. The
|
|
3
|
+
// daemon follows every job it starts, and every job still running when a new
|
|
4
|
+
// image of it boots; a terminal whose stream the daemon cut follows the rest
|
|
5
|
+
// of its job here, from the line it had reached.
|
|
6
|
+
//
|
|
7
|
+
// the one constraint a reader breaks first: the verdict is exit.json, never
|
|
8
|
+
// the pid. A pid can outlive its job as a zombie nobody reaps, and a job can
|
|
9
|
+
// write exit.json a moment before its pid goes, so the folder is drained once
|
|
10
|
+
// more after either.
|
|
11
|
+
import { closeSync, openSync, readSync } from 'fs'
|
|
12
|
+
import type { CheckEvent } from './check-runner.ts'
|
|
13
|
+
import { jobAlive, jobChecks, jobOut, readExit, readJob } from './jobs.ts'
|
|
14
|
+
|
|
15
|
+
export type FollowTap = { line: (l: string) => void; check?: (e: CheckEvent) => void }
|
|
16
|
+
|
|
17
|
+
/** one file read from a byte offset, one complete line at a time */
|
|
18
|
+
class Tail {
|
|
19
|
+
private at = 0
|
|
20
|
+
private partial = ''
|
|
21
|
+
constructor(private path: string) {}
|
|
22
|
+
|
|
23
|
+
lines(): string[] {
|
|
24
|
+
let fd: number
|
|
25
|
+
try { fd = openSync(this.path, 'r') } catch { return [] }
|
|
26
|
+
const chunks: string[] = []
|
|
27
|
+
try {
|
|
28
|
+
const buf = Buffer.alloc(64 * 1024)
|
|
29
|
+
for (;;) {
|
|
30
|
+
const n = readSync(fd, buf, 0, buf.length, this.at)
|
|
31
|
+
if (n <= 0) break
|
|
32
|
+
this.at += n
|
|
33
|
+
chunks.push(buf.subarray(0, n).toString('utf8'))
|
|
34
|
+
}
|
|
35
|
+
} finally { closeSync(fd) }
|
|
36
|
+
if (chunks.length === 0) return []
|
|
37
|
+
const all = (this.partial + chunks.join('')).split('\n')
|
|
38
|
+
this.partial = all.pop() ?? ''
|
|
39
|
+
return all
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** what is left once the writer is gone: a last line with no newline */
|
|
43
|
+
rest(): string[] {
|
|
44
|
+
const out = this.lines()
|
|
45
|
+
if (this.partial !== '') out.push(this.partial)
|
|
46
|
+
this.partial = ''
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type FollowOpts = {
|
|
52
|
+
/** out.log lines already delivered elsewhere, skipped here */
|
|
53
|
+
skipLines?: number
|
|
54
|
+
pollMs?: number
|
|
55
|
+
/** how often the pid is asked whether it still runs (it costs a ps) */
|
|
56
|
+
aliveEveryMs?: number
|
|
57
|
+
alive?: (pid: number) => boolean
|
|
58
|
+
sleep?: (ms: number) => Promise<void>
|
|
59
|
+
/** stop following early (the tab went away); resolves null */
|
|
60
|
+
abort?: () => boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** the job's exit code once it has one, 1 for a job that died without one,
|
|
64
|
+
* null when abort said stop */
|
|
65
|
+
export async function followJob(root: string, id: string, tap: FollowTap, o: FollowOpts = {}): Promise<number | null> {
|
|
66
|
+
const out = new Tail(jobOut(root, id))
|
|
67
|
+
const checks = new Tail(jobChecks(root, id))
|
|
68
|
+
const sleep = o.sleep ?? Bun.sleep
|
|
69
|
+
const isAlive = o.alive ?? jobAlive
|
|
70
|
+
let skip = o.skipLines ?? 0
|
|
71
|
+
const drain = (end: boolean): void => {
|
|
72
|
+
for (const l of end ? out.rest() : out.lines()) {
|
|
73
|
+
if (skip > 0) { skip--; continue }
|
|
74
|
+
tap.line(l)
|
|
75
|
+
}
|
|
76
|
+
for (const l of end ? checks.rest() : checks.lines()) {
|
|
77
|
+
if (l === '' || tap.check == null) continue
|
|
78
|
+
try { tap.check(JSON.parse(l) as CheckEvent) } catch { /* a torn line: the ledger has the verdict */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let lastAlive = 0
|
|
82
|
+
for (;;) {
|
|
83
|
+
drain(false)
|
|
84
|
+
const exit = readExit(root, id)
|
|
85
|
+
if (exit != null) { drain(true); return exit.code }
|
|
86
|
+
if (o.abort?.() === true) return null
|
|
87
|
+
const now = Date.now()
|
|
88
|
+
if (now - lastAlive >= (o.aliveEveryMs ?? 1_000)) {
|
|
89
|
+
lastAlive = now
|
|
90
|
+
const job = readJob(root, id)
|
|
91
|
+
if (job == null || !isAlive(job.pid)) {
|
|
92
|
+
// it may have written its verdict between the two reads
|
|
93
|
+
const late = readExit(root, id)
|
|
94
|
+
drain(true)
|
|
95
|
+
if (late != null) return late.code
|
|
96
|
+
tap.line(`FAIL: gate job ${id} died without a verdict (pid ${job?.pid ?? '?'} is gone)`)
|
|
97
|
+
return 1
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await sleep(o.pollMs ?? 100)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// the inside of a gate job the daemon started (jobs.ts startJob): the run's
|
|
2
|
+
// options come from the job folder rather than from flags, every verdict is
|
|
3
|
+
// appended to checks.ndjson for whoever follows it, and a background run takes
|
|
4
|
+
// the machine's one background turn here, so the turn outlives a daemon
|
|
5
|
+
// reload the same as the run does.
|
|
6
|
+
import { basename } from 'path'
|
|
7
|
+
import { useOneLib } from './one-lib.ts'
|
|
8
|
+
import { appendJobCheck, type JobKind, type JobOpts } from './jobs.ts'
|
|
9
|
+
import { autoGatePool, takeSlot } from './slots.ts'
|
|
10
|
+
import type { CheckEvent } from './check-runner.ts'
|
|
11
|
+
|
|
12
|
+
export async function runJob(root: string, id: string, kind: JobKind, job: JobOpts, print: (l: string) => void): Promise<number> {
|
|
13
|
+
// before the repo config loads: it imports this copy of the lib, never its own install
|
|
14
|
+
if (job.oneLib === true) useOneLib()
|
|
15
|
+
const turn = job.turn === true ? await takeSlot(autoGatePool(), { repo: basename(root), check: 'auto gate' }, { pollMs: 250 }) : null
|
|
16
|
+
try {
|
|
17
|
+
const onCheck = (e: CheckEvent): void => appendJobCheck(root, id, e)
|
|
18
|
+
if (kind === 'ci') {
|
|
19
|
+
const { runCi } = await import('./run-ci.ts')
|
|
20
|
+
return await runCi(root, { ...job.opts, print, onCheck })
|
|
21
|
+
}
|
|
22
|
+
const { runCheck } = await import('./run-check.ts')
|
|
23
|
+
return await runCheck(root, { ...job.opts, print, onCheck })
|
|
24
|
+
} finally {
|
|
25
|
+
turn?.release()
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/core/jobs.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// DETACHED GATES: `shipkit ci --detach` starts an ordinary gate in the
|
|
2
|
-
// background and hands back a run id, `shipkit status [id]` reads it back.
|
|
2
|
+
// background and hands back a run id, `shipkit status [id]` reads it back. The
|
|
3
|
+
// daemon runs every gate of its repos the same way (job-follow.ts), so a
|
|
4
|
+
// daemon reload never kills one.
|
|
3
5
|
//
|
|
4
6
|
// The background process OWNS NOTHING. It is the same gate a terminal runs,
|
|
5
7
|
// its answers land in the same ledger and its cost in the same run record;
|
|
6
8
|
// this module only remembers where its output went and how it ended, in
|
|
7
9
|
// `.shipkit/tmp/shipkit-jobs/<id>/`: `job.json` (who, what, when), `out.log` (its
|
|
8
|
-
// wire lines as it prints them)
|
|
10
|
+
// wire lines as it prints them), `checks.ndjson` (one verdict per line, for a
|
|
11
|
+
// job the daemon started), `opts.json` (the run's options, same) and
|
|
12
|
+
// `exit.json` (the verdict, once).
|
|
9
13
|
import { spawn } from 'child_process'
|
|
10
|
-
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
|
|
14
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
|
|
11
15
|
import { join } from 'path'
|
|
12
16
|
import { LAYOUT } from './layout.ts'
|
|
13
17
|
import { atomicWrite } from './locked-write.ts'
|
|
@@ -16,16 +20,22 @@ import { fmtStamp } from './wire.ts'
|
|
|
16
20
|
/** status exit code while the run has no verdict yet */
|
|
17
21
|
export const STILL_RUNNING = 3
|
|
18
22
|
|
|
19
|
-
type
|
|
20
|
-
type
|
|
23
|
+
export type JobKind = 'ci' | 'check'
|
|
24
|
+
export type Job = { id: string; pid: number; args: string[]; startedAt: number; kind?: JobKind }
|
|
25
|
+
export type Exit = { code: number; finishedAt: number }
|
|
21
26
|
|
|
22
|
-
const JOBS = `${LAYOUT.tmp}/shipkit-jobs`
|
|
27
|
+
export const JOBS = `${LAYOUT.tmp}/shipkit-jobs`
|
|
23
28
|
/** how long a finished run stays readable */
|
|
24
|
-
const
|
|
29
|
+
export const JOBS_KEEP_MS = 7 * 24 * 60 * 60 * 1000
|
|
30
|
+
/** the most finished runs kept, newest first */
|
|
31
|
+
export const JOBS_MAX = 100
|
|
25
32
|
|
|
26
|
-
const ciLine = (args: string[]): string => [
|
|
33
|
+
const ciLine = (args: string[], kind: JobKind = 'ci'): string => [`shipkit ${kind}`, ...args].join(' ')
|
|
27
34
|
|
|
28
|
-
export const jobDir =(root: string, id: string): string => join(root, JOBS, id)
|
|
35
|
+
export const jobDir = (root: string, id: string): string => join(root, JOBS, id)
|
|
36
|
+
export const jobOut = (root: string, id: string): string => join(jobDir(root, id), 'out.log')
|
|
37
|
+
export const jobChecks = (root: string, id: string): string => join(jobDir(root, id), 'checks.ndjson')
|
|
38
|
+
const jobOpts = (root: string, id: string): string => join(jobDir(root, id), 'opts.json')
|
|
29
39
|
|
|
30
40
|
const readJson = <T>(p: string): T | null => {
|
|
31
41
|
try { return JSON.parse(readFileSync(p, 'utf8')) as T } catch { return null }
|
|
@@ -35,19 +45,41 @@ const alive = (pid: number): boolean => {
|
|
|
35
45
|
try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
export function
|
|
48
|
+
/** alive and not a zombie: a job the daemon started and never reaps (its
|
|
49
|
+
* previous image did) stays in the process table after it exits */
|
|
50
|
+
export function jobAlive(pid: number): boolean {
|
|
51
|
+
if (pid <= 0 || !alive(pid)) return false
|
|
52
|
+
const st = Bun.spawnSync(['ps', '-o', 'stat=', '-p', String(pid)]).stdout.toString().trim()
|
|
53
|
+
return st !== '' && !st.startsWith('Z')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let seq = 0
|
|
57
|
+
|
|
58
|
+
/** what a job the daemon starts carries beside its flags: the run's options
|
|
59
|
+
* as the runner takes them, one lib copy for the repo config, and the
|
|
60
|
+
* machine's one background turn for a run nobody waits on */
|
|
61
|
+
export type JobOpts = { opts: Record<string, unknown>; oneLib?: boolean; turn?: boolean }
|
|
62
|
+
|
|
63
|
+
/** start `shipkit <kind> <args> --job <id>` detached from this process and
|
|
64
|
+
* its terminal, and return at once */
|
|
65
|
+
export function startJob(root: string, o: { cli: string; kind: JobKind; args: string[]; job?: JobOpts }): { id: string; pid: number } {
|
|
41
66
|
sweepJobs(root)
|
|
42
|
-
const id =
|
|
67
|
+
const id = `${o.kind}-${Date.now().toString(36)}-${process.pid.toString(36)}${(seq++).toString(36)}`
|
|
43
68
|
const dir = jobDir(root, id)
|
|
44
69
|
mkdirSync(dir, { recursive: true })
|
|
70
|
+
if (o.job != null) atomicWrite(jobOpts(root, id), JSON.stringify(o.job))
|
|
45
71
|
const out = openSync(join(dir, 'out.log'), 'a')
|
|
46
|
-
const child = spawn(process.execPath, [o.cli,
|
|
72
|
+
const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
|
|
47
73
|
closeSync(out)
|
|
48
74
|
child.unref()
|
|
49
75
|
const pid = child.pid ?? 0
|
|
50
|
-
atomicWrite(join(dir, 'job.json'), JSON.stringify({ id, pid, args: o.args, startedAt: Date.now() } satisfies Job))
|
|
76
|
+
atomicWrite(join(dir, 'job.json'), JSON.stringify({ id, pid, args: o.args, startedAt: Date.now(), kind: o.kind } satisfies Job))
|
|
77
|
+
return { id, pid }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `shipkit ci --detach`: a job, and the two lines that say how to read it back */
|
|
81
|
+
export function startDetached(root: string, o: { cli: string; args: string[] }): { id: string; pid: number; lines: string[] } {
|
|
82
|
+
const { id, pid } = startJob(root, { cli: o.cli, kind: 'ci', args: o.args })
|
|
51
83
|
return {
|
|
52
84
|
id,
|
|
53
85
|
pid,
|
|
@@ -58,6 +90,30 @@ export function startDetached(root: string, o: { cli: string; args: string[] }):
|
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
|
|
93
|
+
/** the options a daemon job was started with, null for a `--detach` one */
|
|
94
|
+
export const readJobOpts = (root: string, id: string): JobOpts | null => readJson<JobOpts>(jobOpts(root, id))
|
|
95
|
+
|
|
96
|
+
/** one verdict of the job, for whoever follows it */
|
|
97
|
+
export function appendJobCheck(root: string, id: string, e: unknown): void {
|
|
98
|
+
try { appendFileSync(jobChecks(root, id), `${JSON.stringify(e)}\n`) } catch { /* the folder was swept: the verdict is in the ledger anyway */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const readJob = (root: string, id: string): Job | null => readJson<Job>(join(jobDir(root, id), 'job.json'))
|
|
102
|
+
export const readExit = (root: string, id: string): Exit | null => readJson<Exit>(join(jobDir(root, id), 'exit.json'))
|
|
103
|
+
|
|
104
|
+
/** the jobs of this checkout still running: started, no verdict, pid alive */
|
|
105
|
+
export function liveJobs(root: string): Job[] {
|
|
106
|
+
const out: Job[] = []
|
|
107
|
+
try {
|
|
108
|
+
for (const name of readdirSync(join(root, JOBS))) {
|
|
109
|
+
const j = readJob(root, name)
|
|
110
|
+
if (j == null || existsSync(join(root, JOBS, name, 'exit.json')) || !jobAlive(j.pid)) continue
|
|
111
|
+
out.push(j)
|
|
112
|
+
}
|
|
113
|
+
} catch { /* no jobs yet */ }
|
|
114
|
+
return out.sort((a, b) => a.startedAt - b.startedAt)
|
|
115
|
+
}
|
|
116
|
+
|
|
61
117
|
/** the detached gate records how it ended. Called by the gate itself. */
|
|
62
118
|
export function recordExit(root: string, id: string, code: number): void {
|
|
63
119
|
const dir = jobDir(root, id)
|
|
@@ -85,7 +141,7 @@ export function jobStatus(root: string, id?: string): { code: number; lines: str
|
|
|
85
141
|
const dir = jobDir(root, want)
|
|
86
142
|
const job = readJson<Job>(join(dir, 'job.json'))
|
|
87
143
|
if (job == null) return { code: 2, lines: [`FAIL: no detached run '${want}' in this checkout`, ` fix: shipkit status (no id) shows the latest one`] }
|
|
88
|
-
const lines = [`ok: run ${job.id}: ${ciLine(job.args)} (pid ${job.pid}, started ${fmtStamp(job.startedAt)})`]
|
|
144
|
+
const lines = [`ok: run ${job.id}: ${ciLine(job.args, job.kind)} (pid ${job.pid}, started ${fmtStamp(job.startedAt)})`]
|
|
89
145
|
let out = ''
|
|
90
146
|
try { out = readFileSync(join(dir, 'out.log'), 'utf8') } catch { /* nothing printed yet */ }
|
|
91
147
|
lines.push(...out.split('\n').filter((l) => l !== ''))
|
|
@@ -97,22 +153,26 @@ export function jobStatus(root: string, id?: string): { code: number; lines: str
|
|
|
97
153
|
}
|
|
98
154
|
// it may have written its verdict between the two reads
|
|
99
155
|
e ??= exit()
|
|
100
|
-
if (e == null) return { code: 1, lines: [...lines, `FAIL: run ${job.id} died without a verdict (pid ${job.pid} is gone)`, ` fix: ${ciLine(job.args)}`] }
|
|
156
|
+
if (e == null) return { code: 1, lines: [...lines, `FAIL: run ${job.id} died without a verdict (pid ${job.pid} is gone)`, ` fix: ${ciLine(job.args, job.kind)}`] }
|
|
101
157
|
const took = `${((e.finishedAt - job.startedAt) / 1000).toFixed(1)}s`
|
|
102
158
|
lines.push(e.code === 0 ? `ok: run ${job.id} finished green in ${took}` : `FAIL: run ${job.id} finished red (exit ${e.code}) in ${took}`)
|
|
103
159
|
return { code: e.code, lines }
|
|
104
160
|
}
|
|
105
161
|
|
|
106
|
-
/** finished runs older than a week go
|
|
107
|
-
|
|
162
|
+
/** finished runs older than a week go, then the oldest past the count cap;
|
|
163
|
+
* a running one never does */
|
|
164
|
+
export function sweepJobs(root: string, now = Date.now(), o: { keepMs?: number; max?: number } = {}): void {
|
|
108
165
|
const base = join(root, JOBS)
|
|
166
|
+
let dirs: { dir: string; mtime: number }[]
|
|
109
167
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
168
|
+
dirs = readdirSync(base).flatMap((name) => {
|
|
169
|
+
try { return [{ dir: join(base, name), mtime: statSync(join(base, name)).mtimeMs }] } catch { return [] }
|
|
170
|
+
})
|
|
171
|
+
} catch { return /* no jobs yet: nothing to sweep */ }
|
|
172
|
+
dirs.sort((a, b) => b.mtime - a.mtime).forEach((d, i) => {
|
|
173
|
+
if (d.mtime > now - (o.keepMs ?? JOBS_KEEP_MS) && i < (o.max ?? JOBS_MAX)) return
|
|
174
|
+
const j = readJson<Job>(join(d.dir, 'job.json'))
|
|
175
|
+
if (j != null && alive(j.pid)) return
|
|
176
|
+
try { rmSync(d.dir, { recursive: true, force: true }) } catch { /* raced: gone either way */ }
|
|
177
|
+
})
|
|
118
178
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// It reads ONLY through the check context it is handed, so SK016 is cached
|
|
18
18
|
// on the listing of check files and their sources.
|
|
19
19
|
import { basename, join, relative, resolve } from 'path'
|
|
20
|
+
import { statementEnd } from './statement-end.ts'
|
|
20
21
|
import { fallbackNames, isCheckFilePath, readLegacyCheck, type LegacyCheck } from './check-file-names.ts'
|
|
21
22
|
import type { ScanCtx } from './check-ctx.ts'
|
|
22
23
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -177,20 +178,6 @@ export const checkFileName = (id: string): string => `${id.replace(/[^A-Za-z0-9.
|
|
|
177
178
|
|
|
178
179
|
// ── editing repo.config.ts ───────────────────────────────────────────────
|
|
179
180
|
|
|
180
|
-
/** a statement that starts on `i`, ending where its brackets balance and the
|
|
181
|
-
* line is not obviously continued. Deliberately simple: anything it cannot
|
|
182
|
-
* read lands as a hand pass rather than as a half rewritten config. */
|
|
183
|
-
function statementEnd(lines: string[], i: number): number {
|
|
184
|
-
let depth = 0
|
|
185
|
-
for (let j = i; j < lines.length; j++) {
|
|
186
|
-
for (const ch of lines[j]!) {
|
|
187
|
-
if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
188
|
-
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
|
189
|
-
}
|
|
190
|
-
if (depth <= 0) return j
|
|
191
|
-
}
|
|
192
|
-
return lines.length - 1
|
|
193
|
-
}
|
|
194
181
|
|
|
195
182
|
/** the config text without one top level key, its trailing comma and the
|
|
196
183
|
* comment block written above it. Null when the key is not there, or when it
|