rvlib-shipkit 1.4.0 → 1.5.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 (161) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/check.ts +68 -95
  5. package/src/checks/SK001-package-json-shape.check.ts +6 -2
  6. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  7. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  8. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  9. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  10. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  11. package/src/checks/SK007-github-visibility.check.ts +4 -2
  12. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  13. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  14. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  15. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  16. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  17. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  18. package/src/checks/SK014-prose-ste.check.ts +4 -0
  19. package/src/checks/SK015-nested-repo.check.ts +4 -2
  20. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  21. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  22. package/src/checks/SK018-test-files-run.check.ts +6 -2
  23. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  24. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  25. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  26. package/src/checks/SK024-identity-colors.check.ts +5 -3
  27. package/src/checks/SK025-release-drift.check.ts +4 -5
  28. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  29. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  30. package/src/checks/SK028-readme.check.ts +5 -1
  31. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  32. package/src/cli/cmd_Asks.ts +5 -5
  33. package/src/cli/cmd_Assets.ts +7 -7
  34. package/src/cli/cmd_Bump.ts +12 -11
  35. package/src/cli/cmd_Check.ts +8 -7
  36. package/src/cli/cmd_Checks.ts +7 -6
  37. package/src/cli/cmd_Ci.ts +12 -11
  38. package/src/cli/cmd_Commands.ts +16 -13
  39. package/src/cli/cmd_ConfigKeys.ts +7 -6
  40. package/src/cli/cmd_Contracts.ts +3 -3
  41. package/src/cli/cmd_CrossRepoActivity.ts +6 -5
  42. package/src/cli/cmd_Fleet.ts +9 -7
  43. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  44. package/src/cli/cmd_Home.ts +23 -17
  45. package/src/cli/cmd_Hooks.ts +6 -5
  46. package/src/cli/cmd_Init.ts +5 -4
  47. package/src/cli/cmd_Keys.ts +8 -7
  48. package/src/cli/cmd_Mv.ts +7 -6
  49. package/src/cli/cmd_Needs.ts +4 -3
  50. package/src/cli/cmd_Overview.ts +4 -3
  51. package/src/cli/cmd_Pb.ts +2 -2
  52. package/src/cli/cmd_Planned.ts +6 -5
  53. package/src/cli/cmd_Prose.ts +5 -5
  54. package/src/cli/cmd_Reflect.ts +7 -7
  55. package/src/cli/cmd_Release.ts +6 -5
  56. package/src/cli/cmd_Releases.ts +5 -4
  57. package/src/cli/cmd_Serve.ts +4 -3
  58. package/src/cli/cmd_Smoke.ts +5 -4
  59. package/src/cli/cmd_Start.ts +7 -6
  60. package/src/cli/cmd_Status.ts +4 -3
  61. package/src/cli/cmd_Tui.ts +3 -2
  62. package/src/cli/cmd_When.ts +71 -0
  63. package/src/cli/cmd_Wiki.ts +4 -4
  64. package/src/cli.ts +7 -2
  65. package/src/collections/checks.ts +2 -0
  66. package/src/collections/collection-types.ts +9 -4
  67. package/src/core/canonical-scripts.ts +1 -1
  68. package/src/core/check-runner.ts +37 -20
  69. package/src/core/config-rewrite.ts +37 -24
  70. package/src/core/discover-checks.ts +23 -2
  71. package/src/core/legacy-shapes.ts +39 -10
  72. package/src/core/one-way-in-policy.ts +2 -2
  73. package/src/core/outcome-words.ts +18 -2
  74. package/src/core/port-check-cli.ts +52 -0
  75. package/src/core/port-check-file.ts +295 -0
  76. package/src/core/port-check-files.ts +31 -0
  77. package/src/core/retention.ts +0 -14
  78. package/src/core/run-check.ts +12 -9
  79. package/src/core/run-ci.ts +8 -4
  80. package/src/core/run-fix.ts +30 -23
  81. package/src/core/run-fixers.ts +42 -5
  82. package/src/core/runs-matrix.ts +179 -0
  83. package/src/core/runs.ts +242 -0
  84. package/src/core/update-policy.ts +20 -0
  85. package/src/core/when-block.ts +29 -30
  86. package/src/core/when.ts +41 -103
  87. package/src/core/wire.ts +10 -14
  88. package/src/fleet/scan-fleet.ts +20 -4
  89. package/src/fleet/update-fleet.ts +6 -6
  90. package/src/home/home-launchd.ts +20 -4
  91. package/src/home/home-routes.ts +12 -1
  92. package/src/home/hubs.ts +5 -5
  93. package/src/home/pm.ts +8 -0
  94. package/src/home/run-pm.ts +1 -0
  95. package/src/home/serve-web.ts +2 -2
  96. package/src/home/supervise.ts +24 -1
  97. package/src/hooks/run-hooks.ts +48 -4
  98. package/src/init-repo.ts +6 -6
  99. package/src/ledger/gate-checks.ts +113 -59
  100. package/src/ledger/git-key.ts +5 -1
  101. package/src/ledger/ledger.ts +5 -5
  102. package/src/ledger/toolchain-identity.ts +45 -27
  103. package/src/plan/repo-source.ts +18 -39
  104. package/src/release/run-release.ts +5 -0
  105. package/src/repo-config.ts +30 -55
  106. package/src/rules/rule.ts +2 -2
  107. package/src/scaffold/files/agent-coding.md +1 -1
  108. package/src/scaffold/files/agent-index.md +1 -1
  109. package/src/scaffold/files/verify-skill.md +2 -2
  110. package/src/serve/hub-app/activity-st.ts +5 -4
  111. package/src/serve/hub-app/activity-view.tsx +1 -1
  112. package/src/serve/hub-app/admin-view.tsx +13 -11
  113. package/src/serve/hub-app/check-view.tsx +66 -46
  114. package/src/serve/hub-app/check-words.ts +1 -11
  115. package/src/serve/hub-app/checks-svc.ts +14 -35
  116. package/src/serve/hub-app/col-tables-view.tsx +13 -6
  117. package/src/serve/hub-app/cols-svc.ts +5 -4
  118. package/src/serve/hub-app/dock-st.ts +3 -2
  119. package/src/serve/hub-app/dock-view.tsx +36 -0
  120. package/src/serve/hub-app/dock.css +6 -0
  121. package/src/serve/hub-app/float-cards.tsx +30 -0
  122. package/src/serve/hub-app/floating.tsx +41 -4
  123. package/src/serve/hub-app/git-st.ts +42 -0
  124. package/src/serve/hub-app/git-view.tsx +61 -0
  125. package/src/serve/hub-app/hash-route.ts +45 -1
  126. package/src/serve/hub-app/header-bar.tsx +62 -72
  127. package/src/serve/hub-app/health-svc.ts +3 -2
  128. package/src/serve/hub-app/home-st.ts +14 -2
  129. package/src/serve/hub-app/home-strip.css +43 -8
  130. package/src/serve/hub-app/home-strip.tsx +111 -47
  131. package/src/serve/hub-app/home.html +1 -0
  132. package/src/serve/hub-app/hub-app.tsx +14 -4
  133. package/src/serve/hub-app/hub-types.ts +5 -2
  134. package/src/serve/hub-app/hub.css +65 -43
  135. package/src/serve/hub-app/icons.tsx +11 -0
  136. package/src/serve/hub-app/index.html +1 -0
  137. package/src/serve/hub-app/keys.ts +5 -2
  138. package/src/serve/hub-app/live-svc.ts +3 -2
  139. package/src/serve/hub-app/machine-st.ts +18 -22
  140. package/src/serve/hub-app/plan-view.tsx +51 -0
  141. package/src/serve/hub-app/planned-st.ts +9 -4
  142. package/src/serve/hub-app/processes-view.tsx +38 -0
  143. package/src/serve/hub-app/projects-svc.ts +15 -10
  144. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  145. package/src/serve/hub-app/reach.ts +36 -0
  146. package/src/serve/hub-app/readme-svc.ts +3 -2
  147. package/src/serve/hub-app/repo-sections.ts +117 -0
  148. package/src/serve/hub-app/repo-st.ts +46 -9
  149. package/src/serve/hub-app/sidebar.tsx +12 -37
  150. package/src/serve/hub-app/timeline-svc.ts +3 -2
  151. package/src/serve/hub-app/tree-build.ts +149 -155
  152. package/src/serve/hub-app/tree-row.tsx +39 -9
  153. package/src/serve/hub-app/tree-svc.ts +70 -49
  154. package/src/serve/hub-app/when-view.tsx +89 -0
  155. package/src/serve/hub-app/when.css +17 -0
  156. package/src/serve/hub-runtime.ts +3 -16
  157. package/src/serve/run-serve.ts +1 -1
  158. package/src/serve/serve-hub.ts +4 -14
  159. package/src/serve/hub-app/processes-svc.ts +0 -63
  160. package/src/serve/hub-app/repo-sections.tsx +0 -61
  161. package/src/serve/repo-processes.ts +0 -89
@@ -14,6 +14,7 @@ import { processGroups, type ProcessGroup } from '../../home/process-rows.ts'
14
14
  import { ClockSvc } from './clock-svc.ts'
15
15
  import type { HomeSt } from './home-st.ts'
16
16
  import type { HubDot } from './projects-svc.ts'
17
+ import { ownTrouble, reach, Unreachable } from './reach.ts'
17
18
 
18
19
  export const MACHINE_POLL_MS = 1_500
19
20
  /** past the web's own wait on the daemon (serve-web.ts MACHINE_ANSWER_MS), so
@@ -38,13 +39,9 @@ function loadKept(): Kept | null {
38
39
  }
39
40
  }
40
41
 
41
- /** the poll failed because nothing behind the page answers, not on an error of an answer */
42
- /** the web (no answer at all) or the daemon behind it (the web says so) */
43
- class DownError extends Error {
44
- constructor(msg: string, readonly who: 'web' | 'daemon') {
45
- super(msg)
46
- }
47
- }
42
+ /** the web not answering, in the words of the daemon's (serve-web.ts daemonDownText) */
43
+ export const webDownText = (why: string): string =>
44
+ `the shipkit web does not answer on ${globalThis.location?.origin ?? 'this page\'s origin'} (${why}). \`shipkit pm\` runs it; this page comes back by itself once it answers`
48
45
 
49
46
  export type MachineOpts = { reload?: () => void; timeoutMs?: number }
50
47
 
@@ -149,7 +146,7 @@ export class MachineSt {
149
146
  /** pm is polled beside the daemon: either one down leaves the other shown */
150
147
  private async loadPm(): Promise<void> {
151
148
  try {
152
- const r = await fetch('/api/pm/status.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
149
+ const r = await reach('/api/pm/status.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
153
150
  if (!r.ok) throw new Error(await r.text())
154
151
  const j = (await r.json()) as PmAnswer
155
152
  runInAction(() => {
@@ -180,19 +177,15 @@ export class MachineSt {
180
177
  if (this.polling) return
181
178
  this.polling = true
182
179
  const pm = this.loadPm()
183
- // never held on: a widget that answers late only shows late
184
- if (!this.widgetsPolling) {
180
+ // never held on: a widget that answers late only shows late. Asked only
181
+ // while the overview shows: the daemon renders a widget for a reader,
182
+ // and a render can cost another machine an ssh session
183
+ if (!this.widgetsPolling && this.st.page === 'overview' && globalThis.document?.visibilityState !== 'hidden') {
185
184
  this.widgetsPolling = true
186
185
  void this.loadWidgets().finally(() => { this.widgetsPolling = false })
187
186
  }
188
187
  try {
189
- let r: Response
190
- try {
191
- r = await fetch('/api/home.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
192
- } catch (e) {
193
- throw new DownError(`the home does not answer: ${(e as Error).message}`, 'web')
194
- }
195
- if (r.headers.get('x-shipkit-daemon') === 'down') throw new DownError(await r.text(), 'daemon')
188
+ const r = await reach('/api/home.json', { signal: AbortSignal.timeout(this.o.timeoutMs ?? MACHINE_TIMEOUT_MS) })
196
189
  if (!r.ok) throw new Error(`/api/home.json answered ${r.status}: ${await r.text()}`)
197
190
  const j = (await r.json()) as HomeAnswer
198
191
  const back = this.wasDown
@@ -213,11 +206,12 @@ export class MachineSt {
213
206
  if (back) this.reload()
214
207
  } catch (e) {
215
208
  const msg = (e as Error).message
216
- if (e instanceof DownError) {
209
+ if (e instanceof Unreachable) {
217
210
  this.wasDown = true
211
+ const who = e.who === 'web' ? 'web' : 'daemon'
218
212
  runInAction(() => {
219
- this.down = msg
220
- this.downWho = e.who
213
+ this.down = who === 'web' ? webDownText(msg) : msg
214
+ this.downWho = who
221
215
  })
222
216
  } else {
223
217
  this.pollError = true
@@ -253,14 +247,16 @@ export class MachineSt {
253
247
 
254
248
  private async post(path: string, body: unknown): Promise<boolean> {
255
249
  try {
256
- const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
250
+ const r = await reach(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
257
251
  if (!r.ok) throw new Error(await r.text())
258
252
  this.pollError = false
259
253
  runInAction(() => { this.error = '' })
260
254
  void this.load()
261
255
  return true
262
256
  } catch (e) {
263
- runInAction(() => { this.error = (e as Error).message })
257
+ // the machine not answering is the down card's, which the next poll raises
258
+ runInAction(() => { this.error = ownTrouble(e, this) })
259
+ void this.load()
264
260
  return false
265
261
  }
266
262
  }
@@ -0,0 +1,51 @@
1
+ // A repo's plan page: what the machine runs here on its own time, soonest
2
+ // first, then the checks only a save runs. The rows are this repo's slice of
3
+ // the machine's plan, already carried by /api/resources.json, so the page
4
+ // reads nothing of its own. The home's planned page is the same plan for
5
+ // every repo at once.
6
+ import { observer } from 'mobx-react-lite'
7
+ import type { PlanRow } from '../../plan/planner.ts'
8
+ import { LAST_TONE, markOf, reasonWords, whenWords } from './planned-st.ts'
9
+ import type { RepoSt } from './repo-st.ts'
10
+ import { ageWords } from './time-ago.tsx'
11
+
12
+ const soonest = (a: PlanRow, b: PlanRow): number => (a.dueAt ?? Infinity) - (b.dueAt ?? Infinity) || a.check.localeCompare(b.check)
13
+
14
+ const PlanLine = observer(({ app, r }: { app: RepoSt; r: PlanRow }) => {
15
+ const now = app.clock.now
16
+ const m = markOf(r)
17
+ const running = r.state === 'running'
18
+ return (
19
+ <tr className={`pln m-${m.mark}`}>
20
+ <td className="plnwhen">{whenWords(r, now)}</td>
21
+ <td><a href="#" title={r.title ?? r.check} onClick={(e) => { e.preventDefault(); app.timeline.openCheck(r.check) }}>{r.check}</a>{r.title != null ? <span className="dim"> {r.title}</span> : null}</td>
22
+ <td className="dim" title={`rule: ${r.rule}\ninactive: ${r.inactive}`}>{r.error != null ? <span className="tl red">{r.error}</span> : reasonWords(r)}</td>
23
+ <td>{r.last == null ? <span className="dim">never ran</span> : <span className={`tl ${LAST_TONE[r.last.status]}`}>{r.last.status}, {ageWords(r.last.at, now)}</span>}</td>
24
+ <td className="plnrun"><button className="ghost" disabled={running} onClick={() => void app.home.planned.run(r)}>{running ? <><span className="spin" />running</> : 'run now'}</button></td>
25
+ </tr>
26
+ )
27
+ })
28
+
29
+ const PlanTable = observer(({ app, rows }: { app: RepoSt; rows: PlanRow[] }) => (
30
+ <table className="plntable">
31
+ <tbody>{rows.map((r) => <PlanLine key={r.check} app={app} r={r} />)}</tbody>
32
+ </table>
33
+ ))
34
+
35
+ export const PlanPage = observer(({ app }: { app: RepoSt }) => {
36
+ const all = [...(app.cols.planned ?? [])].sort(soonest)
37
+ const timed = all.filter((r) => r.dueAt != null)
38
+ const onSave = all.filter((r) => r.dueAt == null)
39
+ return (
40
+ <div className="colcard checkcard">
41
+ <div className="chead">
42
+ <span className="tid">plan</span>
43
+ <h3 className="ctitle">{app.name}</h3>
44
+ </div>
45
+ <div className="usays">what the machine runs here on its own time, soonest first. Every repo at once: the planned page (⌘')</div>
46
+ {app.cols.planned == null ? <div className="dim">no plan covers this repo</div> : null}
47
+ {timed.length > 0 ? <section className="csec"><div className="cstitle">next</div><PlanTable app={app} rows={timed} /></section> : null}
48
+ {onSave.length > 0 ? <section className="csec"><div className="cstitle">on a save only</div><PlanTable app={app} rows={onSave} /></section> : null}
49
+ </div>
50
+ )
51
+ })
@@ -8,6 +8,7 @@ import type { PlannedAnswer } from '../../home/home-routes.ts'
8
8
  import type { PlanRow } from '../../plan/planner.ts'
9
9
  import { sectionOf, timeline, titleWords, waitingRows, waitingWords } from '../../plan/plan-timeline.ts'
10
10
  import type { HomeSt } from './home-st.ts'
11
+ import { ownTrouble, reach } from './reach.ts'
11
12
 
12
13
  export const PLANNED_POLL_MS = 1_000
13
14
 
@@ -78,18 +79,22 @@ export class PlannedSt {
78
79
 
79
80
  async poll(): Promise<void> {
80
81
  try {
81
- const r = await fetch('/api/home/planned.json', { signal: AbortSignal.timeout(8_000) })
82
+ const r = await reach('/api/home/planned.json', { signal: AbortSignal.timeout(8_000) })
82
83
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
83
84
  const j = (await r.json()) as PlannedAnswer
84
85
  runInAction(() => { this.data = j; this.error = '' })
85
86
  } catch (e) {
86
- runInAction(() => { this.error = (e as Error).message })
87
+ runInAction(() => { this.error = ownTrouble(e, this.st.machine) })
87
88
  }
88
89
  }
89
90
 
90
91
  async run(row: PlanRow): Promise<void> {
91
- const r = await fetch('/api/home/planned/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo: row.repo, check: row.check }) })
92
- if (!r.ok) runInAction(() => { this.error = `run ${row.check}: ${r.status} ${r.statusText}` })
92
+ try {
93
+ const r = await reach('/api/home/planned/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo: row.repo, check: row.check }) })
94
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
95
+ } catch (e) {
96
+ runInAction(() => { this.error = ownTrouble(e, this.st.machine, (m) => `run ${row.check}: ${m}`) })
97
+ }
93
98
  await this.poll()
94
99
  }
95
100
 
@@ -0,0 +1,38 @@
1
+ // The processes page of a repo: one card per global process it declares,
2
+ // each the process menu's two panels (actions, then its description as
3
+ // markdown) and its log under them. pm runs them, so the page reads the
4
+ // machine, never the hub. The card a tree row opened is scrolled to and lit.
5
+ import { observer } from 'mobx-react-lite'
6
+ import { useEffect, useRef } from 'react'
7
+ import { Log, ProcessMenu } from './home-strip.tsx'
8
+ import type { RepoSt } from './repo-st.ts'
9
+ import type { ProcessRow } from '../../home/process-rows.ts'
10
+
11
+ const noop = (): void => {}
12
+
13
+ const ProcessCard = observer(({ app, r, lit }: { app: RepoSt; r: ProcessRow; lit: boolean }) => {
14
+ const el = useRef<HTMLElement>(null)
15
+ useEffect(() => { if (lit) el.current?.scrollIntoView({ block: 'nearest' }) }, [lit])
16
+ const m = app.home.machine
17
+ if (m == null) return null
18
+ return (
19
+ <section ref={el} className={`csec pcard${lit ? ' lit' : ''}`} id={`process-${r.id}`}>
20
+ <ProcessMenu m={m} r={r} close={noop} lastLog={r.tail.length === 0} />
21
+ {r.tail.length === 0 ? null : <><div className="cstitle">log</div><Log lines={r.tail} /></>}
22
+ </section>
23
+ )
24
+ })
25
+
26
+ export const ProcessesPage = observer(({ app, id }: { app: RepoSt; id: string | null }) => {
27
+ const rows = app.globalProcesses
28
+ return (
29
+ <div className="colcard checkcard">
30
+ <div className="chead">
31
+ <span className="tid">processes</span>
32
+ <h3 className="ctitle">{app.name}</h3>
33
+ </div>
34
+ <div className="usays">the processes this repo declares in its <code>.shipkit.ts</code> files. pm runs them, so they run whether or not the hub answers</div>
35
+ {rows.length === 0 ? <div className="dim">this repo declares no global process</div> : rows.map((r) => <ProcessCard key={r.id} app={app} r={r} lit={r.id === id} />)}
36
+ </div>
37
+ )
38
+ })
@@ -5,6 +5,11 @@
5
5
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
6
  import type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
7
7
  import { identityOfName, type Identity } from '../../core/identity-color.ts'
8
+ import { ownTrouble, reach } from './reach.ts'
9
+
10
+ /** the list never says the page's server is gone, even where no machine
11
+ * card is (a hub on its own): that hub's trouble card says it */
12
+ const SAID_ELSEWHERE = true
8
13
 
9
14
  export type { ArchivedRow, ProjectRow as Project, Release } from '../project-gutter-route.ts'
10
15
 
@@ -250,12 +255,12 @@ export class ProjectsSvc {
250
255
  * loads its hub on its next tick */
251
256
  async setArchived(name: string, on: boolean): Promise<void> {
252
257
  try {
253
- const r = await fetch('/api/projects/archived', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, archived: on }) })
258
+ const r = await reach('/api/projects/archived', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, archived: on }) })
254
259
  if (!r.ok) throw new Error(await r.text())
255
260
  runInAction(() => { this.error = '' })
256
261
  await this.load()
257
262
  } catch (e) {
258
- runInAction(() => { this.error = `${on ? 'archive' : 'unarchive'} ${name}: ${(e as Error).message}` })
263
+ runInAction(() => { this.error = ownTrouble(e, SAID_ELSEWHERE, (m) => `${on ? 'archive' : 'unarchive'} ${name}: ${m}`) })
259
264
  }
260
265
  }
261
266
 
@@ -302,7 +307,7 @@ export class ProjectsSvc {
302
307
 
303
308
  private async saveIdentity(name: string, id: Identity, h: ReturnType<typeof setTimeout>): Promise<void> {
304
309
  try {
305
- const r = await fetch('/api/projects/identity', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, ...id }) })
310
+ const r = await reach('/api/projects/identity', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, ...id }) })
306
311
  if (!r.ok) throw new Error(await r.text())
307
312
  const j = (await r.json()) as { identity: Identity }
308
313
  runInAction(() => {
@@ -310,7 +315,7 @@ export class ProjectsSvc {
310
315
  this.error = ''
311
316
  })
312
317
  } catch (e) {
313
- runInAction(() => { this.error = `colours of ${name}: ${(e as Error).message}` })
318
+ runInAction(() => { this.error = ownTrouble(e, SAID_ELSEWHERE, (m) => `colours of ${name}: ${m}`) })
314
319
  } finally {
315
320
  // a later pick rests on its own timer, which keeps the guard
316
321
  if (this.identitySaves.get(name) === h) this.identitySaves.delete(name)
@@ -390,11 +395,11 @@ export class ProjectsSvc {
390
395
  const by = new Map(this.list.map((p) => [p.name, p]))
391
396
  this.list = names.map((n) => by.get(n)).filter((p): p is Project => p != null)
392
397
  try {
393
- const r = await fetch('/api/projects/order', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names }) })
398
+ const r = await reach('/api/projects/order', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names }) })
394
399
  if (!r.ok) throw new Error(await r.text())
395
400
  runInAction(() => { this.error = '' })
396
401
  } catch (e) {
397
- runInAction(() => { this.error = `reorder: ${(e as Error).message}` })
402
+ runInAction(() => { this.error = ownTrouble(e, SAID_ELSEWHERE, (m) => `reorder: ${m}`) })
398
403
  }
399
404
  }
400
405
 
@@ -427,7 +432,7 @@ export class ProjectsSvc {
427
432
  async load(): Promise<void> {
428
433
  const order = this.orderGen
429
434
  try {
430
- const r = await fetch('/api/projects.json')
435
+ const r = await reach('/api/projects.json')
431
436
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
432
437
  const j = (await r.json()) as { projects: Project[]; archived?: ArchivedRow[] }
433
438
  runInAction(() => {
@@ -440,17 +445,17 @@ export class ProjectsSvc {
440
445
  this.fresh = true
441
446
  })
442
447
  } catch (e) {
443
- runInAction(() => { this.error = `projects: ${(e as Error).message}` })
448
+ runInAction(() => { this.error = ownTrouble(e, SAID_ELSEWHERE, (m) => `projects: ${m}`) })
444
449
  }
445
450
  }
446
451
 
447
452
  async open(name: string): Promise<void> {
448
453
  try {
449
- const r = await fetch('/api/projects/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
454
+ const r = await reach('/api/projects/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
450
455
  if (!r.ok) throw new Error(await r.text())
451
456
  runInAction(() => { this.error = '' })
452
457
  } catch (e) {
453
- runInAction(() => { this.error = `open ${name}: ${(e as Error).message}` })
458
+ runInAction(() => { this.error = ownTrouble(e, SAID_ELSEWHERE, (m) => `open ${name}: ${m}`) })
454
459
  }
455
460
  }
456
461
  }
@@ -6,6 +6,7 @@
6
6
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
7
  import type { ProseGroup, ProseView } from '../../prose/prose-view.ts'
8
8
  import type { RepoSt } from './repo-st.ts'
9
+ import { ownTrouble, reach } from './reach.ts'
9
10
 
10
11
  export type TierFilter = 'all' | 'blocking' | 'soft'
11
12
  export type ListKind = 'allow' | 'ban'
@@ -37,12 +38,12 @@ export class ProseHitsSt {
37
38
  async load(): Promise<void> {
38
39
  runInAction(() => { this.loading = true })
39
40
  try {
40
- const r = await fetch(this.st.url('/api/prose.json'))
41
+ const r = await reach(this.st.url('/api/prose.json'))
41
42
  if (!r.ok) throw new Error(await r.text())
42
43
  const v = (await r.json()) as ProseView
43
44
  runInAction(() => { this.view = v; this.error = '' })
44
45
  } catch (e) {
45
- runInAction(() => { this.error = `the hits could not be read: ${(e as Error).message}` })
46
+ runInAction(() => { this.error = ownTrouble(e, this.st.home.machine, (m) => `the hits could not be read: ${m}`) })
46
47
  }
47
48
  runInAction(() => { this.loading = false })
48
49
  }
@@ -0,0 +1,36 @@
1
+ // the one reading of "nothing behind the page answers": the web itself (a
2
+ // fetch that never gets an answer), or the daemon or pm behind it (the web's
3
+ // tagged 502, serve-web.ts). On the home that is one fact about the machine,
4
+ // said once, by the card under the strip (MachineSt). Every other surface
5
+ // that fails for it stays silent and keeps what it last read.
6
+
7
+ export type Who = 'web' | 'daemon' | 'pm'
8
+
9
+ export class Unreachable extends Error {
10
+ constructor(msg: string, readonly who: Who) {
11
+ super(msg)
12
+ }
13
+ }
14
+
15
+ /** fetch, with the machine not answering thrown as Unreachable. Any other
16
+ * answer comes back as it is, an error status included */
17
+ export async function reach(input: string, init?: RequestInit): Promise<Response> {
18
+ let r: Response
19
+ try {
20
+ r = await fetch(input, init)
21
+ } catch (e) {
22
+ throw new Unreachable((e as Error).message, 'web')
23
+ }
24
+ if (r.headers.get('x-shipkit-daemon') === 'down') throw new Unreachable(await r.text(), 'daemon')
25
+ if (r.headers.get('x-shipkit-pm') === 'down') throw new Unreachable(await r.text(), 'pm')
26
+ return r
27
+ }
28
+
29
+ /** the words a surface shows for its failed request, or '' when the failure
30
+ * is the machine's: the machine card says that. `machine` is whether the
31
+ * page has one: a hub serving on its own has none, so a server it cannot
32
+ * reach is its own trouble there */
33
+ export function ownTrouble(e: unknown, machine: unknown, words: (msg: string) => string = (m) => m): string {
34
+ if (e instanceof Unreachable && (e.who !== 'web' || machine != null)) return ''
35
+ return words((e as Error).message)
36
+ }
@@ -2,6 +2,7 @@
2
2
  // empty page shows, so an edited README appears without a reload
3
3
  import { makeAutoObservable, runInAction } from 'mobx'
4
4
  import type { RepoSt } from './repo-st.ts'
5
+ import { ownTrouble, reach } from './reach.ts'
5
6
 
6
7
  export class ReadmeSvc {
7
8
  /** the file name at the root, null when the repo has none */
@@ -19,7 +20,7 @@ export class ReadmeSvc {
19
20
 
20
21
  async load(): Promise<void> {
21
22
  try {
22
- const r = await fetch(this.st.url('/api/readme.json'))
23
+ const r = await reach(this.st.url('/api/readme.json'))
23
24
  if (!r.ok) throw new Error(`http ${r.status}: ${await r.text()}`)
24
25
  const j = (await r.json()) as { file: string | null; md: string | null }
25
26
  runInAction(() => {
@@ -30,7 +31,7 @@ export class ReadmeSvc {
30
31
  })
31
32
  } catch (e) {
32
33
  runInAction(() => {
33
- this.error = `README fetch broke: ${(e as Error).message}`
34
+ this.error = ownTrouble(e, this.st.home.machine, (m) => `README fetch broke: ${m}`) || null
34
35
  this.loaded = true
35
36
  })
36
37
  }
@@ -0,0 +1,117 @@
1
+ // A repo page's sections: the pages reached for most, the top rows of the
2
+ // left pane's tree (tree-row.tsx draws them the way the project list draws its
3
+ // lines: an icon, a label, a count where it says something, its ⌥ shortcut at
4
+ // the end, keys.ts, the current one lit). A page this repo does not have is
5
+ // not a row. The counts read the severity filter, like everything under them,
6
+ // and come from what the page already holds (the kept screen on a reload), so
7
+ // none arrives late and moves the tree.
8
+ import { RUNS_ID } from '../../collections/collection-types.ts'
9
+ import type { CheckOwner, HashSel } from './hash-route.ts'
10
+ import { sectionLabel, type SectionId } from './keys.ts'
11
+ import type { RepoSt } from './repo-st.ts'
12
+ import { passesFilter } from './tree-build.ts'
13
+ import { openWikis, WIKIS } from './wikis-view.tsx'
14
+
15
+ export type RepoSection = {
16
+ id: SectionId
17
+ label: string
18
+ title: string
19
+ /** null when a number would say nothing */
20
+ count: number | null
21
+ tone: 'red' | 'warn' | null
22
+ on: boolean
23
+ go: () => void
24
+ }
25
+
26
+ const counted = (n: number, tone: 'red' | 'warn'): Pick<RepoSection, 'count' | 'tone'> => (n === 0 ? { count: null, tone: null } : { count: n, tone })
27
+
28
+ /** the section a page belongs to: a collection, one of its items, its own
29
+ * page. null for a page no section owns (the table of every check). A check
30
+ * lives under whose it is, which only the resource knows: `ownerOf` reads it */
31
+ export function sectionOfSel(sel: HashSel | null, ownerOf: (checkItemId: string) => CheckOwner | null = () => null): SectionId | null {
32
+ if (sel == null) return 'readme'
33
+ switch (sel.t) {
34
+ case 'readme': return 'readme'
35
+ case 'processes': return 'processes'
36
+ case 'plan': return 'plan'
37
+ case 'git': return 'git'
38
+ case 'when': return 'when'
39
+ case 'checks': return sel.from
40
+ case 'admin': return 'admin'
41
+ case 'tests': return 'tests'
42
+ case 'asset': case 'cat': return 'media'
43
+ case 'status': return null
44
+ case 'col': case 'item': {
45
+ const col = sel.t === 'col' ? sel.id : sel.col
46
+ if (col === RUNS_ID) return 'runs'
47
+ if (col === WIKIS) return 'wikis'
48
+ if (col === 'contracts') return 'contracts'
49
+ if (col === 'creds') return 'secrets'
50
+ if (col === 'checks') return sel.t === 'item' ? ownerOf(sel.id) : null
51
+ return col === 'asks' || col === 'tests' || col === 'reflections' ? col : null
52
+ }
53
+ }
54
+ }
55
+
56
+ /** the section a repo's current page belongs to, a check read off its
57
+ * resource for whose it is */
58
+ export const sectionOfRepo = (app: RepoSt): SectionId | null =>
59
+ sectionOfSel(app.sel, (id) => app.cols.find('checks', id)?.check?.from ?? null)
60
+
61
+ /** open a section's own page, through the calls a click makes */
62
+ export function openSection(app: RepoSt, id: SectionId): void {
63
+ switch (id) {
64
+ case 'readme': return app.showReadme()
65
+ case 'contracts': return app.showCol('contracts')
66
+ case 'shipkit': case 'repo': return app.showChecks(id)
67
+ case 'secrets': return app.showCol('creds')
68
+ case 'runs': return app.cols.select('runs', RUNS_ID)
69
+ case 'processes': return app.showProcesses(null)
70
+ case 'plan': return app.showPlan()
71
+ case 'git': return app.showGit()
72
+ case 'when': return app.showWhen()
73
+ case 'reflections': return app.showCol('reflections')
74
+ case 'asks': return app.showCol('asks')
75
+ case 'tests': return app.showTests(null)
76
+ case 'media': {
77
+ const cat = app.media.assets[0]?.cat ?? app.cols.cachedCat ?? undefined
78
+ if (cat != null) app.showCat(cat)
79
+ return
80
+ }
81
+ case 'wikis': return openWikis(app)
82
+ case 'admin': return app.showAdmin()
83
+ }
84
+ }
85
+
86
+ export function repoSections(app: RepoSt): RepoSection[] {
87
+ const has = (col: string): boolean => app.cols.colMeta.some((c) => c.id === col)
88
+ const cur = sectionOfRepo(app)
89
+ const res = app.cols.resources.filter((r) => passesFilter(r, app.filter))
90
+ const reds = (col: string): number => res.filter((r) => r.collection === col && r.state === 'red').length
91
+ 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) })
93
+ const out: RepoSection[] = [line('readme', 'the README, what the repo is', none)]
94
+ 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))
100
+ // a page, never a resource: every hub keeps a run record
101
+ out.push(line('runs', 'every run on record, the slowest checks and the longest runs', none))
102
+ // the plan and git come from the machine, so a hub of its own has neither
103
+ if (app.home.machine != null) out.push(line('plan', 'what the machine runs here on its own time, and when', none))
104
+ 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')))
106
+ 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))
115
+ if (app.home.machine != null) out.push(line('admin', 'reload the hub, keep the repo active, archive it', none))
116
+ return out
117
+ }
@@ -8,7 +8,7 @@
8
8
  import { makeAutoObservable } from 'mobx'
9
9
  import { ClockSvc } from './clock-svc.ts'
10
10
  import { ColsSvc } from './cols-svc.ts'
11
- import { canonicalSel, currentHash, hashFor, parseHash, selOf, type HashSel } from './hash-route.ts'
11
+ import { canonicalSel, currentHash, hashFor, parseHash, selOf, type CheckOwner, type HashSel } from './hash-route.ts'
12
12
  import { ComfySvc } from './comfy-svc.ts'
13
13
  import { HealthSvc } from './health-svc.ts'
14
14
  import { ReadmeSvc } from './readme-svc.ts'
@@ -17,10 +17,11 @@ import { MediaSvc } from './media-svc.ts'
17
17
  import { TimelineSvc } from './timeline-svc.ts'
18
18
  import { TreeSvc } from './tree-svc.ts'
19
19
  import { ChecksSvc } from './checks-svc.ts'
20
- import { ProcessesSvc } from './processes-svc.ts'
20
+ import { GitSt } from './git-st.ts'
21
+ import type { ProcessRow } from '../../home/process-rows.ts'
21
22
  import { TestsSvc } from './tests-svc.ts'
22
23
  import { AdminSvc } from './admin-svc.ts'
23
- import { repoSections } from './repo-sections.tsx'
24
+ import { repoSections } from './repo-sections.ts'
24
25
  import { sectionKeyLabel } from './keys.ts'
25
26
  import type { Resource } from './hub-types.ts'
26
27
  import type { HomeSt } from './home-st.ts'
@@ -59,7 +60,8 @@ export class RepoSt {
59
60
  timeline: TimelineSvc
60
61
  /** the runs in flight, whoever started them (live-svc.ts) */
61
62
  live: LiveSvc
62
- processes: ProcessesSvc
63
+ /** the git page's one repo read (git-st.ts) */
64
+ git: GitSt
63
65
  /** the pages behind the test groups of the tree (tests-svc.ts) */
64
66
  tests: TestsSvc
65
67
  /** the admin page: archive behind its confirmation, reload, keep active */
@@ -98,10 +100,10 @@ export class RepoSt {
98
100
  this.comfy = new ComfySvc(this)
99
101
  this.timeline = new TimelineSvc(this)
100
102
  this.live = new LiveSvc(this)
101
- this.processes = new ProcessesSvc(this)
103
+ this.git = new GitSt(this)
102
104
  this.tests = new TestsSvc(this)
103
105
  this.admin = new AdminSvc(this)
104
- makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, processes: false, tests: false, admin: false, searchEl: false, beat: false })
106
+ makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, git: false, tests: false, admin: false, searchEl: false, beat: false })
105
107
  }
106
108
 
107
109
  /** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
@@ -150,7 +152,6 @@ export class RepoSt {
150
152
  const steps: (() => Promise<unknown>)[] = [
151
153
  () => this.cols.poll(),
152
154
  () => this.readOnce(),
153
- () => this.processes.load(),
154
155
  () => this.media.heartbeat(),
155
156
  () => this.live.poll(),
156
157
  () => this.timeline.load(),
@@ -171,7 +172,6 @@ export class RepoSt {
171
172
  this.applyHash()
172
173
  }
173
174
  this.clock.start()
174
- this.processes.start()
175
175
  this.media.start()
176
176
  this.cols.start()
177
177
  this.live.start()
@@ -182,7 +182,6 @@ export class RepoSt {
182
182
  if (!this.polling) return
183
183
  this.polling = false
184
184
  this.clock.stop()
185
- this.processes.stop()
186
185
  this.media.stop()
187
186
  this.cols.stop()
188
187
  this.live.stop()
@@ -276,6 +275,7 @@ export class RepoSt {
276
275
  * its own item card. A gate check has a page of its own now, so nothing
277
276
  * lands on the raw wall of text unless it is asked for by name */
278
277
  openResource(r: Resource): void {
278
+ if (r.type === 'process') return this.showProcesses(r.id.replace(/^process:/, ''))
279
279
  const assetId = r.id.replace(/^media:/, '')
280
280
  if (r.collection === 'media' && this.media.assets.some((a) => a.id === assetId)) {
281
281
  this.selectAsset(assetId)
@@ -290,6 +290,37 @@ export class RepoSt {
290
290
  this.setSel({ t: 'admin' })
291
291
  }
292
292
 
293
+ /** the repo's README, the page a repo opens on */
294
+ showReadme(): void {
295
+ this.setSel({ t: 'readme' })
296
+ }
297
+
298
+ /** the global processes this repo declares, as pm runs them. None on a
299
+ * hub of its own, since only the home talks to pm */
300
+ get globalProcesses(): ProcessRow[] {
301
+ return this.home.machine?.processGroups.find((g) => g.core !== true && g.repo === this.key)?.rows ?? []
302
+ }
303
+ /** what the machine plans to run here */
304
+ showPlan(): void {
305
+ this.setSel({ t: 'plan' })
306
+ }
307
+ /** what runs when: every check on every context */
308
+ showWhen(): void {
309
+ this.setSel({ t: 'when' })
310
+ }
311
+ /** the checks one owner ships, as the checks table */
312
+ showChecks(from: CheckOwner): void {
313
+ this.setSel({ t: 'checks', from })
314
+ }
315
+ /** the repo's git */
316
+ showGit(): void {
317
+ this.setSel({ t: 'git' })
318
+ }
319
+ /** the processes page, `id` the card to scroll to and light */
320
+ showProcesses(id: string | null): void {
321
+ this.setSel({ t: 'processes', id })
322
+ }
323
+
293
324
  /** collection view — one card per instance */
294
325
  showCol(id: string): void {
295
326
  this.setSel({ t: 'col', id })
@@ -304,6 +335,12 @@ export class RepoSt {
304
335
  case 'status': return this.showStatus()
305
336
  case 'tests': return this.showTests(sel.check)
306
337
  case 'admin': return this.showAdmin()
338
+ case 'readme': return this.showReadme()
339
+ case 'processes': return this.showProcesses(sel.id)
340
+ case 'plan': return this.showPlan()
341
+ case 'git': return this.showGit()
342
+ case 'when': return this.showWhen()
343
+ case 'checks': return this.showChecks(sel.from)
307
344
  }
308
345
  }
309
346
  /** the url decides the page: a reload, back, forward and a pasted link