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.
Files changed (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,117 @@
1
+ // shipkit planned: the planned page in the console. The plan lives in the
2
+ // daemon (its marks, the running batch, the catch up spread all exist only
3
+ // there), so this asks the daemon's route, the one the page polls, and only
4
+ // renders it: the order and every word come from plan-timeline.ts, the page's
5
+ // own functions. Nothing is computed from disk: a plan rebuilt here would load
6
+ // every repo's checks and still not know what the daemon's plan holds.
7
+ import { Command, Option } from 'clipanion'
8
+ import { daemonUrl } from '../home/home-paths.ts'
9
+ import type { PlannedAnswer } from '../home/home-routes.ts'
10
+ import { markOf, spanShort, timeline, titleWords, waitingRows, waitingWords, whenWords, type Section } from '../plan/plan-timeline.ts'
11
+ import type { PlanRow } from '../plan/planner.ts'
12
+
13
+ /** the daemon's planned answer, or null when it does not answer */
14
+ export async function askPlanned(url = daemonUrl()): Promise<PlannedAnswer | null> {
15
+ try {
16
+ const r = await fetch(`${url}/api/home/planned.json`, { signal: AbortSignal.timeout(8_000) })
17
+ if (!r.ok) return null
18
+ return (await r.json()) as PlannedAnswer
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /** the answer for one repo: its rows and its load error only */
25
+ export function onlyRepo(a: PlannedAnswer, repo: string): PlannedAnswer {
26
+ return { ...a, rows: a.rows.filter((r) => r.repo === repo), errors: a.errors.filter((e) => e.repo === repo) }
27
+ }
28
+
29
+ const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
30
+
31
+ const lastWords = (r: PlanRow, now: number): string =>
32
+ r.last == null ? 'never ran' : `last ${r.last.status} ${spanShort(Math.max(0, now - r.last.at))} ago${r.last.streak > 1 ? ` (${r.last.streak} in a row)` : ''}`
33
+
34
+ const SECTION_WORDS: Record<Section, string> = { now: 'now', next: 'next' }
35
+
36
+ /** the page's one list as text: its three sections in its order, a line per
37
+ * row (when, repo, check and title, mark, last result) and the rule under
38
+ * it; the checks that only wait for a save are counted, or listed with `all` */
39
+ export function renderPlanned(a: PlannedAnswer, o: { all?: boolean } = {}): string[] {
40
+ const out: string[] = [...(a.held ?? [])]
41
+ const list = timeline(a.rows)
42
+ const idle = a.rows.filter((r) => r.state === 'idle')
43
+ const w = {
44
+ when: Math.max(0, ...list.map((x) => whenWords(x.row, a.now).length)),
45
+ what: Math.max(0, ...list.map((x) => `${x.row.repo} ${x.row.check}`.length)),
46
+ }
47
+ let prev: Section | null = null
48
+ for (const { section, row } of list) {
49
+ if (section !== prev) {
50
+ out.push(SECTION_WORDS[section])
51
+ prev = section
52
+ }
53
+ const m = markOf(row)
54
+ const title = titleWords(row.title, row.check)
55
+ const cells = [` ${pad(whenWords(row, a.now), w.when)}`, pad(`${row.repo} ${row.check}`, w.what), m.words, lastWords(row, a.now)]
56
+ out.push(cells.filter((c) => c !== '').join(' '))
57
+ out.push(` ${title == null ? '' : `${title} · `}${row.rule}`)
58
+ if (row.error != null) out.push(` error: ${row.error}`)
59
+ }
60
+ if (list.length === 0) out.push(a.rows.length === 0 ? 'nothing is planned' : 'nothing has a time of its own')
61
+ const waiting = waitingRows(a.rows)
62
+ if (waiting.length > 0) {
63
+ out.push(`${waitingWords(a.rows)}${o.all === true ? '' : ' (--all lists them)'}`)
64
+ if (o.all === true) for (const r of waiting) out.push(` ${r.repo} ${r.check} ${lastWords(r, a.now)} · ${r.rule}`)
65
+ }
66
+ if (o.all === true && idle.length > 0) {
67
+ out.push('on a save only')
68
+ for (const r of [...idle].sort((x, y) => x.repo.localeCompare(y.repo) || x.check.localeCompare(y.check))) out.push(` ${r.repo} ${r.check} ${lastWords(r, a.now)} · ${r.rule}`)
69
+ }
70
+ for (const e of a.errors) out.push(`WARN ${e.repo}: ${e.error}`)
71
+ const n = { now: list.filter((x) => x.section === 'now').length, next: list.filter((x) => x.section === 'next').length }
72
+ out.push(`${n.now} now, ${n.next} next, ${waiting.length} waiting, ${idle.length} on a save only${o.all === true || idle.length === 0 ? '' : ' (--all lists them)'}`)
73
+ return out
74
+ }
75
+
76
+ export class PlannedCommand extends Command {
77
+ static override paths = [['planned']]
78
+
79
+ static override usage = Command.Usage({
80
+ category: 'Home',
81
+ description: 'the planned page in the console: every check the machine runs on its own time, running now, then next, each with its repo, check and title, when, the rule that planned it and its last result. Asks the daemon, which holds the plan. With the daemon down it says so and exits 0',
82
+ examples: [
83
+ ['the plan, as the page lists it', 'shipkit planned'],
84
+ ['one repo only', 'shipkit planned --repo my-app'],
85
+ ['also the checks that only a save runs', 'shipkit planned --all'],
86
+ ['the route\'s exact payload', 'shipkit planned --json'],
87
+ ],
88
+ })
89
+
90
+ json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, rows, errors, running, held }' })
91
+ repo = Option.String('--repo', { required: false, description: 'only this repo\'s rows' })
92
+ all = Option.Boolean('--all', false, { description: 'also list the checks that only a save runs (the page leaves them out)' })
93
+
94
+ async execute(): Promise<number> {
95
+ const got = await askPlanned()
96
+ if (got == null) {
97
+ const why = `the daemon does not answer on ${daemonUrl()}, and the plan lives there: no plan is known (\`shipkit pm\` starts it)`
98
+ if (this.json) {
99
+ this.context.stdout.write('null\n')
100
+ this.context.stderr.write(`${why}\n`)
101
+ } else this.context.stdout.write(`${why}\n`)
102
+ return 0
103
+ }
104
+ const a = this.repo == null ? got : onlyRepo(got, this.repo)
105
+ if (this.json) {
106
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
107
+ return 0
108
+ }
109
+ if (this.repo != null && a.rows.length === 0 && a.errors.length === 0) {
110
+ const known = [...new Set(got.rows.map((r) => r.repo))].sort()
111
+ this.context.stdout.write(`no planned check in ${this.repo}; the plan holds ${known.join(', ') || 'no repo'}\n`)
112
+ return 0
113
+ }
114
+ for (const l of renderPlanned(a, { all: this.all })) this.context.stdout.write(`${l}\n`)
115
+ return 0
116
+ }
117
+ }
package/src/cli.ts CHANGED
@@ -9,10 +9,15 @@ import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenComma
9
9
  import { BumpCommand } from './cli/cmd_Bump.ts'
10
10
  import { CheckCommand } from './cli/cmd_Check.ts'
11
11
  import { CommandsCommand } from './cli/cmd_Commands.ts'
12
+ import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
12
13
  import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
14
+ import { GlobalProcessesCommand } from './cli/cmd_GlobalProcesses.ts'
15
+ import { PlannedCommand } from './cli/cmd_Planned.ts'
16
+ import { OverviewCommand } from './cli/cmd_Overview.ts'
13
17
  import { CiCommand } from './cli/cmd_Ci.ts'
14
18
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
15
19
  import { FleetCommand } from './cli/cmd_Fleet.ts'
20
+ import { DaemonCommand, GuiCommand, PmCommand, PmInstallCommand, PmUninstallCommand, WebCommand } from './cli/cmd_Home.ts'
16
21
  import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
17
22
  import { InitCommand } from './cli/cmd_Init.ts'
18
23
  import { KeysCommand } from './cli/cmd_Keys.ts'
@@ -59,6 +64,12 @@ export function buildCli(): Cli {
59
64
  cli.register(CheckCommand)
60
65
  cli.register(TuiCommand)
61
66
  cli.register(ServeCommand)
67
+ cli.register(PmCommand)
68
+ cli.register(DaemonCommand)
69
+ cli.register(WebCommand)
70
+ cli.register(PmInstallCommand)
71
+ cli.register(PmUninstallCommand)
72
+ cli.register(GuiCommand)
62
73
  cli.register(ContractsCommand)
63
74
  cli.register(AsksCommand)
64
75
  cli.register(AsksCheckCommand)
@@ -79,6 +90,10 @@ export function buildCli(): Cli {
79
90
  cli.register(SmokeCommand)
80
91
  cli.register(ReflectCommand)
81
92
  cli.register(KeysCommand)
93
+ cli.register(GlobalProcessesCommand)
94
+ cli.register(CrossRepoActivityCommand)
95
+ cli.register(PlannedCommand)
96
+ cli.register(OverviewCommand)
82
97
  cli.register(ChecksCommand)
83
98
  cli.register(UnitsAliasCommand)
84
99
  cli.register(CommandsCommand)
@@ -87,5 +102,18 @@ export function buildCli(): Cli {
87
102
  return cli
88
103
  }
89
104
 
90
- // only when this file IS the process, so importing it costs nothing
91
- if (import.meta.main) void buildCli().runExit(process.argv.slice(2))
105
+ // only when this file IS the process, so importing it costs nothing. A repo's
106
+ // installed copy first hands the command to the house source (handoff.ts). One copy
107
+ // of the lib per process: a repo config that imports its own install lands on
108
+ // this one (one-lib.ts), so the machine's version runs in every repo. Then pm
109
+ // is started when it is down (autostart.ts), before the command runs
110
+ if (import.meta.main) {
111
+ const argv = aliasArgs(process.argv.slice(2))
112
+ const { handoff } = await import('./home/handoff.ts')
113
+ handoff(argv)
114
+ const { useOneLib } = await import('./core/one-lib.ts')
115
+ useOneLib()
116
+ const { autostart } = await import('./home/autostart.ts')
117
+ await autostart(argv)
118
+ void buildCli().runExit(argv)
119
+ }
@@ -18,7 +18,7 @@ import { homedir } from 'os'
18
18
  import { isAbsolute, join, relative, resolve } from 'path'
19
19
  import { rawName, routesToShipkit } from '../core/canonical-scripts.ts'
20
20
  import { FLOOR_IDS } from '../core/floor.ts'
21
- import { formatLine, parseDuration } from '../core/wire.ts'
21
+ import { FOREVER_MS, formatLine, ttlMsOf } from '../core/wire.ts'
22
22
  import { gateInventory, type CheckRow } from '../ledger/gate-checks.ts'
23
23
  import { readLastLines, readRuns, runsPath, type LastLine, type RunRow } from '../ledger/run-log.ts'
24
24
  import { checkStatsFor, type CheckStat } from '../ledger/check-stats.ts'
@@ -166,7 +166,8 @@ function instanceOf(root: string, c: RepoConfig, row: CheckRow, stat: CheckStat
166
166
  export function keepsUntil(ttl: string | undefined, stat: Pick<CheckStat, 'measuredAt' | 'measuredStatus'> | undefined): number | null {
167
167
  if (ttl == null || ttl === '' || stat?.measuredAt == null) return null
168
168
  let ms = 0
169
- try { ms = parseDuration(ttl) } catch { return null }
169
+ try { ms = ttlMsOf(ttl) } catch { return null }
170
+ if (ms >= FOREVER_MS && stat.measuredStatus === 'pass') return null
170
171
  if (ms <= 0) return null
171
172
  return stat.measuredAt + (stat.measuredStatus === 'pass' ? ms : Math.min(ms, NEGATIVE_TTL_CAP_MS))
172
173
  }
@@ -31,7 +31,7 @@ import { testsCollection } from './tests.ts'
31
31
  import { checksCollection } from './checks.ts'
32
32
  import { LAYOUT } from '../core/layout.ts'
33
33
  import { isBudgetLine } from '../core/budget.ts'
34
- import { GH_LOGIN_CRED } from '../checks/SK021-github-login.check.ts'
34
+ import { GH_LOGIN_CRED } from '../core/gh-login.ts'
35
35
 
36
36
  const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? LAYOUT.asks)
37
37
  // read path must not mint an asks/ dir (listAsks mkdirs on read)
@@ -127,9 +127,15 @@ export type TestCheckRow = {
127
127
  shape: Shape | null
128
128
  }
129
129
 
130
+ /** one file of any test check, named with the check that runs it */
131
+ export type TestFileRow = FileTime & { check: string }
132
+
130
133
  export type TestsRootView = {
131
134
  /** slowest wall first, the never ran ones last */
132
135
  checks: TestCheckRow[]
136
+ /** every file of every test check, slowest first, the never ran ones
137
+ * last: each check's own list, merged */
138
+ files: TestFileRow[]
133
139
  failing: FailingFile[]
134
140
  }
135
141
 
@@ -270,8 +276,10 @@ function lastRunOf(checkId: string, at: number, files: FileTime[], sumMs: number
270
276
 
271
277
  export function shapeTestsRoot(o: TestTimeInput): TestsRootView {
272
278
  const ids = [...new Set(o.facts.filter((f) => f.standing === 'runs' && f.checkId != null).map((f) => f.checkId!))]
279
+ const files: TestFileRow[] = []
273
280
  const checks = ids.map((checkId): TestCheckRow => {
274
281
  const v = shapeTestCheck(checkId, o)
282
+ for (const f of v.files) files.push({ ...f, check: checkId })
275
283
  const top = v.files.find((f) => f.ms != null)
276
284
  return {
277
285
  checkId,
@@ -284,5 +292,6 @@ export function shapeTestsRoot(o: TestTimeInput): TestsRootView {
284
292
  shape: v.last?.shape ?? null,
285
293
  }
286
294
  }).sort((a, b) => (b.wallMs ?? -1) - (a.wallMs ?? -1) || a.checkId.localeCompare(b.checkId))
287
- return { checks, failing: failingOf(o.facts) }
295
+ files.sort((a, b) => (b.ms ?? -1) - (a.ms ?? -1) || a.file.localeCompare(b.file))
296
+ return { checks, files, failing: failingOf(o.facts) }
288
297
  }
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, resolve } from 'path'
10
10
  import { defaultHubPort } from '../serve/hub-port.ts'
11
+ import { portHolders } from '../core/port-holders.ts'
11
12
 
12
13
  export type ComfyWorkflow = { id: string; file: string }
13
14
  export type ComfyPanelUp = { url: string; port: number }
@@ -127,22 +128,6 @@ const KILL_STEP_MS = 25
127
128
  const QUIET_TRIES = 60
128
129
  const QUIET_STEP_MS = 50
129
130
 
130
- /** the pids LISTENING on a tcp port, on darwin and linux. `-sTCP:LISTEN`
131
- * carries the whole answer: `-i tcp:<port>` alone also matches every client
132
- * connected to it, this process included, and those must never be killed.
133
- * lsof sits in /usr/sbin on darwin, which a gui-launched shell can miss */
134
- function portHolders(port: number): number[] {
135
- const lsof = Bun.which('lsof') ?? ['/usr/sbin/lsof', '/usr/bin/lsof'].find((p) => existsSync(p))
136
- if (lsof == null) return []
137
- const r = Bun.spawnSync([lsof, '-ti', `tcp:${port}`, '-sTCP:LISTEN'], { stdout: 'pipe', stderr: 'ignore' })
138
- const pids = r.stdout
139
- .toString()
140
- .split('\n')
141
- .map((l) => Number(l.trim()))
142
- .filter((n) => Number.isInteger(n) && n > 0)
143
- return [...new Set(pids)]
144
- }
145
-
146
131
  /** a pid that died between the scan and the signal is the outcome we wanted */
147
132
  function kill(pid: number, signal: 'SIGTERM' | 'SIGKILL'): void {
148
133
  try {
@@ -0,0 +1,46 @@
1
+ // AGENT SPAWN, found statically: code that starts a Claude or agent process on
2
+ // its own (a spawn or exec of `claude`, `claude -p`, the agent sdk). No tool
3
+ // here starts an agent unless its owner asks in person, so the sweep tests
4
+ // (tests/no-agent-spawn.test.ts, and the house repo's own) fail on any hit.
5
+ //
6
+ // a string that only MENTIONS claude (a url, `~/.claude/projects`, a help
7
+ // line naming the product) is no spawn, and comments are skipped.
8
+
9
+ export type AgentSpawn = { line: number; what: string }
10
+
11
+ const SHAPES: { re: RegExp; what: string }[] = [
12
+ // the sdks that run an agent loop in process or spawn the cli
13
+ { re: /(['"`])@anthropic-ai\/(?:claude-agent-sdk|claude-code)(?:\/[^'"`]*)?\1/, what: 'imports the agent sdk' },
14
+ // an argv whose command is claude: spawn(['claude', …]), c.exec(['claude'])
15
+ { re: /\[\s*(['"`])(?:[^'"`\s]*\/)?claude\1/, what: 'an argv that runs claude' },
16
+ // spawn('claude', …), execSync('claude -p …'), execFile('claude')
17
+ { re: /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execa|execaSync|fork)\s*\(\s*(['"`])(?:[^'"`\s]*\/)?claude(?:\s[^'"`]*)?\1/, what: 'a process call that runs claude' },
18
+ // the bun shell: $`claude -p …`
19
+ { re: /\$\s*`\s*(?:[^`\s]*\/)?claude(?:\s|`)/, what: 'a bun shell line that runs claude' },
20
+ // the headless form, wherever the string goes
21
+ { re: /(['"`])(?:[^'"`\s]*\/)?claude\s+(?:-p|--print)\b/, what: 'a claude -p command line' },
22
+ ]
23
+
24
+ /** the first agent spawn in one source text, 1-based line, comments skipped */
25
+ export function agentSpawnIn(src: string): AgentSpawn | null {
26
+ const lines = src.split('\n')
27
+ let inBlock = false
28
+ for (let i = 0; i < lines.length; i++) {
29
+ let l = lines[i]!
30
+ if (inBlock) {
31
+ const end = l.indexOf('*/')
32
+ if (end < 0) continue
33
+ l = l.slice(end + 2)
34
+ inBlock = false
35
+ }
36
+ l = l.replace(/\/\*.*?\*\//g, '')
37
+ const open = l.indexOf('/*')
38
+ if (open >= 0) {
39
+ inBlock = true
40
+ l = l.slice(0, open)
41
+ }
42
+ l = l.replace(/(^|\s)\/\/.*$/, '$1')
43
+ for (const s of SHAPES) if (s.re.test(l)) return { line: i + 1, what: s.what }
44
+ }
45
+ return null
46
+ }
@@ -0,0 +1,82 @@
1
+ // `archived: true` in repo.config.ts: the repo stays known and listed, and
2
+ // nothing runs there (no hub load, so no watch, no plan rows, no gates, no
3
+ // processes, no widgets, no identity fill). Read and written as TEXT, never by
4
+ // loading the config: the daemon and the page rows read many configs per poll,
5
+ // and the toggle moves that one key and no other byte.
6
+ import { readFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { atomicWrite } from './locked-write.ts'
9
+ import { configDepths } from './identity-color.ts'
10
+
11
+ const KEY_RE = /(?:^|[{,])\s*(['"]?)archived\1\s*:\s*(true|false)\b/gm
12
+
13
+ /** every top level `archived` key of `defineRepo({…})` with its value and the
14
+ * offsets of the whole `archived: <value>` token */
15
+ function archivedKeys(text: string): { on: boolean; start: number; end: number }[] {
16
+ const depth = configDepths(text)
17
+ const out: { on: boolean; start: number; end: number }[] = []
18
+ for (const m of text.matchAll(KEY_RE)) {
19
+ const lead = /^[{,]/.test(m[0]) ? 1 : 0
20
+ const sep = lead === 1 ? m.index : m.index - 1
21
+ if (sep < 0 || depth[sep] !== 1) continue
22
+ const start = m.index + lead + /^[{,]?(\s*)/.exec(m[0])![1]!.length
23
+ out.push({ on: m[2] === 'true', start, end: m.index + m[0].length })
24
+ }
25
+ return out
26
+ }
27
+
28
+ /** does the config text archive the repo */
29
+ export const archivedOfText = (text: string): boolean => archivedKeys(text).some((k) => k.on)
30
+
31
+ /** the config text with the repo archived or not. On: the key reads true, a
32
+ * missing one lands as the first key of `defineRepo({`. Off: the key goes,
33
+ * so a round trip gives the text back byte for byte */
34
+ export function setArchivedText(text: string, on: boolean): string {
35
+ const keys = archivedKeys(text)
36
+ if (keys.length > 1) throw new Error('repo.config.ts declares `archived` more than once, set it by hand')
37
+ const k = keys[0]
38
+ if (on) {
39
+ if (k != null) return k.on ? text : `${text.slice(0, k.start)}archived: true${text.slice(k.end)}`
40
+ const lines = text.split('\n')
41
+ const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
42
+ if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `archived` into, set it by hand')
43
+ const line = lines[open]!
44
+ if (/defineRepo\(\s*\{\s*$/.test(line)) {
45
+ const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
46
+ const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
47
+ return [...lines.slice(0, open + 1), `${pad}archived: true,`, ...lines.slice(open + 1)].join('\n')
48
+ }
49
+ lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}archived: true, `)
50
+ return lines.join('\n')
51
+ }
52
+ if (k == null) return text
53
+ // a line of its own goes whole, an inline key goes with its comma
54
+ const lineStart = text.lastIndexOf('\n', k.start - 1) + 1
55
+ const lineEnd = text.indexOf('\n', k.end)
56
+ const rest = text.slice(k.end, lineEnd === -1 ? text.length : lineEnd)
57
+ if (text.slice(lineStart, k.start).trim() === '' && /^\s*,?\s*$/.test(rest)) return text.slice(0, lineStart) + text.slice(lineEnd === -1 ? text.length : lineEnd + 1)
58
+ const after = /^\s*,\s*/.exec(text.slice(k.end))
59
+ if (after != null) return text.slice(0, k.start) + text.slice(k.end + after[0].length)
60
+ const before = /,\s*$/.exec(text.slice(0, k.start))
61
+ return before != null ? text.slice(0, k.start - before[0].length) + text.slice(k.end) : text.slice(0, k.start) + text.slice(k.end)
62
+ }
63
+
64
+ const CONFIG_FILE = 'repo.config.ts'
65
+
66
+ /** is this repo archived; false when it has no readable config */
67
+ export function isArchived(root: string): boolean {
68
+ try {
69
+ return archivedOfText(readFileSync(join(root, CONFIG_FILE), 'utf8'))
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ /** archive the repo or bring it back: only that key of its config moves */
76
+ export function writeArchived(root: string, on: boolean): boolean {
77
+ const path = join(root, CONFIG_FILE)
78
+ const text = readFileSync(path, 'utf8')
79
+ const out = setArchivedText(text, on)
80
+ if (out !== text) atomicWrite(path, out)
81
+ return on
82
+ }
@@ -39,9 +39,11 @@ export type ShareOpts = {
39
39
  abort?: () => boolean
40
40
  }
41
41
 
42
- /** how long an answer file stays on disk. Only an in flight asker can adopt
43
- * one, so this is housekeeping, not a validity window. */
44
- const KEEP_MS = 60 * 60 * 1000
42
+ /** how long a slot file stays on disk. Only an in flight asker can adopt an
43
+ * answer, so this is housekeeping, not a validity window. A live holder
44
+ * heartbeats its lock (locked-write.ts), so a lock this old is one a killed
45
+ * run left behind. */
46
+ export const INFLIGHT_KEEP_MS = 60 * 60 * 1000
45
47
 
46
48
  const readShared = <T>(path: string): Shared<T> | null => {
47
49
  try {
@@ -50,12 +52,15 @@ const readShared = <T>(path: string): Shared<T> | null => {
50
52
  } catch { return null }
51
53
  }
52
54
 
53
- const sweep = (dir: string, now: number): void => {
55
+ /** every slot file past the keep window goes: answers, the locks killed runs
56
+ * left, the graves of a steal. One level of namespace dirs below `dir` is
57
+ * walked too. */
58
+ export function sweepSlots(dir: string, now: number, keepMs = INFLIGHT_KEEP_MS): void {
54
59
  try {
55
- for (const f of readdirSync(dir)) {
56
- if (f.endsWith('.lock')) continue
57
- const p = `${dir}/${f}`
58
- try { if (statSync(p).mtimeMs < now - KEEP_MS) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
60
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
61
+ const p = `${dir}/${e.name}`
62
+ if (e.isDirectory()) { sweepSlots(p, now, keepMs); continue }
63
+ try { if (statSync(p).mtimeMs < now - keepMs) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
59
64
  }
60
65
  } catch { /* housekeeping never fails a run */ }
61
66
  }
@@ -86,7 +91,7 @@ export async function shareRun<T>(slot: string, o: ShareOpts, body: () => Promis
86
91
  // an answer nobody can adopt costs the waiters a measurement, never a verdict
87
92
  }
88
93
  }
89
- sweep(dirname(slot), now)
94
+ sweepSlots(dirname(slot), now)
90
95
  return { kind: 'measured', value: r.value }
91
96
  })
92
97
  if (leased.ok) return leased.value
@@ -65,12 +65,21 @@ export function routesToShipkit(body: string): boolean {
65
65
  return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
66
66
  }
67
67
 
68
- // recover the consumption style from existing scripts: any shipkit command
69
- // already running `bun <path>/cli.ts <cmd>` fixes the style for new bodies
68
+ /** is this path shipkit's own cli: a file named exactly `cli.ts` under a
69
+ * directory whose name says shipkit. A repo's own cli (`src/cli.ts`,
70
+ * `src/cli/comfy-ts-cli.ts`) is not, and taking it for shipkit writes
71
+ * scripts that call a command that cli does not have */
72
+ export function isShipkitCli(path: string): boolean {
73
+ const parts = path.split('/')
74
+ return parts.at(-1) === 'cli.ts' && parts.slice(0, -1).some((d) => /shipkit/.test(d))
75
+ }
76
+
77
+ // recover the consumption style from existing scripts: any script already
78
+ // running shipkit's cli by path fixes the style for new bodies
70
79
  export function inferBin(scripts: Record<string, string>): string | undefined {
71
80
  for (const body of Object.values(scripts)) {
72
81
  const m = body.match(/^bun (?:--watch )?(\S+cli\.ts) /)
73
- if (m != null && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
82
+ if (m != null && isShipkitCli(m[1]!) && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
74
83
  }
75
84
  return undefined
76
85
  }
@@ -5,6 +5,14 @@
5
5
  import { basename, relative } from 'path'
6
6
 
7
7
  export const CHECK_SUFFIX = '.check.ts'
8
+ /** the unit file: a check, a global process or a widget, by its default
9
+ * export (shipkit-files.ts). Discovery reads both suffixes, and will later
10
+ * read this one alone */
11
+ export const UNIT_SUFFIX = '.shipkit.ts'
12
+
13
+ /** the name a unit file answers to when it declares no id: its basename,
14
+ * `.check` dropped too, so `x.check.shipkit.ts` is `x` */
15
+ export const unitBase = (file: string): string => basename(file).slice(0, -UNIT_SUFFIX.length).replace(/\.check$/, '')
8
16
 
9
17
  /** dirs discovery never walks into, beside every dot dir: installs, build
10
18
  * output, scratch, and templates meant for other repos */