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
@@ -165,9 +165,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
165
165
  tagTemplate: rc.tag ?? ROOT_TAG,
166
166
  changelogRel: rc.changelog ?? 'CHANGELOG.md',
167
167
  npm: rc.npm,
168
- // the root IS the repo: its package.json private flag is the whole
169
- // declaration, and a public root package publishes by default
170
- npmWhenUndeclared: 'from-private-flag',
168
+ undeclaredFix: 'add `release: { npm: true }` to repo.config.ts to publish it',
171
169
  githubRelease: rc.githubRelease ?? true,
172
170
  tsconfigRel: rc.tsconfig,
173
171
  checkFolderName: false,
@@ -204,7 +202,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
204
202
  tagTemplate: decl.tag ?? SUB_TAG,
205
203
  changelogRel: decl.changelog ?? `${decl.dir}/CHANGELOG.md`,
206
204
  npm: decl.npm,
207
- npmWhenUndeclared: 'refuse',
205
+ undeclaredFix: 'add `npm: true` to this units[] entry to publish it',
208
206
  githubRelease: decl.githubRelease ?? false,
209
207
  tsconfigRel: decl.tsconfig,
210
208
  // only the DEFAULT tag renders the package name into a git tag, so
@@ -226,11 +224,9 @@ function build(p: {
226
224
  tagTemplate: string
227
225
  changelogRel: string
228
226
  npm: boolean | undefined
229
- /** how an UNDECLARED npm target resolves. The root IS the repo, so its
230
- * package.json private flag is the whole declaration. A sub-unit has no
231
- * such declaration, so a non-private package with no `npm` key is
232
- * ambiguous and REFUSES. */
233
- npmWhenUndeclared: 'from-private-flag' | 'refuse'
227
+ /** where this unit's npm declaration goes, for the refusal a non-private
228
+ * package with no `npm` key gets */
229
+ undeclaredFix: string
234
230
  githubRelease: boolean
235
231
  tsconfigRel: string | undefined
236
232
  checkFolderName: boolean
@@ -256,10 +252,10 @@ function build(p: {
256
252
  // about must refuse, not quietly resolve to no-publish. A silent false
257
253
  // here is a lib that bumps, tags and cuts a release while publishing
258
254
  // nothing, and the only trace is one note line in a long log.
259
- if (p.npm == null && !isPrivate && p.npmWhenUndeclared === 'refuse')
260
- errors.push(at('package.json is not private and this units[] entry does not say `npm` — add `npm: true` to publish it, or `private: true` to its package.json to keep it internal'))
255
+ if (p.npm == null && !isPrivate)
256
+ errors.push(at(`package.json is not private and no npm target is declared: ${p.undeclaredFix}, or \`private: true\` to its package.json to keep it internal`))
261
257
  if (errors.length > 0) return { error: errors }
262
- const declaredNpm = p.npm ?? (p.npmWhenUndeclared === 'from-private-flag' && !isPrivate)
258
+ const declaredNpm = p.npm === true
263
259
  const tsconfig = p.tsconfigRel != null ? join(p.root, p.tsconfigRel) : join(p.dir, 'tsconfig.json')
264
260
  return {
265
261
  unit: {
@@ -59,6 +59,27 @@ export type ServeConfig = {
59
59
  * prints beside localhost and rides on every ping message, because a
60
60
  * phone cannot resolve localhost */
61
61
  publicHost?: string
62
+ /** the long running processes this repo needs (a dev server, a watcher).
63
+ * The hub supervises them: a switch each on its page, a crash restarts,
64
+ * and they outlive a hub restart */
65
+ processes?: ProcessDecl[]
66
+ /** the ids of the global processes this repo needs (any house repo's
67
+ * `globalProcess` unit files). The home rail marks the repo while one is off */
68
+ uses?: string[]
69
+ }
70
+
71
+ /** one long running process a repo needs, supervised by its hub */
72
+ export type ProcessDecl = {
73
+ /** kebab-case, unique in this repo */
74
+ id: string
75
+ /** the command as a list of words, run from the repo root */
76
+ cmd: string[]
77
+ /** where it runs, repo-relative (default the repo root) */
78
+ cwd?: string
79
+ /** the url it serves, probed for up and shown as a link */
80
+ url?: string
81
+ /** starts with the hub. Default false: it starts from its switch */
82
+ auto?: boolean
62
83
  }
63
84
 
64
85
  export type RepoConfig = {
@@ -164,8 +185,16 @@ export type RepoConfig = {
164
185
  * lint and prints a skip line carrying the reason. `defineRepo` throws on
165
186
  * an unknown id or a blank reason */
166
187
  ruleExempt?: Record<string, string>
167
- /** declared github visibility INTENT (absent = 'private') — rule SK007
168
- * fails loud when `gh repo view` disagrees in either direction */
188
+ /** the repo is done for now: nothing processes it. The house discovery
189
+ * leaves it out, so it has no rail row, no hub (no file watch, no plan
190
+ * rows, no automatic gates), no processes or widgets, no identity fill,
191
+ * no activity row, and fleet skips it. Only the archived page lists it.
192
+ * The button in the repo header and the page's unarchive toggle it, and
193
+ * only this key moves (core/archived.ts) */
194
+ archived?: boolean
195
+ /** declared github visibility INTENT (absent = 'private'): public means
196
+ * strangers read the tracked tree. What each check does with it:
197
+ * wiki/build/shipkit/built-in-checks.md */
169
198
  visibility?: 'private' | 'public'
170
199
  /** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
171
200
  * whose system prompt points at `brief` (an md file holding the standing
@@ -250,13 +279,34 @@ export type RepoConfig = {
250
279
  }
251
280
  /** the `shipkit serve` hub: port + optional graph hooks */
252
281
  serve?: ServeConfig
282
+ /** the repo's own colours, one #rrggbb hex each: colorLight reads on a
283
+ * light background, colorDark on a dark one. Every page that shows the
284
+ * repo draws its name and its rail tile in them. Rule SK024 warns when
285
+ * they are missing, and its fix derives them from the repo icon */
286
+ identity?: {
287
+ /** #rrggbb that reads on a light background */
288
+ colorLight: string
289
+ /** #rrggbb that reads on a dark background: the home page draws this one */
290
+ colorDark: string
291
+ }
253
292
  /** where a gate run writes its per task logs, repo-relative (default
254
293
  * '.shipkit/tmp/shipkit-ci'). Each run owns a subdir of it, so several agents in
255
294
  * one checkout never overwrite each other. Keep it gitignored. */
256
295
  logDir?: string
257
296
  }
258
297
 
259
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
298
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
299
+
300
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/
301
+ const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
302
+
303
+ /** why a process declaration cannot run, or null */
304
+ export function processProblem(d: ProcessDecl): string | null {
305
+ if (!KEBAB.test(d.id ?? '')) return `serve.processes: id '${String(d.id)}' must be kebab-case`
306
+ if (!words(d.cmd)) return `serve.processes['${d.id}'] needs cmd: a list of words`
307
+ if (d.url != null && !/^https?:\/\//.test(d.url)) return `serve.processes['${d.id}'].url must be an http url`
308
+ return null
309
+ }
260
310
 
261
311
  export function defineRepo(c: RepoConfig): RepoConfig {
262
312
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -266,11 +316,18 @@ export function defineRepo(c: RepoConfig): RepoConfig {
266
316
  // a half-declared reviewer would lint a path like `.claude/agents/.md`
267
317
  if (c.reviewer != null && (!/^[a-z0-9][a-z0-9-]*$/.test(c.reviewer.name ?? '') || (c.reviewer.brief ?? '') === ''))
268
318
  throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
319
+ const pids = new Set<string>()
320
+ for (const d of c.serve?.processes ?? []) {
321
+ const why = processProblem(d)
322
+ if (why != null) throw new Error(`defineRepo: ${why}`)
323
+ if (pids.has(d.id)) throw new Error(`defineRepo: serve.processes declares '${d.id}' twice`)
324
+ pids.add(d.id)
325
+ }
269
326
  // unknown keys die loud — a typo'd or removed key silently changing the
270
327
  // gate is exactly the never-silence failure mode (A23)
271
328
  for (const k of Object.keys(c))
272
329
  if (!KNOWN_KEYS.has(k))
273
- throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : ''}`)
330
+ throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
274
331
  // a typo'd unit key would silently drop a target and release the wrong shape
275
332
  for (const u of c.units ?? []) {
276
333
  if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
@@ -0,0 +1,53 @@
1
+ // The dock's data on the server: every house repo's record, read for the
2
+ // live window and shaped into one view (ledger/house-runs.ts). One instance
3
+ // serves every page that asks, the home's daemon and a hub serving on its own
4
+ // alike.
5
+ //
6
+ // the one constraint a reader breaks first: this is asked every few seconds
7
+ // while a dock is open, so nothing here may cost a record read when nothing
8
+ // moved. A repo's record is opened only when its db or its write ahead log
9
+ // changed since the last read (the window only slides, and the shaping drops
10
+ // what slid out), and one shaped answer serves every request inside SHARE_MS.
11
+ import { existsSync } from 'fs'
12
+ import { parallelCap } from '../core/parallel.ts'
13
+ import { shapeHouse, type HouseRecord, type HouseView } from '../ledger/house-runs.ts'
14
+ import { LIVE_HISTORY_MS } from '../ledger/live-timeline.ts'
15
+ import { readWindowReadonly } from '../ledger/run-log.ts'
16
+ import { recordOf, recordStamp } from './project-gutter.ts'
17
+
18
+ /** how long one shaped answer serves every page that asks */
19
+ export const SHARE_MS = 1_000
20
+
21
+ export type HouseRepo = { name: string; path: string }
22
+
23
+ export class HouseRuns {
24
+ private records = new Map<string, { stamp: string; rec: HouseRecord }>()
25
+ private shaped: { at: number; view: HouseView } | null = null
26
+ /** records opened since this instance was built: what a test measures */
27
+ reads = 0
28
+
29
+ constructor(private list: () => HouseRepo[], private now: () => number = Date.now, private cap: () => number = parallelCap) {}
30
+
31
+ view(): HouseView {
32
+ const now = this.now()
33
+ if (this.shaped != null && now - this.shaped.at < SHARE_MS) return this.shaped.view
34
+ const since = now - LIVE_HISTORY_MS
35
+ const next = new Map<string, { stamp: string; rec: HouseRecord }>()
36
+ for (const r of this.list()) {
37
+ const path = recordOf(r.path)
38
+ if (path == null || !existsSync(path)) continue
39
+ const stamp = recordStamp(path)
40
+ let hit = this.records.get(r.path)
41
+ if (hit == null || hit.stamp !== stamp) {
42
+ this.reads++
43
+ hit = { stamp, rec: { repo: r.name, ...readWindowReadonly(path, since) } }
44
+ } else if (hit.rec.repo !== r.name) hit = { stamp, rec: { ...hit.rec, repo: r.name } }
45
+ next.set(r.path, hit)
46
+ }
47
+ // a repo gone from the list takes its rows with it
48
+ this.records = next
49
+ const view = shapeHouse({ now, cap: this.cap(), repos: [...next.values()].map((x) => x.rec) })
50
+ this.shaped = { at: now, view }
51
+ return view
52
+ }
53
+ }
@@ -0,0 +1,168 @@
1
+ // the activity page's state: what every house repo committed in a window and
2
+ // what it holds uncommitted, as the daemon reads it from local git. It asks
3
+ // again every ACTIVITY_POLL_MS, only while the page is shown AND the tab is
4
+ // visible, so a page left in the background costs the daemon nothing. The
5
+ // window and the commit cap are remembered per viewer.
6
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
+ import { clampCap, DEFAULT_CAP, DEFAULT_MINUTES, idleRow, WINDOW_MINUTES, type ActivityAnswer, type ActivityRow } from '../../home/activity-shape.ts'
8
+
9
+ // the words the page and `shipkit cross-repo-activity` share live beside the shapes
10
+ export { ageShort, CAP_MAX, CAP_MIN, clampCap, DEFAULT_CAP, dirtWords, filesWords, upstreamWords, windowWords } from '../../home/activity-shape.ts'
11
+ import type { HomeSt } from './home-st.ts'
12
+
13
+ export const ACTIVITY_POLL_MS = 60_000
14
+ const MINUTES_KEY = 'home:activity:minutes'
15
+ const CAP_KEY = 'home:activity:cap'
16
+
17
+ /** busy rows in the daemon's (rail) order, then the quiet ones below them */
18
+ export function splitRows(rows: ActivityRow[]): { busy: ActivityRow[]; idle: ActivityRow[] } {
19
+ return { busy: rows.filter((r) => !idleRow(r)), idle: rows.filter(idleRow) }
20
+ }
21
+
22
+ function kept(key: string): string | null {
23
+ try {
24
+ return globalThis.localStorage?.getItem(key) ?? null
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+ function keptMinutes(): number {
30
+ const n = Number(kept(MINUTES_KEY))
31
+ return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
32
+ }
33
+ function keep(key: string, n: number): void {
34
+ try { globalThis.localStorage?.setItem(key, String(n)) } catch { /* private window: the pick lasts the tab */ }
35
+ }
36
+
37
+ export class ActivitySt {
38
+ data: ActivityAnswer | null = null
39
+ error = ''
40
+ minutes: number = keptMinutes()
41
+ /** the most commits a repo shows before a click shows the rest */
42
+ cap: number = clampCap(kept(CAP_KEY) ?? DEFAULT_CAP)
43
+ /** repos whose commits all show, past the cap */
44
+ expanded = new Set<string>()
45
+ /** repos whose failing and warning checks show under their row */
46
+ checksOpen = new Set<string>()
47
+ /** a read is in flight */
48
+ loading = false
49
+ /** when the next read goes, epoch ms */
50
+ nextAt = 0
51
+ /** the page's clock, one tick a second while shown */
52
+ now = Date.now()
53
+ private timer: ReturnType<typeof setInterval> | null = null
54
+ private gen = 0
55
+ /** a tab coming back reads only when its countdown is already due */
56
+ private readonly onVisible = (): void => {
57
+ if (this.timer != null) this.tick()
58
+ }
59
+
60
+ constructor(private st: HomeSt) {
61
+ makeAutoObservable<this, 'st' | 'timer' | 'gen' | 'onVisible'>(this, { st: false, timer: false, gen: false, onVisible: false, data: observableRef })
62
+ }
63
+
64
+ get rows() {
65
+ return splitRows(this.data?.rows ?? [])
66
+ }
67
+
68
+ /** the answer shown is for another window than the one picked: it dims until the new one lands */
69
+ get stale(): boolean {
70
+ return this.data != null && this.data.minutes !== this.minutes
71
+ }
72
+
73
+ /** whole seconds to the next read */
74
+ get countdown(): number {
75
+ return Math.max(0, Math.ceil((this.nextAt - this.now) / 1000))
76
+ }
77
+
78
+ setMinutes(m: number): void {
79
+ this.minutes = m
80
+ keep(MINUTES_KEY, m)
81
+ void this.poll()
82
+ }
83
+
84
+ /** a typed cap, clamped to CAP_MIN..CAP_MAX */
85
+ setCap(v: unknown): void {
86
+ this.cap = clampCap(v)
87
+ keep(CAP_KEY, this.cap)
88
+ }
89
+
90
+ toggle(name: string): void {
91
+ if (this.expanded.has(name)) this.expanded.delete(name)
92
+ else this.expanded.add(name)
93
+ }
94
+
95
+ /** a click on the status dot: its checks show under the row, or hide */
96
+ toggleChecks(name: string): void {
97
+ if (this.checksOpen.has(name)) this.checksOpen.delete(name)
98
+ else this.checksOpen.add(name)
99
+ }
100
+
101
+ openCheck(repo: string, check: string): void {
102
+ this.st.openCheck(repo, check)
103
+ }
104
+
105
+ /** the repo in Fork, the git client: the daemon opens it by name */
106
+ async openFork(name: string): Promise<void> {
107
+ try {
108
+ const r = await fetch('/api/projects/fork', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
109
+ if (!r.ok) throw new Error(await r.text())
110
+ runInAction(() => { this.error = '' })
111
+ } catch (e) {
112
+ runInAction(() => { this.error = `open ${name} in Fork: ${(e as Error).message}` })
113
+ }
114
+ }
115
+
116
+ openRepo(name: string): void {
117
+ this.st.select(name)
118
+ }
119
+
120
+ /** the refresh button: a new git pass now, whatever the countdown says */
121
+ refresh(): void {
122
+ if (!this.loading) void this.poll(true)
123
+ }
124
+
125
+ /** one read. The daemon shares an answer younger than its ttl between
126
+ * readers and never serves an older one; only `fresh` forces a pass */
127
+ async poll(fresh = false): Promise<void> {
128
+ const id = ++this.gen
129
+ runInAction(() => { this.loading = true; this.nextAt = Date.now() + ACTIVITY_POLL_MS })
130
+ try {
131
+ const r = await fetch(`/api/home/activity.json?m=${this.minutes}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(30_000) })
132
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
133
+ const j = (await r.json()) as ActivityAnswer
134
+ runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
135
+ } catch (e) {
136
+ runInAction(() => { if (id === this.gen) this.error = (e as Error).message })
137
+ } finally {
138
+ // the countdown starts when the answer lands, so reads never overlap
139
+ runInAction(() => { if (id === this.gen) { this.loading = false; this.nextAt = Date.now() + ACTIVITY_POLL_MS } })
140
+ }
141
+ }
142
+
143
+ /** one tick a second: the countdown moves, and a due read goes, unless
144
+ * the tab is hidden (the daemon reads git only for a viewer) */
145
+ private tick(): void {
146
+ this.now = Date.now()
147
+ if (this.loading || this.now < this.nextAt) return
148
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
149
+ void this.poll()
150
+ }
151
+
152
+ /** reads while the page is shown */
153
+ start(): void {
154
+ if (this.timer != null) return
155
+ void this.poll()
156
+ this.timer = setInterval(() => this.tick(), 1_000)
157
+ document.addEventListener('visibilitychange', this.onVisible)
158
+ }
159
+
160
+ stop(): void {
161
+ if (this.timer == null) return
162
+ clearInterval(this.timer)
163
+ this.timer = null
164
+ this.gen++
165
+ this.loading = false
166
+ document.removeEventListener('visibilitychange', this.onVisible)
167
+ }
168
+ }
@@ -0,0 +1,218 @@
1
+ // `/activity`: what every house repo committed in the window and what it holds
2
+ // uncommitted, as ONE table with a row group per repo, so a glance says which
3
+ // agents did what. Each commit carries its own state (↑ orange unpushed, ✓
4
+ // green pushed), uncommitted work is a red line above the commits, the branch
5
+ // is green on main and orange elsewhere. On top: the window, the commit cap
6
+ // (a number, 1 to 15), then when the next read goes. Repos with nothing in the
7
+ // window sit below the busy ones, each with its last commit in grey.
8
+ import { observer } from 'mobx-react-lite'
9
+ import type { ReactNode } from 'react'
10
+ import { idleRow, isMainBranch, WINDOW_MINUTES, type ActivityCommit, type ActivityRow, type StatusCheck } from '../../home/activity-shape.ts'
11
+ import { ageShort, CAP_MAX, CAP_MIN, dirtWords, filesWords, upstreamWords, windowWords } from './activity-st.ts'
12
+ import { Tip } from './floating.tsx'
13
+ import { Icon, ICON } from './icons.tsx'
14
+ import type { HomeSt } from './home-st.ts'
15
+ import { checkAddress, plainClick, repoPage } from './home-st.ts'
16
+ import { stampWords } from './time-ago.tsx'
17
+
18
+ /** the repo's icon and name; a plain click selects it in place */
19
+ const RepoName = observer(({ app, name }: { app: HomeSt; name: string }) => {
20
+ const icon = app.projects.list.find((p) => p.name === name)?.icon ?? null
21
+ return (
22
+ <a className="acrepo" href={repoPage(name)} onClick={(e) => {
23
+ if (!plainClick(e)) return
24
+ e.preventDefault()
25
+ app.activity.openRepo(name)
26
+ }}>
27
+ {icon == null ? null : <img src={icon} alt="" width={14} height={14} draggable={false} />}
28
+ <span>{name}</span>
29
+ </a>
30
+ )
31
+ })
32
+
33
+ /** a bar of exclusive buttons */
34
+ const Bar = <T extends number>({ label, values, value, words, set }: { label: string; values: readonly T[]; value: number; words: (v: T) => string; set: (v: T) => void }) => (
35
+ <div className="acwin" role="radiogroup" aria-label={label}>
36
+ {values.map((v) => (
37
+ <button key={v} role="radio" aria-checked={value === v} className={value === v ? 'on' : ''} onClick={() => set(v)}>{words(v)}</button>
38
+ ))}
39
+ </div>
40
+ )
41
+
42
+ /** when the next read goes; it spins while one runs */
43
+ const Next = observer(({ app }: { app: HomeSt }) => {
44
+ const st = app.activity
45
+ return (
46
+ <button type="button" className="acnext acpill" disabled={st.loading} onClick={() => st.refresh()}
47
+ title="read every repo now · the page reads them again every minute while it is shown">
48
+ <span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
49
+ {st.loading ? '' : ` ${st.countdown}s`}
50
+ </button>
51
+ )
52
+ })
53
+
54
+ /** one commit's cells; `old` is the last one, from before the window */
55
+ const Commit = ({ c, now, old }: { c: ActivityCommit; now: number; old: boolean }) => (
56
+ <>
57
+ <td className="acsha">{c.sha}</td>
58
+ <td className="acwhen" title={stampWords(c.at)}>{ageShort(c.at, now)}</td>
59
+ <td className={`actitle ${c.pushed ? 'pushed' : 'unpushed'}${old ? ' old' : ''}`} title={`${old ? 'the last commit, before the window. ' : ''}${c.pushed ? 'pushed' : 'not pushed'}: ${c.title}`}>
60
+ <span className="acmark">{c.pushed ? '✓' : '↑'}</span>
61
+ {c.branch == null ? null : <span className="acside">{c.branch}</span>}
62
+ {c.title}
63
+ </td>
64
+ <td className="acwho">{c.author}</td>
65
+ </>
66
+ )
67
+
68
+ /** the checks behind a red or orange dot, failing first. An answer from
69
+ * a daemon that sends no `failing` still names them, by id */
70
+ export const failingOf = (r: ActivityRow): StatusCheck[] => {
71
+ if (r.failing != null) return r.failing
72
+ const c = r.status
73
+ if (c == null) return []
74
+ const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '' })
75
+ return [...c.reds.map((id) => one(id, 'fail')), ...c.warns.map((id) => one(id, 'warn'))]
76
+ }
77
+
78
+ /** each failing and warning check: its id (a link to its page), its title,
79
+ * how old that outcome is, and the first line it printed about it */
80
+ export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow }) => {
81
+ const now = app.activity.now
82
+ return (
83
+ <ul className="acchecks">
84
+ {failingOf(r).map((c) => (
85
+ <li key={c.id} className={c.status === 'fail' ? 'fail' : 'warn'}>
86
+ <span className="acchkhead">
87
+ <span className="acchkmark">{c.status === 'fail' ? '✗' : '!'}</span>
88
+ <a className="acchkid" href={checkAddress(r.name, c.id)} onClick={(e) => {
89
+ if (!plainClick(e)) return
90
+ e.preventDefault()
91
+ app.activity.openCheck(r.name, c.id)
92
+ }}>{c.id}</a>
93
+ {c.title !== c.id ? <span className="acchktitle">{c.title}</span> : null}
94
+ {c.at == null ? null : <span className="acdim" title={stampWords(c.at)}>{ageShort(c.at, now)}</span>}
95
+ </span>
96
+ {c.line === '' ? null : <span className="acchkline">{c.line}</span>}
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ )
101
+ })
102
+
103
+ /** the repo's checks as its own run record last saw them, only the ids it
104
+ * declares today: one dot, red, orange or green, with the count behind it.
105
+ * Hovering it floats the failing and warning checks; a click lists them
106
+ * under the row, so they read without hover too */
107
+ const Status = observer(({ app, r, span }: { app: HomeSt; r: ActivityRow; span: number }) => {
108
+ const c = r.status
109
+ if (c == null) return (
110
+ <td rowSpan={span} className="acstatus acdim">
111
+ <Tip content="no shipkit run recorded in this repo yet" placement="right">·</Tip>
112
+ </td>
113
+ )
114
+ const n = c.state === 'red' ? c.reds.length : c.state === 'orange' ? c.warns.length : 0
115
+ const tone = c.state === 'red' ? 'red' : c.state === 'orange' ? 'warn' : 'ok'
116
+ if (c.reds.length + c.warns.length === 0) return (
117
+ <td rowSpan={span} className={`acstatus t-${tone}`}>
118
+ <Tip content="every check passed on its last run" placement="right">●</Tip>
119
+ </td>
120
+ )
121
+ const open = app.activity.checksOpen.has(r.name)
122
+ return (
123
+ <td rowSpan={span} className={`acstatus t-${tone}`}>
124
+ <Tip content={<CheckList app={app} r={r} />} tipClass="acchktip" interactive placement="right-start" fallbacks={['bottom-start', 'left-start']}>
125
+ <button type="button" className={`acdot t-${tone}`} aria-expanded={open} aria-label={`${r.name}: ${c.reds.length} failing, ${c.warns.length} warning, click to ${open ? 'hide' : 'list'} them`}
126
+ onClick={() => app.activity.toggleChecks(r.name)}>
127
+ ●{n > 0 ? ` ${n}` : ''}
128
+ </button>
129
+ </Tip>
130
+ </td>
131
+ )
132
+ })
133
+
134
+ const Group = observer(({ app, r, now }: { app: HomeSt; r: ActivityRow; now: number }) => {
135
+ const st = app.activity
136
+ const open = st.expanded.has(r.name)
137
+ const capped = open ? r.commits : r.commits.slice(0, st.cap)
138
+ const more = r.commits.length - capped.length
139
+ const folds = more > 0 || (open && r.commits.length > st.cap)
140
+ const dirt = r.error == null ? dirtWords(r) : ''
141
+ const lines = (dirt !== '' || r.error != null ? 1 : 0) + capped.length + (r.last != null ? 1 : 0) + (folds ? 1 : 0)
142
+ const span = Math.max(1, lines)
143
+ const branch = r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
144
+ const head = (
145
+ <>
146
+ <td rowSpan={span} className="acname"><RepoName app={app} name={r.name} /></td>
147
+ <Status app={app} r={r} span={span} />
148
+ <td rowSpan={span} className={`acbranch ${isMainBranch(r.branch) ? 't-ok' : 't-warn'}`} title={`${branch}: ${upstreamWords(r)}`}>{branch}</td>
149
+ {/* the lead in to the commit list, on its first line */}
150
+ <td rowSpan={span} className="acforkcell">
151
+ <button type="button" className="acfork" title="open in Fork" aria-label={`open ${r.name} in Fork`} onClick={() => void st.openFork(r.name)}><Icon d={ICON.fork} /></button>
152
+ </td>
153
+ </>
154
+ )
155
+ const rows: ReactNode[] = []
156
+ if (r.error != null) rows.push(<td key="e" colSpan={4} className="acerr" title={r.error}>git failed: {r.error}</td>)
157
+ else if (dirt !== '') rows.push(
158
+ <td key="d" colSpan={4} className="acdirty">
159
+ <Tip className="acdirt" tipClass="acfiles" placement="bottom-start" content={<pre>{filesWords(r)}</pre>}>{dirt}</Tip>
160
+ </td>,
161
+ )
162
+ for (const c of capped) rows.push(<Commit c={c} now={now} old={false} />)
163
+ // nothing newer in the window: HEAD's last commit, grey
164
+ if (r.last != null) rows.push(<Commit c={r.last} now={now} old />)
165
+ if (folds) rows.push(<td key="m" colSpan={4}><button className="acmore" onClick={() => st.toggle(r.name)}>{open ? 'show fewer' : `${more} more`}</button></td>)
166
+ if (rows.length === 0) rows.push(<td key="n" colSpan={4} className="acdim">no commit yet</td>)
167
+ return (
168
+ <tbody className={`acgrp${idleRow(r) ? ' quiet' : ''}`}>
169
+ {rows.map((cells, i) => <tr key={i}>{i === 0 ? head : null}{cells}</tr>)}
170
+ {st.checksOpen.has(r.name) && failingOf(r).length > 0 ? <tr className="acchkrow"><td colSpan={8}><CheckList app={app} r={r} /></td></tr> : null}
171
+ </tbody>
172
+ )
173
+ })
174
+
175
+ export const ActivityView = observer(({ app }: { app: HomeSt }) => {
176
+ const st = app.activity
177
+ const d = st.data
178
+ const now = d?.now ?? st.now
179
+ const { busy, idle } = st.rows
180
+ return (
181
+ <div className="hall activity">
182
+ <div className="actop">
183
+ <Bar label="how far back" values={WINDOW_MINUTES} value={st.minutes} words={windowWords} set={(m) => st.setMinutes(m)} />
184
+ <label className="accap" title={`the most commits a repo shows, ${CAP_MIN} to ${CAP_MAX}`}>
185
+ {/* uncontrolled while typed, so an emptied box can take a new number; it reads back the clamped cap on blur */}
186
+ <input type="number" min={CAP_MIN} max={CAP_MAX} step={1} defaultValue={st.cap} aria-label="commits per repo"
187
+ onChange={(e) => { if (e.currentTarget.value !== '') st.setCap(e.currentTarget.value) }}
188
+ onBlur={(e) => { e.currentTarget.value = String(st.cap) }} />
189
+ <span className="acdim">commits</span>
190
+ </label>
191
+ <Next app={app} />
192
+ {d == null ? <span className="acdim">reading every repo…</span> : (
193
+ <>
194
+ <span className="acpill">{busy.length} busy</span>
195
+ <span className="acpill">{idle.length} quiet</span>
196
+ <span className="acpill">read in {d.ms}ms</span>
197
+ </>
198
+ )}
199
+ </div>
200
+ {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
201
+ {d != null && busy.length === 0 ? <div className="acdim">nothing committed or uncommitted in the last {windowWords(st.minutes)}</div> : null}
202
+ {busy.length + idle.length === 0 ? null : (
203
+ <table className={`actable${st.stale ? ' stale' : ''}`}>
204
+ <colgroup>
205
+ <col className="c-name" /><col className="c-status" /><col className="c-branch" />
206
+ <col className="c-fork" /><col className="c-sha" /><col className="c-when" /><col /><col className="c-who" />
207
+ </colgroup>
208
+ <thead>
209
+ <tr><th>repo</th><th>status</th><th>branch</th><th colSpan={5}>commits, newest first</th></tr>
210
+ </thead>
211
+ {busy.map((r) => <Group key={r.name} app={app} r={r} now={now} />)}
212
+ {idle.map((r) => <Group key={r.name} app={app} r={r} now={now} />)}
213
+ </table>
214
+ )}
215
+ <p className="hdim">in the console: shipkit activity</p>
216
+ </div>
217
+ )
218
+ })