rvlib-shipkit 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +235 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +92 -0
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +340 -0
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// what a repo declares about itself and its root unit, read from its config.
|
|
2
|
+
// Its own module, free of any fs import, so a check that asks it stays keyed
|
|
3
|
+
// on what it read.
|
|
4
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
5
|
+
|
|
6
|
+
/** the github visibility this repo declares (absent = 'private') */
|
|
7
|
+
export const declaredVisibility = (c: RepoConfig): 'private' | 'public' => c.visibility ?? 'private'
|
|
8
|
+
|
|
9
|
+
/** declared public: strangers read the tracked tree */
|
|
10
|
+
export const declaredPublic = (c: RepoConfig): boolean => c.visibility === 'public'
|
|
11
|
+
|
|
12
|
+
/** the root unit declares an npm target. Declared, never inferred from a
|
|
13
|
+
* missing `private` flag, so an app that forgot the flag is still an app */
|
|
14
|
+
export const rootPublishesNpm = (c: RepoConfig): boolean => c.release?.npm === true
|
|
@@ -0,0 +1,222 @@
|
|
|
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; the
|
|
4
|
+
// house repo's one `stores-under-cap` check fails when a store is past its
|
|
5
|
+
// cap, so a sweep that stopped running 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 the
|
|
49
|
+
* house check 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
|
+
/** a cap in words: `200 entries, 30d, 5.0 MB a file` */
|
|
63
|
+
export const capWords = (s: Store): string =>
|
|
64
|
+
[
|
|
65
|
+
s.cap.maxCount != null ? `${s.cap.maxCount} entries` : null,
|
|
66
|
+
s.cap.maxAgeMs != null ? fmtAge(s.cap.maxAgeMs) : null,
|
|
67
|
+
s.cap.maxFileBytes != null ? `${fmtBytes(s.cap.maxFileBytes)} a file` : null,
|
|
68
|
+
s.cap.maxBytes != null ? fmtBytes(s.cap.maxBytes) : null,
|
|
69
|
+
].filter((x) => x != null).join(', ')
|
|
70
|
+
|
|
71
|
+
export const fmtBytes = (n: number): string => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`)
|
|
72
|
+
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`)
|
|
73
|
+
|
|
74
|
+
/** every file under a dir (not following links), skipping dirs `skip` names */
|
|
75
|
+
export function walkUse(dir: string, o: { match?: (name: string) => boolean; skip?: (name: string) => boolean } = {}): Use | null {
|
|
76
|
+
if (!existsSync(dir)) return { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
|
|
77
|
+
const use: Use = { count: 0, oldestAt: null, bytes: 0, largestFile: 0 }
|
|
78
|
+
const walk = (d: string): void => {
|
|
79
|
+
let list
|
|
80
|
+
try { list = readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
81
|
+
for (const e of list) {
|
|
82
|
+
const p = join(d, e.name)
|
|
83
|
+
if (e.isDirectory()) { if (o.skip?.(e.name) !== true) walk(p); continue }
|
|
84
|
+
if (!e.isFile() || (o.match != null && !o.match(e.name))) continue
|
|
85
|
+
try {
|
|
86
|
+
const s = statSync(p)
|
|
87
|
+
use.count++
|
|
88
|
+
use.bytes += s.size
|
|
89
|
+
use.largestFile = Math.max(use.largestFile, s.size)
|
|
90
|
+
use.oldestAt = use.oldestAt == null ? s.mtimeMs : Math.min(use.oldestAt, s.mtimeMs)
|
|
91
|
+
} catch { /* raced: gone */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
walk(dir)
|
|
95
|
+
return use
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const counted = (count: number, oldestAt: number | null): Use => ({ count, oldestAt, bytes: 0, largestFile: 0 })
|
|
99
|
+
|
|
100
|
+
/** the stores one repo holds */
|
|
101
|
+
export function repoStores(root: string): Store[] {
|
|
102
|
+
const sk = shipkitRoot(root)
|
|
103
|
+
const logs = join(root, CI_LOG_DIR)
|
|
104
|
+
const jobs = join(root, JOBS)
|
|
105
|
+
const gitDir = gitRoot(root)?.gitDir
|
|
106
|
+
const stores: Store[] = [
|
|
107
|
+
{
|
|
108
|
+
id: 'answers',
|
|
109
|
+
where: `${answersDb(root)} (answers)`,
|
|
110
|
+
cap: { maxAgeMs: ANSWERS_KEEP_MS, maxCount: MAX_ANSWERS },
|
|
111
|
+
measure: () => {
|
|
112
|
+
if (!existsSync(answersDb(root))) return counted(0, null)
|
|
113
|
+
const u = answersUse(answersDb(root))
|
|
114
|
+
return u == null ? null : counted(u.count, u.oldestUsed)
|
|
115
|
+
},
|
|
116
|
+
sweep: (now) => { sweepAnswers(answersDb(root), now) },
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
// the json store this table replaced: read once, then gone
|
|
120
|
+
id: 'json answers',
|
|
121
|
+
where: legacyStoreDir(root),
|
|
122
|
+
cap: { maxCount: 0 },
|
|
123
|
+
measure: () => walkUse(legacyStoreDir(root)),
|
|
124
|
+
sweep: async (now) => { await importLegacyStore(root, now) },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'run record',
|
|
128
|
+
where: `${dbPath(root)} (runs, invocations, suites)`,
|
|
129
|
+
cap: { maxAgeMs: RECORD_KEEP_MS, maxCount: MAX_ROWS },
|
|
130
|
+
measure: () => {
|
|
131
|
+
if (!existsSync(dbPath(root))) return counted(0, null)
|
|
132
|
+
const u = recordUse(dbPath(root))
|
|
133
|
+
return u == null ? null : counted(u.count, u.oldest)
|
|
134
|
+
},
|
|
135
|
+
// the last db store: the answers above and these rows are one
|
|
136
|
+
// file, which the deletes leave full of free pages
|
|
137
|
+
sweep: (now) => {
|
|
138
|
+
sweepRuns(dbPath(root), now)
|
|
139
|
+
compactDb(dbPath(root))
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'inflight',
|
|
144
|
+
where: join(sk, 'inflight'),
|
|
145
|
+
cap: { maxAgeMs: INFLIGHT_KEEP_MS },
|
|
146
|
+
measure: () => walkUse(join(sk, 'inflight')),
|
|
147
|
+
sweep: (now) => sweepSlots(join(sk, 'inflight'), now),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'run logs',
|
|
151
|
+
where: logs,
|
|
152
|
+
cap: { maxAgeMs: RUN_DIR_KEEP_MS, maxCount: RUN_DIR_MAX },
|
|
153
|
+
measure: () => {
|
|
154
|
+
const all = runEntries(logs)
|
|
155
|
+
return counted(all.length, all.length === 0 ? null : Math.min(...all.map((e) => e.started)))
|
|
156
|
+
},
|
|
157
|
+
sweep: (now) => sweepRunDirs(logs, now),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'jobs',
|
|
161
|
+
where: jobs,
|
|
162
|
+
cap: { maxAgeMs: JOBS_KEEP_MS, maxCount: JOBS_MAX },
|
|
163
|
+
measure: () => {
|
|
164
|
+
let names: string[] = []
|
|
165
|
+
try { names = readdirSync(jobs) } catch { return counted(0, null) }
|
|
166
|
+
let oldest: number | null = null
|
|
167
|
+
for (const n of names) try { const m = statSync(join(jobs, n)).mtimeMs; oldest = oldest == null ? m : Math.min(oldest, m) } catch { /* raced */ }
|
|
168
|
+
return counted(names.length, oldest)
|
|
169
|
+
},
|
|
170
|
+
sweep: (now) => sweepJobs(root, now),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
// the dev servers the hub runs for the repo (serve.processes)
|
|
174
|
+
id: 'process logs',
|
|
175
|
+
where: `${processDir(root)}/*.log`,
|
|
176
|
+
cap: { maxFileBytes: LOG_MAX_BYTES },
|
|
177
|
+
measure: () => walkUse(processDir(root), { match: (n) => n.endsWith('.log') }),
|
|
178
|
+
sweep: () => {
|
|
179
|
+
let names: string[] = []
|
|
180
|
+
try { names = readdirSync(processDir(root)) } catch { return }
|
|
181
|
+
for (const n of names) if (n.endsWith('.log')) capLog(join(processDir(root), n))
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
]
|
|
185
|
+
if (gitDir != null) {
|
|
186
|
+
const dir = join(gitDir, 'shipkit')
|
|
187
|
+
stores.push({
|
|
188
|
+
id: 'scan indexes',
|
|
189
|
+
where: `${dir}/scan-<pid>.idx`,
|
|
190
|
+
// one per process scanning at once, and a machine runs a handful
|
|
191
|
+
cap: { maxAgeMs: SCAN_INDEX_KEEP_MS, maxCount: 16 },
|
|
192
|
+
measure: () => walkUse(dir, { match: (n) => /^scan-\d+\.idx(\.tmp)?$/.test(n), skip: () => true }),
|
|
193
|
+
sweep: (now) => sweepScanIndexes(dir, now),
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
return stores
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type Measured = { store: Store; use: Use | null; over: string | null }
|
|
200
|
+
|
|
201
|
+
export function measureStores(stores: Store[], now = Date.now()): Measured[] {
|
|
202
|
+
return stores.map((store) => {
|
|
203
|
+
const use = store.measure()
|
|
204
|
+
return { store, use, over: use == null ? null : overCap(use, store.cap, now) }
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** sweep every store, one after the other, yielding between them so a
|
|
209
|
+
* daemon keeps serving. A sweep that throws is reported, never fatal */
|
|
210
|
+
export async function sweepStores(stores: Store[], now = Date.now()): Promise<{ id: string; error: string | null }[]> {
|
|
211
|
+
const out: { id: string; error: string | null }[] = []
|
|
212
|
+
for (const s of stores) {
|
|
213
|
+
try {
|
|
214
|
+
await s.sweep(now)
|
|
215
|
+
out.push({ id: s.id, error: null })
|
|
216
|
+
} catch (e) {
|
|
217
|
+
out.push({ id: s.id, error: (e as Error).message })
|
|
218
|
+
}
|
|
219
|
+
await Bun.sleep(0)
|
|
220
|
+
}
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// built-in check ids that no longer exist, and where their question went. An
|
|
2
|
+
// id is never reused, so a doc or a changelog may still name one, and
|
|
3
|
+
// discovery refuses a built-in that takes one again.
|
|
4
|
+
export const RETIRED_CHECK_IDS: Readonly<Record<string, string>> = {
|
|
5
|
+
SK021: "the gh login is a machine fact: the house repo's github-login check runs src/core/gh-login.ts",
|
|
6
|
+
SK023: "the store caps are the machine daemon's health: the house repo's stores-under-cap check",
|
|
7
|
+
}
|
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'
|
|
@@ -37,6 +37,9 @@ export type CheckOpts = {
|
|
|
37
37
|
status?: boolean
|
|
38
38
|
/** line sink — commands pass their context stream; defaults to console */
|
|
39
39
|
print?: (line: string) => void
|
|
40
|
+
/** where the lines nobody acts on go: the parked list and the green
|
|
41
|
+
* summary. `shipkit ci` shows them only under --verbose. Default: print */
|
|
42
|
+
detail?: (line: string) => void
|
|
40
43
|
/** per-CHECK completion tap: the real status, whether it replayed, what it
|
|
41
44
|
* cost. The demotion of a non-blocking failure happens in the LINES (a
|
|
42
45
|
* FAIL is rewritten to a warn before anything sees it), so `status` here
|
|
@@ -55,6 +58,9 @@ export type CheckOpts = {
|
|
|
55
58
|
onlyBlocking?: boolean
|
|
56
59
|
/** which test tier the caller is asking for */
|
|
57
60
|
tier?: TestTier
|
|
61
|
+
/** colour the wire lines. Default: this process's stdout is a terminal. A
|
|
62
|
+
* hub running a terminal's gate passes the terminal's answer */
|
|
63
|
+
color?: boolean
|
|
58
64
|
/** which question: the gate's set (`shipkit ci`) or the health pass's */
|
|
59
65
|
audience?: Audience
|
|
60
66
|
/** the caller narrowed the run to these ids (`--only`) */
|
|
@@ -187,16 +193,18 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
187
193
|
root,
|
|
188
194
|
config: c,
|
|
189
195
|
logDir: c.logDir ?? join(root, CI_LOG_DIR),
|
|
190
|
-
cache: {
|
|
196
|
+
cache: { db: answersDb(root), ns: c.name, now: Date.now },
|
|
191
197
|
ledger,
|
|
192
198
|
log,
|
|
193
199
|
childEnv: { [SHARE_ENV]: sharePath, ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}) },
|
|
194
200
|
bail: o.bail,
|
|
201
|
+
color: o.color,
|
|
195
202
|
print: sink,
|
|
203
|
+
detail: o.detail,
|
|
196
204
|
onCheck: (e) => { collected.push(...e.lines); o.onCheck?.(e) },
|
|
197
205
|
})
|
|
198
206
|
const lines: Line[] = []
|
|
199
|
-
const say = (l: Line): void => { lines.push(l);
|
|
207
|
+
const say = (l: Line, to: (line: string) => void = print): void => { lines.push(l); to(formatLine(l, false)) }
|
|
200
208
|
// a declaration in a shape the check file replaced is reported by check
|
|
201
209
|
// SK016, which also carries the fixer that rewrites it: one owner, one
|
|
202
210
|
// line, one command, rather than a warning per surface.
|
|
@@ -206,7 +214,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
|
|
|
206
214
|
// page just replaced
|
|
207
215
|
if (summary.parked.length > 0) {
|
|
208
216
|
const head = summary.parked.slice(0, 8).join(', ')
|
|
209
|
-
say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` })
|
|
217
|
+
say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
|
|
210
218
|
}
|
|
211
219
|
// the survey dies with the run that made it, and only the run that made it
|
|
212
220
|
if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
|