rvlib-shipkit 1.5.0 → 1.7.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 (94) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +12 -8
  4. package/src/assets/server/studio-server.ts +14 -2
  5. package/src/check.ts +5 -8
  6. package/src/checks/SK001-package-json-shape.check.ts +5 -4
  7. package/src/checks/SK003-claude-md-shape.check.ts +2 -2
  8. package/src/checks/SK030-cache-audit.check.ts +34 -0
  9. package/src/checks/SK031-trace-audit.check.ts +39 -0
  10. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  11. package/src/cli/cmd_Assets.ts +10 -6
  12. package/src/cli/cmd_CacheAudit.ts +43 -0
  13. package/src/cli/cmd_CrossRepoActivity.ts +2 -1
  14. package/src/cli/cmd_GlobalProcesses.ts +4 -3
  15. package/src/cli/cmd_Home.ts +3 -2
  16. package/src/cli/cmd_Insights.ts +78 -0
  17. package/src/cli/cmd_Keys.ts +12 -1
  18. package/src/cli/cmd_Overview.ts +1 -1
  19. package/src/cli/cmd_Serve.ts +2 -1
  20. package/src/cli/cmd_TraceAudit.ts +62 -0
  21. package/src/cli.ts +6 -0
  22. package/src/collections/collection-types.ts +3 -0
  23. package/src/collections/providers.ts +5 -3
  24. package/src/core/app-icon.ts +154 -0
  25. package/src/core/audit-report.ts +21 -0
  26. package/src/core/cache-audit.ts +72 -0
  27. package/src/core/canonical-scripts.ts +1 -1
  28. package/src/core/check-ctx.ts +14 -5
  29. package/src/core/check-runner.ts +6 -3
  30. package/src/core/floor-resolve.ts +5 -7
  31. package/src/core/git-root.ts +13 -0
  32. package/src/core/ignored-block.ts +94 -0
  33. package/src/core/ignored-inputs.ts +47 -0
  34. package/src/core/jobs.ts +9 -2
  35. package/src/core/one-lib.ts +59 -4
  36. package/src/core/read-set.ts +97 -9
  37. package/src/core/run-ci.ts +6 -1
  38. package/src/core/run-fixers.ts +8 -2
  39. package/src/core/trace-audit.ts +129 -0
  40. package/src/home/gui-app/ShipkitGui.swift +125 -41
  41. package/src/home/gui-app.ts +24 -27
  42. package/src/home/home-routes.ts +15 -0
  43. package/src/home/home-start.ts +11 -5
  44. package/src/home/insights-shape.ts +124 -0
  45. package/src/home/insights.ts +90 -0
  46. package/src/home/open-app.ts +2 -2
  47. package/src/home/serve-web.ts +3 -3
  48. package/src/home/via-hub.ts +14 -0
  49. package/src/home/widgets.ts +12 -2
  50. package/src/ledger/db.ts +2 -13
  51. package/src/ledger/gate-checks.ts +21 -3
  52. package/src/ledger/git-key.ts +148 -30
  53. package/src/ledger/git-scan.ts +5 -2
  54. package/src/ledger/ledger.ts +131 -16
  55. package/src/ledger/store.ts +14 -0
  56. package/src/ledger/toolchain-identity.ts +210 -25
  57. package/src/problems/extract.ts +3 -0
  58. package/src/repo-config.ts +18 -1
  59. package/src/serve/hub-app/admin-svc.ts +36 -3
  60. package/src/serve/hub-app/admin-view.tsx +33 -1
  61. package/src/serve/hub-app/col-card.tsx +1 -44
  62. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  63. package/src/serve/hub-app/col-tables.ts +27 -0
  64. package/src/serve/hub-app/home-st.ts +13 -6
  65. package/src/serve/hub-app/home-strip.css +7 -9
  66. package/src/serve/hub-app/home-strip.tsx +8 -17
  67. package/src/serve/hub-app/home.html +1 -0
  68. package/src/serve/hub-app/hub-app.tsx +20 -4
  69. package/src/serve/hub-app/hub.css +24 -20
  70. package/src/serve/hub-app/icons.tsx +1 -0
  71. package/src/serve/hub-app/index.html +1 -0
  72. package/src/serve/hub-app/insights-st.ts +111 -0
  73. package/src/serve/hub-app/insights-view.tsx +112 -0
  74. package/src/serve/hub-app/insights.css +24 -0
  75. package/src/serve/hub-app/keys.ts +11 -3
  76. package/src/serve/hub-app/machine-st.ts +23 -0
  77. package/src/serve/hub-app/palette-st.ts +2 -1
  78. package/src/serve/hub-app/processes-view.tsx +16 -9
  79. package/src/serve/hub-app/project-list.css +4 -1
  80. package/src/serve/hub-app/project-list.tsx +9 -5
  81. package/src/serve/hub-app/projects-svc.ts +11 -1
  82. package/src/serve/hub-app/repo-sections.ts +31 -17
  83. package/src/serve/hub-app/repo-st.ts +25 -6
  84. package/src/serve/hub-app/sidebar.tsx +5 -5
  85. package/src/serve/hub-app/tree-build.ts +2 -0
  86. package/src/serve/hub-app/tree-row.tsx +18 -5
  87. package/src/serve/hub-app/tree-svc.ts +27 -7
  88. package/src/serve/hub-runtime.ts +4 -2
  89. package/src/serve/project-gutter-route.ts +22 -1
  90. package/src/shipkit-files.ts +3 -2
  91. package/src/suite/judge-suites.ts +6 -0
  92. package/src/trace/interpose.c +655 -0
  93. package/src/trace/read-trace.ts +485 -0
  94. package/src/trace/tracer.ts +282 -0
@@ -1,8 +1,11 @@
1
1
  // A repo page's sections: the pages reached for most, the top rows of the
2
2
  // left pane's tree (tree-row.tsx draws them the way the project list draws its
3
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 row. The counts read the severity filter, like everything under them,
4
+ // the end, keys.ts, the current one lit). Every repo draws the same rows, so
5
+ // the list reads the same from repo to repo: a section this repo has nothing
6
+ // in is a greyed row, and only opens a page that can say it is empty (the
7
+ // processes page). Plan, git and admin come from the machine, so a hub of its
8
+ // own has none of the three. The counts read the severity filter, like everything under them,
6
9
  // and come from what the page already holds (the kept screen on a reload), so
7
10
  // none arrives late and moves the tree.
8
11
  import { RUNS_ID } from '../../collections/collection-types.ts'
@@ -18,6 +21,10 @@ export type RepoSection = {
18
21
  title: string
19
22
  /** null when a number would say nothing */
20
23
  count: number | null
24
+ /** this repo has nothing there: the row is greyed */
25
+ empty: boolean
26
+ /** its click opens a page: false for an empty section with no empty page */
27
+ opens: boolean
21
28
  tone: 'red' | 'warn' | null
22
29
  on: boolean
23
30
  go: () => void
@@ -83,35 +90,42 @@ export function openSection(app: RepoSt, id: SectionId): void {
83
90
  }
84
91
  }
85
92
 
93
+ /** the sections whose page says it is empty, so an empty row still opens it */
94
+ const EMPTY_PAGES = new Set<SectionId>(['processes'])
95
+
86
96
  export function repoSections(app: RepoSt): RepoSection[] {
87
97
  const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
88
98
  const cur = sectionOfRepo(app)
89
99
  const res = app.cols.resources.filter((r) => passesFilter(r, app.filter))
90
100
  const reds = (col: string): number => res.filter((r) => r.collection === col && r.state === 'red').length
91
101
  const none = { count: null, tone: null }
92
- const line = (id: SectionId, title: string, count: Pick<RepoSection, 'count' | 'tone'>): RepoSection => ({ id, label: sectionLabel(id), title, ...count, on: cur === id, go: () => openSection(app, id) })
102
+ const line = (id: SectionId, title: string, count: Pick<RepoSection, 'count' | 'tone'>, empty: string | null = null): RepoSection => {
103
+ const opens = empty == null || EMPTY_PAGES.has(id)
104
+ return { id, label: sectionLabel(id), title: empty ?? title, ...(empty == null ? count : none), empty: empty != null, opens, on: opens && cur === id, go: opens ? () => openSection(app, id) : () => {} }
105
+ }
93
106
  const out: RepoSection[] = [line('readme', 'the README, what the repo is', none)]
94
107
  const ownReds = (from: CheckOwner): number => res.filter((r) => r.check?.from === from && r.state === 'red').length
95
- if (has('contracts')) out.push(line('contracts', 'what a release venue asks of this repo, and what it still lacks', counted(reds('contracts'), 'red')))
96
- if (has('checks')) out.push(line('shipkit', 'the checks shipkit ships to every repo, one page each', counted(ownReds('shipkit'), 'red')))
97
- if (has('checks')) out.push(line('repo', 'the checks this repo declares itself, one page each', counted(ownReds('repo'), 'red')))
98
- if (has('creds')) out.push(line('secrets', 'the credentials this repo needs, present or missing', counted(reds('creds'), 'red')))
99
- if (has('checks')) out.push(line('when', 'what runs when: every check on every context (commit, ci, full, release, health, save, clock), with the legend', none))
108
+ const unless = (on: boolean, why: string): string | null => (on ? null : why)
109
+ out.push(line('contracts', 'what a release venue asks of this repo, and what it still lacks', counted(reds('contracts'), 'red'), unless(has('contracts'), 'no contract: no release venue applies to this repo')))
110
+ const noChecks = unless(has('checks'), 'no checks yet: its hub has not answered')
111
+ out.push(line('shipkit', 'the checks shipkit ships to every repo, one page each', counted(ownReds('shipkit'), 'red'), noChecks))
112
+ out.push(line('repo', 'the checks this repo declares itself, one page each', counted(ownReds('repo'), 'red'), noChecks))
113
+ out.push(line('secrets', 'the credentials this repo needs, present or missing', counted(reds('creds'), 'red'), unless(has('creds'), 'no secrets: this repo needs no credential')))
114
+ out.push(line('when', 'what runs when: every check on every context (commit, ci, full, release, health, save, clock), with the legend', none, noChecks))
100
115
  // a page, never a resource: every hub keeps a run record
101
116
  out.push(line('runs', 'every run on record, the slowest checks and the longest runs', none))
102
117
  // the plan and git come from the machine, so a hub of its own has neither
103
118
  if (app.home.machine != null) out.push(line('plan', 'what the machine runs here on its own time, and when', none))
104
119
  const procs = app.globalProcesses
105
- if (procs.length > 0) out.push(line('processes', 'the processes this repo declares: start, stop, open, their description and their log', counted(procs.filter((r) => r.dot === 'crashed').length, 'red')))
120
+ out.push(line('processes', 'the processes this repo declares: start, stop, open, their description and their log', counted(procs.filter((r) => r.dot === 'crashed').length, 'red'),
121
+ unless(procs.length > 0, 'no process: this repo declares no global process (a <id>.shipkit.ts file with globalProcess adds one)')))
106
122
  if (app.home.machine != null) out.push(line('git', 'the branch, its upstream, what is not committed, the last commits', none))
107
- if (has('asks')) {
108
- const open = res.filter((r) => r.collection === 'asks' && r.type === 'ask' && r.slot !== 'done').length
109
- out.push(line('asks', 'what waits on a human', counted(open, 'warn')))
110
- }
111
- if (has('reflections')) out.push(line('reflections', 'friction this repo logged against the wiki, waiting to be folded in', counted(res.filter((r) => r.type === 'reflection').length, 'warn')))
112
- if (has('tests')) out.push(line('tests', 'every test file, slowest first, then each test check', counted(reds('tests'), 'red')))
113
- if ((app.media.assets[0]?.cat ?? app.cols.cachedCat) != null) out.push(line('media', 'the assets, in the atelier', none))
114
- if (has(WIKIS)) out.push(line('wikis', 'every wiki this repo mounts, as a link graph', none))
123
+ const open = res.filter((r) => r.collection === 'asks' && r.type === 'ask' && r.slot !== 'done').length
124
+ out.push(line('asks', 'what waits on a human', counted(open, 'warn'), unless(has('asks'), 'no asks: nothing has ever waited on a human here')))
125
+ out.push(line('reflections', 'friction this repo logged against the wiki, waiting to be folded in', counted(res.filter((r) => r.type === 'reflection').length, 'warn'), unless(has('reflections'), 'no reflections: this repo logged no friction')))
126
+ out.push(line('tests', 'every test file, slowest first, then each test check', counted(reds('tests'), 'red'), unless(has('tests'), 'no tests: this repo has no test file')))
127
+ out.push(line('media', 'the assets, in the atelier', none, unless((app.media.assets[0]?.cat ?? app.cols.cachedCat) != null, 'no media: this repo has no asset kit')))
128
+ out.push(line('wikis', 'every wiki this repo mounts, as a link graph', none, unless(has(WIKIS), 'no wiki: this repo mounts none')))
115
129
  if (app.home.machine != null) out.push(line('admin', 'reload the hub, keep the repo active, archive it', none))
116
130
  return out
117
131
  }
@@ -30,7 +30,7 @@ export type Sel = HashSel | null
30
30
 
31
31
  /** one picker target (cmd+j): run() fires the SAME action clicking would */
32
32
  /** what a picker entry wears: a repo's own icon, or a place's button icon */
33
- export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'archived' }
33
+ export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'insights' | 'archived' }
34
34
  /** `key`: the shortcut that does the same, printed at the entry's end */
35
35
  export type Jump = { id: string; label: string; section: string; run: () => void; face?: JumpFace; key?: string }
36
36
 
@@ -227,9 +227,9 @@ export class RepoSt {
227
227
  out.push({ id: `asset:${a.id}`, label: a.label, section: a.cat, run: () => this.selectAsset(a.id) })
228
228
  // the section lines, with their keys: the same list the left pane draws
229
229
  const secs = repoSections(this)
230
- for (const s of secs) out.push({ id: `section:${s.id}`, label: s.label, section: 'section', key: sectionKeyLabel(s.id), run: s.go })
230
+ for (const s of secs.filter((x) => x.opens)) out.push({ id: `section:${s.id}`, label: s.label, section: 'section', key: sectionKeyLabel(s.id), run: s.go })
231
231
  for (const col of this.cols.sidebarCols) {
232
- if (!secs.some((s) => s.id === col.id)) out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
232
+ if (!secs.some((s) => s.id === col.id && s.opens)) out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
233
233
  for (const i of col.instances)
234
234
  out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => this.cols.select(col.id, i.id) })
235
235
  }
@@ -256,10 +256,27 @@ export class RepoSt {
256
256
  void this.tests.load(check)
257
257
  }
258
258
 
259
+ /** a walk with the tree's arrows is under way, and whether the current
260
+ * history entry is its own: a held arrow opens ~30 pages a second, and
261
+ * the browser throws past 100 pushState calls per 10 seconds */
262
+ private walking = false
263
+ private walked = false
264
+ /** open pages as one walk: the first step is a history entry, every next
265
+ * one rewrites it, so back returns to where the walk started */
266
+ walk(open: () => void): void {
267
+ this.walking = true
268
+ try {
269
+ open()
270
+ } finally {
271
+ this.walking = false
272
+ }
273
+ }
274
+
259
275
  /** the ONE place a selection is written, so the url always describes what
260
276
  * is on screen. A new page is a history entry, so back returns to the
261
- * page before it. The same address is never pushed twice: applyHash
262
- * lands here too, with the hash already naming this selection */
277
+ * page before it, except inside a walk (walk). The same address is never
278
+ * pushed twice: applyHash lands here too, with the hash already naming
279
+ * this selection */
263
280
  setSel(sel: Sel): void {
264
281
  this.sel = sel
265
282
  const h = hashFor(sel)
@@ -269,7 +286,9 @@ export class RepoSt {
269
286
  // the tool set rides after the asset id (media-svc syncHash), so an
270
287
  // address is the same page whatever tools it carries
271
288
  if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) return
272
- history.pushState(null, '', `#${h}`)
289
+ if (this.walking && this.walked) history.replaceState(null, '', `#${h}`)
290
+ else history.pushState(null, '', `#${h}`)
291
+ this.walked = this.walking
273
292
  }
274
293
  /** one tree row, opened: an asset goes to the atelier, everything else to
275
294
  * its own item card. A gate check has a page of its own now, so nothing
@@ -3,7 +3,7 @@
3
3
  // whose rows under them are every resource of the repo. Only the tree
4
4
  // scrolls, so the filter stays in reach. The row itself is tree-row.tsx.
5
5
  import { observer } from 'mobx-react-lite'
6
- import { SEARCH_KEY } from './keys.ts'
6
+ import { isTreeWalk, SEARCH_KEY } from './keys.ts'
7
7
  import type { RepoSt } from './repo-st.ts'
8
8
  import { TreeRowView } from './tree-row.tsx'
9
9
 
@@ -11,9 +11,9 @@ const Tree = observer(({ app }: { app: RepoSt }) => {
11
11
  const t = app.tree
12
12
  const empty = app.cols.loaded && app.filter !== 'all' && app.query.trim() === '' && t.visible.length === 0
13
13
  return (
14
- // roving focus: the arrows belong to the tree only while it HAS the
15
- // focus, so the atelier keeps its own keys
16
- <div className="tree" tabIndex={0} onKeyDown={(e) => { if (t.key(e.key)) e.preventDefault() }}>
14
+ // the page's key handler drives it (hub-app.tsx), so the arrows need
15
+ // no focus here; the atelier consumes its own keys before that
16
+ <div className="tree">
17
17
  {t.rows.map((r) => <TreeRowView key={r.key} app={app} row={r} />)}
18
18
  {!app.cols.loaded && !app.cols.cached ? <div className="tempty twait"><span className="spin" />reading the repo…</div> : null}
19
19
  {empty ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : 'nothing needs you ✨'}</div> : null}
@@ -46,7 +46,7 @@ export const Sidebar = observer(({ app }: { app: RepoSt }) => (
46
46
  <span className="sfield">
47
47
  <input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree by name or path" value={app.query}
48
48
  onChange={(e) => app.setQuery(e.target.value)}
49
- onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
49
+ onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery(''); else if (isTreeWalk(e.key) && app.tree.key(e.key)) e.preventDefault() }} />
50
50
  <kbd className="skey">{SEARCH_KEY}</kbd>
51
51
  </span>
52
52
  <SegFilter app={app} />
@@ -171,6 +171,7 @@ const groupOf = (r: Resource): Seg[] => {
171
171
  if (r.test != null) return []
172
172
  if (r.check != null || r.type === 'contract' || r.type === 'secret') return []
173
173
  if (r.type === 'ask') return isDone(r) ? [{ key: 'type:ask:done', label: 'done', kind: 'done', page: null }] : []
174
+ if (r.type === 'asset' && r.group != null) return [{ key: `cat:${r.group}`, label: r.group, kind: 'category', page: { t: 'cat', cat: r.group } }]
174
175
  if (r.type === 'wiki' || r.type === 'asset' || r.type === 'test' || r.type === 'process' || r.type === 'reflection') return []
175
176
  return [{ key: `type:${r.type}`, label: r.type, kind: r.type, page: { t: 'col', id: r.collection } }]
176
177
  }
@@ -381,6 +382,7 @@ export const TYPE_MARK: Record<string, TypeMark> = {
381
382
  ask: { glyph: '\u25c9', hue: 'k2', word: 'an ask: something only a human can do' },
382
383
  secret: { glyph: '\u25c8', hue: 'k4', word: 'a secret this repo needs, present or missing' },
383
384
  asset: { glyph: '\u25c6', hue: 'k1', word: 'an asset of the kit, with its rolls and its pick' },
385
+ category: { glyph: '\u25a6', hue: 'k1', word: 'a category of the kit: its page shows every asset in it' },
384
386
  post: { glyph: '\u25a4', hue: 'k2', word: 'a post waiting in the social queue' },
385
387
  workflow: { glyph: '\u29c9', hue: 'k6', word: 'a comfy workflow module' },
386
388
  wiki: { glyph: '\u2b21', hue: 'k3', word: 'a markdown wiki, opened as its link graph' },
@@ -6,12 +6,21 @@
6
6
  // No row carries prose: the words (the duration too) are the tooltips and
7
7
  // the page it opens. A click opens, only the chevron folds.
8
8
  import { observer } from 'mobx-react-lite'
9
- import type { CSSProperties } from 'react'
9
+ import { useEffect, useRef, type CSSProperties, type RefObject } from 'react'
10
10
  import { Icon, ICON } from './icons.tsx'
11
11
  import { sectionKeyLabel } from './keys.ts'
12
12
  import type { RepoSt } from './repo-st.ts'
13
13
  import { GLYPH, markOf, rowName, rowSlots, type TreeRow } from './tree-build.ts'
14
14
 
15
+ /** the row under the cursor stays in sight while the arrows move it */
16
+ const useInSight = (on: boolean): RefObject<HTMLDivElement | null> => {
17
+ const el = useRef<HTMLDivElement | null>(null)
18
+ useEffect(() => {
19
+ if (on) el.current?.scrollIntoView({ block: 'nearest' })
20
+ }, [on])
21
+ return el
22
+ }
23
+
15
24
  /** the fold marker: a chevron, right when the branch is shut and down when
16
25
  * it is open. The 9px triangle it replaces read as a dot with an opinion */
17
26
  export const Caret = ({ open }: { open: boolean }): React.ReactElement => (
@@ -34,8 +43,9 @@ export const SectionRowView = observer(({ app, row }: { app: RepoSt; row: TreeRo
34
43
  const id = row.section!
35
44
  const s = t.sections.find((x) => x.id === id)
36
45
  const key = s == null ? null : sectionKeyLabel(id)
46
+ const el = useInSight(t.cursor === row.key)
37
47
  return (
38
- <div className={`tsec${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
48
+ <div ref={el} className={`tsec${s?.empty === true ? ' empty' : ''}${s?.opens === false ? ' noop' : ''}${s?.on === true ? ' cur' : ''}${t.cursor === row.key ? ' on' : ''}`} aria-current={s?.on === true ? 'page' : undefined}
39
49
  title={s == null ? row.label : `${s.title} (${key})`} onClick={() => t.click(row)}>
40
50
  <Fold app={app} row={row} />
41
51
  <Icon d={ICON[id]} />
@@ -46,16 +56,19 @@ export const SectionRowView = observer(({ app, row }: { app: RepoSt; row: TreeRo
46
56
  )
47
57
  })
48
58
 
49
- export const TreeRowView = observer(({ app, row }: { app: RepoSt; row: TreeRow }) => {
50
- if (row.section != null) return <SectionRowView app={app} row={row} />
59
+ export const TreeRowView = ({ app, row }: { app: RepoSt; row: TreeRow }): React.ReactElement =>
60
+ row.section != null ? <SectionRowView app={app} row={row} /> : <LeafRowView app={app} row={row} />
61
+
62
+ const LeafRowView = observer(({ app, row }: { app: RepoSt; row: TreeRow }) => {
51
63
  const t = app.tree
52
64
  const u = app.checks
53
65
  const running = row.checks.some((id) => u.isRunning(id))
54
66
  const s = rowSlots(row, running)
55
67
  const mark = markOf(row.res, row.kind)
56
68
  const name = rowName(row)
69
+ const el = useInSight(t.cursor === row.key)
57
70
  return (
58
- <div className={`trow${t.cursor === row.key ? ' on' : ''}`} style={{ '--d': row.depth } as CSSProperties}
71
+ <div ref={el} className={`trow${t.cursor === row.key ? ' on' : ''}`} style={{ '--d': row.depth } as CSSProperties}
59
72
  title={s.hint} onClick={() => t.click(row)}>
60
73
  <span className="tw" onClick={row.dir ? (e) => { e.stopPropagation(); t.fold(row) } : undefined}>
61
74
  {row.dir ? <Caret open={t.isOpen(row.key)} /> : null}
@@ -150,12 +150,35 @@ export class TreeSvc {
150
150
  if (row.dir) this.toggle(row.key)
151
151
  }
152
152
 
153
+ /** where the arrows start: the cursor, else the section of the page shown */
154
+ private get from(): number {
155
+ const rows = this.rows
156
+ const i = rows.findIndex((r) => r.key === this.cursor)
157
+ if (i !== -1) return i
158
+ const on = this.sections.find((s) => s.on)?.id
159
+ return on == null ? -1 : rows.findIndex((r) => r.section === on)
160
+ }
161
+
162
+ /** an arrow lands on a row and opens its page at once. A branch with
163
+ * nothing to open only takes the cursor: a fold is the chevron's job */
164
+ private land(row: TreeRow): void {
165
+ this.cursor = row.key
166
+ if (row.section != null || row.page != null || row.res != null) this.st.walk(() => this.activate(row))
167
+ }
168
+
169
+ /** a greyed section with no page: the arrows step over it */
170
+ private inert(row: TreeRow): boolean {
171
+ return row.section != null && this.sections.find((s) => s.id === row.section)?.opens === false
172
+ }
173
+
153
174
  move(d: number): void {
154
175
  const rows = this.rows
155
176
  if (rows.length === 0) return
156
- const i = rows.findIndex((r) => r.key === this.cursor)
157
- const next = i === -1 ? (d > 0 ? 0 : rows.length - 1) : Math.min(rows.length - 1, Math.max(0, i + d))
158
- this.cursor = rows[next]!.key
177
+ const i = this.from
178
+ let next = i === -1 ? (d > 0 ? 0 : rows.length - 1) : i + d
179
+ while (next >= 0 && next < rows.length && this.inert(rows[next]!)) next += d
180
+ if (next < 0 || next >= rows.length || next === i) return
181
+ this.land(rows[next]!)
159
182
  }
160
183
  /** right descends: a folded row unfolds, anything else steps down */
161
184
  right(): void {
@@ -171,10 +194,7 @@ export class TreeSvc {
171
194
  const rows = this.rows
172
195
  const i = rows.findIndex((x) => x.key === r.key)
173
196
  for (let n = i - 1; n >= 0; n--) {
174
- if (rows[n]!.depth < r.depth) {
175
- this.cursor = rows[n]!.key
176
- return
177
- }
197
+ if (rows[n]!.depth < r.depth) return this.land(rows[n]!)
178
198
  }
179
199
  }
180
200
  enter(): void {
@@ -202,7 +202,9 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
202
202
  // ONE way a run executes. In process for a hub of its own; as a detached
203
203
  // job in the daemon, which follows it: its lines and verdicts reach the
204
204
  // same taps, and a reload of the daemon leaves it running
205
- const exec = async (kind: 'ci' | 'check', opts: Record<string, unknown>, tap: CheckTap, background: boolean): Promise<number> => {
205
+ const exec = async (kind: 'ci' | 'check', sent: Record<string, unknown>, tap: CheckTap, background: boolean): Promise<number> => {
206
+ // the switches a terminal set ride beside the options, into the job's env
207
+ const { env, ...opts } = sent as { env?: Record<string, string> }
206
208
  if (jobs == null) {
207
209
  const turn = background ? await takeSlot(autoGatePool(), { repo: c.name, check: 'auto gate' }, { pollMs: 250 }) : null
208
210
  try {
@@ -214,7 +216,7 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
214
216
  }
215
217
  // a job still running here, started before this image or by a terminal's --detach
216
218
  while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
217
- const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background } })
219
+ const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background, ...(env == null ? {} : { env }) } })
218
220
  tap.job?.(j.id)
219
221
  return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
220
222
  }
@@ -4,7 +4,8 @@
4
4
  // and the uncommitted count ask git only when a stat says a commit, a tag or
5
5
  // the index moved, a few repos per poll, and a repo's run record is opened
6
6
  // only when it moved, so a steady poll spawns ps and lsof and nothing else. The icon route
7
- // serves only the icon files a discovered repo carries, and POST
7
+ // serves only the icon files a discovered repo carries, the app-icon route
8
+ // draws the app icon (core/app-icon.ts) for the admin page's preview, and POST
8
9
  // /api/projects/open opens a discovered repo in VS Code, POST
9
10
  // /api/projects/fork in Fork: a request names a repo, never a path. The rail ORDER is one text file for the whole machine
10
11
  // (ORDER_FILE), so every hub shows the same order and a hand edit is seen
@@ -21,11 +22,14 @@ import {
21
22
  import { readLastStatusReadonly } from '../ledger/run-log.ts'
22
23
  import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
23
24
  import { writeIdentity } from '../core/identity.ts'
25
+ import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
24
26
  import { writeArchived } from '../core/archived.ts'
25
27
 
26
28
  export type { Gate }
27
29
 
28
30
  export const POLL_MS = 5_000
31
+ /** the app icon preview: sharp on a 2x screen at the admin page's 128px */
32
+ const PREVIEW_PX = 256
29
33
  /** how long a release answer holds for a checkout whose `.git` is not a
30
34
  * folder, since no stat can say when it moved */
31
35
  export const RELEASE_TTL_MS = 60_000
@@ -377,6 +381,23 @@ export function projectGutter(d: GutterDeps) {
377
381
  headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
378
382
  })
379
383
  }
384
+ if (path.startsWith('/api/projects/app-icon/')) {
385
+ if (repos.length === 0) await answer()
386
+ const r = findAny(decodeURIComponent(path.slice('/api/projects/app-icon/'.length)))
387
+ if (r == null) return new Response('not a house repo', { status: 404 })
388
+ const icon = appIconOf(r.path)
389
+ if (icon == null) return new Response('no png mark: the app wears the default icon', { status: 404 })
390
+ // the colours a picker is on right now, before they are saved; else the config's
391
+ const q = new URL(req.url).searchParams
392
+ const asked = { colorLight: q.get('light') ?? '', colorDark: q.get('dark') ?? '' }
393
+ const id = identityProblem(asked) == null ? asked : identityOfRoot(r.path)
394
+ try {
395
+ const [png] = await appIconPngs(await readFile(icon), id, [PREVIEW_PX])
396
+ return new Response(new Uint8Array(png!), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=60' } })
397
+ } catch (e) {
398
+ return new Response(`could not draw the icon from ${icon}: ${(e as Error).message}`, { status: 422 })
399
+ }
400
+ }
380
401
  if (path === '/api/projects/identity' && req.method === 'POST') {
381
402
  if (repos.length === 0) await answer()
382
403
  const b = (await req.json().catch(() => ({}))) as { name?: unknown; colorLight?: unknown; colorDark?: unknown }
@@ -61,8 +61,9 @@ export type WidgetDef = {
61
61
  title?: string
62
62
  /** how often the daemon renders it again (default 5000, at least 1000) */
63
63
  everyMs?: number
64
- /** the html it shows, rendered in the daemon. A throw shows as its error */
65
- render: () => string | Promise<string>
64
+ /** the html it shows, rendered in the daemon. A throw shows as its error.
65
+ * `force`: a reader pressed refresh, so skip any cache or backoff */
66
+ render: (o: { force: boolean }) => string | Promise<string>
66
67
  }
67
68
 
68
69
  export type GlobalProcessUnit = GlobalProcessDef & { kind: 'globalProcess' }
@@ -294,6 +294,10 @@ export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, P
294
294
  return { rerun, cleared }
295
295
  }
296
296
 
297
+ /** the recap bun prints after the last file, "3 tests failed:", repeats
298
+ * every (fail) line under no header of its own: it closes the last file */
299
+ export const BUN_RECAP = /^\s*\d+ tests? failed:\s*$/
300
+
297
301
  /** the suites with a failing test, read off bun's report: a file that failed
298
302
  * prints its path as a header line, then its `(fail)` lines under it. A
299
303
  * passing file prints nothing at all in a piped run, which is why this reads
@@ -304,6 +308,7 @@ export function failedSuites(output: string): string[] {
304
308
  for (const line of output.split('\n')) {
305
309
  const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
306
310
  if (head != null) { cur = normSuitePath(head[1]!); continue }
311
+ if (BUN_RECAP.test(line)) { cur = null; continue }
307
312
  // a file that cannot load prints no `(fail)` line, only this banner
308
313
  if (cur != null && (/^\(fail\) /.test(line) || line.startsWith('# Unhandled error between tests'))) out.add(cur)
309
314
  }
@@ -322,6 +327,7 @@ export function failLines(output: string): Map<string, string> {
322
327
  const line = raw.trimEnd()
323
328
  const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
324
329
  if (head != null) { cur = normSuitePath(head[1]!); err = null; continue }
330
+ if (BUN_RECAP.test(line)) { cur = null; continue }
325
331
  if (cur == null || out.has(cur)) continue
326
332
  if (err == null && /^\s*error: /.test(line)) err = line.trim()
327
333
  const fail = line.match(/^\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/)