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
@@ -0,0 +1,168 @@
1
+ // the activity page's state: what every house repo committed in a window and
2
+ // what it holds uncommitted, as the daemon reads it from local git. It asks
3
+ // again every ACTIVITY_POLL_MS, only while the page is shown AND the tab is
4
+ // visible, so a page left in the background costs the daemon nothing. The
5
+ // window and the commit cap are remembered per viewer.
6
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
+ import { clampCap, DEFAULT_CAP, DEFAULT_MINUTES, idleRow, WINDOW_MINUTES, type ActivityAnswer, type ActivityRow } from '../../home/activity-shape.ts'
8
+
9
+ // the words the page and `shipkit cross-repo-activity` share live beside the shapes
10
+ export { ageShort, CAP_MAX, CAP_MIN, clampCap, DEFAULT_CAP, dirtWords, filesWords, upstreamWords, windowWords } from '../../home/activity-shape.ts'
11
+ import type { HomeSt } from './home-st.ts'
12
+
13
+ export const ACTIVITY_POLL_MS = 60_000
14
+ const MINUTES_KEY = 'home:activity:minutes'
15
+ const CAP_KEY = 'home:activity:cap'
16
+
17
+ /** busy rows in the daemon's (rail) order, then the quiet ones below them */
18
+ export function splitRows(rows: ActivityRow[]): { busy: ActivityRow[]; idle: ActivityRow[] } {
19
+ return { busy: rows.filter((r) => !idleRow(r)), idle: rows.filter(idleRow) }
20
+ }
21
+
22
+ function kept(key: string): string | null {
23
+ try {
24
+ return globalThis.localStorage?.getItem(key) ?? null
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+ function keptMinutes(): number {
30
+ const n = Number(kept(MINUTES_KEY))
31
+ return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
32
+ }
33
+ function keep(key: string, n: number): void {
34
+ try { globalThis.localStorage?.setItem(key, String(n)) } catch { /* private window: the pick lasts the tab */ }
35
+ }
36
+
37
+ export class ActivitySt {
38
+ data: ActivityAnswer | null = null
39
+ error = ''
40
+ minutes: number = keptMinutes()
41
+ /** the most commits a repo shows before a click shows the rest */
42
+ cap: number = clampCap(kept(CAP_KEY) ?? DEFAULT_CAP)
43
+ /** repos whose commits all show, past the cap */
44
+ expanded = new Set<string>()
45
+ /** repos whose failing and warning checks show under their row */
46
+ checksOpen = new Set<string>()
47
+ /** a read is in flight */
48
+ loading = false
49
+ /** when the next read goes, epoch ms */
50
+ nextAt = 0
51
+ /** the page's clock, one tick a second while shown */
52
+ now = Date.now()
53
+ private timer: ReturnType<typeof setInterval> | null = null
54
+ private gen = 0
55
+ /** a tab coming back reads only when its countdown is already due */
56
+ private readonly onVisible = (): void => {
57
+ if (this.timer != null) this.tick()
58
+ }
59
+
60
+ constructor(private st: HomeSt) {
61
+ makeAutoObservable<this, 'st' | 'timer' | 'gen' | 'onVisible'>(this, { st: false, timer: false, gen: false, onVisible: false, data: observableRef })
62
+ }
63
+
64
+ get rows() {
65
+ return splitRows(this.data?.rows ?? [])
66
+ }
67
+
68
+ /** the answer shown is for another window than the one picked: it dims until the new one lands */
69
+ get stale(): boolean {
70
+ return this.data != null && this.data.minutes !== this.minutes
71
+ }
72
+
73
+ /** whole seconds to the next read */
74
+ get countdown(): number {
75
+ return Math.max(0, Math.ceil((this.nextAt - this.now) / 1000))
76
+ }
77
+
78
+ setMinutes(m: number): void {
79
+ this.minutes = m
80
+ keep(MINUTES_KEY, m)
81
+ void this.poll()
82
+ }
83
+
84
+ /** a typed cap, clamped to CAP_MIN..CAP_MAX */
85
+ setCap(v: unknown): void {
86
+ this.cap = clampCap(v)
87
+ keep(CAP_KEY, this.cap)
88
+ }
89
+
90
+ toggle(name: string): void {
91
+ if (this.expanded.has(name)) this.expanded.delete(name)
92
+ else this.expanded.add(name)
93
+ }
94
+
95
+ /** a click on the status dot: its checks show under the row, or hide */
96
+ toggleChecks(name: string): void {
97
+ if (this.checksOpen.has(name)) this.checksOpen.delete(name)
98
+ else this.checksOpen.add(name)
99
+ }
100
+
101
+ openCheck(repo: string, check: string): void {
102
+ this.st.openCheck(repo, check)
103
+ }
104
+
105
+ /** the repo in Fork, the git client: the daemon opens it by name */
106
+ async openFork(name: string): Promise<void> {
107
+ try {
108
+ const r = await fetch('/api/projects/fork', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
109
+ if (!r.ok) throw new Error(await r.text())
110
+ runInAction(() => { this.error = '' })
111
+ } catch (e) {
112
+ runInAction(() => { this.error = `open ${name} in Fork: ${(e as Error).message}` })
113
+ }
114
+ }
115
+
116
+ openRepo(name: string): void {
117
+ this.st.select(name)
118
+ }
119
+
120
+ /** the refresh button: a new git pass now, whatever the countdown says */
121
+ refresh(): void {
122
+ if (!this.loading) void this.poll(true)
123
+ }
124
+
125
+ /** one read. The daemon shares an answer younger than its ttl between
126
+ * readers and never serves an older one; only `fresh` forces a pass */
127
+ async poll(fresh = false): Promise<void> {
128
+ const id = ++this.gen
129
+ runInAction(() => { this.loading = true; this.nextAt = Date.now() + ACTIVITY_POLL_MS })
130
+ try {
131
+ const r = await fetch(`/api/home/activity.json?m=${this.minutes}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(30_000) })
132
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
133
+ const j = (await r.json()) as ActivityAnswer
134
+ runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
135
+ } catch (e) {
136
+ runInAction(() => { if (id === this.gen) this.error = (e as Error).message })
137
+ } finally {
138
+ // the countdown starts when the answer lands, so reads never overlap
139
+ runInAction(() => { if (id === this.gen) { this.loading = false; this.nextAt = Date.now() + ACTIVITY_POLL_MS } })
140
+ }
141
+ }
142
+
143
+ /** one tick a second: the countdown moves, and a due read goes, unless
144
+ * the tab is hidden (the daemon reads git only for a viewer) */
145
+ private tick(): void {
146
+ this.now = Date.now()
147
+ if (this.loading || this.now < this.nextAt) return
148
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
149
+ void this.poll()
150
+ }
151
+
152
+ /** reads while the page is shown */
153
+ start(): void {
154
+ if (this.timer != null) return
155
+ void this.poll()
156
+ this.timer = setInterval(() => this.tick(), 1_000)
157
+ document.addEventListener('visibilitychange', this.onVisible)
158
+ }
159
+
160
+ stop(): void {
161
+ if (this.timer == null) return
162
+ clearInterval(this.timer)
163
+ this.timer = null
164
+ this.gen++
165
+ this.loading = false
166
+ document.removeEventListener('visibilitychange', this.onVisible)
167
+ }
168
+ }
@@ -0,0 +1,218 @@
1
+ // `/activity`: what every house repo committed in the window and what it holds
2
+ // uncommitted, as ONE table with a row group per repo, so a glance says which
3
+ // agents did what. Each commit carries its own state (↑ orange unpushed, ✓
4
+ // green pushed), uncommitted work is a red line above the commits, the branch
5
+ // is green on main and orange elsewhere. On top: the window, the commit cap
6
+ // (a number, 1 to 15), then when the next read goes. Repos with nothing in the
7
+ // window sit below the busy ones, each with its last commit in grey.
8
+ import { observer } from 'mobx-react-lite'
9
+ import type { ReactNode } from 'react'
10
+ import { idleRow, isMainBranch, WINDOW_MINUTES, type ActivityCommit, type ActivityRow, type StatusCheck } from '../../home/activity-shape.ts'
11
+ import { ageShort, CAP_MAX, CAP_MIN, dirtWords, filesWords, upstreamWords, windowWords } from './activity-st.ts'
12
+ import { Tip } from './floating.tsx'
13
+ import { Icon, ICON } from './icons.tsx'
14
+ import type { HomeSt } from './home-st.ts'
15
+ import { checkAddress, plainClick, repoPage } from './home-st.ts'
16
+ import { stampWords } from './time-ago.tsx'
17
+
18
+ /** the repo's icon and name; a plain click selects it in place */
19
+ const RepoName = observer(({ app, name }: { app: HomeSt; name: string }) => {
20
+ const icon = app.projects.list.find((p) => p.name === name)?.icon ?? null
21
+ return (
22
+ <a className="acrepo" href={repoPage(name)} onClick={(e) => {
23
+ if (!plainClick(e)) return
24
+ e.preventDefault()
25
+ app.activity.openRepo(name)
26
+ }}>
27
+ {icon == null ? null : <img src={icon} alt="" width={14} height={14} draggable={false} />}
28
+ <span>{name}</span>
29
+ </a>
30
+ )
31
+ })
32
+
33
+ /** a bar of exclusive buttons */
34
+ const Bar = <T extends number>({ label, values, value, words, set }: { label: string; values: readonly T[]; value: number; words: (v: T) => string; set: (v: T) => void }) => (
35
+ <div className="acwin" role="radiogroup" aria-label={label}>
36
+ {values.map((v) => (
37
+ <button key={v} role="radio" aria-checked={value === v} className={value === v ? 'on' : ''} onClick={() => set(v)}>{words(v)}</button>
38
+ ))}
39
+ </div>
40
+ )
41
+
42
+ /** when the next read goes; it spins while one runs */
43
+ const Next = observer(({ app }: { app: HomeSt }) => {
44
+ const st = app.activity
45
+ return (
46
+ <button type="button" className="acnext acpill" disabled={st.loading} onClick={() => st.refresh()}
47
+ title="read every repo now · the page reads them again every minute while it is shown">
48
+ <span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
49
+ {st.loading ? '' : ` ${st.countdown}s`}
50
+ </button>
51
+ )
52
+ })
53
+
54
+ /** one commit's cells; `old` is the last one, from before the window */
55
+ const Commit = ({ c, now, old }: { c: ActivityCommit; now: number; old: boolean }) => (
56
+ <>
57
+ <td className="acsha">{c.sha}</td>
58
+ <td className="acwhen" title={stampWords(c.at)}>{ageShort(c.at, now)}</td>
59
+ <td className={`actitle ${c.pushed ? 'pushed' : 'unpushed'}${old ? ' old' : ''}`} title={`${old ? 'the last commit, before the window. ' : ''}${c.pushed ? 'pushed' : 'not pushed'}: ${c.title}`}>
60
+ <span className="acmark">{c.pushed ? '✓' : '↑'}</span>
61
+ {c.branch == null ? null : <span className="acside">{c.branch}</span>}
62
+ {c.title}
63
+ </td>
64
+ <td className="acwho">{c.author}</td>
65
+ </>
66
+ )
67
+
68
+ /** the checks behind a red or orange dot, failing first. An answer from
69
+ * a daemon that sends no `failing` still names them, by id */
70
+ export const failingOf = (r: ActivityRow): StatusCheck[] => {
71
+ if (r.failing != null) return r.failing
72
+ const c = r.status
73
+ if (c == null) return []
74
+ const one = (id: string, status: 'fail' | 'warn'): StatusCheck => ({ id, title: id, status, at: c.since[id] ?? null, line: '' })
75
+ return [...c.reds.map((id) => one(id, 'fail')), ...c.warns.map((id) => one(id, 'warn'))]
76
+ }
77
+
78
+ /** each failing and warning check: its id (a link to its page), its title,
79
+ * how old that outcome is, and the first line it printed about it */
80
+ export const CheckList = observer(({ app, r }: { app: HomeSt; r: ActivityRow }) => {
81
+ const now = app.activity.now
82
+ return (
83
+ <ul className="acchecks">
84
+ {failingOf(r).map((c) => (
85
+ <li key={c.id} className={c.status === 'fail' ? 'fail' : 'warn'}>
86
+ <span className="acchkhead">
87
+ <span className="acchkmark">{c.status === 'fail' ? '✗' : '!'}</span>
88
+ <a className="acchkid" href={checkAddress(r.name, c.id)} onClick={(e) => {
89
+ if (!plainClick(e)) return
90
+ e.preventDefault()
91
+ app.activity.openCheck(r.name, c.id)
92
+ }}>{c.id}</a>
93
+ {c.title !== c.id ? <span className="acchktitle">{c.title}</span> : null}
94
+ {c.at == null ? null : <span className="acdim" title={stampWords(c.at)}>{ageShort(c.at, now)}</span>}
95
+ </span>
96
+ {c.line === '' ? null : <span className="acchkline">{c.line}</span>}
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ )
101
+ })
102
+
103
+ /** the repo's checks as its own run record last saw them, only the ids it
104
+ * declares today: one dot, red, orange or green, with the count behind it.
105
+ * Hovering it floats the failing and warning checks; a click lists them
106
+ * under the row, so they read without hover too */
107
+ const Status = observer(({ app, r, span }: { app: HomeSt; r: ActivityRow; span: number }) => {
108
+ const c = r.status
109
+ if (c == null) return (
110
+ <td rowSpan={span} className="acstatus acdim">
111
+ <Tip content="no shipkit run recorded in this repo yet" placement="right">·</Tip>
112
+ </td>
113
+ )
114
+ const n = c.state === 'red' ? c.reds.length : c.state === 'orange' ? c.warns.length : 0
115
+ const tone = c.state === 'red' ? 'red' : c.state === 'orange' ? 'warn' : 'ok'
116
+ if (c.reds.length + c.warns.length === 0) return (
117
+ <td rowSpan={span} className={`acstatus t-${tone}`}>
118
+ <Tip content="every check passed on its last run" placement="right">●</Tip>
119
+ </td>
120
+ )
121
+ const open = app.activity.checksOpen.has(r.name)
122
+ return (
123
+ <td rowSpan={span} className={`acstatus t-${tone}`}>
124
+ <Tip content={<CheckList app={app} r={r} />} tipClass="acchktip" interactive placement="right-start" fallbacks={['bottom-start', 'left-start']}>
125
+ <button type="button" className={`acdot t-${tone}`} aria-expanded={open} aria-label={`${r.name}: ${c.reds.length} failing, ${c.warns.length} warning, click to ${open ? 'hide' : 'list'} them`}
126
+ onClick={() => app.activity.toggleChecks(r.name)}>
127
+ ●{n > 0 ? ` ${n}` : ''}
128
+ </button>
129
+ </Tip>
130
+ </td>
131
+ )
132
+ })
133
+
134
+ const Group = observer(({ app, r, now }: { app: HomeSt; r: ActivityRow; now: number }) => {
135
+ const st = app.activity
136
+ const open = st.expanded.has(r.name)
137
+ const capped = open ? r.commits : r.commits.slice(0, st.cap)
138
+ const more = r.commits.length - capped.length
139
+ const folds = more > 0 || (open && r.commits.length > st.cap)
140
+ const dirt = r.error == null ? dirtWords(r) : ''
141
+ const lines = (dirt !== '' || r.error != null ? 1 : 0) + capped.length + (r.last != null ? 1 : 0) + (folds ? 1 : 0)
142
+ const span = Math.max(1, lines)
143
+ const branch = r.branch ?? (r.detached == null ? '' : `detached ${r.detached}`)
144
+ const head = (
145
+ <>
146
+ <td rowSpan={span} className="acname"><RepoName app={app} name={r.name} /></td>
147
+ <Status app={app} r={r} span={span} />
148
+ <td rowSpan={span} className={`acbranch ${isMainBranch(r.branch) ? 't-ok' : 't-warn'}`} title={`${branch}: ${upstreamWords(r)}`}>{branch}</td>
149
+ {/* the lead in to the commit list, on its first line */}
150
+ <td rowSpan={span} className="acforkcell">
151
+ <button type="button" className="acfork" title="open in Fork" aria-label={`open ${r.name} in Fork`} onClick={() => void st.openFork(r.name)}><Icon d={ICON.fork} /></button>
152
+ </td>
153
+ </>
154
+ )
155
+ const rows: ReactNode[] = []
156
+ if (r.error != null) rows.push(<td key="e" colSpan={4} className="acerr" title={r.error}>git failed: {r.error}</td>)
157
+ else if (dirt !== '') rows.push(
158
+ <td key="d" colSpan={4} className="acdirty">
159
+ <Tip className="acdirt" tipClass="acfiles" placement="bottom-start" content={<pre>{filesWords(r)}</pre>}>{dirt}</Tip>
160
+ </td>,
161
+ )
162
+ for (const c of capped) rows.push(<Commit c={c} now={now} old={false} />)
163
+ // nothing newer in the window: HEAD's last commit, grey
164
+ if (r.last != null) rows.push(<Commit c={r.last} now={now} old />)
165
+ if (folds) rows.push(<td key="m" colSpan={4}><button className="acmore" onClick={() => st.toggle(r.name)}>{open ? 'show fewer' : `${more} more`}</button></td>)
166
+ if (rows.length === 0) rows.push(<td key="n" colSpan={4} className="acdim">no commit yet</td>)
167
+ return (
168
+ <tbody className={`acgrp${idleRow(r) ? ' quiet' : ''}`}>
169
+ {rows.map((cells, i) => <tr key={i}>{i === 0 ? head : null}{cells}</tr>)}
170
+ {st.checksOpen.has(r.name) && failingOf(r).length > 0 ? <tr className="acchkrow"><td colSpan={8}><CheckList app={app} r={r} /></td></tr> : null}
171
+ </tbody>
172
+ )
173
+ })
174
+
175
+ export const ActivityView = observer(({ app }: { app: HomeSt }) => {
176
+ const st = app.activity
177
+ const d = st.data
178
+ const now = d?.now ?? st.now
179
+ const { busy, idle } = st.rows
180
+ return (
181
+ <div className="hall activity">
182
+ <div className="actop">
183
+ <Bar label="how far back" values={WINDOW_MINUTES} value={st.minutes} words={windowWords} set={(m) => st.setMinutes(m)} />
184
+ <label className="accap" title={`the most commits a repo shows, ${CAP_MIN} to ${CAP_MAX}`}>
185
+ {/* uncontrolled while typed, so an emptied box can take a new number; it reads back the clamped cap on blur */}
186
+ <input type="number" min={CAP_MIN} max={CAP_MAX} step={1} defaultValue={st.cap} aria-label="commits per repo"
187
+ onChange={(e) => { if (e.currentTarget.value !== '') st.setCap(e.currentTarget.value) }}
188
+ onBlur={(e) => { e.currentTarget.value = String(st.cap) }} />
189
+ <span className="acdim">commits</span>
190
+ </label>
191
+ <Next app={app} />
192
+ {d == null ? <span className="acdim">reading every repo…</span> : (
193
+ <>
194
+ <span className="acpill">{busy.length} busy</span>
195
+ <span className="acpill">{idle.length} quiet</span>
196
+ <span className="acpill">read in {d.ms}ms</span>
197
+ </>
198
+ )}
199
+ </div>
200
+ {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
201
+ {d != null && busy.length === 0 ? <div className="acdim">nothing committed or uncommitted in the last {windowWords(st.minutes)}</div> : null}
202
+ {busy.length + idle.length === 0 ? null : (
203
+ <table className={`actable${st.stale ? ' stale' : ''}`}>
204
+ <colgroup>
205
+ <col className="c-name" /><col className="c-status" /><col className="c-branch" />
206
+ <col className="c-fork" /><col className="c-sha" /><col className="c-when" /><col /><col className="c-who" />
207
+ </colgroup>
208
+ <thead>
209
+ <tr><th>repo</th><th>status</th><th>branch</th><th colSpan={5}>commits, newest first</th></tr>
210
+ </thead>
211
+ {busy.map((r) => <Group key={r.name} app={app} r={r} now={now} />)}
212
+ {idle.map((r) => <Group key={r.name} app={app} r={r} now={now} />)}
213
+ </table>
214
+ )}
215
+ <p className="hdim">in the console: shipkit activity</p>
216
+ </div>
217
+ )
218
+ })
@@ -0,0 +1,78 @@
1
+ /* the activity page (activity-view.tsx): one table, a row group per repo. The
2
+ table is fixed width so a long title ellipses instead of scrolling the page
3
+ sideways; the file list floats on hover. State colours only: a branch green
4
+ on main, orange elsewhere; a commit orange until pushed; dirt red. */
5
+ .activity{font-size:13px;container-type:inline-size}
6
+ .actop{display:flex;align-items:center;flex-wrap:wrap;gap:var(--s3);margin-bottom:var(--s3)}
7
+ .acwin{display:inline-flex;border:1px solid var(--line);border-radius:4px;overflow:hidden}
8
+ .acwin button{border:0;border-radius:0;padding:var(--s0) var(--s2);background:none;color:var(--dim);font-size:12px}
9
+ .acwin button:hover{color:var(--ink);background:var(--panel2)}
10
+ .acwin button.on{color:var(--accent);background:var(--sel)}
11
+ .accap{display:inline-flex;align-items:center;gap:var(--s1);font-size:12px}
12
+ .accap input{width:44px;height:22px;box-sizing:border-box;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:var(--s0) var(--s1);font:inherit;font-variant-numeric:tabular-nums}
13
+ .accap input:focus{outline:none;border-color:var(--accent)}
14
+ .acnext{display:inline-flex;align-items:center;min-width:40px;color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums}
15
+ /* the counts beside the controls, and the refresh button, as small rounded chips */
16
+ .acpill{display:inline-flex;align-items:center;gap:var(--s1);padding:0 var(--s2);border:1px solid var(--line);border-radius:999px;background:var(--hi3);color:var(--dim);font-size:11.5px;line-height:20px;font-variant-numeric:tabular-nums;white-space:nowrap}
17
+ button.acpill{cursor:pointer;font:inherit;font-size:11.5px}
18
+ button.acpill:hover:not(:disabled){color:var(--ink);border-color:var(--accent)}
19
+ button.acpill:disabled{cursor:default}
20
+ .acring{display:inline-block}
21
+ .acring.on{color:var(--accent);animation:acspin 1s linear infinite}
22
+ @keyframes acspin{to{transform:rotate(360deg)}}
23
+ .acdim{color:var(--dim)}
24
+ .actable{width:100%;table-layout:fixed;border-collapse:collapse}
25
+ .actable.stale{opacity:.5}
26
+ .actable th{text-align:left;font-weight:400;color:var(--dim);font-size:11.5px;padding:var(--s1) var(--s2);border-bottom:1px solid var(--line)}
27
+ .actable td{padding:var(--s0) var(--s2);vertical-align:top;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
28
+ .acgrp{border-bottom:1px solid var(--line)}
29
+ .acgrp tr:first-child td{padding-top:var(--s1)}
30
+ .acgrp tr:last-child td{padding-bottom:var(--s1)}
31
+ .acgrp:hover{background:var(--panel)}
32
+ .actable{--acname-w:170px}.actable,.acchktip{--acmark-w:10px}.c-name{width:var(--acname-w)}.c-status{width:52px}.c-branch{width:150px}
33
+ .c-sha{width:72px}.c-when{width:56px}.c-who{width:90px}
34
+ .acrepo{display:inline-flex;align-items:center;gap:var(--s1);max-width:100%;color:var(--ink);text-decoration:none}
35
+ .acrepo span{overflow:hidden;text-overflow:ellipsis}
36
+ .acrepo img{flex:none;border-radius:3px;object-fit:contain}
37
+ .acrepo:hover span{text-decoration:underline}
38
+ .t-ok{color:var(--ok)}.t-warn{color:var(--warn)}.t-red{color:var(--red)}
39
+ .acstatus{white-space:nowrap;font-variant-numeric:tabular-nums;cursor:default}
40
+ /* the status dot is a button: hover floats its checks, a click lists them under the row */
41
+ .acdot{border:0;background:none;padding:0 var(--s1);margin:0 calc(-1 * var(--s1));font:inherit;font-variant-numeric:tabular-nums;color:inherit;cursor:pointer;border-radius:3px}
42
+ .acdot:hover,.acdot[aria-expanded="true"]{background:var(--panel2)}
43
+ .acchktip{z-index:40;max-width:560px;max-height:60vh;overflow:auto;background:var(--panel2);border:1px solid var(--line);border-radius:3px;box-shadow:0 2px 10px var(--shadow);padding:var(--s1) var(--s2);color:var(--ink);font-size:12px}
44
+ .acchecks{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--s1);white-space:normal}
45
+ .acchecks li{display:flex;flex-direction:column;min-width:0}
46
+ .acchkhead{display:flex;align-items:baseline;gap:var(--s2);min-width:0}
47
+ .acchkmark{flex:none;width:var(--acmark-w)}
48
+ .acchecks .fail .acchkmark,.acchecks .fail .acchkid{color:var(--red)}
49
+ .acchecks .warn .acchkmark,.acchecks .warn .acchkid{color:var(--warn)}
50
+ .acchkid{flex:none;font:11.5px ui-monospace,Menlo,monospace;text-decoration:none}
51
+ .acchkid:hover{text-decoration:underline}
52
+ .acchktitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
53
+ .acchkline{margin-left:calc(var(--acmark-w) + var(--s2));color:var(--dim);font:11.5px/1.4 ui-monospace,Menlo,monospace;overflow-wrap:anywhere}
54
+ .acchkrow td{padding:var(--s1) var(--s2) var(--s2) calc(var(--acname-w) + var(--s2));white-space:normal}
55
+ .acdirty,.acdirt,.acerr{color:var(--red)}
56
+ .acdirt{cursor:help}
57
+ .acsha{font:11.5px ui-monospace,Menlo,monospace;color:var(--dim)}
58
+ .acwhen{color:var(--dim);font-variant-numeric:tabular-nums;text-align:right}
59
+ .acmark{display:inline-block;width:14px;font-size:11px}
60
+ .pushed .acmark{color:var(--ok)}
61
+ .unpushed{color:var(--warn)}
62
+ /* the last commit, from before the window: grey, its marker kept */
63
+ .actitle.old,.unpushed.old{color:var(--dim)}
64
+ .acside{display:inline-block;margin-right:var(--s1);padding:0 var(--s1);border:1px solid var(--line);border-radius:3px;color:var(--dim);font-size:11px}
65
+ .acwho{color:var(--dim)}
66
+ .acmore{border:0;background:none;padding:0;color:var(--accent);font-size:12px}
67
+ .acmore:hover{text-decoration:underline}
68
+ .acfiles{z-index:40;max-width:480px;max-height:60vh;overflow:hidden;background:var(--panel2);border:1px solid var(--line);border-radius:3px;box-shadow:0 2px 10px var(--shadow);padding:var(--s1) var(--s2);color:var(--ink);pointer-events:none}
69
+ .acfiles pre{margin:0;font:11.5px/1.4 ui-monospace,Menlo,monospace;white-space:pre-wrap;word-break:break-all}
70
+ /* narrow: the sha and the author go, the title keeps its room */
71
+ @container (max-width:680px){.c-sha,.acsha,.c-who,.acwho{display:none}.actable{--acname-w:120px}.c-branch{width:100px}.acchkrow td{padding-left:var(--s2)}}
72
+ /* the open in Fork button leads the commit list, quiet until its row is hovered */
73
+ .c-fork{width:22px}
74
+ .actable td.acforkcell{padding-right:0;overflow:visible}
75
+ .acfork{display:inline-flex;align-items:center;vertical-align:-2px;padding:0;border:0;background:none;color:var(--dim);cursor:pointer;opacity:.5}
76
+ .acfork .hico{width:13px;height:13px}
77
+ .acgrp:hover .acfork{opacity:1}
78
+ .acfork:hover{color:var(--accent)}
@@ -0,0 +1,41 @@
1
+ // A repo's admin page, as state: what administers the repo rather than works
2
+ // in it. Archive asks first (the confirmation is this state, so it survives a
3
+ // re-render and a test drives it headless), reload and keep active go to the
4
+ // machine by the repo's key. On a hub of its own there is no machine, and no
5
+ // admin page.
6
+ import { makeAutoObservable } from 'mobx'
7
+ import type { RepoSt } from './repo-st.ts'
8
+
9
+ export class AdminSvc {
10
+ /** the archive button was pressed: its confirmation shows in its place */
11
+ confirming = false
12
+
13
+ constructor(private st: RepoSt) {
14
+ makeAutoObservable<this, 'st'>(this, { st: false })
15
+ }
16
+
17
+ askArchive(): void {
18
+ this.confirming = true
19
+ }
20
+ cancel(): void {
21
+ this.confirming = false
22
+ }
23
+ /** archived, the repo leaves the project list and its page shows the
24
+ * archived note with its way back */
25
+ archive(): void {
26
+ this.confirming = false
27
+ void this.st.home.projects.setArchived(this.st.key, true)
28
+ }
29
+
30
+ /** its hub, stopped in the daemon and loaded again with its config read fresh */
31
+ reload(): Promise<boolean> {
32
+ return this.st.home.machine?.hub(this.st.key, 'restart') ?? Promise.resolve(false)
33
+ }
34
+
35
+ get active(): boolean {
36
+ return this.st.home.machine?.isActive(this.st.key) === true
37
+ }
38
+ setActive(on: boolean): Promise<boolean> {
39
+ return this.st.home.machine?.setActive(this.st.key, on) ?? Promise.resolve(false)
40
+ }
41
+ }
@@ -0,0 +1,42 @@
1
+ // THE ADMIN PAGE of a repo (`#page:admin`): what administers the repo rather
2
+ // than works in it, one row per control, each with the one sentence that says
3
+ // what it does. State in admin-svc.ts. Only on the home: a hub of its own has
4
+ // no machine to administer it.
5
+ import { observer } from 'mobx-react-lite'
6
+ import type { ReactNode } from 'react'
7
+ import type { RepoSt } from './repo-st.ts'
8
+
9
+ const Row = ({ title, says, children }: { title: string; says: string; children: ReactNode }) => (
10
+ <div className="adrow">
11
+ <div className="adtext">
12
+ <b>{title}</b>
13
+ <span className="dim">{says}</span>
14
+ </div>
15
+ <div className="adctl">{children}</div>
16
+ </div>
17
+ )
18
+
19
+ export const AdminPage = observer(({ app }: { app: RepoSt }) => {
20
+ const a = app.admin
21
+ const w = app.home.machine?.worker(app.key)
22
+ const name = app.name
23
+ return (
24
+ <div className="colcard checkcard">
25
+ <div className="chead">
26
+ <span className="tid">admin</span>
27
+ <h3 className="ctitle">{name}</h3>
28
+ </div>
29
+ <Row title="reload the hub" says={`stops ${name}'s hub in the daemon and loads it again, its repo.config.ts read fresh and its processes started again. The daemon already does it on its own when repo.config.ts changes: this is for a hub that is stuck.${w == null ? '' : ` Its hub is ${w.status} now.`}`}>
30
+ <button className="ghost" onClick={() => void a.reload()}>reload</button>
31
+ </Row>
32
+ <Row title="keep active" says="an active repo runs the checks the plan holds for it and gets its red pings. It is active on its own while an agent works in it or VS Code has it open: this keeps it active without either.">
33
+ <label className="adflag"><input type="checkbox" checked={a.active} onChange={(e) => void a.setActive(e.currentTarget.checked)} />active</label>
34
+ </Row>
35
+ <Row title="archive" says="it leaves the project list for the archived page, and nothing runs there: no hub, no watch, no plan rows, no processes, no widgets. Written to repo.config.ts as archived: true, and undone from the archived page.">
36
+ {a.confirming
37
+ ? <span className="adconfirm"><span>archive {name}?</span><button className="primary" onClick={() => a.archive()}>archive it</button><button className="ghost" onClick={() => a.cancel()}>cancel</button></span>
38
+ : <button className="ghost" onClick={() => a.askArchive()}>archive</button>}
39
+ </Row>
40
+ </div>
41
+ )
42
+ })
@@ -1,12 +1,12 @@
1
1
  // The view state of THE run chart: its zoom, the width it has on screen and
2
2
  // what is under the pointer, over the geometry (timeline-geometry.ts) and the
3
- // drawing (timeline-chart.tsx). The live strip owns it, and nothing else in
4
- // the hub draws a run. What differs is the source: folded, a rolling window
5
- // that ends now; unfolded, the whole record opened on its last minutes.
3
+ // drawing (timeline-chart.tsx). What differs is the source: the dock's, a
4
+ // rolling window that ends now with one group of lanes per repo; a repo's
5
+ // runs page's, its whole record opened on its last minutes.
6
6
  import { makeAutoObservable, observableRef } from 'mobx'
7
7
  import type { TimelineInvocation } from '../../ledger/run-timeline.ts'
8
8
  import {
9
- anchorScroll, buildAxis, fitScale, fitWindow, layoutTimeline, maxZoom, PAD_PX, windowAxis, ZOOM_MIN, zoomStep, type Axis, type Layout, type Span,
9
+ anchorScroll, buildAxis, fitScale, fitWindow, layoutGroups, layoutTimeline, maxZoom, PAD_PX, windowAxis, ZOOM_MIN, zoomStep, type Axis, type ChartGroup, type Layout, type Span,
10
10
  } from './timeline-geometry.ts'
11
11
 
12
12
  /** what a chart draws. `windowMs` null = the whole record, with idle
@@ -23,6 +23,11 @@ export type ChartSource = {
23
23
  /** the span the view opens on: at its home zoom this much time, ending
24
24
  * now, fills the width. Absent = home is zoom 1, the whole source */
25
25
  homeSpanMs?: number
26
+ /** one group of lanes per key, over a rolling window only. `invocations`
27
+ * then holds every group's, and `laneCap` bounds each group */
28
+ groups?: ChartGroup[]
29
+ /** the fewest lanes a group takes, so its label fits beside them */
30
+ minLanes?: number
26
31
  }
27
32
 
28
33
  /** what the tooltip says, and the viewport point it is fixed at */
@@ -117,7 +122,9 @@ export class ChartView {
117
122
  get layout(): Layout | null {
118
123
  const s = this.src
119
124
  if (s == null || this.viewW === 0) return null
120
- return layoutTimeline(s.invocations, this.now, this.scale, { from: this.from ?? undefined, laneCap: s.laneCap, cap: s.cap })
125
+ const from = this.from
126
+ if (s.groups != null && from != null) return layoutGroups(s.groups, this.now, this.scale, { from, laneCap: s.laneCap, minLanes: s.minLanes, cap: s.cap })
127
+ return layoutTimeline(s.invocations, this.now, this.scale, { from: from ?? undefined, laneCap: s.laneCap, cap: s.cap })
121
128
  }
122
129
 
123
130
  private axisAt(scale: number): Axis {
@@ -35,6 +35,8 @@ export function rememberedFact(u: CachePop): RememberedFact {
35
35
  return { v: 'until a file it reads changes', inputs, help: `keyed on ${list(inputs)}: the answer is ${CACHED} until a change under ${inputs.length > 1 ? 'them' : 'it'}, then it runs again` }
36
36
  if (u.reads === true && inputs.length === 0 && (u.ttl == null || u.ttl === '') && u.never == null)
37
37
  return { v: 'until a file it read changes', inputs: [], help: `keyed on what its last run read: the answer is ${CACHED} until one of those files, existence probes or listings changes, then it runs again. \`shipkit keys --why\` names them` }
38
+ if (u.ttl === 'forever')
39
+ return { v: 'until its declaration changes', inputs: [], help: 'a decided fact (`ttl: \'forever\'`): a clean answer is kept until the value it checks is declared differently, and it runs only while no answer exists for that value' }
38
40
  if (u.ttl != null && u.ttl !== '') {
39
41
  let every = u.ttl
40
42
  try { every = spanWords(parseDuration(u.ttl)) } catch { /* an unparsable ttl is shown as written */ }
@@ -0,0 +1,32 @@
1
+ /* small controls any page can use. Colours are hub.css tokens, spacing its
2
+ scale (tests/hub-css-invariants.test.ts reads this sheet too) */
3
+
4
+ /* the cycling button (cycle-button.tsx): the current label, one dot per
5
+ option with the current one in the ink colour, then its shortcut */
6
+ .cycle{flex:none;display:inline-flex;align-items:center;gap:var(--s1);height:18px;padding:0 var(--s1);background:var(--panel2);border:1px solid var(--line);border-radius:9px;
7
+ color:var(--ink);font-size:10px;line-height:1;cursor:pointer}
8
+ .cycle:hover{border-color:var(--accent)}
9
+ .cyclbl{min-width:26px;text-align:center}
10
+ .cycdots{display:inline-flex;align-items:center;gap:var(--s0)}
11
+ .cycdot{width:4px;height:4px;border-radius:2px;background:var(--dim);opacity:.5}
12
+ .cycdot.on{background:var(--ink);opacity:1}
13
+ .cyckey{font:9px/1 ui-monospace,Menlo,monospace;color:var(--dim);background:none;border:0;padding:0}
14
+
15
+ /* a row of pills (pill-row.tsx): one row always, the rest folded into +N.
16
+ The probe is an invisible copy that only gives the widths */
17
+ .pchips{position:relative;display:flex;align-items:center;gap:var(--s1);min-width:0;overflow:hidden;white-space:nowrap}
18
+ .pchipw{flex:none;display:inline-flex}
19
+ .pchipprobe{position:absolute;left:0;top:0;width:max-content;overflow:visible;visibility:hidden;pointer-events:none}
20
+ .pchip{display:inline-flex;align-items:center;gap:var(--s1);height:16px;padding:0 var(--s1);border:1px solid var(--line);border-radius:8px;
21
+ background:var(--panel2);color:var(--dim);font-size:10px;line-height:1}
22
+ .pchip.accent{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,var(--line))}
23
+ .pchip.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,var(--line))}
24
+ .pchip img,.pchip .dgico{flex:none;border-radius:2px}
25
+ .pcmark{font-weight:700}
26
+ .pcmark.ok{color:var(--ok)}
27
+ .pcmark.red{color:var(--red)}
28
+ .pcmark.warn{color:var(--warn)}
29
+ .pcmark.dim{color:var(--dim)}
30
+ .pctext{color:var(--ink);opacity:.8}
31
+ .pchip.accent .pctext,.pchip.warn .pctext{color:inherit;opacity:1}
32
+ .pcage{color:var(--dim);opacity:.75}