rvlib-shipkit 1.1.0 → 1.2.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/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- 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/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -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 +208 -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 +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -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 +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -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/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- 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 +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- 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 +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- 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/{hub-st.ts → repo-st.ts} +155 -84
- 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/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- 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 +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -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/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// THE MACHINE DB: what shipkit remembers about this machine, whatever repo
|
|
2
|
+
// asks, in one sqlite file: pm's children and resources (their pid records),
|
|
3
|
+
// the repos flagged active, the rail order, the self update answer, the
|
|
4
|
+
// instance registry. One `state` table of named json values.
|
|
5
|
+
//
|
|
6
|
+
// The one constraint a reader breaks first: a failure reads as "nothing
|
|
7
|
+
// remembered", and every caller treats that as the safe side (no adoption,
|
|
8
|
+
// no replay, the pid rule). Slot LOCKS are not here: they are leases a dead
|
|
9
|
+
// pid must release at once, which a file does and a row does not.
|
|
10
|
+
import { Database } from 'bun:sqlite'
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
|
12
|
+
import { homedir } from 'os'
|
|
13
|
+
import { dirname, join } from 'path'
|
|
14
|
+
import { ensureWal } from '../ledger/db.ts'
|
|
15
|
+
|
|
16
|
+
export const MACHINE_DB_ENV = 'SHIPKIT_MACHINE_DB'
|
|
17
|
+
|
|
18
|
+
/** `~/.config/shipkit/shipkit.sqlite`; beside a moved home dir when one is
|
|
19
|
+
* set, so a smoke or a suite with its own home never touches the real one */
|
|
20
|
+
export function machineDbPath(): string {
|
|
21
|
+
const env = process.env[MACHINE_DB_ENV]
|
|
22
|
+
if (env != null && env !== '') return env
|
|
23
|
+
const home = process.env.SHIPKIT_HOME_DIR
|
|
24
|
+
if (home != null && home !== '') return join(home, 'machine.sqlite')
|
|
25
|
+
return join(homedir(), '.config', 'shipkit', 'shipkit.sqlite')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a machine write waits this long on another process's */
|
|
29
|
+
const BUSY_MS = 2_000
|
|
30
|
+
|
|
31
|
+
const SCHEMA = 'create table if not exists state (name text primary key, value text not null, at integer not null)'
|
|
32
|
+
|
|
33
|
+
function withMachine<R>(body: (db: Database) => R, path = machineDbPath()): R | null {
|
|
34
|
+
let db: Database | null = null
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
37
|
+
db = new Database(path, { create: true, readwrite: true })
|
|
38
|
+
db.exec(`pragma busy_timeout = ${BUSY_MS}`)
|
|
39
|
+
ensureWal(db, BUSY_MS)
|
|
40
|
+
db.exec(SCHEMA)
|
|
41
|
+
return body(db)
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
} finally {
|
|
45
|
+
try { db?.close() } catch { /* never opened */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function machineGet<T>(name: string): T | null {
|
|
50
|
+
const row = withMachine((db) => db.query('select value from state where name = ?').get(name) as { value: string } | null)
|
|
51
|
+
if (row == null) return null
|
|
52
|
+
try { return JSON.parse(row.value) as T } catch { return null }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function machineSet(name: string, value: unknown): boolean {
|
|
56
|
+
return withMachine((db) => {
|
|
57
|
+
db.query('insert or replace into state (name, value, at) values (?, ?, ?)').run(name, JSON.stringify(value), Date.now())
|
|
58
|
+
return true
|
|
59
|
+
}) ?? false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function machineDelete(name: string): void {
|
|
63
|
+
withMachine((db) => db.query('delete from state where name = ?').run(name))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** every value whose name starts with `prefix` */
|
|
67
|
+
export function machineList<T>(prefix: string): { name: string; value: T; at: number }[] {
|
|
68
|
+
const rows = withMachine((db) => db.query('select name, value, at from state where substr(name, 1, ?) = ?').all(prefix.length, prefix) as { name: string; value: string; at: number }[]) ?? []
|
|
69
|
+
return rows.flatMap((r) => {
|
|
70
|
+
try { return [{ name: r.name, value: JSON.parse(r.value) as T, at: r.at }] } catch { return [] }
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** the migration of one json file: its value becomes the row, then the file
|
|
75
|
+
* goes. Only when the row does not exist yet, so a file an older copy wrote
|
|
76
|
+
* after the move never overwrites a newer row */
|
|
77
|
+
export function importMachineFile<T>(name: string, file: string, parse: (text: string) => T = (t) => JSON.parse(t) as T): void {
|
|
78
|
+
if (!existsSync(file)) return
|
|
79
|
+
try {
|
|
80
|
+
if (machineGet(name) == null) machineSet(name, parse(readFileSync(file, 'utf8')))
|
|
81
|
+
rmSync(file, { force: true })
|
|
82
|
+
} catch { /* an unreadable file is left for a person to look at */ }
|
|
83
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// one copy of the toolkit per process: a process that loads several repos
|
|
2
|
+
// (the home) makes every `rvlib-shipkit` import, from any repo config or check
|
|
3
|
+
// file, land on its OWN copy. A repo's install is never loaded, so there is one
|
|
4
|
+
// module instance per lib file: one mobx, one class identity, one version.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: register it BEFORE the first repo
|
|
7
|
+
// config is imported. A module already loaded keeps the copy it resolved.
|
|
8
|
+
import { readFileSync, realpathSync } from 'fs'
|
|
9
|
+
import { extname, join } from 'path'
|
|
10
|
+
import { LIB_ROOT } from './lib-root.ts'
|
|
11
|
+
|
|
12
|
+
const NAME = 'rvlib-shipkit'
|
|
13
|
+
|
|
14
|
+
type Exports = Record<string, string>
|
|
15
|
+
|
|
16
|
+
const readExports = (root: string): Exports => {
|
|
17
|
+
try {
|
|
18
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { exports?: Exports }
|
|
19
|
+
return pkg.exports ?? { '.': './src/shipkit.ts' }
|
|
20
|
+
} catch {
|
|
21
|
+
return { '.': './src/shipkit.ts' }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** the file a specifier names inside `root`, through its package exports
|
|
26
|
+
* (`.`, `./contracts`, `./src/*`), or null when it is not this package */
|
|
27
|
+
export function libTarget(spec: string, root: string = LIB_ROOT, exports: Exports = readExports(root)): string | null {
|
|
28
|
+
if (spec !== NAME && !spec.startsWith(`${NAME}/`)) return null
|
|
29
|
+
const sub = `.${spec.slice(NAME.length)}`
|
|
30
|
+
const exact = exports[sub]
|
|
31
|
+
if (exact != null) return join(root, exact)
|
|
32
|
+
for (const [k, v] of Object.entries(exports)) {
|
|
33
|
+
if (!k.endsWith('/*') || !sub.startsWith(k.slice(0, -1))) continue
|
|
34
|
+
return join(root, v.slice(0, -1) + sub.slice(k.length - 1))
|
|
35
|
+
}
|
|
36
|
+
return join(root, sub)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** a file of ANOTHER copy of the package (a repo's node_modules, bun's
|
|
40
|
+
* install cache), as the path inside that copy, else null */
|
|
41
|
+
export function foreignLibPath(path: string, root: string = LIB_ROOT): string | null {
|
|
42
|
+
const m = /\/(?:node_modules\/rvlib-shipkit|install\/cache\/rvlib-shipkit@[^/]*)\/(.+)$/.exec(path)
|
|
43
|
+
if (m == null) return null
|
|
44
|
+
const own = real(root)
|
|
45
|
+
if (path.startsWith(`${root}/`) || real(path).startsWith(`${own}/`)) return null
|
|
46
|
+
return m[1]!
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const real = (p: string): string => {
|
|
50
|
+
try {
|
|
51
|
+
return realpathSync(p)
|
|
52
|
+
} catch {
|
|
53
|
+
return p
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.tsx': 'tsx', '.js': 'js', '.mjs': 'js', '.jsx': 'jsx' }
|
|
58
|
+
|
|
59
|
+
let installed = false
|
|
60
|
+
|
|
61
|
+
/** route every import of the package to this process's copy. Idempotent */
|
|
62
|
+
export function useOneLib(root: string = LIB_ROOT): void {
|
|
63
|
+
if (installed) return
|
|
64
|
+
installed = true
|
|
65
|
+
const exports = readExports(root)
|
|
66
|
+
Bun.plugin({
|
|
67
|
+
name: 'shipkit-one-lib',
|
|
68
|
+
setup(build) {
|
|
69
|
+
// a subpath is resolved here, before any other copy is looked at
|
|
70
|
+
build.onResolve({ filter: /^rvlib-shipkit\// }, (a) => {
|
|
71
|
+
const path = libTarget(a.path, root, exports)
|
|
72
|
+
return path == null ? undefined : { path }
|
|
73
|
+
})
|
|
74
|
+
// the bare name never reaches onResolve at runtime, so the file
|
|
75
|
+
// another copy resolved to is loaded as a re-export of ours
|
|
76
|
+
build.onLoad({ filter: /\/(node_modules\/rvlib-shipkit|install\/cache\/rvlib-shipkit@[^/]*)\// }, (a) => {
|
|
77
|
+
const loader = LOADERS[extname(a.path)] ?? 'ts'
|
|
78
|
+
const sub = foreignLibPath(a.path, root)
|
|
79
|
+
if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader }
|
|
80
|
+
const target = JSON.stringify(join(root, sub))
|
|
81
|
+
return { contents: `export * from ${target}\nimport * as own from ${target}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// who holds a local tcp port, and the process group a pid runs in: what a
|
|
2
|
+
// takeover of a server started elsewhere needs to stop it. Unreadable (no
|
|
3
|
+
// lsof, no ps) means an empty answer, never a throw.
|
|
4
|
+
import { existsSync } from 'fs'
|
|
5
|
+
|
|
6
|
+
/** the pids LISTENING on a tcp port, on darwin and linux. `-sTCP:LISTEN`
|
|
7
|
+
* carries the whole answer: `-i tcp:<port>` alone also matches every client
|
|
8
|
+
* connected to it, this process included, and those must never be killed.
|
|
9
|
+
* lsof sits in /usr/sbin on darwin, which a gui-launched shell can miss */
|
|
10
|
+
export function portHolders(port: number): number[] {
|
|
11
|
+
const lsof = Bun.which('lsof') ?? ['/usr/sbin/lsof', '/usr/bin/lsof'].find((p) => existsSync(p))
|
|
12
|
+
if (lsof == null) return []
|
|
13
|
+
const r = Bun.spawnSync([lsof, '-ti', `tcp:${port}`, '-sTCP:LISTEN'], { stdout: 'pipe', stderr: 'ignore' })
|
|
14
|
+
const pids = r.stdout
|
|
15
|
+
.toString()
|
|
16
|
+
.split('\n')
|
|
17
|
+
.map((l) => Number(l.trim()))
|
|
18
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
19
|
+
return [...new Set(pids)]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** the process group of a pid, null when ps cannot say */
|
|
23
|
+
export function groupOf(pid: number): number | null {
|
|
24
|
+
const ps = Bun.which('ps') ?? (existsSync('/bin/ps') ? '/bin/ps' : null)
|
|
25
|
+
if (ps == null) return null
|
|
26
|
+
const r = Bun.spawnSync([ps, '-o', 'pgid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
|
|
27
|
+
const g = Number(r.stdout.toString().trim())
|
|
28
|
+
return Number.isInteger(g) && g > 0 ? g : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** the port a local url serves on, null for a remote host or no url */
|
|
32
|
+
export function localPort(url: string | null): number | null {
|
|
33
|
+
if (url == null) return null
|
|
34
|
+
let u: URL
|
|
35
|
+
try {
|
|
36
|
+
u = new URL(url)
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
if (!['127.0.0.1', 'localhost', '[::1]', '0.0.0.0'].includes(u.hostname)) return null
|
|
41
|
+
const p = u.port !== '' ? Number(u.port) : u.protocol === 'https:' ? 443 : u.protocol === 'http:' ? 80 : NaN
|
|
42
|
+
return Number.isInteger(p) && p > 0 ? p : null
|
|
43
|
+
}
|