rvlib-shipkit 1.6.0 → 1.8.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 (41) hide show
  1. package/CHANGELOG.md +26 -2
  2. package/package.json +1 -1
  3. package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
  4. package/src/checks/SK033-global-contribution.check.ts +47 -0
  5. package/src/cli/cmd_GlobalContext.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +38 -1
  7. package/src/cli/cmd_Insights.ts +78 -0
  8. package/src/cli.ts +6 -1
  9. package/src/core/global-contribution.ts +42 -0
  10. package/src/core/trace-audit.ts +16 -7
  11. package/src/home/global-context.ts +145 -0
  12. package/src/home/gui-app/ShipkitGui.swift +125 -41
  13. package/src/home/home-routes.ts +25 -0
  14. package/src/home/insights-shape.ts +124 -0
  15. package/src/home/insights.ts +90 -0
  16. package/src/home/serve-web.ts +3 -3
  17. package/src/home/widgets.ts +12 -2
  18. package/src/serve/hub-app/home-st.ts +12 -5
  19. package/src/serve/hub-app/home-strip.css +2 -0
  20. package/src/serve/hub-app/home-strip.tsx +4 -0
  21. package/src/serve/hub-app/home.html +1 -0
  22. package/src/serve/hub-app/hub-app.tsx +2 -0
  23. package/src/serve/hub-app/icons.tsx +1 -0
  24. package/src/serve/hub-app/index.html +1 -0
  25. package/src/serve/hub-app/insights-st.ts +111 -0
  26. package/src/serve/hub-app/insights-view.tsx +112 -0
  27. package/src/serve/hub-app/insights.css +24 -0
  28. package/src/serve/hub-app/keys.ts +1 -1
  29. package/src/serve/hub-app/machine-st.ts +23 -0
  30. package/src/serve/hub-app/palette-st.ts +2 -1
  31. package/src/serve/hub-app/project-list.tsx +3 -2
  32. package/src/serve/hub-app/projects-svc.ts +1 -1
  33. package/src/serve/hub-app/repo-st.ts +1 -1
  34. package/src/serve/hub-runtime.ts +31 -21
  35. package/src/serve/run-lanes.ts +134 -0
  36. package/src/shipkit-files.ts +3 -2
  37. package/src/suite/judge-suites.ts +27 -7
  38. package/src/suite/reads-env.ts +8 -0
  39. package/src/suite/select.ts +510 -0
  40. package/src/suite/suite-cli.ts +196 -14
  41. package/src/suite/suite-reads-preload.ts +335 -0
@@ -57,6 +57,8 @@
57
57
  .hwidget{flex:0 1 auto;max-width:100%;padding:var(--s0) var(--s2);border:1px solid var(--line);border-radius:3px;font-size:12px;line-height:1.4;overflow:auto}
58
58
  .hwidget h3{margin:0;font-size:12px;font-weight:650;white-space:nowrap}
59
59
  .hwidget h3 .hdim{font-weight:400}
60
+ .hwidget h3 .hwre{width:16px;height:16px;margin-left:var(--s1)}
61
+ .hwidget h3 .hwre .hico{width:12px;height:12px}
60
62
  /* meters in two columns: <table class="wmeters">, label · bar · value, twice a row */
61
63
  .wmeters td{padding-right:var(--s1)}
62
64
  .wmeters td:nth-child(3n){padding-right:var(--s3)}
@@ -211,6 +211,10 @@ const Monitor = observer(({ m }: { m: MachineSt }) => {
211
211
  {x.title}
212
212
  {head == null ? null : <span className="hdim"> · <span dangerouslySetInnerHTML={{ __html: head }} /></span>}
213
213
  {x.error != null && x.html != null ? <i className="gpwarn" title={x.error} /> : null}
214
+ <button className="gpi hwre" aria-label="refresh" disabled={m.refreshing.has(`${x.repo}/${x.id}`)} onClick={() => void m.refreshWidget(x.repo, x.id)}
215
+ title="render it again now, skipping any wait it keeps between samples">
216
+ {m.refreshing.has(`${x.repo}/${x.id}`) ? <span className="spin" /> : <Icon d={ICON.restart} />}
217
+ </button>
214
218
  </h3>
215
219
  {x.html != null ? <div dangerouslySetInnerHTML={{ __html: body }} />
216
220
  : x.error != null ? <div className="hwerr">{x.error}</div>
@@ -11,6 +11,7 @@
11
11
  <link rel="stylesheet" href="./planned.css" />
12
12
  <link rel="stylesheet" href="./when.css" />
13
13
  <link rel="stylesheet" href="./activity.css" />
14
+ <link rel="stylesheet" href="./insights.css" />
14
15
  <link rel="stylesheet" href="./readme.css" />
15
16
  <link rel="stylesheet" href="./dock.css" />
16
17
  <link rel="stylesheet" href="./controls.css" />
@@ -37,6 +37,7 @@ import { LIST, SIDEBAR } from './layout-st.ts'
37
37
  import { ArchivedView, HomeStrip, NotUp, Overview } from './home-strip.tsx'
38
38
  import { PlannedView } from './planned-view.tsx'
39
39
  import { ActivityView } from './activity-view.tsx'
40
+ import { InsightsView } from './insights-view.tsx'
40
41
  import { Palette } from './palette-view.tsx'
41
42
  import { SCREEN_WAIT_MS } from './screen-cache.ts'
42
43
  import { isSearchKey, pageKey, sectionKey } from './keys.ts'
@@ -320,6 +321,7 @@ const App = observer(() => {
320
321
  {home.page === 'overview' ? <Overview app={home} /> : null}
321
322
  {home.page === 'planned' ? <PlannedView app={home} /> : null}
322
323
  {home.page === 'activity' ? <ActivityView app={home} /> : null}
324
+ {home.page === 'insights' ? <InsightsView app={home} /> : null}
323
325
  {home.page === 'archived' ? <ArchivedView app={home} /> : null}
324
326
  {home.visited.map((r) => <RepoView key={r.key} app={r} />)}
325
327
  </Panel>
@@ -12,6 +12,7 @@ export const ICON = {
12
12
  reflections: 'M4 3.5h12v9H9l-4 3.5v-3.5H4zM7.5 7h5M7.5 9.5h3',
13
13
  monitor: 'M3 4h14v9H3zM7 16.5h6M10 13v3.5',
14
14
  archived: 'M2.5 4h15v3.5h-15zM4 7.5V16h12V7.5M8 10.5h4',
15
+ insights: 'M3 16.5h14M5 16.5V11M9 16.5V5M13 16.5V8.5M17 16.5V12',
15
16
  home: 'M3 9.5 10 3.5l7 6M5 8v8.5h10V8M8.5 16.5v-4.5h3v4.5',
16
17
  planned: 'M5.5 3h9M5.5 17h9M6.5 3c0 4 7 4 7 7s-7 3-7 7M13.5 3c0 4-7 4-7 7s7 3 7 7',
17
18
  browser: 'M10 2.5a7.5 7.5 0 1 0 0 15a7.5 7.5 0 1 0 0-15M2.5 10h15M10 2.5c-3 3-3 12 0 15M10 2.5c3 3 3 12 0 15',
@@ -11,6 +11,7 @@
11
11
  <link rel="stylesheet" href="./planned.css" />
12
12
  <link rel="stylesheet" href="./when.css" />
13
13
  <link rel="stylesheet" href="./activity.css" />
14
+ <link rel="stylesheet" href="./insights.css" />
14
15
  <link rel="stylesheet" href="./readme.css" />
15
16
  <link rel="stylesheet" href="./dock.css" />
16
17
  <link rel="stylesheet" href="./controls.css" />
@@ -0,0 +1,111 @@
1
+ // the insights page's state: the daemon's boxes for one scope, the tag that
2
+ // filters which boxes show, and one query that filters every box's rows. It
3
+ // reads once when the page shows and on the refresh button, never on a clock:
4
+ // manifests change far slower than anyone reads them. The scope and the tag
5
+ // are remembered per viewer.
6
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
+ import { rowMatches, SCOPES, tagsOf, type InsightBox, type InsightRow, type InsightsAnswer, type Scope } from '../../home/insights-shape.ts'
8
+ import type { HomeSt } from './home-st.ts'
9
+ import { ownTrouble, reach } from './reach.ts'
10
+
11
+ /** the rows a box shows before its "show all" */
12
+ export const BOX_ROWS = 25
13
+ const SCOPE_KEY = 'home:insights:scope'
14
+ const TAG_KEY = 'home:insights:tag'
15
+
16
+ function kept(key: string): string | null {
17
+ try {
18
+ return globalThis.localStorage?.getItem(key) ?? null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+ function keep(key: string, v: string): void {
24
+ try { globalThis.localStorage?.setItem(key, v) } catch { /* private window: the pick lasts the tab */ }
25
+ }
26
+
27
+ export class InsightsSt {
28
+ data: InsightsAnswer | null = null
29
+ error = ''
30
+ loading = false
31
+ scope: Scope = (SCOPES as readonly string[]).includes(kept(SCOPE_KEY) ?? '') ? (kept(SCOPE_KEY) as Scope) : 'house'
32
+ /** the tag whose boxes show, null for every box */
33
+ tag: string | null = kept(TAG_KEY) || null
34
+ query = ''
35
+ /** boxes showing every row, past BOX_ROWS */
36
+ full = new Set<string>()
37
+ /** `box/key` of each row whose uses show under it */
38
+ open = new Set<string>()
39
+ private gen = 0
40
+
41
+ constructor(private st: HomeSt) {
42
+ makeAutoObservable<this, 'st' | 'gen'>(this, { st: false, gen: false, data: observableRef })
43
+ }
44
+
45
+ get tags(): string[] {
46
+ return tagsOf(this.data?.boxes ?? [])
47
+ }
48
+
49
+ /** the boxes the tag lets through, each with the rows the query lets through */
50
+ get boxes(): { box: InsightBox; rows: InsightRow[] }[] {
51
+ return (this.data?.boxes ?? [])
52
+ .filter((b) => this.tag == null || !this.tags.includes(this.tag) || b.tags.includes(this.tag))
53
+ .map((box) => ({ box, rows: box.rows.filter((r) => rowMatches(box, r, this.query)) }))
54
+ }
55
+
56
+ setScope(s: Scope): void {
57
+ if (s === this.scope) return
58
+ this.scope = s
59
+ keep(SCOPE_KEY, s)
60
+ void this.load()
61
+ }
62
+
63
+ setTag(t: string | null): void {
64
+ this.tag = t
65
+ keep(TAG_KEY, t ?? '')
66
+ }
67
+
68
+ setQuery(q: string): void {
69
+ this.query = q
70
+ }
71
+
72
+ toggleFull(box: string): void {
73
+ if (this.full.has(box)) this.full.delete(box)
74
+ else this.full.add(box)
75
+ }
76
+
77
+ toggleRow(box: string, key: string): void {
78
+ const k = `${box}/${key}`
79
+ if (this.open.has(k)) this.open.delete(k)
80
+ else this.open.add(k)
81
+ }
82
+
83
+ openRepo(name: string): void {
84
+ this.st.select(name)
85
+ }
86
+
87
+ /** the refresh button: the daemon reads every manifest again */
88
+ refresh(): void {
89
+ if (!this.loading) void this.load(true)
90
+ }
91
+
92
+ async load(fresh = false): Promise<void> {
93
+ const id = ++this.gen
94
+ this.loading = true
95
+ try {
96
+ const r = await reach(`/api/home/insights.json?scope=${this.scope}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(60_000) })
97
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
98
+ const j = (await r.json()) as InsightsAnswer
99
+ runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
100
+ } catch (e) {
101
+ runInAction(() => { if (id === this.gen) this.error = ownTrouble(e, this.st.machine) })
102
+ } finally {
103
+ runInAction(() => { if (id === this.gen) this.loading = false })
104
+ }
105
+ }
106
+
107
+ /** reads when the page shows */
108
+ start(): void {
109
+ void this.load()
110
+ }
111
+ }
@@ -0,0 +1,112 @@
1
+ // `/insights`: boxes about every repo of a scope, each carrying tags. On top:
2
+ // the scope (house repos or all of ~/dev), the tag row that filters the boxes,
3
+ // one query that filters every box's rows, and the refresh. A box lists its
4
+ // rows by how many repos use each key; a click on a row shows every use, the
5
+ // house repos as links to their page.
6
+ import { observer } from 'mobx-react-lite'
7
+ import { placeWords, type InsightBox, type InsightRow, type Scope } from '../../home/insights-shape.ts'
8
+ import type { HomeSt } from './home-st.ts'
9
+ import { plainClick, repoPage } from './home-st.ts'
10
+ import { BOX_ROWS } from './insights-st.ts'
11
+
12
+ const SCOPE_WORDS: Record<Scope, { label: string; title: string }> = {
13
+ house: { label: 'house', title: 'the repos on the project list' },
14
+ dev: { label: '~/dev', title: 'every folder of ~/dev with a package.json or a .git, wired or not' },
15
+ }
16
+
17
+ /** the values a row shows inline before "+n" */
18
+ const VALUES_SHOWN = 3
19
+
20
+ const Row = observer(({ app, box, r }: { app: HomeSt; box: InsightBox; r: InsightRow }) => {
21
+ const st = app.insights
22
+ const open = st.open.has(`${box.id}/${r.key}`)
23
+ const house = new Set(app.projects.list.map((p) => p.name))
24
+ return (
25
+ <>
26
+ <tr className={`inrow${open ? ' open' : ''}`} onClick={() => st.toggleRow(box.id, r.key)} title={`${r.uses.length} use${r.uses.length === 1 ? '' : 's'}: click to list them`}>
27
+ <td className="inkey">{r.key}</td>
28
+ <td className="incount">{r.repos}</td>
29
+ <td className="invals">
30
+ {r.values.slice(0, VALUES_SHOWN).map((v) => <code key={v}>{v}</code>)}
31
+ {r.values.length > VALUES_SHOWN ? <span className="acdim"> +{r.values.length - VALUES_SHOWN}</span> : null}
32
+ </td>
33
+ </tr>
34
+ {!open ? null : r.uses.map((u, i) => (
35
+ <tr key={i} className="inuse">
36
+ <td className="inplace">
37
+ {house.has(u.repo) ? (
38
+ <a href={repoPage(u.repo)} onClick={(e) => {
39
+ if (!plainClick(e)) return
40
+ e.preventDefault()
41
+ st.openRepo(u.repo)
42
+ }}>{placeWords(u)}</a>
43
+ ) : placeWords(u)}
44
+ </td>
45
+ <td colSpan={2} className="invals"><code>{u.value}</code>{u.dev ? <span className="acdim"> dev</span> : null}</td>
46
+ </tr>
47
+ ))}
48
+ </>
49
+ )
50
+ })
51
+
52
+ const Box = observer(({ app, box, rows }: { app: HomeSt; box: InsightBox; rows: InsightRow[] }) => {
53
+ const st = app.insights
54
+ const full = st.full.has(box.id)
55
+ const shown = full ? rows : rows.slice(0, BOX_ROWS)
56
+ return (
57
+ <section className="inbox">
58
+ <h3>
59
+ <span>{box.title}</span>
60
+ {box.tags.map((t) => <button key={t} className={`intag${st.tag === t ? ' on' : ''}`} onClick={() => st.setTag(st.tag === t ? null : t)}>{t}</button>)}
61
+ <span className="sp" />
62
+ <span className="acdim">{rows.length === box.rows.length ? rows.length : `${rows.length} of ${box.rows.length}`}</span>
63
+ </h3>
64
+ <p className="acdim">{box.hint}</p>
65
+ {rows.length === 0 ? <p className="acdim">no {box.noun} matches</p> : (
66
+ <table className="intable">
67
+ <colgroup><col /><col className="c-count" /><col /></colgroup>
68
+ <thead><tr><th>{box.noun}</th><th>repos</th><th>values</th></tr></thead>
69
+ <tbody>{shown.map((r) => <Row key={r.key} app={app} box={box} r={r} />)}</tbody>
70
+ </table>
71
+ )}
72
+ {rows.length > BOX_ROWS ? <button className="ghost hsm inmore" onClick={() => st.toggleFull(box.id)}>{full ? `show the first ${BOX_ROWS}` : `show all ${rows.length}`}</button> : null}
73
+ </section>
74
+ )
75
+ })
76
+
77
+ export const InsightsView = observer(({ app }: { app: HomeSt }) => {
78
+ const st = app.insights
79
+ const d = st.data
80
+ return (
81
+ <div className="hall activity insights">
82
+ <div className="actop">
83
+ <div className="acwin" role="radiogroup" aria-label="which repos">
84
+ {(Object.keys(SCOPE_WORDS) as Scope[]).map((s) => (
85
+ <button key={s} role="radio" aria-checked={st.scope === s} className={st.scope === s ? 'on' : ''} title={SCOPE_WORDS[s].title} onClick={() => st.setScope(s)}>{SCOPE_WORDS[s].label}</button>
86
+ ))}
87
+ </div>
88
+ <div className="acwin" role="radiogroup" aria-label="which boxes">
89
+ <button role="radio" aria-checked={st.tag == null} className={st.tag == null ? 'on' : ''} onClick={() => st.setTag(null)}>all</button>
90
+ {st.tags.map((t) => <button key={t} role="radio" aria-checked={st.tag === t} className={st.tag === t ? 'on' : ''} onClick={() => st.setTag(t)}>{t}</button>)}
91
+ </div>
92
+ <input className="inquery" type="search" placeholder="filter: a package, a script…" aria-label="filter every box" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
93
+ <button type="button" className="acpill" disabled={st.loading} onClick={() => st.refresh()} title="read every package.json again now · an answer is kept 5 minutes">
94
+ <span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
95
+ </button>
96
+ {d == null ? <span className="acdim">reading every package.json…</span> : (
97
+ <>
98
+ <span className="acpill">{d.repos} repos</span>
99
+ <span className="acpill">{d.files} package.json</span>
100
+ <span className="acpill">read in {d.ms}ms</span>
101
+ </>
102
+ )}
103
+ </div>
104
+ {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
105
+ {d != null && d.errors.length > 0 ? <div className="hnote" title={d.errors.join('\n')}>{d.errors.length} package.json would not parse</div> : null}
106
+ <div className={`ingrid${d != null && d.scope !== st.scope ? ' stale' : ''}`}>
107
+ {st.boxes.map(({ box, rows }) => <Box key={box.id} app={app} box={box} rows={rows} />)}
108
+ </div>
109
+ <p className="hdim">in the console: shipkit insights</p>
110
+ </div>
111
+ )
112
+ })
@@ -0,0 +1,24 @@
1
+ /* the insights page (insights-view.tsx): the activity page's top bar, then a
2
+ grid of boxes. A row opens in place to list its uses. */
3
+ .ingrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr));gap:var(--s3);align-items:start}
4
+ .ingrid.stale{opacity:.5}
5
+ .inbox{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);min-width:0}
6
+ .inbox h3{display:flex;align-items:center;gap:var(--s2);margin:0;font-size:13px;font-weight:650}
7
+ .inbox > p{margin:var(--s0) 0 var(--s2);font-size:12px}
8
+ .intag{border:1px solid var(--line);border-radius:999px;padding:0 var(--s2);background:none;color:var(--dim);font-size:11px;font-weight:400;line-height:18px;cursor:pointer}
9
+ .intag:hover,.intag.on{color:var(--accent);border-color:var(--accent)}
10
+ .inquery{height:22px;min-width:200px;box-sizing:border-box;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:0 var(--s2);font:inherit;font-size:12px}
11
+ .inquery:focus{outline:none;border-color:var(--accent)}
12
+ .intable{width:100%;table-layout:fixed;border-collapse:collapse}
13
+ .intable col.c-count{width:56px}
14
+ .intable th{text-align:left;font-weight:400;color:var(--dim);font-size:11.5px;padding:var(--s0) var(--s1);border-bottom:1px solid var(--line)}
15
+ .intable td{padding:0 var(--s1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:top}
16
+ .inrow{cursor:pointer}
17
+ .inrow:hover td,.inrow.open td{background:var(--panel2)}
18
+ .inkey{font-weight:600}
19
+ .incount{font-variant-numeric:tabular-nums;text-align:right;padding-right:var(--s3) !important}
20
+ .invals code{margin-right:var(--s1);font-size:11.5px;color:var(--dim)}
21
+ .inuse td{font-size:12px}
22
+ .inuse .inplace{padding-left:var(--s3)}
23
+ .inuse .inplace a{color:var(--accent);text-decoration:none}
24
+ .inmore{margin-top:var(--s1)}
@@ -27,7 +27,7 @@ export function pageKey(e: KeyLike): PageKey | null {
27
27
  }
28
28
 
29
29
  /** the shortcut each place of the home carries on its button */
30
- export const PLACE_KEY = { overview: '⌘K', planned: "⌘'", activity: '⌘L' } as const
30
+ export const PLACE_KEY: Record<'overview' | 'planned' | 'activity', string> & Partial<Record<string, string>> = { overview: '⌘K', planned: "⌘'", activity: '⌘L' }
31
31
  /** the picker's shortcut, on the first button, before home */
32
32
  export const PICKER_KEY = '⌘J'
33
33
  /** the repo above and below, hinted at the project list's foot */
@@ -7,6 +7,7 @@
7
7
  // of this (HomeSt.machine is null).
8
8
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
9
9
  import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
10
+ import type { WidgetView } from '../../home/widgets.ts'
10
11
  import type { WorkerView } from '../../home/hubs.ts'
11
12
  import type { OpenHow, ResourceView } from '../../home/resources.ts'
12
13
  import type { PmAnswer, Role } from '../../home/pm.ts'
@@ -51,6 +52,8 @@ export class MachineSt {
51
52
  pm: PmAnswer | null = null
52
53
  /** every house repo's widgets, as the daemon last rendered them */
53
54
  widgets: WidgetsAnswer | null = null
55
+ /** `repo/id` of each widget a refresh is rendering */
56
+ refreshing = new Set<string>()
54
57
  /** why pm did not answer the last poll, or '' while it does */
55
58
  pmDown = ''
56
59
  /** the last action that failed */
@@ -261,6 +264,26 @@ export class MachineSt {
261
264
  }
262
265
  }
263
266
 
267
+ /** one widget rendered now by the daemon, with force (its cache or
268
+ * backoff skipped), its new view swapped in */
269
+ async refreshWidget(repo: string, id: string): Promise<void> {
270
+ const k = `${repo}/${id}`
271
+ if (this.refreshing.has(k)) return
272
+ this.refreshing.add(k)
273
+ try {
274
+ const r = await reach('/api/home/widgets/refresh', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo, id }) })
275
+ if (!r.ok) throw new Error(await r.text())
276
+ const v = (await r.json()) as WidgetView
277
+ runInAction(() => {
278
+ if (this.widgets != null) this.widgets = { ...this.widgets, widgets: this.widgets.widgets.map((w) => (w.repo === repo && w.id === id ? v : w)) }
279
+ })
280
+ } catch (e) {
281
+ runInAction(() => { this.error = ownTrouble(e, this) })
282
+ } finally {
283
+ runInAction(() => { this.refreshing.delete(k) })
284
+ }
285
+ }
286
+
264
287
  hub(name: string, act: 'ensure' | 'restart'): Promise<boolean> {
265
288
  return this.post('/api/home/hub', { name, act })
266
289
  }
@@ -1,5 +1,5 @@
1
1
  // the one picker of the page (cmd+j, keys.ts), a node of the root: the
2
- // home's places (home, activity, planned, in the list's order) first, then the shown repo's own jump
2
+ // home's places (home, activity, planned, insights, archived) first, then the shown repo's own jump
3
3
  // targets, then every other house repo as a place to go. Another repo's
4
4
  // resources are never listed: a jump to a repo selects it in place, and its
5
5
  // own targets are one more cmd+j away.
@@ -27,6 +27,7 @@ export class PaletteSt {
27
27
  { id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
28
28
  { id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
29
29
  { id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
30
+ { id: 'place:insights', label: 'insights', section: 'place', face: { place: 'insights' }, run: () => st.navigate('/insights') },
30
31
  { id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
31
32
  ]
32
33
  }
@@ -37,6 +37,7 @@ const PLACES: { id: Place | 'picker'; label: string; title: string }[] = [
37
37
  { id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
38
38
  { id: 'activity', label: 'activity', title: 'what every repo committed lately, what it holds uncommitted, its branch and whether it is pushed' },
39
39
  { id: 'planned', label: 'planned', title: 'every check the machine runs on its own: now, next, late, and why' },
40
+ { id: 'insights', label: 'insights', title: 'boxes about every repo: the packages they use and by how many, version drift, script names' },
40
41
  ]
41
42
 
42
43
  /** the icon's size on a row, in px, as attributes and in the sheet */
@@ -165,10 +166,10 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
165
166
  const on = id === 'picker' ? s.pickerOpen() : s.place === id
166
167
  const key = id === 'picker' ? PICKER_KEY : PLACE_KEY[id]
167
168
  return (
168
- <button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={`${pl.title} (${key})`} onClick={run}>
169
+ <button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={key == null ? pl.title : `${pl.title} (${key})`} onClick={run}>
169
170
  <Icon d={ICON[id === 'picker' ? 'search' : id === 'overview' ? 'home' : id]} />
170
171
  <span className="pjname">{pl.label}</span>
171
- <kbd className="pjkey">{key}</kbd>
172
+ {key == null ? null : <kbd className="pjkey">{key}</kbd>}
172
173
  </button>
173
174
  )
174
175
  })}
@@ -178,7 +178,7 @@ export function cardModel(p: Project, now: number): CardModel {
178
178
  }
179
179
 
180
180
  /** a page of the home that is no repo's */
181
- export type Place = 'overview' | 'planned' | 'activity'
181
+ export type Place = 'overview' | 'planned' | 'activity' | 'insights'
182
182
 
183
183
  export class ProjectsSvc {
184
184
  /** the last list this browser saw until the first poll answers, so the
@@ -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
 
@@ -41,6 +41,7 @@ import { autoGatePool, takeSlot } from '../core/slots.ts'
41
41
  import { liveJobs, startJob } from '../core/jobs.ts'
42
42
  import { followJob } from '../core/job-follow.ts'
43
43
  import { RUN_JOB } from '../home/home-words.ts'
44
+ import { RunLanes, runKey, treeStamp, writesTree } from './run-lanes.ts'
44
45
  import { OtherRepos } from './other-repos.ts'
45
46
  import { HouseRuns } from './house-runs.ts'
46
47
  import { recordStamp } from './project-gutter.ts'
@@ -185,9 +186,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
185
186
  const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
186
187
  // every run this process has going, whoever started it
187
188
  let active = 0
188
- // ONE run at a time, whoever asked: two gates over one tree would measure
189
- // the same checks twice and race each other's repair pass
190
- let chain: Promise<unknown> = Promise.resolve()
189
+ // every run, whoever asked: an identical one attaches, a repair pass goes
190
+ // alone, the rest overlap under the machine slot pool (run-lanes.ts)
191
+ const lanes = new RunLanes(() => treeStamp(root))
192
+ // the gate jobs this runtime started: one beside another is the lanes' call
193
+ const mine = new Set<string>()
191
194
  // the latest verdict of every check, printed as one problem frame after
192
195
  // each run for the vscode problems pane (problems/problem-line.ts)
193
196
  const board = new ProblemBoard()
@@ -215,13 +218,22 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
215
218
  }
216
219
  }
217
220
  // a job still running here, started before this image or by a terminal's --detach
218
- while (liveJobs(root).length > 0) await Bun.sleep(jobs.pollMs ?? 250)
221
+ const foreign = (): boolean => liveJobs(root).some((j) => !mine.has(j.id))
222
+ if (foreign()) tap.line('note: a gate job this hub did not start is running here, so this one starts when it ends')
223
+ while (foreign()) await Bun.sleep(jobs.pollMs ?? 250)
219
224
  const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background, ...(env == null ? {} : { env }) } })
225
+ mine.add(j.id)
220
226
  tap.job?.(j.id)
221
- return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
227
+ try {
228
+ return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
229
+ } finally {
230
+ mine.delete(j.id)
231
+ }
222
232
  }
223
233
  const runChecks: HubDeps['runChecks'] = (tap, o) => {
224
- const run = chain.then(async () => {
234
+ // only a whole gate the hub was asked for runs the repair pass (hubGateOpts)
235
+ const writes = o?.auto !== true && o?.gate === true && (o.only == null || o.only.length === 0)
236
+ return lanes.submit({ key: null, writes, print: tap.line }, async () => {
225
237
  active++
226
238
  // a run the hub starts on its own takes the machine's background
227
239
  // turn, once it is this hub's turn, never while it queues
@@ -243,27 +255,25 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
243
255
  for (const l of board.frame()) io.print(l)
244
256
  }
245
257
  })
246
- chain = run.catch(() => {})
247
- return run
248
258
  }
249
259
  // a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
250
- // in the same one-at-a-time chain as the hub's own runs, feeding the same
251
- // problems frame, recorded as the cli's
260
+ // in the same lanes as the hub's own runs, feeding the same problems
261
+ // frame, recorded as the cli's
252
262
  const runCli: HubDeps['runCli'] = (kind, opts, print) => {
253
- // never on a --json run: its output is the json and nothing else
254
- if ((active > 0 || (jobs != null && liveJobs(root).length > 0)) && opts.json !== true) print('note: this hub has a run in flight, so this one starts when it ends')
255
- const run = chain.then(async () => {
263
+ // a --json run prints the json and nothing else, so it never shares
264
+ const key = opts.json === true ? null : runKey(kind, opts)
265
+ // `check --fix` runs in process (home/via-hub.ts), so a check here never repairs
266
+ const writes = kind === 'ci' && writesTree(opts)
267
+ return lanes.submit({ key, writes, print, quiet: opts.json === true }, async (tee) => {
256
268
  active++
257
269
  try {
258
270
  await refresh()
259
- return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: print, check: onBoard, job: (id) => print(`${RUN_JOB}${id}`) }, false)
271
+ return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: tee, check: onBoard, job: (id) => tee(`${RUN_JOB}${id}`) }, false)
260
272
  } finally {
261
273
  active--
262
274
  for (const l of board.frame()) io.print(l)
263
275
  }
264
276
  })
265
- chain = run.catch(() => {})
266
- return run
267
277
  }
268
278
  const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
269
279
  const only = o?.only != null && o.only.length > 0 ? o.only : undefined
@@ -287,11 +297,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
287
297
  if (adopted.length > 0) {
288
298
  io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
289
299
  active++
290
- const run = Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).finally(() => {
291
- active--
292
- for (const l of board.frame()) io.print(l)
293
- })
294
- chain = run.catch(() => {})
300
+ void lanes.submit({ key: null, writes: true, print: () => {} }, () =>
301
+ Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).then(() => 0).finally(() => {
302
+ active--
303
+ for (const l of board.frame()) io.print(l)
304
+ })).catch(() => {})
295
305
  }
296
306
  }
297
307
  // resolved once: it shells out to git, and the page asks on every poll