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
@@ -1,45 +1,42 @@
1
1
  // ONE GATE CHECK, on its own page, as ONE compact block: the id as a tag, the
2
2
  // title, the sentence it wrote about itself, the exact command it runs, then
3
3
  // one grid of facts, each a dim label and a short value whose explanation is
4
- // the row's `(?)`. No box holds a single field. Navigation is the tree on the
4
+ // the row's `(?)`, then where it runs: one line per context. No box holds a single field. Navigation is the tree on the
5
5
  // left, so there is no back link.
6
6
  import { observer } from 'mobx-react-lite'
7
7
  import { useEffect, useRef } from 'react'
8
8
  import { CACHED } from '../../core/outcome-words.ts'
9
+ import { cellMeaning, MATRIX_AXES, RUNS_LEGEND } from '../../core/runs-matrix.ts'
9
10
  import { durationTone, fmtMs } from './dur.ts'
10
11
  import { Help, TERMS } from './help.tsx'
11
12
  import type { RepoSt } from './repo-st.ts'
12
- import type { SlowSuite, CheckFacts, RecentRun, WhenAt } from './hub-types.ts'
13
+ import type { SlowSuite, CheckFacts, RecentRun } from './hub-types.ts'
13
14
  import { renderMd, renderMdInline } from './md.ts'
14
15
  import { nextRun } from './next-run.ts'
15
16
  import { ProseHits } from './prose-hits-view.tsx'
16
17
  import { stampWords, TimeAgo, TimeAhead } from './time-ago.tsx'
17
- import { checkName, commandOf, docPath, editorHref, lastRun, rememberedFact, repoPath, whatFacts, WHEN_OPTION, type ShortPath } from './check-words.ts'
18
+ import { checkName, commandOf, docPath, editorHref, lastRun, rememberedFact, repoPath, whatFacts, type ShortPath } from './check-words.ts'
18
19
 
19
20
  /** which declaration is answering, so the switch never looks like it wrote
20
21
  * something it did not. The CHIP is the short one, its help is the sentence */
21
22
  const SOURCE_CHIP: Record<CheckFacts['whenSource'], string> = {
22
- when: 'repo.config.ts',
23
+ off: 'repo.config.ts',
24
+ when: 'when (retired)',
25
+ ruleExempt: 'ruleExempt (retired)',
23
26
  'check-file': 'the check file',
24
- ruleExempt: 'ruleExempt',
25
- optIn: 'optIn',
26
- 'gate-export': 'gate = false',
27
27
  defaults: 'defaults',
28
- default: 'default',
28
+ default: 'derived',
29
29
  }
30
30
 
31
31
  const SOURCE_WORD: Record<CheckFacts['whenSource'], string> = {
32
- when: 'set in the `when` block of `repo.config.ts`, which this switch writes',
33
- 'check-file': 'declared by the check file itself, and the `when` block overrides it',
34
- ruleExempt: 'from the older `ruleExempt`, which reads as off',
35
- optIn: 'from the older `optIn` key, which reads as health',
36
- 'gate-export': 'from `export const gate = false` in its own file',
32
+ off: 'turned off in the `off` block of `repo.config.ts`, which this switch writes',
33
+ when: 'turned off by the retired `when` key, which `shipkit ci` ports to `off` (SK029)',
34
+ ruleExempt: 'turned off by the retired `ruleExempt` key, which `shipkit ci` ports to `off` (SK029)',
35
+ 'check-file': 'declared by the check file itself: every context it runs in, no default',
37
36
  defaults: 'from the `defaults` block, which removes the floor slot entirely',
38
- default: 'nobody declared where this check runs, so it takes the default',
37
+ default: 'a row with no file of its own: the code that derives it writes every context',
39
38
  }
40
39
 
41
- const WHEN_VALUES: WhenAt[] = ['always', 'release', 'health', 'off']
42
-
43
40
  const VERDICT_WORD: Record<'pass' | 'warn' | 'fail' | 'skip', string> = {
44
41
  pass: 'passed', warn: 'warned', fail: 'failed', skip: 'skipped',
45
42
  }
@@ -126,44 +123,67 @@ export const CommandLine = ({ app, u }: { app: RepoSt; u: CheckFacts }) => {
126
123
  )
127
124
  }
128
125
 
129
- // ── the switch ───────────────────────────────────────────────────────────
126
+ // ── where it runs ────────────────────────────────────────────────────────
127
+
128
+ /** every context, one line each: the value the check declared and what it
129
+ * means in the legend's words. The cache closes it, the off switch ends it */
130
+ const WhereItRuns = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
131
+ const r = u.runs
132
+ return (
133
+ <Section title="where it runs" help="every context a check declares, with no default. The when page of this repo shows every check at once, and `sk when` prints the same">
134
+ {r.off != null
135
+ ? <div className="wline">runs nowhere: {r.off}</div>
136
+ : <div className="cruns">
137
+ {MATRIX_AXES.map((k) => {
138
+ const c = r.cells[k]
139
+ const a = RUNS_LEGEND.find((x) => x.axis === k)!
140
+ return (
141
+ <div key={k} className={`crun${c.on ? ' on' : ''}`}>
142
+ <span className="ck" title={`${k}: ${a.context.replace(/`/g, '')}. From ${c.source}`}>{a.short}</span>
143
+ <span className="crv">{c.on ? c.text : 'no'}</span>
144
+ <span className="crm">{cellMeaning(k, c.text)}</span>
145
+ </div>
146
+ )
147
+ })}
148
+ <div className="crun cache">
149
+ <span className="ck" title={`cache: how an answer is kept. ${r.cache}`}>cache</span>
150
+ <span className="crv">{r.cache.split(/[\s:]/)[0]}</span>
151
+ <span className="crm">{cellMeaning('cache', r.cache)}</span>
152
+ </div>
153
+ </div>}
154
+ {r.derivedWhy == null ? null : <div className="note">no file declares this row: {r.derivedWhy}</div>}
155
+ <div className="cfacts"><OffSwitch app={app} u={u} /></div>
156
+ </Section>
157
+ )
158
+ })
130
159
 
131
- /** RUN WHEN, one select named by effect. It writes `repo.config.ts` itself,
132
- * through the managed block, and leaves the file dirty: a change of place is
133
- * a change its owner reviews in a git client, never a commit made on their
134
- * behalf. Choosing never asks for the reason before it writes. */
135
- const RunWhen = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
160
+ // ── the off switch ───────────────────────────────────────────────────────
161
+
162
+ /** the one thing this repo says about a check: that it is OFF here, with the
163
+ * reason. It writes `repo.config.ts` through the managed block and leaves the
164
+ * file dirty for its owner to review */
165
+ const OffSwitch = observer(({ app, u }: { app: RepoSt; u: CheckFacts }) => {
136
166
  const s = app.checks
137
167
  const g = s.gate
138
- const shown = s.whenShown(u)
139
168
  const reason = s.reasonFor(u.checkId)
169
+ const off = u.runs.off
140
170
  return (
141
- <Row k="run when" help="where this check runs. Changing it writes repo.config.ts and leaves the file for you to commit">
171
+ <Row k="off in this repo" wide help="where this check runs is its own declaration; the one thing this repo says is that it is OFF here, with why. The switch writes the `off` block of repo.config.ts and leaves the file for you to commit">
142
172
  <span className="cwhen">
143
- <select className="wsel" value={shown} disabled={s.gateBusy} onChange={(e) => s.chooseWhen(u.checkId, e.target.value as WhenAt)}>
144
- {WHEN_VALUES.map((v) => <option key={v} value={v}>{WHEN_OPTION[v]}</option>)}
145
- </select>
146
- <Help text={
147
- <span className="legend">
148
- {WHEN_VALUES.map((v) => <span key={v} className="lrow"><b>{WHEN_OPTION[v]}</b> ({v}): {TERMS[v]}</span>)}
149
- </span>
150
- } />
173
+ {off != null ? <span className="tl red">off: {off}</span> : <span className="dim">runs where it declares</span>}
151
174
  <span className="stchip">{SOURCE_CHIP[u.whenSource]}</span>
152
175
  <MdHelp text={SOURCE_WORD[u.whenSource]} />
153
- {u.whenSource === 'when'
154
- ? <button className="wclear" disabled={s.gateBusy} onClick={() => void s.clearWhen(u.checkId)}>drop the entry</button>
155
- : null}
176
+ <a href="#" onClick={(e) => { e.preventDefault(); app.showWhen() }}>what runs when</a>
156
177
  </span>
157
- {shown === 'off'
158
- ? <span className="cblock">
159
- <input className="wreason" placeholder="why it is off (required, and it rides in the config)"
160
- value={reason} onChange={(e) => s.setReason(u.checkId, e.target.value)} />
161
- {u.when === 'off' && u.whenReason != null ? <span className="note">reason today: {u.whenReason}</span> : null}
162
- <button disabled={s.gateBusy || reason.trim() === ''} onClick={() => void s.setWhen(u.checkId, 'off')}>
163
- {u.when === 'off' ? 'change the reason' : 'turn it off'}
164
- </button>
165
- </span>
166
- : null}
178
+ {off != null && u.whenSource === 'off'
179
+ ? <span className="cblock"><button disabled={s.gateBusy} onClick={() => void s.turnOn(u.checkId)}>turn it back on</button></span>
180
+ : off == null
181
+ ? <span className="cblock">
182
+ <input className="wreason" placeholder="why this repo turns it off (required, it rides in the config)"
183
+ value={reason} onChange={(e) => s.setReason(u.checkId, e.target.value)} />
184
+ <button disabled={s.gateBusy || reason.trim() === ''} onClick={() => void s.setOff(u.checkId)}>turn it off here</button>
185
+ </span>
186
+ : null}
167
187
  {g != null && g.state === 'stale'
168
188
  ? <span className="cblock warnline">the managed block in {g.path} was hand edited: reconcile it, then the switch works again</span>
169
189
  : null}
@@ -321,7 +341,6 @@ export const CheckCard = observer(({ app, id }: { app: RepoSt; id: string }) =>
321
341
  {whatFacts(u).map((f) => (
322
342
  <Row key={f.k} k={f.k} help={f.help}>{f.tone == null ? f.v : <span className={`tl ${f.tone}`}>{f.v}</span>}</Row>
323
343
  ))}
324
- <RunWhen app={app} u={u} />
325
344
  <Remembered u={u} />
326
345
  {/* the help never depends on the second, so it reads the wall
327
346
  clock and the row does not repaint on every tick */}
@@ -338,6 +357,7 @@ export const CheckCard = observer(({ app, id }: { app: RepoSt; id: string }) =>
338
357
  {u.group == null ? null : <Row k="group" help="a label several checks share, so `--skip <group>` parks all of them at once">{u.group}</Row>}
339
358
  <Timings u={u} />
340
359
  </div>
360
+ <WhereItRuns app={app} u={u} />
341
361
  {u.build && u.checkId === 'test' ? <SlowSuites app={app} /> : null}
342
362
  {u.checkId === 'SK014' ? <ProseHits app={app} /> : null}
343
363
  <LastOutput app={app} lines={u.lastLines} />
@@ -49,17 +49,7 @@ export function rememberedFact(u: CachePop): RememberedFact {
49
49
  const cadence = CADENCE[u.when ?? 'always']
50
50
  if (u.never != null)
51
51
  return { v: 'never', inputs: [], help: `never cached, so it runs ${cadence}${u.never === '' ? '' : `: ${u.never}`}` }
52
- return { v: 'never', inputs: [], help: `nothing declared, so it runs ${cadence}. \`inputs\`, \`ttl\` or \`never\` in the check says otherwise` }
53
- }
54
-
55
- /** the four places a check runs, named by what they do. The config keeps its
56
- * own words (`always`, `release`, `health`, `off`), and these are only what
57
- * the select under `run when` shows */
58
- export const WHEN_OPTION: Record<WhenAt, string> = {
59
- always: 'always',
60
- release: 'before a release',
61
- health: 'health pass only',
62
- off: 'never',
52
+ return { v: 'never', inputs: [], help: `nothing keys it, so it runs ${cadence}. A check file keys it with \`cache: { by: 'files' | 'reads' | 'ttl' | 'fact' }\`` }
63
53
  }
64
54
 
65
55
  /** what a check IS: three FACTS, each a short value a reader scans, with the
@@ -10,9 +10,9 @@
10
10
  import { makeAutoObservable, runInAction } from 'mobx'
11
11
  import type { RepoSt } from './repo-st.ts'
12
12
  import { applyRunEvent, idleRun, startRun, stripText, type RunProgress } from './run-strip.ts'
13
- import type { GateBlock, Resource, ResourceState, SlowSuite, CheckEvent, WhenAt } from './hub-types.ts'
14
- import { WHEN_OPTION } from './check-words.ts'
13
+ import type { GateBlock, Resource, ResourceState, SlowSuite, CheckEvent } from './hub-types.ts'
15
14
  import { lineText } from '../../core/wire.ts'
15
+ import { ownTrouble, reach } from './reach.ts'
16
16
 
17
17
  /** the colour rule, the SAME one the server applies, because a row must not
18
18
  * change meaning between a live result and the poll that confirms it */
@@ -55,9 +55,6 @@ export class ChecksSvc {
55
55
  gateBusy = false
56
56
  /** the reason box, per check id: a check is only turned off WITH a reason */
57
57
  reasons = new Map<string, string>()
58
- /** checks whose select says never while the reason is still being typed:
59
- * off is written only once it has one */
60
- pendingOff = new Set<string>()
61
58
  /** the slow suite form under the test check */
62
59
  suite: SlowSuite = { file: '', catches: '', whyNotFast: '' }
63
60
 
@@ -94,12 +91,12 @@ export class ChecksSvc {
94
91
  * silent overwrite */
95
92
  async loadGate(): Promise<void> {
96
93
  try {
97
- const r = await fetch(this.st.url('/api/checks/when'))
94
+ const r = await reach(this.st.url('/api/checks/when'))
98
95
  if (!r.ok) throw new Error(await r.text())
99
96
  const b = (await r.json()) as GateBlock
100
97
  runInAction(() => { this.gate = b })
101
98
  } catch (e) {
102
- runInAction(() => { this.gateNote = `the repo config could not be read: ${(e as Error).message}` })
99
+ runInAction(() => { this.gateNote = ownTrouble(e, this.st.home.machine, (m) => `the repo config could not be read: ${m}`) })
103
100
  }
104
101
  }
105
102
 
@@ -114,7 +111,7 @@ export class ChecksSvc {
114
111
  // the hash of the file the page last read: without it a write
115
112
  // could land on top of an edit this page never saw
116
113
  if (this.gate == null) await this.loadGate()
117
- const r = await fetch(this.st.url('/api/checks/when'), { method: 'POST', body: JSON.stringify({ ...edit, expect: this.gate?.hash }) })
114
+ const r = await reach(this.st.url('/api/checks/when'), { method: 'POST', body: JSON.stringify({ ...edit, expect: this.gate?.hash }) })
118
115
  if (!r.ok) throw new Error(await r.text())
119
116
  const b = (await r.json()) as GateBlock
120
117
  runInAction(() => { this.gate = b; this.gateNote = said })
@@ -123,7 +120,7 @@ export class ChecksSvc {
123
120
  // has read the config it just wrote
124
121
  await this.st.cols.poll(true)
125
122
  } catch (e) {
126
- runInAction(() => { this.gateNote = `refused: ${(e as Error).message}` })
123
+ runInAction(() => { this.gateNote = ownTrouble(e, this.st.home.machine, (m) => `refused: ${m}`) })
127
124
  // whatever the file really says now, so the next try is not
128
125
  // refused for a hash the page has already outgrown
129
126
  await this.loadGate()
@@ -132,37 +129,19 @@ export class ChecksSvc {
132
129
  return took
133
130
  }
134
131
 
135
- /** what the select shows: never while a reason is being typed, else
136
- * where the check runs today */
137
- whenShown(u: { checkId: string; when: WhenAt }): WhenAt {
138
- return this.pendingOff.has(u.checkId) ? 'off' : u.when
139
- }
140
-
141
- /** the select moved. Every place but never writes at once, and never
142
- * waits for its reason */
143
- chooseWhen(checkId: string, at: WhenAt): void {
144
- if (at === 'off') {
145
- this.pendingOff.add(checkId)
146
- this.gateNote = 'never needs a reason: type it, then turn it off'
147
- return
148
- }
149
- this.pendingOff.delete(checkId)
150
- void this.setWhen(checkId, at)
151
- }
152
-
153
- async setWhen(checkId: string, at: WhenAt): Promise<void> {
132
+ /** turn a check off in this repo, with the reason typed in its box */
133
+ async setOff(checkId: string): Promise<void> {
154
134
  const reason = this.reasonFor(checkId).trim()
155
- if (at === 'off' && reason === '') {
156
- runInAction(() => { this.gateNote = 'never needs a reason: a check nobody can explain is a silenced gate' })
135
+ if (reason === '') {
136
+ runInAction(() => { this.gateNote = 'off needs a reason: a check nobody can explain is a silenced gate' })
157
137
  return
158
138
  }
159
- await this.writeGate({ when: { [checkId]: at === 'off' ? `off:${reason}` : at } }, `${checkId} is set to ${WHEN_OPTION[at]} — repo.config.ts is edited and waiting for your commit`)
160
- runInAction(() => { this.pendingOff.delete(checkId) })
139
+ await this.writeGate({ off: { [checkId]: reason } }, `${checkId} is off in this repo — repo.config.ts is edited and waiting for your commit`)
161
140
  }
162
141
 
163
- /** back to whatever the check itself declares, by dropping the entry */
164
- async clearWhen(checkId: string): Promise<void> {
165
- await this.writeGate({ when: { [checkId]: null } }, `${checkId} is back to what it declares itself — repo.config.ts is edited and waiting for your commit`)
142
+ /** turn it back on: it runs where it declares again */
143
+ async turnOn(checkId: string): Promise<void> {
144
+ await this.writeGate({ off: { [checkId]: null } }, `${checkId} runs where it declares again — repo.config.ts is edited and waiting for your commit`)
166
145
  }
167
146
 
168
147
  async addSlow(): Promise<void> {
@@ -3,22 +3,27 @@
3
3
  // lines. What needs attention comes first; the quiet groups (passing, off,
4
4
  // done) fold by default and each viewer's fold choice is kept. The filter
5
5
  // input and the severity keys narrow the lines, since both read
6
- // `visibleInstances`. Groups, order and words: col-tables.ts.
6
+ // `visibleInstances`. The checks page opens on the line that counts where
7
+ // its checks run. Groups, order and words: col-tables.ts.
7
8
  import { observer } from 'mobx-react-lite'
8
9
  import type { ReactNode } from 'react'
9
10
  import { checkName } from './check-words.ts'
10
11
  import { askGroups, checkGroups, dayMs, findingLine, planWord, type TableGroup } from './col-tables.ts'
11
12
  import { durationTone, fmtMs } from './dur.ts'
13
+ import type { CheckOwner } from './hash-route.ts'
12
14
  import type { Resource } from './hub-types.ts'
15
+ import { sectionLabel } from './keys.ts'
13
16
  import type { RepoSt } from './repo-st.ts'
14
17
  import { TimeAgo } from './time-ago.tsx'
15
18
 
16
19
  const emptyWords = (app: RepoSt): string => (app.filter === 'red' ? 'no reds here' : app.filter === 'needs' ? 'nothing here needs you' : app.query.trim() !== '' ? 'nothing matches the filter' : 'nothing here yet')
17
20
 
18
- /** the page frame: the collection's title and count, then the table */
19
- const Page = ({ title, n, cols, empty, children }: { title: string; n: number; cols: ReactNode; empty: string; children: ReactNode }) => (
21
+ /** the page frame: the collection's title and count, a line under it when
22
+ * the page has one, then the table */
23
+ const Page = ({ title, n, lead, cols, empty, children }: { title: string; n: number; lead?: string; cols: ReactNode; empty: string; children: ReactNode }) => (
20
24
  <div className="ctpage">
21
25
  <div className="cghead">{title}<span className="cnt">{n}</span></div>
26
+ {lead == null || lead === '' ? null : <div className="ctlead">{lead}</div>}
22
27
  {n === 0 ? <div className="ph">{empty}</div> : (
23
28
  <table className="actable cttable">
24
29
  <colgroup>{cols}</colgroup>
@@ -73,17 +78,19 @@ const CheckRow = observer(({ app, r }: { app: RepoSt; r: Resource }) => {
73
78
  )
74
79
  })
75
80
 
76
- export const CheckTable = observer(({ app, id }: { app: RepoSt; id: string }) => {
81
+ /** every check, or only the ones one owner ships (`from`) */
82
+ export const CheckTable = observer(({ app, id, from }: { app: RepoSt; id: string; from?: CheckOwner }) => {
77
83
  const col = app.cols.sidebarCols.find((x) => x.id === id)
78
84
  if (col == null) return <div className="stagewrap"><div className="ph">collection vanished — the truth moved on</div></div>
79
- const items = app.cols.visibleInstances(col)
85
+ const items = app.cols.visibleInstances(col).filter((r) => from == null || r.check?.from === from)
80
86
  const cols = (
81
87
  <>
82
88
  <col className="c-dot" /><col className="c-id" /><col className="c-title" /><col className="c-age" /><col className="c-dur" /><col /><col className="c-plan" />
83
89
  </>
84
90
  )
85
91
  return (
86
- <Page title={col.title} n={items.length} cols={cols} empty={emptyWords(app)}>
92
+ // where the checks run, counted over every check whatever the filter
93
+ <Page title={from == null ? col.title : sectionLabel(from)} n={items.length} lead={app.tree.whenLine} cols={cols} empty={emptyWords(app)}>
87
94
  {checkGroups(items).map((g) => <Group key={g.key} app={app} col={col.id} g={g} span={7} row={(r) => <CheckRow key={r.id} app={app} r={r} />} />)}
88
95
  </Page>
89
96
  )
@@ -8,6 +8,7 @@ import type { RepoSt } from './repo-st.ts'
8
8
  import type { Col, ColMeta, HubProblem, Resource } from './hub-types.ts'
9
9
  import type { PlanRow } from '../../plan/planner.ts'
10
10
  import { readScreen, SCREEN_WRITE_MS, writeScreen } from './screen-cache.ts'
11
+ import { ownTrouble, reach } from './reach.ts'
11
12
 
12
13
  /** where a viewer's fold choices on the checks and asks tables are kept,
13
14
  * one per browser and not per repo: `checks:pass` open is a habit */
@@ -132,7 +133,7 @@ export class ColsSvc {
132
133
  * the server keeps warm */
133
134
  async poll(fresh = false): Promise<void> {
134
135
  try {
135
- const r = await fetch(this.st.url(fresh ? '/api/resources.json?fresh=1' : '/api/resources.json'))
136
+ const r = await reach(this.st.url(fresh ? '/api/resources.json?fresh=1' : '/api/resources.json'))
136
137
  if (!r.ok) throw new Error(await r.text())
137
138
  const j = (await r.json()) as Answer
138
139
  runInAction(() => {
@@ -148,7 +149,7 @@ export class ColsSvc {
148
149
  } catch (e) {
149
150
  // a dead server must SAY so, in the header, never leave a tree
150
151
  // that reads like a healthy repo. The last rows stay, dated by it
151
- runInAction(() => { this.fetchErr = `the page cannot reach this hub: ${(e as Error).message}` })
152
+ runInAction(() => { this.fetchErr = ownTrouble(e, this.st.home.machine, (m) => `the page cannot reach this hub: ${m}`) || null })
152
153
  }
153
154
  }
154
155
 
@@ -237,12 +238,12 @@ export class ColsSvc {
237
238
  private async act(path: string, body: Record<string, unknown>): Promise<boolean> {
238
239
  this.actErr = ''
239
240
  try {
240
- const r = await fetch(this.st.url(`/api/act/${path}`), { method: 'POST', body: JSON.stringify(body) })
241
+ const r = await reach(this.st.url(`/api/act/${path}`), { method: 'POST', body: JSON.stringify(body) })
241
242
  if (!r.ok) throw new Error(await r.text())
242
243
  await this.poll(true)
243
244
  return true
244
245
  } catch (e) {
245
- runInAction(() => { this.actErr = (e as Error).message })
246
+ runInAction(() => { this.actErr = ownTrouble(e, this.st.home.machine) })
246
247
  return false
247
248
  }
248
249
  }
@@ -22,6 +22,7 @@ import { serverNow } from './live-svc.ts'
22
22
  import { ROWS, type RowSize } from './timeline-geometry.ts'
23
23
  import { ageWords } from './time-ago.tsx'
24
24
  import type { HomeSt } from './home-st.ts'
25
+ import { ownTrouble, reach } from './reach.ts'
25
26
 
26
27
  export type DockSize = 'line' | 'slim' | 'open'
27
28
 
@@ -296,7 +297,7 @@ export class DockSt {
296
297
  if (this.inFlight) return
297
298
  this.inFlight = true
298
299
  try {
299
- const r = await fetch('/api/house-runs.json')
300
+ const r = await reach('/api/house-runs.json')
300
301
  if (!r.ok) throw new Error(await r.text())
301
302
  const v = (await r.json()) as HouseView
302
303
  runInAction(() => {
@@ -306,7 +307,7 @@ export class DockSt {
306
307
  this.error = ''
307
308
  })
308
309
  } catch (e) {
309
- runInAction(() => { this.error = `the runs could not be read: ${(e as Error).message}` })
310
+ runInAction(() => { this.error = ownTrouble(e, this.home.machine, (m) => `the runs could not be read: ${m}`) })
310
311
  } finally {
311
312
  this.inFlight = false
312
313
  }
@@ -4,6 +4,9 @@
4
4
  // then the status as small pills that fold into +N rather than wrap. The
5
5
  // state is dock-st.ts; the height is the page's panel (hub-app.tsx).
6
6
  import { observer } from 'mobx-react-lite'
7
+ import { useEffect, useState } from 'react'
8
+ import { Tip } from './floating.tsx'
9
+ import { Icon, ICON } from './icons.tsx'
7
10
  import type { HomeSt } from './home-st.ts'
8
11
  import { CycleButton } from './cycle-button.tsx'
9
12
  import { DOCK_KEY } from './keys.ts'
@@ -15,6 +18,38 @@ import type { PlacedGroup } from './timeline-geometry.ts'
15
18
  /** what the dock is, on its size button's hover */
16
19
  export const DOCK_HELP = `every check shipkit runs in every repo, whoever started it: a page, an agent in a terminal, the plan. The line, the pills alone; slim, the last two minutes, one group of lanes per repo; open, the last ten, zoomed by the wheel and panned by a drag, its height dragged at the top edge`
17
20
 
21
+ const COPIED_MS = 1500
22
+
23
+ /** the page's own address, to paste somewhere: the app window has no url bar */
24
+ export const CopyUrl = () => {
25
+ const [href, setHref] = useState(() => globalThis.location?.href ?? '')
26
+ const [said, setSaid] = useState<string | null>(null)
27
+ // the router moves the address with pushState and replaceState, which no
28
+ // event reports: read it when the pointer or the focus arrives
29
+ const sync = () => setHref(location.href)
30
+ useEffect(() => {
31
+ if (said == null) return
32
+ const t = setTimeout(() => setSaid(null), COPIED_MS)
33
+ return () => clearTimeout(t)
34
+ }, [said])
35
+ const click = async () => {
36
+ try {
37
+ if (globalThis.navigator?.clipboard == null) throw new Error('this page has no clipboard')
38
+ await navigator.clipboard.writeText(location.href)
39
+ setSaid('copied')
40
+ } catch (e) {
41
+ setSaid(`could not copy: ${(e as Error).message}`)
42
+ }
43
+ }
44
+ return (
45
+ <Tip className="dcopy" instant placement="top-end" content={<><div>{said ?? 'click to copy this page\'s url'}</div><code>{href}</code></>}>
46
+ <button className={`ghost dcopyb${said === 'copied' ? ' ok' : ''}`} aria-label="copy this page's url" onMouseEnter={sync} onFocus={sync} onClick={() => void click()}>
47
+ <Icon d={ICON.link} />
48
+ </button>
49
+ </Tip>
50
+ )
51
+ }
52
+
18
53
  /** a group's label: the repo's icon and its name in its colour */
19
54
  const GroupLabel = observer(({ app, g }: { app: HomeSt; g: PlacedGroup }) => {
20
55
  const p = app.projects.list.find((x) => x.name === g.key)
@@ -55,6 +90,7 @@ export const Dock = observer(({ app }: { app: HomeSt }) => {
55
90
  <CycleButton options={DOCK_SIZES} value={d.size} onChange={(s) => d.setSize(s)} shortcut={DOCK_KEY} title={DOCK_HELP} />
56
91
  <PillRow items={d.pills.map((p) => ({ key: p.key, title: p.title, node: <PillFace app={app} p={p} /> }))} />
57
92
  {d.error !== '' ? <span className="warnline">{d.error}</span> : null}
93
+ <CopyUrl />
58
94
  </div>
59
95
  {d.size === 'line' ? null : <div className="dbody">
60
96
  {v == null ? null : groups.length === 0
@@ -6,6 +6,12 @@
6
6
  .dhead{display:flex;gap:var(--s2);align-items:center;height:22px;flex:none;padding:0 var(--s3)}
7
7
  /* the status pills take the room left, and fold rather than wrap */
8
8
  .dhead .pchips{flex:1}
9
+ /* the page's url to the clipboard, at the bottom right corner */
10
+ .dcopy{flex:none;display:flex;margin-left:auto}
11
+ .dcopyb{display:flex;align-items:center;padding:0 var(--s0);color:var(--dim)}
12
+ .dcopyb:hover{color:var(--ink)}
13
+ .dcopyb.ok{color:var(--ok)}
14
+ .dcopyb .hico{width:13px;height:13px;color:inherit}
9
15
  .dbody{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 var(--s3) var(--s1);scrollbar-width:thin;scrollbar-color:var(--line) transparent}
10
16
  .dempty{padding:var(--s1) 0;color:var(--dim)}
11
17
  .dock .tlax.compact{height:auto;padding-top:0}
@@ -0,0 +1,30 @@
1
+ // THE way the page says trouble: cards floating at the top right, just under
2
+ // the bar, taking no layout room, so one coming or going moves nothing, and
3
+ // nothing drawn while all is well. A bar never carries trouble text. Styles:
4
+ // home-strip.css .htoasts.
5
+ import type { ReactNode } from 'react'
6
+
7
+ export type Card = {
8
+ key: string
9
+ tone: 'red' | 'warn'
10
+ /** a bold lead word or two ("web crashed"), before the words */
11
+ head?: ReactNode
12
+ body?: ReactNode
13
+ }
14
+
15
+ /** the cards, in a zero height box placed right after the bar, so they
16
+ * float just under it whatever the bar's height */
17
+ export const FloatCards = ({ cards }: { cards: Card[] }) => {
18
+ if (cards.length === 0) return null
19
+ return (
20
+ <div className="hanchor">
21
+ <div className="htoasts">
22
+ {cards.map((c) => (
23
+ <div key={c.key} className={c.tone === 'warn' ? 'htoast warn' : 'htoast'} role="alert">
24
+ {c.head == null ? null : <><b>{c.head}</b> </>}{c.body}
25
+ </div>
26
+ ))}
27
+ </div>
28
+ </div>
29
+ )
30
+ }
@@ -3,8 +3,9 @@
3
3
  // (flip to stay in the window, shift off its edges, an offset from the
4
4
  // trigger), a portal lifts it out of every box that clips, and hover opens
5
5
  // it after a short delay, so a pointer passing by never flickers one open.
6
- // Nothing here is positioned by hand.
7
- import { autoUpdate, flip, FloatingPortal, offset, safePolygon, shift, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, type Placement } from '@floating-ui/react'
6
+ // A popover (Pop) opens on a click instead, and Escape or a click outside
7
+ // closes it. Nothing here is positioned by hand.
8
+ import { autoUpdate, flip, FloatingFocusManager, FloatingPortal, offset, safePolygon, shift, useClick, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, type Placement } from '@floating-ui/react'
8
9
  import { observer, useLocalObservable } from 'mobx-react-lite'
9
10
  import type { HTMLAttributes, ReactNode } from 'react'
10
11
 
@@ -25,13 +26,15 @@ type TipProps = Omit<HTMLAttributes<HTMLSpanElement>, 'content'> & {
25
26
  fallbacks?: Placement[]
26
27
  /** the pointer can move into it (a log to scroll): it stays open on the way */
27
28
  interactive?: boolean
29
+ /** opens the moment the pointer arrives, for a trigger whose only words are its tip */
30
+ instant?: boolean
28
31
  }
29
32
 
30
33
  /** a trigger and what floats over it on hover or focus */
31
- export const Tip = observer(({ content, children, tipClass = 'ftip', placement = 'top', fallbacks, interactive = false, ...rest }: TipProps) => {
34
+ export const Tip = observer(({ content, children, tipClass = 'ftip', placement = 'top', fallbacks, interactive = false, instant = false, ...rest }: TipProps) => {
32
35
  const st = useLocalObservable(() => ({ open: false, set(v: boolean) { this.open = v } }))
33
36
  const f = useFloating({ open: st.open, onOpenChange: (v) => st.set(v), placement, strategy: 'fixed', middleware: middleware(6, fallbacks), whileElementsMounted: autoUpdate })
34
- const hover = useHover(f.context, { delay: { open: OPEN_MS, close: interactive ? 100 : 0 }, handleClose: interactive ? safePolygon() : null })
37
+ const hover = useHover(f.context, { delay: { open: instant ? 0 : OPEN_MS, close: interactive ? 100 : 0 }, handleClose: interactive ? safePolygon() : null })
35
38
  const focus = useFocus(f.context)
36
39
  const dismiss = useDismiss(f.context)
37
40
  const role = useRole(f.context, { role: 'tooltip' })
@@ -47,3 +50,37 @@ export const Tip = observer(({ content, children, tipClass = 'ftip', placement =
47
50
  </>
48
51
  )
49
52
  })
53
+
54
+ type PopProps = {
55
+ /** what the trigger button shows */
56
+ label: ReactNode
57
+ className?: string
58
+ 'aria-label'?: string
59
+ popClass?: string
60
+ placement?: Placement
61
+ /** what floats, given the way to close it (an entry that acts closes it) */
62
+ children: (close: () => void) => ReactNode
63
+ }
64
+
65
+ /** a button that opens what floats under it on a click; focus moves into
66
+ * it, and Escape or a click outside closes it */
67
+ export const Pop = observer(({ label, className, popClass = 'fpop', placement = 'bottom-start', children, ...rest }: PopProps) => {
68
+ const st = useLocalObservable(() => ({ open: false, set(v: boolean) { this.open = v } }))
69
+ const f = useFloating({ open: st.open, onOpenChange: (v) => st.set(v), placement, strategy: 'fixed', middleware: middleware(4), whileElementsMounted: autoUpdate })
70
+ const click = useClick(f.context)
71
+ const dismiss = useDismiss(f.context)
72
+ const role = useRole(f.context, { role: 'dialog' })
73
+ const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role])
74
+ return (
75
+ <>
76
+ <button type="button" ref={f.refs.setReference} className={className} {...getReferenceProps(rest)}>{label}</button>
77
+ {st.open ? (
78
+ <FloatingPortal>
79
+ <FloatingFocusManager context={f.context} modal={false}>
80
+ <div ref={f.refs.setFloating} className={popClass} style={f.floatingStyles} {...getFloatingProps()}>{children(() => st.set(false))}</div>
81
+ </FloatingFocusManager>
82
+ </FloatingPortal>
83
+ ) : null}
84
+ </>
85
+ )
86
+ })
@@ -0,0 +1,42 @@
1
+ // A repo's git page state: one repo's git, read from the daemon while the
2
+ // page is shown (`/api/home/git.json`), a week of commits and HEAD's last
3
+ // one past that. The activity page reads every repo for a window; this reads
4
+ // one, whatever that window is.
5
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
+ import type { ActivityRow } from '../../home/activity-shape.ts'
7
+ import type { RepoSt } from './repo-st.ts'
8
+ import { ownTrouble, reach } from './reach.ts'
9
+
10
+ export const GIT_POLL_MS = 30_000
11
+
12
+ export class GitSt {
13
+ row: ActivityRow | null = null
14
+ error = ''
15
+ private timer: ReturnType<typeof setInterval> | null = null
16
+
17
+ constructor(private st: RepoSt) {
18
+ makeAutoObservable<this, 'st' | 'timer'>(this, { st: false, timer: false, row: observableRef })
19
+ }
20
+
21
+ async load(): Promise<void> {
22
+ try {
23
+ const r = await reach(`/api/home/git.json?repo=${encodeURIComponent(this.st.key)}`, { signal: AbortSignal.timeout(30_000) })
24
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
25
+ const j = (await r.json()) as ActivityRow
26
+ runInAction(() => { this.row = j; this.error = '' })
27
+ } catch (e) {
28
+ runInAction(() => { this.error = ownTrouble(e, this.st.home.machine, (m) => `read git: ${m}`) })
29
+ }
30
+ }
31
+
32
+ /** reads while the page is shown */
33
+ start(): void {
34
+ if (this.timer != null) return
35
+ void this.load()
36
+ this.timer = setInterval(() => void this.load(), GIT_POLL_MS)
37
+ }
38
+ stop(): void {
39
+ if (this.timer != null) clearInterval(this.timer)
40
+ this.timer = null
41
+ }
42
+ }