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,213 @@
|
|
|
1
|
+
// NOTHING GROWS UNBOUND: every store shipkit writes declares a cap here (an
|
|
2
|
+
// age, a count, a size), and one sweep enforces all of them. The daemon runs
|
|
3
|
+
// it on boot and hourly for every repo it loads and for the machine; rule
|
|
4
|
+
// SK023 fails when a store is past its cap, so a sweep that stopped running
|
|
5
|
+
// is loud. Each store's sweep lives beside the code that writes it, so a gate
|
|
6
|
+
// on a machine with no daemon still keeps its own stores in bounds.
|
|
7
|
+
//
|
|
8
|
+
// The one constraint a reader breaks first: a sweep never removes what a live
|
|
9
|
+
// process may still use. Every sweep below checks a pid or an age first.
|
|
10
|
+
import { existsSync, readdirSync, statSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { INFLIGHT_KEEP_MS, sweepSlots } from './attach.ts'
|
|
13
|
+
import { RUN_DIR_KEEP_MS, RUN_DIR_MAX, runEntries, sweepRunDirs } from './check-runner.ts'
|
|
14
|
+
import { gitRoot } from './git-root.ts'
|
|
15
|
+
import { JOBS, JOBS_KEEP_MS, JOBS_MAX, sweepJobs } from './jobs.ts'
|
|
16
|
+
import { CI_LOG_DIR } from './layout.ts'
|
|
17
|
+
import { compactDb, dbPath, shipkitRoot } from '../ledger/db.ts'
|
|
18
|
+
import { SCAN_INDEX_KEEP_MS, sweepScanIndexes } from '../ledger/git-scan.ts'
|
|
19
|
+
import { capLog, LOG_MAX_BYTES } from '../home/supervise.ts'
|
|
20
|
+
import { processDir } from '../serve/repo-processes.ts'
|
|
21
|
+
import { KEEP_MS as RECORD_KEEP_MS, MAX_ROWS, recordUse, sweepRuns } from '../ledger/run-log.ts'
|
|
22
|
+
import { answersDb, answersUse, importLegacyStore, KEEP_MS as ANSWERS_KEEP_MS, legacyStoreDir, MAX_ANSWERS, sweepAnswers } from '../ledger/store.ts'
|
|
23
|
+
|
|
24
|
+
export type Cap = {
|
|
25
|
+
/** the oldest entry may be this old */
|
|
26
|
+
maxAgeMs?: number
|
|
27
|
+
/** at most this many entries */
|
|
28
|
+
maxCount?: number
|
|
29
|
+
/** every file at most this big */
|
|
30
|
+
maxFileBytes?: number
|
|
31
|
+
/** all of it at most this big */
|
|
32
|
+
maxBytes?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Use = { count: number; oldestAt: number | null; bytes: number; largestFile: number }
|
|
36
|
+
|
|
37
|
+
export type Store = {
|
|
38
|
+
id: string
|
|
39
|
+
/** where it lives, as a person would look for it */
|
|
40
|
+
where: string
|
|
41
|
+
cap: Cap
|
|
42
|
+
/** what it holds now, null when it cannot be read */
|
|
43
|
+
measure(): Use | null
|
|
44
|
+
/** bring it back under its cap */
|
|
45
|
+
sweep(now: number): void | Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** how far past its cap a store may drift between two sweeps before SK023
|
|
49
|
+
* calls it over: the daemon sweeps hourly, and gates write in between */
|
|
50
|
+
export const SLACK = { factor: 2, ageMs: 2 * 60 * 60 * 1000 }
|
|
51
|
+
|
|
52
|
+
/** why a store is past its cap (with the drift a sweep interval allows), or
|
|
53
|
+
* null while it is inside */
|
|
54
|
+
export function overCap(use: Use, cap: Cap, now: number, slack = SLACK): string | null {
|
|
55
|
+
if (cap.maxCount != null && use.count > Math.max(cap.maxCount * slack.factor, cap.maxCount)) return `${use.count} entries, cap ${cap.maxCount}`
|
|
56
|
+
if (cap.maxAgeMs != null && use.oldestAt != null && now - use.oldestAt > cap.maxAgeMs + slack.ageMs) return `oldest ${fmtAge(now - use.oldestAt)} old, cap ${fmtAge(cap.maxAgeMs)}`
|
|
57
|
+
if (cap.maxFileBytes != null && use.largestFile > cap.maxFileBytes * slack.factor) return `a file of ${fmtBytes(use.largestFile)}, cap ${fmtBytes(cap.maxFileBytes)}`
|
|
58
|
+
if (cap.maxBytes != null && use.bytes > cap.maxBytes * slack.factor) return `${fmtBytes(use.bytes)}, cap ${fmtBytes(cap.maxBytes)}`
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const fmtBytes = (n: number): string => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`)
|
|
63
|
+
export const fmtAge = (ms: number): string => (ms >= 86_400_000 ? `${Math.round(ms / 86_400_000)}d` : ms >= 3_600_000 ? `${Math.round(ms / 3_600_000)}h` : `${Math.round(ms / 60_000)}m`)
|
|
64
|
+
|
|
65
|
+
/** every file under a dir (not following links), skipping dirs `skip` names */
|
|
66
|
+
export function walkUse(dir: string, o: { match?: (name: string) => boolean; skip?: (name: string) => boolean } = {}): Use | null {
|
|
67
|
+
if (!existsSync(dir)) return { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
|
|
68
|
+
const use: Use = { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
|
|
69
|
+
const walk = (d: string): void => {
|
|
70
|
+
let list
|
|
71
|
+
try { list = readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
72
|
+
for (const e of list) {
|
|
73
|
+
const p = join(d, e.name)
|
|
74
|
+
if (e.isDirectory()) { if (o.skip?.(e.name) !== true) walk(p); continue }
|
|
75
|
+
if (!e.isFile() || (o.match != null && !o.match(e.name))) continue
|
|
76
|
+
try {
|
|
77
|
+
const s = statSync(p)
|
|
78
|
+
use.count++
|
|
79
|
+
use.bytes += s.size
|
|
80
|
+
use.largestFile = Math.max(use.largestFile, s.size)
|
|
81
|
+
use.oldestAt = use.oldestAt == null ? s.mtimeMs : Math.min(use.oldestAt, s.mtimeMs)
|
|
82
|
+
} catch { /* raced: gone */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
walk(dir)
|
|
86
|
+
return use
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const counted = (count: number, oldestAt: number | null): Use => ({ count, oldestAt, bytes: 0, largestFile: 0 })
|
|
90
|
+
|
|
91
|
+
/** the stores one repo holds */
|
|
92
|
+
export function repoStores(root: string): Store[] {
|
|
93
|
+
const sk = shipkitRoot(root)
|
|
94
|
+
const logs = join(root, CI_LOG_DIR)
|
|
95
|
+
const jobs = join(root, JOBS)
|
|
96
|
+
const gitDir = gitRoot(root)?.gitDir
|
|
97
|
+
const stores: Store[] = [
|
|
98
|
+
{
|
|
99
|
+
id: 'answers',
|
|
100
|
+
where: `${answersDb(root)} (answers)`,
|
|
101
|
+
cap: { maxAgeMs: ANSWERS_KEEP_MS, maxCount: MAX_ANSWERS },
|
|
102
|
+
measure: () => {
|
|
103
|
+
if (!existsSync(answersDb(root))) return counted(0, null)
|
|
104
|
+
const u = answersUse(answersDb(root))
|
|
105
|
+
return u == null ? null : counted(u.count, u.oldestUsed)
|
|
106
|
+
},
|
|
107
|
+
sweep: (now) => { sweepAnswers(answersDb(root), now) },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
// the json store this table replaced: read once, then gone
|
|
111
|
+
id: 'json answers',
|
|
112
|
+
where: legacyStoreDir(root),
|
|
113
|
+
cap: { maxCount: 0 },
|
|
114
|
+
measure: () => walkUse(legacyStoreDir(root)),
|
|
115
|
+
sweep: async (now) => { await importLegacyStore(root, now) },
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'run record',
|
|
119
|
+
where: `${dbPath(root)} (runs, invocations, suites)`,
|
|
120
|
+
cap: { maxAgeMs: RECORD_KEEP_MS, maxCount: MAX_ROWS },
|
|
121
|
+
measure: () => {
|
|
122
|
+
if (!existsSync(dbPath(root))) return counted(0, null)
|
|
123
|
+
const u = recordUse(dbPath(root))
|
|
124
|
+
return u == null ? null : counted(u.count, u.oldest)
|
|
125
|
+
},
|
|
126
|
+
// the last db store: the answers above and these rows are one
|
|
127
|
+
// file, which the deletes leave full of free pages
|
|
128
|
+
sweep: (now) => {
|
|
129
|
+
sweepRuns(dbPath(root), now)
|
|
130
|
+
compactDb(dbPath(root))
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'inflight',
|
|
135
|
+
where: join(sk, 'inflight'),
|
|
136
|
+
cap: { maxAgeMs: INFLIGHT_KEEP_MS },
|
|
137
|
+
measure: () => walkUse(join(sk, 'inflight')),
|
|
138
|
+
sweep: (now) => sweepSlots(join(sk, 'inflight'), now),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'run logs',
|
|
142
|
+
where: logs,
|
|
143
|
+
cap: { maxAgeMs: RUN_DIR_KEEP_MS, maxCount: RUN_DIR_MAX },
|
|
144
|
+
measure: () => {
|
|
145
|
+
const all = runEntries(logs)
|
|
146
|
+
return counted(all.length, all.length === 0 ? null : Math.min(...all.map((e) => e.started)))
|
|
147
|
+
},
|
|
148
|
+
sweep: (now) => sweepRunDirs(logs, now),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'jobs',
|
|
152
|
+
where: jobs,
|
|
153
|
+
cap: { maxAgeMs: JOBS_KEEP_MS, maxCount: JOBS_MAX },
|
|
154
|
+
measure: () => {
|
|
155
|
+
let names: string[] = []
|
|
156
|
+
try { names = readdirSync(jobs) } catch { return counted(0, null) }
|
|
157
|
+
let oldest: number | null = null
|
|
158
|
+
for (const n of names) try { const m = statSync(join(jobs, n)).mtimeMs; oldest = oldest == null ? m : Math.min(oldest, m) } catch { /* raced */ }
|
|
159
|
+
return counted(names.length, oldest)
|
|
160
|
+
},
|
|
161
|
+
sweep: (now) => sweepJobs(root, now),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
// the dev servers the hub runs for the repo (serve.processes)
|
|
165
|
+
id: 'process logs',
|
|
166
|
+
where: `${processDir(root)}/*.log`,
|
|
167
|
+
cap: { maxFileBytes: LOG_MAX_BYTES },
|
|
168
|
+
measure: () => walkUse(processDir(root), { match: (n) => n.endsWith('.log') }),
|
|
169
|
+
sweep: () => {
|
|
170
|
+
let names: string[] = []
|
|
171
|
+
try { names = readdirSync(processDir(root)) } catch { return }
|
|
172
|
+
for (const n of names) if (n.endsWith('.log')) capLog(join(processDir(root), n))
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
if (gitDir != null) {
|
|
177
|
+
const dir = join(gitDir, 'shipkit')
|
|
178
|
+
stores.push({
|
|
179
|
+
id: 'scan indexes',
|
|
180
|
+
where: `${dir}/scan-<pid>.idx`,
|
|
181
|
+
// one per process scanning at once, and a machine runs a handful
|
|
182
|
+
cap: { maxAgeMs: SCAN_INDEX_KEEP_MS, maxCount: 16 },
|
|
183
|
+
measure: () => walkUse(dir, { match: (n) => /^scan-\d+\.idx(\.tmp)?$/.test(n), skip: () => true }),
|
|
184
|
+
sweep: (now) => sweepScanIndexes(dir, now),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return stores
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type Measured = { store: Store; use: Use | null; over: string | null }
|
|
191
|
+
|
|
192
|
+
export function measureStores(stores: Store[], now = Date.now()): Measured[] {
|
|
193
|
+
return stores.map((store) => {
|
|
194
|
+
const use = store.measure()
|
|
195
|
+
return { store, use, over: use == null ? null : overCap(use, store.cap, now) }
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** sweep every store, one after the other, yielding between them so a
|
|
200
|
+
* daemon keeps serving. A sweep that throws is reported, never fatal */
|
|
201
|
+
export async function sweepStores(stores: Store[], now = Date.now()): Promise<{ id: string; error: string | null }[]> {
|
|
202
|
+
const out: { id: string; error: string | null }[] = []
|
|
203
|
+
for (const s of stores) {
|
|
204
|
+
try {
|
|
205
|
+
await s.sweep(now)
|
|
206
|
+
out.push({ id: s.id, error: null })
|
|
207
|
+
} catch (e) {
|
|
208
|
+
out.push({ id: s.id, error: (e as Error).message })
|
|
209
|
+
}
|
|
210
|
+
await Bun.sleep(0)
|
|
211
|
+
}
|
|
212
|
+
return out
|
|
213
|
+
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { join } from 'path'
|
|
|
12
12
|
import { composeChecks, envTier, gateInventory, type GateCheck, type TestTier } from '../ledger/gate-checks.ts'
|
|
13
13
|
import { Ledger } from '../ledger/ledger.ts'
|
|
14
14
|
import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
|
|
15
|
-
import {
|
|
15
|
+
import { answersDb } from '../ledger/store.ts'
|
|
16
16
|
import { gateBinary } from '../ledger/toolchain-identity.ts'
|
|
17
17
|
import { runChecks, selectChecks, type Audience, type CheckEvent, type CheckResult, type RunnableCheck } from './check-runner.ts'
|
|
18
18
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
@@ -55,6 +55,9 @@ export type CheckOpts = {
|
|
|
55
55
|
onlyBlocking?: boolean
|
|
56
56
|
/** which test tier the caller is asking for */
|
|
57
57
|
tier?: TestTier
|
|
58
|
+
/** colour the wire lines. Default: this process's stdout is a terminal. A
|
|
59
|
+
* hub running a terminal's gate passes the terminal's answer */
|
|
60
|
+
color?: boolean
|
|
58
61
|
/** which question: the gate's set (`shipkit ci`) or the health pass's */
|
|
59
62
|
audience?: Audience
|
|
60
63
|
/** the caller narrowed the run to these ids (`--only`) */
|
|
@@ -187,11 +190,12 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
187
190
|
root,
|
|
188
191
|
config: c,
|
|
189
192
|
logDir: c.logDir ?? join(root, CI_LOG_DIR),
|
|
190
|
-
cache: {
|
|
193
|
+
cache: { db: answersDb(root), ns: c.name, now: Date.now },
|
|
191
194
|
ledger,
|
|
192
195
|
log,
|
|
193
196
|
childEnv: { [SHARE_ENV]: sharePath, ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}) },
|
|
194
197
|
bail: o.bail,
|
|
198
|
+
color: o.color,
|
|
195
199
|
print: sink,
|
|
196
200
|
onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
|
|
197
201
|
})
|
package/src/core/run-ci.ts
CHANGED
|
@@ -53,6 +53,8 @@ export type CiOpts = {
|
|
|
53
53
|
* own panel, and running them twice per refresh proves the same thing
|
|
54
54
|
* twice. */
|
|
55
55
|
buildOnly?: boolean
|
|
56
|
+
/** colour the wire lines (run-check.ts CheckOpts.color) */
|
|
57
|
+
color?: boolean
|
|
56
58
|
/** line sink — commands pass their context stream; defaults to console */
|
|
57
59
|
print?: (line: string) => void
|
|
58
60
|
/** per-CHECK completion tap, handed straight to the runner. The hub passes
|
|
@@ -187,6 +189,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
187
189
|
buildOnly: o.buildOnly,
|
|
188
190
|
bail: o.bail,
|
|
189
191
|
fresh: o.fresh,
|
|
192
|
+
color: o.color,
|
|
190
193
|
tier,
|
|
191
194
|
}
|
|
192
195
|
try {
|
package/src/core/slots.ts
CHANGED
|
@@ -30,6 +30,11 @@ export const poolDir = (): string => {
|
|
|
30
30
|
return env != null && env !== '' ? env : join(homedir(), '.cache', 'shipkit', 'slots')
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** the TURN the background gates take, one at a time on the whole machine:
|
|
34
|
+
* every batch the plan runs, for every repo. A gate someone asked for
|
|
35
|
+
* (a terminal, a button) never takes it, so it never waits behind one */
|
|
36
|
+
export const autoGatePool = (): SlotPool => ({ dir: `${poolDir()}-auto-gate`, size: 1 })
|
|
37
|
+
|
|
33
38
|
/** this process's pool, or null when it runs inside a check that holds one */
|
|
34
39
|
export function machinePool(): SlotPool | null {
|
|
35
40
|
return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// where a statement of a config file ends, read as text. Pure, so the page
|
|
2
|
+
// can import it too.
|
|
3
|
+
|
|
4
|
+
/** a statement that starts on `i`, ending where its brackets balance and the
|
|
5
|
+
* line is not obviously continued. Deliberately simple: anything it cannot
|
|
6
|
+
* read lands as a hand pass rather than as a half rewritten config. */
|
|
7
|
+
export function statementEnd(lines: string[], i: number): number {
|
|
8
|
+
let depth = 0
|
|
9
|
+
for (let j = i; j < lines.length; j++) {
|
|
10
|
+
for (const ch of lines[j]!) {
|
|
11
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
12
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
|
13
|
+
}
|
|
14
|
+
if (depth <= 0) return j
|
|
15
|
+
}
|
|
16
|
+
return lines.length - 1
|
|
17
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `shipkit serve` while a home runs: the hub itself runs in the home, and
|
|
2
|
+
// this process only streams its lines. So the VS Code folder-open task keeps its
|
|
3
|
+
// problem matcher and its clickable url, with no change to any repo's
|
|
4
|
+
// tasks.json, and closing VS Code no longer stops the hub.
|
|
5
|
+
//
|
|
6
|
+
// the log is what the home prints for that hub, so the frames the matcher
|
|
7
|
+
// reads arrive as the hub prints them. On attach, the last complete frame is replayed first,
|
|
8
|
+
// so the problems pane fills at once instead of at the next run.
|
|
9
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'fs'
|
|
10
|
+
import { FRAME_BEGIN, FRAME_END_HEAD } from '../problems/problem-line.ts'
|
|
11
|
+
import { daemonUrl, homeDir, homeUrl, hubBase, hubLog } from './home-paths.ts'
|
|
12
|
+
|
|
13
|
+
/** the last complete problem frame in a log's text, begin to end line, or [] */
|
|
14
|
+
export function lastFrame(text: string): string[] {
|
|
15
|
+
const lines = text.split('\n')
|
|
16
|
+
for (let end = lines.length - 1; end >= 0; end--) {
|
|
17
|
+
if (!lines[end]!.startsWith(FRAME_END_HEAD)) continue
|
|
18
|
+
for (let begin = end - 1; begin >= 0; begin--) {
|
|
19
|
+
if (lines[begin] === FRAME_BEGIN) return lines.slice(begin, end + 1)
|
|
20
|
+
if (lines[begin]!.startsWith(FRAME_END_HEAD)) break
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** how often the log is looked at for new bytes */
|
|
27
|
+
export const FOLLOW_MS = 300
|
|
28
|
+
/** the home is asked again this often, so a stream says when it went away */
|
|
29
|
+
export const ENSURE_MS = 60_000
|
|
30
|
+
|
|
31
|
+
type EnsureAnswer = { status?: string; url?: string; blocked?: string | null; note?: string | null }
|
|
32
|
+
|
|
33
|
+
async function ensure(name: string): Promise<EnsureAnswer | string> {
|
|
34
|
+
try {
|
|
35
|
+
const r = await fetch(`${daemonUrl()}/api/home/hub`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, act: 'ensure' }), signal: AbortSignal.timeout(3_000) })
|
|
36
|
+
if (!r.ok) return `${r.status} ${await r.text()}`
|
|
37
|
+
return (await r.json()) as EnsureAnswer
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return (e as Error).message
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** hand the hub to the home and stream its log until killed. 'serve' when
|
|
44
|
+
* the home will not run it (not one of its repos): the caller serves itself */
|
|
45
|
+
export async function attachToHome(name: string, io: { print: (l: string) => void; printErr: (l: string) => void }): Promise<number | 'serve'> {
|
|
46
|
+
const first = await ensure(name)
|
|
47
|
+
if (typeof first === 'string' || first.blocked != null) {
|
|
48
|
+
io.print(` the home did not take ${name}'s hub (${typeof first === 'string' ? first : first.blocked}), so this process serves it`)
|
|
49
|
+
return 'serve'
|
|
50
|
+
}
|
|
51
|
+
io.print(`${name} hub → ${homeUrl().replace('127.0.0.1', 'localhost')}${first.url ?? `${hubBase(name)}/`} (run by the home)`)
|
|
52
|
+
const log = hubLog(homeDir(), name)
|
|
53
|
+
let pos = 0
|
|
54
|
+
if (existsSync(log)) {
|
|
55
|
+
const text = await Bun.file(log).text()
|
|
56
|
+
for (const l of lastFrame(text)) io.print(l)
|
|
57
|
+
pos = Buffer.byteLength(text)
|
|
58
|
+
}
|
|
59
|
+
let partial = ''
|
|
60
|
+
const follow = (): void => {
|
|
61
|
+
let size: number
|
|
62
|
+
try {
|
|
63
|
+
size = statSync(log).size
|
|
64
|
+
} catch {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
// the home cut the log (capLog) or restarted the hub on a new one
|
|
68
|
+
if (size < pos) pos = 0
|
|
69
|
+
if (size === pos) return
|
|
70
|
+
const fd = openSync(log, 'r')
|
|
71
|
+
try {
|
|
72
|
+
const buf = Buffer.alloc(size - pos)
|
|
73
|
+
readSync(fd, buf, 0, buf.length, pos)
|
|
74
|
+
pos = size
|
|
75
|
+
const text = partial + buf.toString('utf8')
|
|
76
|
+
const lines = text.split('\n')
|
|
77
|
+
partial = lines.pop() ?? ''
|
|
78
|
+
for (const l of lines) io.print(l)
|
|
79
|
+
} finally {
|
|
80
|
+
closeSync(fd)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
setInterval(follow, FOLLOW_MS)
|
|
84
|
+
let warned = false
|
|
85
|
+
setInterval(() => {
|
|
86
|
+
void ensure(name).then((r) => {
|
|
87
|
+
if (typeof r === 'string') {
|
|
88
|
+
if (!warned) io.printErr(`WARN the home stopped answering (${r}), so this repo's hub is down with it until launchd brings the home back`)
|
|
89
|
+
warned = true
|
|
90
|
+
} else warned = false
|
|
91
|
+
})
|
|
92
|
+
}, ENSURE_MS)
|
|
93
|
+
await new Promise(() => {})
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// any shipkit command on a machine with a house starts pm first when it does
|
|
2
|
+
// not answer: one localhost probe, then a launchd kickstart, and the agent is
|
|
3
|
+
// installed from the house repo the first time. The command itself never waits
|
|
4
|
+
// for pm: a client that needs the daemon (ci, check, serve) waits on its own.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: a `shipkit pm uninstall` is a
|
|
7
|
+
// machine saying no. It leaves an off marker, and nothing here installs pm
|
|
8
|
+
// again until `shipkit pm install` lifts it.
|
|
9
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { fileURLToPath } from 'url'
|
|
12
|
+
import { SLOT_HELD_ENV } from '../core/slots.ts'
|
|
13
|
+
import { homeDir, pmUp, WORKER_ENV } from './home-paths.ts'
|
|
14
|
+
import { installHome, LABEL } from './home-launchd.ts'
|
|
15
|
+
import { homeInstalled, houseCli, houseRootOf } from './home-start.ts'
|
|
16
|
+
|
|
17
|
+
type Env = Record<string, string | undefined>
|
|
18
|
+
const set = (v: string | undefined): boolean => v != null && v !== ''
|
|
19
|
+
|
|
20
|
+
/** the marker `pm uninstall` leaves: pm stays down until `pm install` */
|
|
21
|
+
export const offMarker = (dir = homeDir()): string => join(dir, 'autostart-off')
|
|
22
|
+
|
|
23
|
+
export function markAutostart(on: boolean, dir = homeDir()): void {
|
|
24
|
+
if (on) rmSync(offMarker(dir), { force: true })
|
|
25
|
+
else {
|
|
26
|
+
mkdirSync(dir, { recursive: true })
|
|
27
|
+
writeFileSync(offMarker(dir), 'shipkit pm uninstall stopped pm here. `shipkit pm install` starts it again\n')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** the commands that ARE the home, or talk to launchd themselves */
|
|
32
|
+
const OWN = new Set(['pm', 'home', 'daemon', 'web', 'gui'])
|
|
33
|
+
|
|
34
|
+
/** why this command does not start pm, or null when it should */
|
|
35
|
+
export function autostartSkip(argv: string[], env: Env, o: { platform?: string; off?: () => boolean } = {}): string | null {
|
|
36
|
+
if (OWN.has(argv[0] ?? '')) return 'a home command'
|
|
37
|
+
if (set(env.CI)) return 'CI'
|
|
38
|
+
if (set(env.SHIPKIT_NO_HOME)) return 'SHIPKIT_NO_HOME'
|
|
39
|
+
if (set(env[WORKER_ENV])) return 'inside the home'
|
|
40
|
+
if (set(env[SLOT_HELD_ENV])) return 'inside a check'
|
|
41
|
+
if ((o.platform ?? process.platform) !== 'darwin') return 'no launchd'
|
|
42
|
+
if ((o.off ?? (() => existsSync(offMarker())))()) return 'pm uninstalled'
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type AutostartDeps = {
|
|
47
|
+
up: () => Promise<boolean>
|
|
48
|
+
installed: () => boolean
|
|
49
|
+
/** the repo that hosts the house wiki, or null on a machine with none */
|
|
50
|
+
house: () => string | null
|
|
51
|
+
install: (root: string) => Promise<number>
|
|
52
|
+
kickstart: () => Promise<number>
|
|
53
|
+
printErr: (l: string) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AutostartResult = 'up' | 'kicked' | 'installed' | 'no house' | 'failed'
|
|
57
|
+
|
|
58
|
+
/** pm answers, or is on its way. Never throws: a command runs even when pm
|
|
59
|
+
* cannot start, and says so once */
|
|
60
|
+
export async function ensurePm(d: AutostartDeps): Promise<AutostartResult> {
|
|
61
|
+
if (await d.up()) return 'up'
|
|
62
|
+
try {
|
|
63
|
+
if (!d.installed()) {
|
|
64
|
+
const root = d.house()
|
|
65
|
+
if (root == null) return 'no house'
|
|
66
|
+
if ((await d.install(root)) !== 0) {
|
|
67
|
+
d.printErr(`WARN pm is down and its LaunchAgent would not install from ${root}: \`shipkit pm install\` there says why`)
|
|
68
|
+
return 'failed'
|
|
69
|
+
}
|
|
70
|
+
return 'installed'
|
|
71
|
+
}
|
|
72
|
+
const code = await d.kickstart()
|
|
73
|
+
if (code !== 0) {
|
|
74
|
+
d.printErr(`WARN pm is down and launchctl kickstart ${LABEL} failed (exit ${code}): \`shipkit pm install\` from the house repo reinstalls it`)
|
|
75
|
+
return 'failed'
|
|
76
|
+
}
|
|
77
|
+
return 'kicked'
|
|
78
|
+
} catch (e) {
|
|
79
|
+
d.printErr(`WARN pm is down and could not be started: ${(e as Error).message}`)
|
|
80
|
+
return 'failed'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const OWN_CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
85
|
+
const LIB_DIR = fileURLToPath(new URL('../..', import.meta.url))
|
|
86
|
+
|
|
87
|
+
/** the real machine's wiring: cwd first, else the house this lib lives in */
|
|
88
|
+
export function machineAutostart(cwd: string): AutostartDeps {
|
|
89
|
+
const quiet = { print: (l: string) => { if (l.startsWith('FAIL')) process.stderr.write(`${l}\n`) } }
|
|
90
|
+
return {
|
|
91
|
+
up: () => pmUp(undefined, 300),
|
|
92
|
+
installed: homeInstalled,
|
|
93
|
+
house: () => houseRootOf(cwd) ?? houseRootOf(LIB_DIR),
|
|
94
|
+
install: (root) => installHome({ root, cli: houseCli(root, OWN_CLI), ...quiet }),
|
|
95
|
+
kickstart: async () => {
|
|
96
|
+
const p = Bun.spawn(['launchctl', 'kickstart', `gui/${process.getuid?.() ?? 501}/${LABEL}`], { stdout: 'ignore', stderr: 'ignore' })
|
|
97
|
+
return p.exited
|
|
98
|
+
},
|
|
99
|
+
printErr: (l) => process.stderr.write(`${l}\n`),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** the cli's first step: start pm when this command should */
|
|
104
|
+
export async function autostart(argv: string[], env: Env = process.env, cwd = process.cwd()): Promise<AutostartResult | string> {
|
|
105
|
+
const skip = autostartSkip(argv, env)
|
|
106
|
+
if (skip != null) return skip
|
|
107
|
+
return ensurePm(machineAutostart(cwd))
|
|
108
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// the `.shipkit.ts` units of every house repo that belong to the machine, not
|
|
2
|
+
// to one repo: its global processes (pm's) and its widgets (the daemon's).
|
|
3
|
+
// Read by importing each file, so the reader must have registered the one
|
|
4
|
+
// copy of the lib first (core/one-lib.ts): a unit file imports it by name.
|
|
5
|
+
import { relative, resolve } from 'path'
|
|
6
|
+
import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
|
|
7
|
+
import { unitKind, type GlobalProcessUnit, type WidgetUnit } from '../shipkit-files.ts'
|
|
8
|
+
|
|
9
|
+
/** one button of a global process, as pm runs it */
|
|
10
|
+
export type ActionDecl = { id: string; label: string; cmd: string[] | null }
|
|
11
|
+
|
|
12
|
+
/** a global process as pm holds it: plain data, paths absolute */
|
|
13
|
+
export type GlobalProcessDecl = {
|
|
14
|
+
id: string
|
|
15
|
+
label: string
|
|
16
|
+
description: string | null
|
|
17
|
+
where: string | null
|
|
18
|
+
cmd: string[]
|
|
19
|
+
down: string[] | null
|
|
20
|
+
/** absolute: where cmd, down and every action run */
|
|
21
|
+
cwd: string
|
|
22
|
+
url: string | null
|
|
23
|
+
probe: string | null
|
|
24
|
+
app: boolean
|
|
25
|
+
actions: ActionDecl[]
|
|
26
|
+
/** the repo that declares it, and the file, repo-relative */
|
|
27
|
+
repo: string
|
|
28
|
+
root: string
|
|
29
|
+
file: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RepoRef = { name: string; path: string }
|
|
33
|
+
|
|
34
|
+
export type LoadedWidget = { repo: string; file: string; def: WidgetUnit }
|
|
35
|
+
|
|
36
|
+
export type GlobalUnits = {
|
|
37
|
+
processes: GlobalProcessDecl[]
|
|
38
|
+
widgets: LoadedWidget[]
|
|
39
|
+
/** a file that would not load, or a process id declared twice */
|
|
40
|
+
errors: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function toDecl(u: GlobalProcessUnit, repo: RepoRef, file: string): GlobalProcessDecl {
|
|
44
|
+
return {
|
|
45
|
+
id: u.id,
|
|
46
|
+
label: u.label ?? u.id,
|
|
47
|
+
description: u.description ?? null,
|
|
48
|
+
where: u.where ?? null,
|
|
49
|
+
cmd: u.cmd,
|
|
50
|
+
down: u.down ?? null,
|
|
51
|
+
cwd: resolve(repo.path, u.cwd ?? '.'),
|
|
52
|
+
url: u.url ?? null,
|
|
53
|
+
probe: u.probe ?? u.url ?? null,
|
|
54
|
+
app: u.app === true,
|
|
55
|
+
actions: Object.entries(u.actions ?? {}).map(([id, a]) => ({ id, label: a.label, cmd: a.cmd ?? null })),
|
|
56
|
+
repo: repo.name,
|
|
57
|
+
root: repo.path,
|
|
58
|
+
file: relative(repo.path, file).replace(/\\/g, '/'),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** every global process and widget the repos declare. A process id is the
|
|
63
|
+
* machine's: a second declaration of one is an error, the first one wins */
|
|
64
|
+
export async function scanGlobalUnits(repos: RepoRef[], o: { files?: (root: string) => string[] } = {}): Promise<GlobalUnits> {
|
|
65
|
+
const out: GlobalUnits = { processes: [], widgets: [], errors: [] }
|
|
66
|
+
for (const repo of repos) {
|
|
67
|
+
for (const f of (o.files ?? repoUnitFiles)(repo.path)) {
|
|
68
|
+
const rel = relative(repo.path, f).replace(/\\/g, '/')
|
|
69
|
+
let d: unknown
|
|
70
|
+
try {
|
|
71
|
+
d = await importFresh(f)
|
|
72
|
+
} catch (e) {
|
|
73
|
+
out.errors.push(`${repo.name}/${rel}: ${(e as Error).message}`)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
const kind = unitKind(d)
|
|
77
|
+
if (kind === 'globalProcess') {
|
|
78
|
+
const u = d as GlobalProcessUnit
|
|
79
|
+
const first = out.processes.find((p) => p.id === u.id)
|
|
80
|
+
if (first != null) out.errors.push(`${repo.name}/${rel}: global process '${u.id}' is already declared by ${first.repo}/${first.file}`)
|
|
81
|
+
else out.processes.push(toDecl(u, repo, f))
|
|
82
|
+
} else if (kind === 'widget') out.widgets.push({ repo: repo.name, file: rel, def: d as WidgetUnit })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|