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.
Files changed (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. 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) and `exit.json` (the verdict, once).
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 Job = { id: string; pid: number; args: string[]; startedAt: number }
20
- type Exit = { code: number; finishedAt: number }
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 KEEP_MS = 7 * 24 * 60 * 60 * 1000
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 => ['shipkit ci', ...args].join(' ')
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
- /** start `shipkit ci <args> --job <id>` detached from this process and its
39
- * terminal, and return at once */
40
- export function startDetached(root: string, o: { cli: string; args: string[] }): { id: string; pid: number; lines: string[] } {
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 = `ci-${Date.now().toString(36)}-${process.pid.toString(36)}`
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, 'ci', ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
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; a running one never does */
107
- function sweepJobs(root: string): void {
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
- for (const name of readdirSync(base)) {
111
- const dir = join(base, name)
112
- const j = readJson<Job>(join(dir, 'job.json'))
113
- try { if (statSync(dir).mtimeMs > Date.now() - KEEP_MS) continue } catch { continue }
114
- if (j != null && alive(j.pid)) continue
115
- rmSync(dir, { recursive: true, force: true })
116
- }
117
- } catch { /* no jobs yet: nothing to sweep */ }
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
+ }