rvlib-shipkit 1.2.0 → 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 (138) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +20 -6
  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 +6 -2
  18. package/src/checks/SK024-identity-colors.check.ts +5 -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_Ci.ts +5 -0
  25. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  26. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  27. package/src/cli/cmd_Keys.ts +17 -3
  28. package/src/cli/cmd_Overview.ts +98 -0
  29. package/src/cli/cmd_Planned.ts +117 -0
  30. package/src/cli.ts +9 -1
  31. package/src/collections/checks.ts +3 -2
  32. package/src/collections/providers.ts +1 -1
  33. package/src/collections/test-time.ts +10 -1
  34. package/src/core/agent-spawn.ts +46 -0
  35. package/src/core/archived.ts +82 -0
  36. package/src/core/canonical-scripts.ts +12 -3
  37. package/src/core/check-runner.ts +40 -12
  38. package/src/core/discover-checks.ts +2 -0
  39. package/src/core/file-stamp.ts +20 -0
  40. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  41. package/src/core/identity-color.ts +1 -1
  42. package/src/core/repo-facts.ts +14 -0
  43. package/src/core/retention.ts +14 -5
  44. package/src/core/retired-checks.ts +7 -0
  45. package/src/core/run-check.ts +6 -2
  46. package/src/core/run-ci.ts +43 -12
  47. package/src/core/run-fix.ts +7 -2
  48. package/src/core/run-fixers.ts +10 -6
  49. package/src/core/wire.ts +11 -0
  50. package/src/fleet/scan-fleet.ts +2 -1
  51. package/src/home/activity-shape.ts +135 -0
  52. package/src/home/activity.ts +249 -0
  53. package/src/home/global-units.ts +3 -2
  54. package/src/home/home-routes.ts +32 -5
  55. package/src/home/pm.ts +37 -4
  56. package/src/home/process-rows.ts +205 -0
  57. package/src/home/resources.ts +35 -8
  58. package/src/home/run-daemon.ts +6 -6
  59. package/src/home/run-pm.ts +1 -0
  60. package/src/home/serve-web.ts +6 -3
  61. package/src/home/supervise.ts +16 -1
  62. package/src/home/widget-text.ts +119 -0
  63. package/src/hooks/run-hooks.ts +20 -3
  64. package/src/ledger/db.ts +1 -1
  65. package/src/ledger/gate-checks.ts +17 -3
  66. package/src/ledger/house-runs.ts +43 -0
  67. package/src/ledger/live-timeline.ts +4 -19
  68. package/src/ledger/run-log.ts +39 -10
  69. package/src/ledger/store.ts +19 -2
  70. package/src/ledger/world-cache.ts +51 -4
  71. package/src/plan/cadence.ts +21 -5
  72. package/src/plan/catch-up.ts +37 -0
  73. package/src/plan/plan-timeline.ts +99 -0
  74. package/src/plan/planner.ts +138 -13
  75. package/src/plan/repo-source.ts +120 -8
  76. package/src/release/run-release.ts +9 -2
  77. package/src/release/unit-resolve.ts +8 -12
  78. package/src/repo-config.ts +11 -3
  79. package/src/serve/house-runs.ts +53 -0
  80. package/src/serve/hub-app/activity-st.ts +168 -0
  81. package/src/serve/hub-app/activity-view.tsx +218 -0
  82. package/src/serve/hub-app/activity.css +78 -0
  83. package/src/serve/hub-app/admin-svc.ts +41 -0
  84. package/src/serve/hub-app/admin-view.tsx +42 -0
  85. package/src/serve/hub-app/chart-view.ts +12 -5
  86. package/src/serve/hub-app/check-words.ts +2 -0
  87. package/src/serve/hub-app/controls.css +32 -0
  88. package/src/serve/hub-app/cycle-button.tsx +36 -0
  89. package/src/serve/hub-app/dock-anim.ts +25 -0
  90. package/src/serve/hub-app/dock-st.ts +329 -0
  91. package/src/serve/hub-app/dock-view.tsx +68 -0
  92. package/src/serve/hub-app/dock.css +35 -0
  93. package/src/serve/hub-app/hash-route.ts +7 -1
  94. package/src/serve/hub-app/header-bar.tsx +79 -104
  95. package/src/serve/hub-app/home-st.ts +49 -12
  96. package/src/serve/hub-app/home-strip.css +65 -22
  97. package/src/serve/hub-app/home-strip.tsx +228 -97
  98. package/src/serve/hub-app/home.html +4 -0
  99. package/src/serve/hub-app/hub-app.tsx +96 -160
  100. package/src/serve/hub-app/hub.css +77 -37
  101. package/src/serve/hub-app/icons.tsx +23 -3
  102. package/src/serve/hub-app/index.html +4 -0
  103. package/src/serve/hub-app/keys.ts +42 -7
  104. package/src/serve/hub-app/live-svc.ts +24 -64
  105. package/src/serve/hub-app/machine-st.ts +64 -1
  106. package/src/serve/hub-app/md.ts +38 -5
  107. package/src/serve/hub-app/next-run.ts +1 -1
  108. package/src/serve/hub-app/palette-st.ts +11 -7
  109. package/src/serve/hub-app/palette-view.tsx +47 -0
  110. package/src/serve/hub-app/pill-row.tsx +65 -0
  111. package/src/serve/hub-app/planned-st.ts +21 -85
  112. package/src/serve/hub-app/planned-view.tsx +12 -7
  113. package/src/serve/hub-app/planned.css +5 -2
  114. package/src/serve/hub-app/project-list.css +43 -21
  115. package/src/serve/hub-app/project-list.tsx +122 -23
  116. package/src/serve/hub-app/projects-svc.ts +120 -12
  117. package/src/serve/hub-app/readme-svc.ts +38 -0
  118. package/src/serve/hub-app/readme-view.tsx +19 -0
  119. package/src/serve/hub-app/readme.css +11 -0
  120. package/src/serve/hub-app/repo-sections.tsx +61 -0
  121. package/src/serve/hub-app/repo-st.ts +25 -6
  122. package/src/serve/hub-app/runs-tabs.ts +1 -1
  123. package/src/serve/hub-app/sidebar.tsx +84 -0
  124. package/src/serve/hub-app/tests-view.tsx +31 -4
  125. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  126. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  127. package/src/serve/hub-app/timeline-svc.ts +2 -2
  128. package/src/serve/hub-app/timeline-view.tsx +20 -3
  129. package/src/serve/hub-runtime.ts +17 -9
  130. package/src/serve/other-repos.ts +8 -45
  131. package/src/serve/project-gutter-route.ts +100 -20
  132. package/src/serve/project-gutter.ts +33 -11
  133. package/src/serve/readme-route.ts +57 -0
  134. package/src/serve/serve-hub.ts +13 -8
  135. package/src/serve/tracked-watch.ts +33 -1
  136. package/src/shipkit-files.ts +20 -4
  137. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  138. package/src/checks/contracts.check.ts +0 -20
@@ -3,10 +3,10 @@
3
3
  // server measures at most once per 5s whatever the number of tabs, so this
4
4
  // poll is cheap.
5
5
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
- import type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
6
+ import type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
7
7
  import { identityOfName, type Identity } from '../../core/identity-color.ts'
8
8
 
9
- export type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
9
+ export type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
10
10
 
11
11
  export const PROJECTS_POLL_MS = 5_000
12
12
  /** how long the colour picker rests before its colour is written */
@@ -16,6 +16,38 @@ export const IDENTITY_SAVE_MS = 400
16
16
  * next load while the daemon is asked again */
17
17
  export const LIST_KEY = 'hub:projects'
18
18
 
19
+ /** where this browser keeps whether the rail's archived rows show */
20
+ export const ARCHIVED_OPEN_KEY = 'hub:archived-open'
21
+
22
+ const keptOpen = (): boolean => {
23
+ try { return globalThis.localStorage?.getItem(ARCHIVED_OPEN_KEY) === '1' } catch { return false }
24
+ }
25
+
26
+ /** where this browser keeps the last sort it picked */
27
+ export const SORT_KEY = 'hub:sort'
28
+
29
+ /** a sort the foot's menu offers: each one reorders the list once, and the
30
+ * result is the new manual order */
31
+ export type SortMode = 'activity' | 'commit' | 'name' | 'asks'
32
+
33
+ /** the menu's lines, in their order: a label naming the order, a title
34
+ * saying how it breaks ties */
35
+ export const SORTS: { id: SortMode; label: string; title: string }[] = [
36
+ { id: 'activity', label: 'last activity', title: 'newest first: the last commit, shipkit run or Claude session' },
37
+ { id: 'commit', label: 'last commit', title: 'newest commit on the checked out branch first' },
38
+ { id: 'name', label: 'name', title: 'A to Z' },
39
+ { id: 'asks', label: 'open asks', title: 'the repo waiting on the most asks first' },
40
+ ]
41
+
42
+ const keptSort = (): SortMode | null => {
43
+ try {
44
+ const v = globalThis.localStorage?.getItem(SORT_KEY)
45
+ return SORTS.some((m) => m.id === v) ? (v as SortMode) : null
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
19
51
  /** the project list this browser last saw, or [] when storage is absent,
20
52
  * blocked or holds anything else */
21
53
  export function loadList(): Project[] {
@@ -78,6 +110,15 @@ export const byActivity = (list: Project[]): string[] =>
78
110
  .sort((a, b) => (b.p.activity ?? -Infinity) - (a.p.activity ?? -Infinity) || a.i - b.i)
79
111
  .map(({ p }) => p.name)
80
112
 
113
+ /** the names in a sort's order, ties in their current order */
114
+ export function sortNames(list: Project[], mode: SortMode): string[] {
115
+ if (mode === 'activity') return byActivity(list)
116
+ const cmp: (a: Project, b: Project) => number = mode === 'commit'
117
+ ? (a, b) => (b.commit ?? -Infinity) - (a.commit ?? -Infinity)
118
+ : mode === 'asks' ? (a, b) => b.asks - a.asks
119
+ : (a, b) => a.name.localeCompare(b.name)
120
+ return list.map((p, i) => ({ p, i })).sort((a, b) => cmp(a.p, b.p) || a.i - b.i).map(({ p }) => p.name)
121
+ }
81
122
 
82
123
  /** the hub state dot the home draws on a tile, only when it needs a look:
83
124
  * its hub starts or crashed. A hub that is up is the normal case and draws none */
@@ -132,7 +173,7 @@ export function cardModel(p: Project, now: number): CardModel {
132
173
  }
133
174
 
134
175
  /** a page of the home that is no repo's */
135
- export type Place = 'overview' | 'planned'
176
+ export type Place = 'overview' | 'planned' | 'activity'
136
177
 
137
178
  export class ProjectsSvc {
138
179
  /** the last list this browser saw until the first poll answers, so the
@@ -140,6 +181,16 @@ export class ProjectsSvc {
140
181
  list: Project[] = loadList()
141
182
  /** why the last poll or open failed, shown on the list's head */
142
183
  error = ''
184
+ /** the repos whose repo.config.ts says `archived: true`: never in `list`,
185
+ * only on the archived page */
186
+ archived: ArchivedRow[] = []
187
+ /** the archived button at the foot of the rail is on, its rows shown, per browser */
188
+ archivedOpen = keptOpen()
189
+ /** the sort menu at the foot of the rail is open */
190
+ sortMenuOpen = false
191
+ /** the last sort picked in this browser; `sortMode` says whether the list
192
+ * is still in its order */
193
+ sortPicked: SortMode | null = keptSort()
143
194
  /** the daemon answered once: until then the list is the kept one */
144
195
  fresh = false
145
196
  /** the row under the pointer, whose hover card shows */
@@ -147,8 +198,9 @@ export class ProjectsSvc {
147
198
  /** the tile being dragged, and the gap it would land in */
148
199
  drag: string | null = null
149
200
  gap: number | null = null
150
- /** the repo the home page shows; null on the overview, the plan, and a
151
- * hub of its own, whose repo is `current`. The list's api is always the
201
+ /** the repo the home page shows, or showed last while a global page is
202
+ * up; null until one is picked, and on a hub of its own, whose repo is
203
+ * `current`. The list's api is always the
152
204
  * page origin's own: the home's machine wide one, or this hub's */
153
205
  selected: string | null = null
154
206
  /** what a single click does: the home shows that repo in place, a hub
@@ -157,8 +209,16 @@ export class ProjectsSvc {
157
209
  /** the two places at the top of the list, the overview and the plan:
158
210
  * the home sets it, a hub of its own has none */
159
211
  onPlace: ((p: Place) => void) | null = null
212
+ /** the archived page, where an archived row of the rail leads: the home sets it */
213
+ onArchived: (() => void) | null = null
214
+ /** the picker's line after the overview: the home sets it */
215
+ onPicker: (() => void) | null = null
216
+ /** the picker is open, so its line is lit: the home sets it */
217
+ pickerOpen: () => boolean = () => false
160
218
  /** the place shown, marked at the top of the list; null on a repo page */
161
219
  place: Place | null = null
220
+ /** a global page is up: the selected repo's row stays marked, dimmer */
221
+ away = false
162
222
  /** the home's hub state per repo; a hub draws none */
163
223
  dotOf: (name: string) => HubDot = () => null
164
224
  /** one line when a machine resource the repo uses is off */
@@ -172,12 +232,38 @@ export class ProjectsSvc {
172
232
  private orderGen = 0
173
233
 
174
234
  constructor() {
175
- makeAutoObservable<this, 'gen' | 'orderGen' | 'identitySaves' | 'saveIdentity' | 'kept' | 'keep'>(this, { gen: false, orderGen: false, identitySaves: false, saveIdentity: false, kept: false, keep: false, list: observableRef, onSelect: false, onPlace: false, dotOf: false, warnOf: false })
235
+ makeAutoObservable<this, 'gen' | 'orderGen' | 'identitySaves' | 'saveIdentity' | 'kept' | 'keep'>(this, { gen: false, orderGen: false, identitySaves: false, saveIdentity: false, kept: false, keep: false, list: observableRef, onSelect: false, onPlace: false, onPicker: false, pickerOpen: false, onArchived: false, dotOf: false, warnOf: false })
176
236
  }
177
237
 
178
- /** the tile drawn as the current one */
238
+ toggleArchivedOpen(): void {
239
+ this.archivedOpen = !this.archivedOpen
240
+ try { globalThis.localStorage?.setItem(ARCHIVED_OPEN_KEY, this.archivedOpen ? '1' : '0') } catch { /* private window: the choice lasts the tab */ }
241
+ }
242
+
243
+ isArchived(name: string): boolean {
244
+ return this.archived.some((p) => p.name === name)
245
+ }
246
+
247
+ /** archive a repo or bring it back: written into its repo.config.ts
248
+ * (`archived`, only that key), then the list is read again, so the repo
249
+ * moves between the rail and the archived page, and the daemon stops or
250
+ * loads its hub on its next tick */
251
+ async setArchived(name: string, on: boolean): Promise<void> {
252
+ try {
253
+ const r = await fetch('/api/projects/archived', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, archived: on }) })
254
+ if (!r.ok) throw new Error(await r.text())
255
+ runInAction(() => { this.error = '' })
256
+ await this.load()
257
+ } catch (e) {
258
+ runInAction(() => { this.error = `${on ? 'archive' : 'unarchive'} ${name}: ${(e as Error).message}` })
259
+ }
260
+ }
261
+
262
+ /** the tile drawn as the current one. The home marks only a repo that
263
+ * was picked (it sets `onPlace`); a hub of its own marks its own repo */
179
264
  isCurrent(p: Project): boolean {
180
- return this.selected != null ? p.name === this.selected : p.current
265
+ if (this.selected != null) return p.name === this.selected
266
+ return this.onPlace == null && p.current
181
267
  }
182
268
 
183
269
  select(name: string): void {
@@ -272,9 +358,30 @@ export class ProjectsSvc {
272
358
  void this.reorder(moveTo(this.list.map((p) => p.name), from, gap))
273
359
  }
274
360
 
275
- /** one reorder by last activity; from then on it is the manual order */
276
- sortByActivity(): void {
277
- void this.reorder(byActivity(this.list))
361
+ /** the sort the list is in now: the last one picked while the order
362
+ * still matches it, else the manual order a drag made */
363
+ get sortMode(): SortMode | 'manual' {
364
+ const m = this.sortPicked
365
+ if (m == null) return 'manual'
366
+ const now = this.list.map((p) => p.name)
367
+ return sortNames(this.list, m).every((n, i) => n === now[i]) ? m : 'manual'
368
+ }
369
+
370
+ /** the button opens or closes the menu, and sorts nothing */
371
+ toggleSortMenu(): void {
372
+ this.sortMenuOpen = !this.sortMenuOpen
373
+ }
374
+ setSortMenu(open: boolean): void {
375
+ this.sortMenuOpen = open
376
+ }
377
+
378
+ /** a pick: one reorder in that sort's order, then the menu closes; from
379
+ * then on it is the manual order */
380
+ sortBy(mode: SortMode): void {
381
+ this.sortMenuOpen = false
382
+ this.sortPicked = mode
383
+ try { globalThis.localStorage?.setItem(SORT_KEY, mode) } catch { /* private window: the pick lasts the tab */ }
384
+ void this.reorder(sortNames(this.list, mode))
278
385
  }
279
386
 
280
387
  /** shows the new order at once, then saves it for every hub */
@@ -322,12 +429,13 @@ export class ProjectsSvc {
322
429
  try {
323
430
  const r = await fetch('/api/projects.json')
324
431
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
325
- const j = (await r.json()) as { projects: Project[] }
432
+ const j = (await r.json()) as { projects: Project[]; archived?: ArchivedRow[] }
326
433
  runInAction(() => {
327
434
  // a reorder since this left, or a drag in flight: the next poll answers
328
435
  if (order !== this.orderGen || this.drag != null) return
329
436
  const mine = new Map(this.list.filter((p) => this.identitySaves.has(p.name)).map((p) => [p.name, p.identity]))
330
437
  this.list = mine.size === 0 ? j.projects : j.projects.map((p) => (mine.has(p.name) ? { ...p, identity: mine.get(p.name)! } : p))
438
+ this.archived = j.archived ?? []
331
439
  this.error = ''
332
440
  this.fresh = true
333
441
  })
@@ -0,0 +1,38 @@
1
+ // the repo's README, shown when no row is picked. Read again each time the
2
+ // empty page shows, so an edited README appears without a reload
3
+ import { makeAutoObservable, runInAction } from 'mobx'
4
+ import type { RepoSt } from './repo-st.ts'
5
+
6
+ export class ReadmeSvc {
7
+ /** the file name at the root, null when the repo has none */
8
+ file: string | null = null
9
+ md: string | null = null
10
+ error: string | null = null
11
+ loaded = false
12
+
13
+ constructor(private st: RepoSt) {
14
+ makeAutoObservable<this, 'st'>(this, { st: false })
15
+ }
16
+
17
+ /** where a README image loads from: the hub's file route, under this repo */
18
+ fileUrl = (rel: string): string => this.st.url(`/api/readme-file/${rel}`)
19
+
20
+ async load(): Promise<void> {
21
+ try {
22
+ const r = await fetch(this.st.url('/api/readme.json'))
23
+ if (!r.ok) throw new Error(`http ${r.status}: ${await r.text()}`)
24
+ const j = (await r.json()) as { file: string | null; md: string | null }
25
+ runInAction(() => {
26
+ this.file = j.file
27
+ this.md = j.md
28
+ this.error = null
29
+ this.loaded = true
30
+ })
31
+ } catch (e) {
32
+ runInAction(() => {
33
+ this.error = `README fetch broke: ${(e as Error).message}`
34
+ this.loaded = true
35
+ })
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,19 @@
1
+ // the page with no row picked: the repo's README, images included, or the
2
+ // hint when the repo has none
3
+ import { observer } from 'mobx-react-lite'
4
+ import { useEffect } from 'react'
5
+ import { renderReadme } from './md.ts'
6
+ import type { RepoSt } from './repo-st.ts'
7
+
8
+ export const ReadmeView = observer(({ app }: { app: RepoSt }) => {
9
+ const r = app.readme
10
+ useEffect(() => { void r.load() }, [r])
11
+ if (r.error != null) return <div className="ph"><div className="errs">{r.error}</div>Pick a row on the left.</div>
12
+ if (r.md == null) return <div className="ph">{r.loaded ? 'Pick a row on the left.' : ''}</div>
13
+ return (
14
+ <div className="readme">
15
+ <div className="readme-head">{r.file} · pick a row on the left for the rest</div>
16
+ <div className="md readme-body" dangerouslySetInnerHTML={{ __html: renderReadme(r.md, r.fileUrl) }} />
17
+ </div>
18
+ )
19
+ })
@@ -0,0 +1,11 @@
1
+ /* the README on a repo page with no row picked: a readable column held to the left
2
+ (the pane centers its child, the auto right margin takes the slack), images at their width */
3
+ .readme{width:100%;max-width:860px;margin:0 auto 0 var(--s3);padding:var(--s3) var(--s4)}
4
+ .readme-head{color:var(--dim);font-size:11px;margin-bottom:var(--s2)}
5
+ .readme-body{font-size:13.5px;line-height:1.6}
6
+ .readme .readme-body h1{font-size:22px;margin:var(--s3) 0 var(--s2)}
7
+ .readme .readme-body h2{font-size:17px;margin:var(--s3) 0 var(--s2);border-bottom:1px solid var(--line);padding-bottom:var(--s1)}
8
+ .readme .readme-body h3{font-size:14px}
9
+ .readme-body img{max-width:100%;height:auto;border-radius:4px}
10
+ .readme-body pre{overflow-x:auto;padding:var(--s2);background:var(--hi3);border-radius:4px}
11
+ .readme-body a{color:var(--blue)}
@@ -0,0 +1,61 @@
1
+ // A repo page's sections: the pages reached for most, one line each at the top
2
+ // of the left pane, over the tree, drawn the way the project list draws its
3
+ // lines (an icon, a label, a count where it says something, its ⌥ shortcut at
4
+ // the end, keys.ts), the current one lit. A page this repo does not have is
5
+ // not a line. The lines come from what the page already holds (the kept
6
+ // screen on a reload), so none arrives late and moves the tree.
7
+ import { observer } from 'mobx-react-lite'
8
+ import { RUNS_ID } from '../../collections/collection-types.ts'
9
+ import { Icon, ICON } from './icons.tsx'
10
+ import { sectionKeyLabel, type SectionId } from './keys.ts'
11
+ import type { RepoSt } from './repo-st.ts'
12
+ import { openWikis, WIKIS } from './wikis-view.tsx'
13
+
14
+ export type RepoSection = {
15
+ id: SectionId
16
+ label: string
17
+ title: string
18
+ /** null when a number would say nothing */
19
+ count: number | null
20
+ tone: 'red' | 'warn' | null
21
+ on: boolean
22
+ go: () => void
23
+ }
24
+
25
+ const counted = (n: number, tone: 'red' | 'warn'): Pick<RepoSection, 'count' | 'tone'> => (n === 0 ? { count: null, tone: null } : { count: n, tone })
26
+
27
+ export function repoSections(app: RepoSt): RepoSection[] {
28
+ const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
29
+ const sel = app.sel
30
+ const onCol = (col: string): boolean => (sel?.t === 'col' && sel.id === col) || (sel?.t === 'item' && sel.col === col)
31
+ const res = app.cols.resources
32
+ const reds = (col: string): number => res.filter((r) => r.collection === col && r.state === 'red').length
33
+ const none = { count: null, tone: null }
34
+ const out: RepoSection[] = []
35
+ if (has('checks')) out.push({ id: 'checks', label: 'checks', title: 'every check the gate and the health pass run, one page each', ...counted(reds('checks'), 'red'), on: onCol('checks'), go: () => app.showCol('checks') })
36
+ // a page, never a resource: every hub keeps a run record
37
+ out.push({ id: 'runs', label: 'runs', title: 'every run on record, the slowest checks and the longest runs', ...none, on: onCol('runs'), go: () => app.cols.select('runs', RUNS_ID) })
38
+ if (has('asks')) {
39
+ const open = res.filter((r) => r.collection === 'asks' && r.type === 'ask' && r.slot !== 'done').length
40
+ out.push({ id: 'asks', label: 'asks', title: 'what waits on a human', ...counted(open, 'warn'), on: onCol('asks'), go: () => app.showCol('asks') })
41
+ }
42
+ if (has('tests')) out.push({ id: 'tests', label: 'tests', title: 'every test file, slowest first, then each test check', ...counted(reds('tests'), 'red'), on: sel?.t === 'tests' || onCol('tests'), go: () => app.showTests(null) })
43
+ const firstCat = app.media.assets[0]?.cat ?? app.cols.cachedCat ?? undefined
44
+ if (firstCat != null) out.push({ id: 'media', label: 'media', title: 'the assets, in the atelier', ...none, on: sel?.t === 'asset' || sel?.t === 'cat', go: () => app.showCat(firstCat) })
45
+ if (has(WIKIS)) out.push({ id: 'wikis', label: 'wikis', title: 'every wiki this repo mounts, as a link graph', ...none, on: onCol(WIKIS), go: () => openWikis(app) })
46
+ if (app.home.machine != null) out.push({ id: 'admin', label: 'admin', title: 'reload the hub, keep the repo active, archive it', ...none, on: sel?.t === 'admin', go: () => app.showAdmin() })
47
+ return out
48
+ }
49
+
50
+ export const Sections = observer(({ app }: { app: RepoSt }) => (
51
+ <nav className="rsecs" aria-label="sections">
52
+ {repoSections(app).map((s) => (
53
+ <button key={s.id} className={`rsec${s.on ? ' cur' : ''}`} aria-current={s.on ? 'page' : undefined} title={`${s.title} (${sectionKeyLabel(s.id)})`} onClick={s.go}>
54
+ <Icon d={ICON[s.id]} />
55
+ <span className="rsecl">{s.label}</span>
56
+ {s.count == null ? null : <b className={`rsecn n${s.tone}`}>{s.count}</b>}
57
+ <kbd className="pjkey">{sectionKeyLabel(s.id)}</kbd>
58
+ </button>
59
+ ))}
60
+ </nav>
61
+ ))
@@ -5,13 +5,13 @@
5
5
  // (hash-route.ts), and setSel is the one place both move together. A repo
6
6
  // that is not shown keeps all of it and polls nothing (resume, pause), and
7
7
  // only the shown one writes the url.
8
- import { RUNS_ID } from '../../collections/collection-types.ts'
9
8
  import { makeAutoObservable } from 'mobx'
10
9
  import { ClockSvc } from './clock-svc.ts'
11
10
  import { ColsSvc } from './cols-svc.ts'
12
11
  import { canonicalSel, currentHash, hashFor, parseHash, selOf, type HashSel } from './hash-route.ts'
13
12
  import { ComfySvc } from './comfy-svc.ts'
14
13
  import { HealthSvc } from './health-svc.ts'
14
+ import { ReadmeSvc } from './readme-svc.ts'
15
15
  import { LiveSvc } from './live-svc.ts'
16
16
  import { MediaSvc } from './media-svc.ts'
17
17
  import { TimelineSvc } from './timeline-svc.ts'
@@ -19,13 +19,19 @@ import { TreeSvc } from './tree-svc.ts'
19
19
  import { ChecksSvc } from './checks-svc.ts'
20
20
  import { ProcessesSvc } from './processes-svc.ts'
21
21
  import { TestsSvc } from './tests-svc.ts'
22
+ import { AdminSvc } from './admin-svc.ts'
23
+ import { repoSections } from './repo-sections.tsx'
24
+ import { sectionKeyLabel } from './keys.ts'
22
25
  import type { Resource } from './hub-types.ts'
23
26
  import type { HomeSt } from './home-st.ts'
24
27
 
25
28
  export type Sel = HashSel | null
26
29
 
27
30
  /** one picker target (cmd+k): run() fires the SAME action clicking would */
28
- export type Jump = { id: string; label: string; section: string; run: () => void }
31
+ /** what a picker entry wears: a repo's own icon, or a place's button icon */
32
+ export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'archived' }
33
+ /** `key`: the shortcut that does the same, printed at the entry's end */
34
+ export type Jump = { id: string; label: string; section: string; run: () => void; face?: JumpFace; key?: string }
29
35
 
30
36
  /** all · needs (yellow+red) · red — one severity ladder for every section */
31
37
  export type Filter = 'all' | 'needs' | 'red'
@@ -47,6 +53,7 @@ export class RepoSt {
47
53
  cols: ColsSvc
48
54
  tree: TreeSvc
49
55
  health: HealthSvc
56
+ readme: ReadmeSvc
50
57
  checks: ChecksSvc
51
58
  comfy: ComfySvc
52
59
  timeline: TimelineSvc
@@ -55,6 +62,8 @@ export class RepoSt {
55
62
  processes: ProcessesSvc
56
63
  /** the pages behind the test groups of the tree (tests-svc.ts) */
57
64
  tests: TestsSvc
65
+ /** the admin page: archive behind its confirmation, reload, keep active */
66
+ admin: AdminSvc
58
67
  sel: Sel = null
59
68
  filter: Filter = 'all'
60
69
  /** sidebar search — live substring filter over asset + collection rows */
@@ -84,13 +93,15 @@ export class RepoSt {
84
93
  this.cols = new ColsSvc(this)
85
94
  this.tree = new TreeSvc(this)
86
95
  this.health = new HealthSvc(this)
96
+ this.readme = new ReadmeSvc(this)
87
97
  this.checks = new ChecksSvc(this)
88
98
  this.comfy = new ComfySvc(this)
89
99
  this.timeline = new TimelineSvc(this)
90
100
  this.live = new LiveSvc(this)
91
101
  this.processes = new ProcessesSvc(this)
92
102
  this.tests = new TestsSvc(this)
93
- makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, checks: false, comfy: false, timeline: false, live: false, processes: false, tests: false, searchEl: false, beat: false })
103
+ this.admin = new AdminSvc(this)
104
+ makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, processes: false, tests: false, admin: false, searchEl: false, beat: false })
94
105
  }
95
106
 
96
107
  /** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
@@ -215,10 +226,11 @@ export class RepoSt {
215
226
  out.push({ id: `cat:${cat}`, label: cat, section: 'media', run: () => this.showCat(cat) })
216
227
  for (const a of this.media.assets)
217
228
  out.push({ id: `asset:${a.id}`, label: a.label, section: a.cat, run: () => this.selectAsset(a.id) })
218
- // the runs page is a page, not a resource, so no collection lists it
219
- out.push({ id: 'page:runs', label: 'runs', section: 'page', run: () => this.cols.select('runs', RUNS_ID) })
229
+ // the section lines, with their keys: the same list the left pane draws
230
+ const secs = repoSections(this)
231
+ for (const s of secs) out.push({ id: `section:${s.id}`, label: s.label, section: 'section', key: sectionKeyLabel(s.id), run: s.go })
220
232
  for (const col of this.cols.sidebarCols) {
221
- out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
233
+ if (!secs.some((s) => s.id === col.id)) out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
222
234
  for (const i of col.instances)
223
235
  out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => this.cols.select(col.id, i.id) })
224
236
  }
@@ -272,6 +284,12 @@ export class RepoSt {
272
284
  this.cols.select(r.collection, r.id)
273
285
  }
274
286
 
287
+ /** what administers the repo: reload its hub, keep it active, archive it */
288
+ showAdmin(): void {
289
+ this.admin.cancel()
290
+ this.setSel({ t: 'admin' })
291
+ }
292
+
275
293
  /** collection view — one card per instance */
276
294
  showCol(id: string): void {
277
295
  this.setSel({ t: 'col', id })
@@ -285,6 +303,7 @@ export class RepoSt {
285
303
  case 'item': return this.cols.select(sel.col, sel.id)
286
304
  case 'status': return this.showStatus()
287
305
  case 'tests': return this.showTests(sel.check)
306
+ case 'admin': return this.showAdmin()
288
307
  }
289
308
  }
290
309
  /** the url decides the page: a reload, back, forward and a pasted link
@@ -7,7 +7,7 @@ export type RunsTab = 'runs' | 'files' | 'slowest' | 'longest'
7
7
 
8
8
  /** the four lists, in tab order. `key` is what the address carries */
9
9
  export const RUNS_TABS: { key: RunsTab; label: string; help: string }[] = [
10
- { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, and the runs the plan starts. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the live strip under the header: unfold it and press fit` },
10
+ { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, and the runs the plan starts. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the chart over the tabs: press fit for the whole record` },
11
11
  { key: 'files', label: 'test files to fix', help: "bun test files, not checks: each file's last recorded run inside the check that ran it (test, test:<unit>), kept when a test in it failed or when it broke the quick tier budget per file. The same verdict colours the file in the tree under tests. Click a file for its page, the check tag for the check" },
12
12
  { key: 'slowest', label: 'slowest checks', help: 'the checks that cost the most when they really run: the mean and the worst of the runs that ran. A check whose worst run passed half its ceiling is flagged, because the next slow machine trips it' },
13
13
  { key: 'longest', label: 'longest runs', help: 'the invocations on record that took the longest, start to end, whatever ran them' },
@@ -0,0 +1,84 @@
1
+ // A repo page's left pane: its sections (repo-sections.tsx), then the tree of
2
+ // every resource of the repo, one row deep in its path, under its filter, its
3
+ // two tabs and its severity filter. Only the tree scrolls, so the sections and
4
+ // the filter stay in reach. The row itself is tree-row.tsx.
5
+ import { observer } from 'mobx-react-lite'
6
+ import { Help } from './help.tsx'
7
+ import { SEARCH_KEY } from './keys.ts'
8
+ import { Sections } from './repo-sections.tsx'
9
+ import type { RepoSt } from './repo-st.ts'
10
+ import { TYPE_MARK } from './tree-build.ts'
11
+ import { TreeRowView } from './tree-row.tsx'
12
+
13
+ const Tree = observer(({ app }: { app: RepoSt }) => {
14
+ const t = app.tree
15
+ return (
16
+ // roving focus: the arrows belong to the tree only while it HAS the
17
+ // focus, so the atelier keeps its own keys
18
+ <div className="tree" tabIndex={0} onKeyDown={(e) => { if (t.key(e.key)) e.preventDefault() }}>
19
+ {t.rows.map((r) => <TreeRowView key={r.key} app={app} row={r} />)}
20
+ {!app.cols.loaded && !app.cols.cached ? <div className="tempty twait"><span className="spin" />reading the repo…</div> : null}
21
+ {app.cols.loaded && t.rows.length === 0 ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : app.filter === 'needs' ? 'nothing needs you ✨' : 'no resources yet'}</div> : null}
22
+ </div>
23
+ )
24
+ })
25
+
26
+ /** what every glyph in the type slot means, behind one `(?)` */
27
+ const TypeLegend = () => (
28
+ <Help text={
29
+ <span className="legend">
30
+ {Object.entries(TYPE_MARK).map(([kind, m]) => (
31
+ <span key={kind} className="lrow"><i className={`ty ${m.hue}`}>{m.glyph}</i>{m.word}</span>
32
+ ))}
33
+ </span>
34
+ } />
35
+ )
36
+
37
+ const TreeTabs = observer(({ app }: { app: RepoSt }) => (
38
+ <span className="seg">
39
+ {(['path', 'type'] as const).map((t) => (
40
+ <button key={t} className={app.tree.tab === t ? 'on' : ''} onClick={() => app.tree.setTab(t)}>by {t}</button>
41
+ ))}
42
+ </span>
43
+ ))
44
+
45
+ // segmented severity filter — all · needs (yellow+red) · red, the address
46
+ // rides at /?f=… so a filtered view is shareable. The shortcut letter rides
47
+ // INSIDE its own button, so the binding is discoverable without a legend
48
+ const FILTER_KEY = { all: 'a', needs: 'n', red: 'r' } as const
49
+ const SegFilter = observer(({ app }: { app: RepoSt }) => (
50
+ <span className="seg">
51
+ {(['all', 'needs', 'red'] as const).map((f) => (
52
+ <button key={f} className={app.filter === f ? 'on' : ''} onClick={() => app.setFilter(f)}>
53
+ {f}<span className="sk">{FILTER_KEY[f]}</span>
54
+ </button>
55
+ ))}
56
+ </span>
57
+ ))
58
+
59
+ export const Sidebar = observer(({ app }: { app: RepoSt }) => (
60
+ <aside>
61
+ <Sections app={app} />
62
+ <div className="ssearch">
63
+ <span className="sfield">
64
+ <input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree" value={app.query}
65
+ onChange={(e) => app.setQuery(e.target.value)}
66
+ onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
67
+ <kbd className="skey">{SEARCH_KEY}</kbd>
68
+ </span>
69
+ <SegFilter app={app} />
70
+ </div>
71
+ <div className="stabs">
72
+ <TreeTabs app={app} />
73
+ <span className="sp" />
74
+ <TypeLegend />
75
+ <span className="cnt">{app.tree.visible.length}</span>
76
+ </div>
77
+ {/* its line is held while the repo is read, so the tree never drops under it */}
78
+ {app.tree.whenLine === '' && app.cols.loaded ? null : <div className="swhen">{app.tree.whenLine === '' ? ' ' : app.tree.whenLine}</div>}
79
+ {app.media.dead != null ? <div className="tempty"><span className="warnline">media API down: {app.media.dead}</span></div> : null}
80
+ <div className="tscroll">
81
+ <Tree app={app} />
82
+ </div>
83
+ </aside>
84
+ ))
@@ -1,11 +1,12 @@
1
- // THE PAGES OF THE TEST GROUPS: `tests` itself, one line per test check, and
1
+ // THE PAGES OF THE TEST GROUPS: `tests` itself, every test file of the repo
2
+ // slowest first then one line per test check, and
2
3
  // each test check (`test`, `test:<unit>`), which answers one question: where
3
4
  // does its time go. The check's own facts (its sentence, its command, its
4
5
  // last wall time and its trend) are the check page's pieces, read from the
5
6
  // resource the tree already holds. The breakdown is the one fetch the page
6
7
  // makes (tests-svc.ts), shaped on the server (collections/test-time.ts).
7
8
  import { observer } from 'mobx-react-lite'
8
- import type { FailingFile, FileTime, LastRun, Phases, Shape, TestCheckRow, TestCheckView, TestsRootView } from '../../collections/test-time.ts'
9
+ import type { FailingFile, FileTime, LastRun, Phases, Shape, TestCheckRow, TestCheckView, TestFileRow, TestsRootView } from '../../collections/test-time.ts'
9
10
  import { CommandLine, LastRunValue, Row, Says, Section, Timings } from './check-view.tsx'
10
11
  import { durationTone, fmtMs } from './dur.ts'
11
12
  import type { RepoSt } from './repo-st.ts'
@@ -217,16 +218,42 @@ const CheckRows = observer(({ app, rows }: { app: RepoSt; rows: TestCheckRow[] }
217
218
  </div>
218
219
  ))
219
220
 
221
+ const STATUS_WORD = { pass: 'pass', fail: 'fail' } as const
222
+
223
+ /** every file of every test check on one list, slowest first: the answer to
224
+ * "which tests are slow", whatever check runs them */
225
+ const AllFiles = observer(({ app, rows }: { app: RepoSt; rows: TestFileRow[] }) => (
226
+ <div className="rlist">
227
+ <div className="rhead rall">
228
+ <span className="tleft">file</span>
229
+ <span className="tleft">check</span>
230
+ <span>duration</span>
231
+ <span>status</span>
232
+ <span>when</span>
233
+ </div>
234
+ {rows.map((f) => (
235
+ <div key={f.file} className={`rline rall${f.status === 'fail' ? ' red' : ''}`} title={f.file} onClick={() => app.tests.openFile(f.file)}>
236
+ <span className="rwhat">{f.inUnit}</span>
237
+ <span className="rtag" title="the check that runs it">{f.check}</span>
238
+ <span className={`rnum ${fileTone(f)}`}>{f.ms == null ? <span className="dim">never ran</span> : fmtMs(f.ms)}</span>
239
+ <span className={`rnum ${f.status === 'fail' ? 'bad' : 'dim'}`}>{f.status == null ? '' : STATUS_WORD[f.status]}</span>
240
+ {f.at == null ? <span /> : <TimeAgo clock={app.clock} at={f.at} />}
241
+ </div>
242
+ ))}
243
+ </div>
244
+ ))
245
+
220
246
  export const TestsRootPage = observer(({ app }: { app: RepoSt }) => {
221
247
  const v: TestsRootView | null = app.tests.root()
222
248
  return (
223
249
  <div className="colcard checkcard">
224
250
  <div className="chead">
225
251
  <span className="tid">tests</span>
226
- <h3 className="ctitle">every test check</h3>
252
+ <h3 className="ctitle">every test</h3>
227
253
  </div>
228
- <div className="usays">each test check's last run: its wall time, its files, how many fail, the slowest file. Click a check for where its time goes</div>
254
+ <div className="usays">every test file by its last recorded duration, then each test check's last run. Click a file for its page, a check for where its time goes</div>
229
255
  {v == null ? <Loading app={app} check={null} /> : <>
256
+ <Section title={`every test file (${v.files.length})`} help="every file of every test check, by its last recorded duration in the shared run, slowest first; the ones that never ran here last. Its tone is against its budget: over it reads red"><AllFiles app={app} rows={v.files} /></Section>
230
257
  <Section title="test checks" help="one line per test check, slowest wall first"><CheckRows app={app} rows={v.checks} /></Section>
231
258
  <Failing app={app} rows={v.failing} showCheck />
232
259
  </>}