rvlib-shipkit 1.3.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +7 -1
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
package/src/home/pm.ts CHANGED
@@ -27,6 +27,10 @@ export type ChildView = ProcView & { tail: string[] }
27
27
  /** where pm, the daemon and the web listen */
28
28
  export type CorePorts = { pm: number; daemon: number; web: number }
29
29
 
30
+ /** a child whose pid lives but has not answered for this long is hung and
31
+ * gets killed: a healthy daemon answers within about 20 s of its start */
32
+ export const CHILD_HANG_MS = 90_000
33
+
30
34
  /** how many log lines each of shipkit's own processes hands the page */
31
35
  const CORE_TAIL = 20
32
36
 
@@ -61,6 +65,8 @@ export type PmDeps = {
61
65
  /** runs a stop or restart of the web once its answer is out: the web
62
66
  * proxies that very request (default: 200 ms later) */
63
67
  later?: (f: () => void) => void
68
+ /** where pm says what it did on its own, a hung child killed */
69
+ say?: (line: string) => void
64
70
  }
65
71
 
66
72
  const WEB_ANSWER_MS = 200
@@ -79,6 +85,8 @@ export class Pm {
79
85
  probe: () => d.probe(role),
80
86
  // a crash loop while its code is being edited heals itself
81
87
  neverGiveUp: true,
88
+ hangMs: CHILD_HANG_MS,
89
+ say: d.say,
82
90
  }, d.deps ?? realDeps)]))
83
91
  }
84
92
 
@@ -154,9 +162,9 @@ export class Pm {
154
162
  if (act === 'start') report(r.set('up'))
155
163
  else if (act === 'stop') report(r.set('down'))
156
164
  else if (act === 'restart') report(r.restart())
157
- else if (act === 'open' || act === 'app') {
165
+ else if (act === 'open' || act === 'chrome' || act === 'app') {
158
166
  if (r.decl.url == null) return new Response(`${r.decl.id} declares no url to open`, { status: 400 })
159
- report(r.open(act === 'app' ? 'app' : 'browser'))
167
+ report(r.open(act === 'open' ? 'browser' : act))
160
168
  } else if (act === 'takeover') {
161
169
  const v = r.takeOverView
162
170
  if (v == null) return new Response(`${r.decl.id} does not run outside the supervisor: nothing to take over`, { status: 409 })
@@ -166,7 +174,7 @@ export class Pm {
166
174
  if (!r.decl.actions.some((a) => a.id === b.action)) return new Response(`${r.decl.id} declares no action '${String(b.action)}'`, { status: 404 })
167
175
  if (r.running != null) return new Response(`${r.decl.id} is already running its action '${r.running}'`, { status: 409 })
168
176
  report(r.action(String(b.action)))
169
- } else return new Response('act must be start, stop, restart, open, app, takeover or action', { status: 400 })
177
+ } else return new Response('act must be start, stop, restart, open, chrome, app, takeover or action', { status: 400 })
170
178
  return Response.json(r.view())
171
179
  }
172
180
  return new Response('not found', { status: 404 })
@@ -9,8 +9,9 @@ import type { CorePorts, PmAnswer } from './pm.ts'
9
9
  import type { ProcessIcon, ResourceView } from './resources.ts'
10
10
  import type { ProcStatus } from './supervise.ts'
11
11
 
12
- /** a status as pm reports it, or unknown while pm does not answer */
13
- export type RowStatus = ProcStatus | 'unknown'
12
+ /** a status as pm reports it, unknown while pm does not answer, silent
13
+ * while pm runs it but the page gets no answer from it */
14
+ export type RowStatus = ProcStatus | 'unknown' | 'silent'
14
15
 
15
16
  /** the dot a row wears: green up, red crashed, grey stopped or unknown, orange on its way */
16
17
  export type RowDot = 'up' | 'crashed' | 'down' | 'starting' | 'unknown'
@@ -46,6 +47,8 @@ export type ProcessRow = {
46
47
  actions: { id: string; label: string }[]
47
48
  running: string | null
48
49
  icon: ProcessIcon | null
50
+ /** its own app is being built or opened */
51
+ appBusy: boolean
49
52
  }
50
53
 
51
54
  /** a repo's processes, or shipkit's own (`core`, always the first group) */
@@ -55,7 +58,7 @@ export type ProcessGroup = { repo: string; core?: true; rows: ProcessRow[] }
55
58
  export const CORE_GROUP = 'shipkit'
56
59
 
57
60
  export const dotOf = (s: RowStatus): RowDot =>
58
- s === 'up' ? 'up' : s === 'crashed' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
61
+ s === 'up' ? 'up' : s === 'crashed' || s === 'silent' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
59
62
 
60
63
  const hostOf = (url: string | null): string | null => (url == null ? null : url.replace(/^https?:\/\//, '').replace(/\/$/, ''))
61
64
 
@@ -85,6 +88,7 @@ export function rowOf(r: ResourceView): ProcessRow {
85
88
  actions: r.actions,
86
89
  running: r.running,
87
90
  icon: r.icon,
91
+ appBusy: r.appBusy === true,
88
92
  }
89
93
  }
90
94
 
@@ -113,6 +117,7 @@ export function declaredRow(d: GlobalProcessDecl): ProcessRow {
113
117
  takeOver: null,
114
118
  actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
115
119
  running: null,
120
+ appBusy: false,
116
121
  // an image is pm's to serve, and pm does not answer
117
122
  icon: d.icon == null || /\.[a-z0-9]+$/i.test(d.icon) ? null : { text: d.icon },
118
123
  }
@@ -143,6 +148,10 @@ export type CoreOpts = {
143
148
  daemonVersion?: string | null
144
149
  /** where each listens, when pm does not say (an older pm, or pm down) */
145
150
  ports?: CorePorts | null
151
+ /** one of shipkit's own not answering the page, and why: its row says
152
+ * no answer whatever pm last reported. pm silent, every other row is
153
+ * the last answer's with its state unknown, so the table keeps its lines */
154
+ silent?: Partial<Record<'pm' | 'daemon' | 'web', string>>
146
155
  }
147
156
 
148
157
  function coreRow(id: 'pm' | 'daemon' | 'web', o: { status: RowStatus; pid: number | null; since: number | null; note: string | null; tail: string[]; version: string | null; port: number | null; restart: boolean }): ProcessRow {
@@ -150,7 +159,7 @@ function coreRow(id: 'pm' | 'daemon' | 'web', o: { status: RowStatus; pid: numbe
150
159
  return {
151
160
  id, label: id, repo: CORE_GROUP, file: '', status: o.status, dot: dotOf(o.status), url, host: hostOf(url), pid: o.pid, since: o.since, note: o.note,
152
161
  description: CORE_ABOUT[id], where: null, lastLog: o.tail.at(-1) ?? null, tail: o.tail,
153
- canStop: false, canRestart: o.restart, core: true, version: o.version, takeOver: null, actions: [], running: null, icon: null,
162
+ canStop: false, canRestart: o.restart, core: true, version: o.version, takeOver: null, actions: [], running: null, icon: null, appBusy: false,
154
163
  }
155
164
  }
156
165
 
@@ -171,13 +180,20 @@ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
171
180
  rows.push(coreRow(id, { status: c.status, pid: c.pid, since: c.since, note: note === '' ? null : note, tail: c.tail, version, port, restart: true }))
172
181
  }
173
182
  }
174
- return { repo: CORE_GROUP, core: true, rows }
183
+ const s = o.silent ?? {}
184
+ return { repo: CORE_GROUP, core: true, rows: rows.map((r) => {
185
+ const why = s[r.id as 'pm' | 'daemon' | 'web']
186
+ return why != null && why !== '' ? { ...r, status: 'silent', dot: 'crashed', note: why } : s.pm ? stale(r) : r
187
+ }) }
175
188
  }
176
189
 
177
190
  /** what the page's table holds, from pm's answer: the core group, then the
178
191
  * global processes by repo */
179
192
  export const processGroups = (pm: PmAnswer | null, order: string[], o: CoreOpts = {}): ProcessGroup[] =>
180
- [coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map(rowOf), order)]
193
+ [coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map((r) => (o.silent?.pm ? stale(rowOf(r)) : rowOf(r))), order)]
194
+
195
+ /** a row from an answer pm can no longer confirm: its state unknown, nothing to click */
196
+ const stale = (r: ProcessRow): ProcessRow => ({ ...r, status: 'unknown', dot: 'unknown', canStop: false, canRestart: false, takeOver: null, running: null, appBusy: false })
181
197
 
182
198
  /** the declared icon, else the repo's own, else none */
183
199
  export const processIcon = (r: { icon: ProcessIcon | null }, repoIcon: string | null): ProcessIcon | null =>
@@ -200,6 +216,7 @@ export function stateWords(r: Pick<ProcessRow, 'status' | 'since'>, now: number)
200
216
  case 'crashed': return age == null ? 'crashed' : `crashed ${age} ago`
201
217
  case 'down': return 'stopped'
202
218
  case 'unknown': return 'unknown (pm down)'
219
+ case 'silent': return 'no answer'
203
220
  default: return r.status
204
221
  }
205
222
  }
@@ -1,14 +1,15 @@
1
1
  // the machine's global processes (ComfyUI, a TTS server, an app panel), each
2
2
  // declared once in a repo's `<name>.shipkit.ts` as `globalProcess({...})`
3
3
  // (shipkit-files.ts) and owned by pm: a switch, a restart, its log, its
4
- // action buttons, and its url opened in the default browser or as a shipkit
5
- // app window.
4
+ // action buttons, and its url opened in the default browser, in Chrome, or
5
+ // as its own app (home/gui-app.ts builds that app in its repo on first use).
6
6
  //
7
7
  // two kinds: with no `down`, the command IS the service, supervised like a
8
8
  // hub (restarted on a crash, signalled on stop). With `down`, the command is
9
9
  // a bring-up that exits (an idempotent ladder script), `down` its reverse,
10
10
  // and the probe is the only truth.
11
- import { mkdirSync, openSync, closeSync } from 'fs'
11
+ import { closeSync, existsSync, mkdirSync, openSync } from 'fs'
12
+ import { homedir } from 'os'
12
13
  import { dirname, join } from 'path'
13
14
  import { fileURLToPath } from 'url'
14
15
  import type { GlobalProcessDecl } from './global-units.ts'
@@ -47,8 +48,14 @@ export type ResourceView = {
47
48
  actions: { id: string; label: string }[]
48
49
  /** the action running now, or null */
49
50
  running: string | null
51
+ /** its own app is being built or opened: the page shows a spinner on
52
+ * that button. Absent from an older pm */
53
+ appBusy?: boolean
50
54
  }
51
55
 
56
+ /** where a url opens: the default browser, Chrome, or the process's own app */
57
+ export type OpenHow = 'browser' | 'chrome' | 'app'
58
+
52
59
  /** an emoji, or the url pm serves a declared image file at */
53
60
  export type ProcessIcon = { text: string } | { src: string }
54
61
 
@@ -83,17 +90,19 @@ export const runOnce: RunOnce = async (cmd, cwd, log) => {
83
90
  }
84
91
  }
85
92
 
86
- /** opens a shipkit app window on a url; what it did lands in the log */
87
- export type OpenApp = (url: string, log: string) => void
93
+ /** opens a process's own app, building it first when it is missing or
94
+ * stale; what it did lands in the log. Resolves with the exit code */
95
+ export type OpenApp = (d: GlobalProcessDecl, log: string) => Promise<number>
88
96
 
89
97
  /** its own process: what building the app needs never loads in pm */
90
98
  const OPEN_APP = fileURLToPath(new URL('./open-app.ts', import.meta.url))
91
99
 
92
- const openApp: OpenApp = (url, log) => {
100
+ const openApp: OpenApp = async (d, log) => {
93
101
  mkdirSync(dirname(log), { recursive: true })
94
102
  const fd = openSync(log, 'a')
95
103
  try {
96
- Bun.spawn([process.execPath, OPEN_APP, url], { stdio: ['ignore', fd, fd] })
104
+ const spec = { id: d.id, label: d.label, url: d.url, icon: d.icon, root: d.root }
105
+ return await Bun.spawn([process.execPath, OPEN_APP, '--process', JSON.stringify(spec)], { stdio: ['ignore', fd, fd] }).exited
97
106
  } finally {
98
107
  closeSync(fd)
99
108
  }
@@ -113,6 +122,26 @@ const browse: Browse = (url, log) => {
113
122
  }
114
123
  }
115
124
 
125
+ const CHROME_APPS = ['/Applications/Google Chrome.app', join(homedir(), 'Applications', 'Google Chrome.app')]
126
+
127
+ /** the command that opens a url in Chrome, else in the default browser */
128
+ export function chromeCmd(url: string, o: { platform?: string; has?: (path: string) => boolean } = {}): string[] {
129
+ const platform = o.platform ?? process.platform
130
+ const has = o.has ?? existsSync
131
+ if (platform === 'darwin') return CHROME_APPS.some(has) ? ['open', '-a', 'Google Chrome', url] : ['open', url]
132
+ return platform === 'win32' ? ['cmd', '/c', 'start', 'chrome', url] : ['xdg-open', url]
133
+ }
134
+
135
+ const chrome: Browse = (url, log) => {
136
+ mkdirSync(dirname(log), { recursive: true })
137
+ const fd = openSync(log, 'a')
138
+ try {
139
+ Bun.spawn(chromeCmd(url), { stdio: ['ignore', fd, fd] })
140
+ } finally {
141
+ closeSync(fd)
142
+ }
143
+ }
144
+
116
145
  /** the function action runner: imports the unit file, calls the action */
117
146
  const ACTION_RUNNER = fileURLToPath(new URL('./run-unit-action.ts', import.meta.url))
118
147
 
@@ -120,10 +149,12 @@ export type ResourceOpts = {
120
149
  probe?: HttpProbe
121
150
  run?: RunOnce
122
151
  deps?: SuperviseDeps
123
- /** the app window */
152
+ /** the process's own app */
124
153
  open?: OpenApp
125
154
  /** the default browser */
126
155
  browse?: Browse
156
+ /** Chrome */
157
+ chrome?: Browse
127
158
  /** the pids listening on a local port, and the process group of a pid */
128
159
  holders?: (port: number) => number[]
129
160
  groupOf?: (pid: number) => number | null
@@ -150,8 +181,10 @@ export class Resource {
150
181
  private busy = false
151
182
  /** the last bring-up exited non zero */
152
183
  private failed = false
153
- /** open it once it answers: in the default browser, or as an app window */
154
- private openWhenUp: 'browser' | 'app' | null = null
184
+ /** open it once it answers */
185
+ private openWhenUp: OpenHow | null = null
186
+ /** its own app is being built or opened */
187
+ appBusy = false
155
188
  /** the status a tick last saw, and since when, for the bring-up kind */
156
189
  private seen: ProcStatus = 'down'
157
190
  private seenAt: number
@@ -248,9 +281,9 @@ export class Resource {
248
281
  await this.set('up')
249
282
  }
250
283
 
251
- /** its url in the default browser, or as a shipkit app window: at once
284
+ /** its url in the default browser, in Chrome, or as its own app: at once
252
285
  * while it answers, else it starts and opens once it does */
253
- async open(how: 'browser' | 'app'): Promise<void> {
286
+ async open(how: OpenHow): Promise<void> {
254
287
  if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
255
288
  if (this.status === 'up') {
256
289
  this.show(how, this.decl.url)
@@ -260,9 +293,27 @@ export class Resource {
260
293
  await this.set('up')
261
294
  }
262
295
 
263
- private show(how: 'browser' | 'app', url: string): void {
264
- if (how === 'app') (this.o.open ?? openApp)(url, this.log)
265
- else (this.o.browse ?? browse)(url, this.log)
296
+ private show(how: OpenHow, url: string): void {
297
+ if (how === 'browser') (this.o.browse ?? browse)(url, this.log)
298
+ else if (how === 'chrome') (this.o.chrome ?? chrome)(url, this.log)
299
+ else if (!this.appBusy) void this.showApp()
300
+ }
301
+
302
+ /** off the request path: the build takes seconds the first time, and
303
+ * the page follows `appBusy` on its poll */
304
+ private async showApp(): Promise<void> {
305
+ this.appBusy = true
306
+ let failed: string | null = null
307
+ try {
308
+ const code = await (this.o.open ?? openApp)(this.decl, this.log)
309
+ if (code !== 0) failed = `opening ${this.decl.label} as an app exited ${code}: see the log`
310
+ } catch (e) {
311
+ failed = `opening ${this.decl.label} as an app: ${(e as Error).message}`
312
+ } finally {
313
+ this.appBusy = false
314
+ }
315
+ // kept beside what the supervisor says, like an action's outcome
316
+ if (failed != null) this.note = this.actionNote = failed
266
317
  }
267
318
 
268
319
  /** one declared button. A function action runs in its own bun process
@@ -328,6 +379,7 @@ export class Resource {
328
379
  icon: processIconOf(d),
329
380
  actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
330
381
  running: this.running,
382
+ appBusy: this.appBusy,
331
383
  }
332
384
  }
333
385
  }
@@ -23,6 +23,7 @@ import { Planner } from '../plan/planner.ts'
23
23
  import { machineCatchUp } from '../plan/catch-up.ts'
24
24
  import { serveDaemon } from './serve-daemon.ts'
25
25
  import { startSweeps } from './machine-stores.ts'
26
+ import { landMotherhome } from '../core/motherhome.ts'
26
27
  import { Widgets } from './widgets.ts'
27
28
 
28
29
  const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
@@ -109,7 +110,11 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
109
110
  // nothing grows unbound: every store of every repo it loads, and the
110
111
  // machine's, swept on boot and hourly (core/retention.ts)
111
112
  const only = onlyRepos()
112
- const sweeps = startSweeps({ roots: () => house.repos.filter((r) => only == null || only.has(r.name)).map((r) => r.path), dir, printErr: io.printErr })
113
+ const sweeps = startSweeps({
114
+ roots: () => house.repos.filter((r) => only == null || only.has(r.name)).map((r) => r.path), dir, printErr: io.printErr,
115
+ // the rows every gate filed land here, one commit a day at most
116
+ also: (now) => { for (const n of landMotherhome({ now })) io.printErr(n) },
117
+ })
113
118
  const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, io })
114
119
  io.print(` ${house.repos.length} repos, state in ${dir}, reloads on ${code.files} lib files`)
115
120
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
@@ -11,16 +11,37 @@ import { reloadOnOwnCode } from './lib-watch.ts'
11
11
  import { Pm, PM_TICK_MS, type Role } from './pm.ts'
12
12
  import { Resources } from './resources.ts'
13
13
  import type { HomeIo } from './run-daemon.ts'
14
- import type { PmConfig } from './pm-config.ts'
14
+ import { stampOf, type PmConfig } from './pm-inputs.ts'
15
15
 
16
16
  const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
17
17
  const PM_CONFIG = fileURLToPath(new URL('./pm-config.ts', import.meta.url))
18
18
 
19
19
  const RUN_PM = fileURLToPath(import.meta.url)
20
20
 
21
- /** how often pm reads the house repos' global processes again */
21
+ /** how often pm stats what its last read of the house side saw */
22
22
  export const PM_RESCAN_MS = 15_000
23
23
 
24
+ /** pm's reread of the house side: the child runs again only once a stat of
25
+ * a path the last answer read moved. A few ms of stats per poll, against a
26
+ * child that imports every unit file */
27
+ export class PmConfigWatch {
28
+ private last: PmConfig | null = null
29
+
30
+ constructor(private readonly read: () => Promise<PmConfig>) {}
31
+
32
+ seed(c: PmConfig): void {
33
+ this.last = c
34
+ }
35
+
36
+ /** the new answer, or null when nothing it read moved */
37
+ async poll(): Promise<PmConfig | null> {
38
+ if (this.last != null && stampOf(this.last.inputs) === this.last.stamp) return null
39
+ const c = await this.read()
40
+ this.last = c
41
+ return c
42
+ }
43
+ }
44
+
24
45
  /** replace this process's image with pm run from this file: same pid, same
25
46
  * cwd, same env, none of cli.ts loaded. False when the runtime has no
26
47
  * execve, and the caller runs pm in process */
@@ -79,6 +100,7 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
79
100
  cmd: (role) => [process.execPath, CLI, role],
80
101
  probe: roleAnswers,
81
102
  resources: new Resources(c.processes, dir),
103
+ say: io.printErr,
82
104
  })
83
105
  let server: Bun.Server<unknown>
84
106
  try {
@@ -98,13 +120,16 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
98
120
  pm.resources.errors = c.errors
99
121
  for (const e of c.errors) io.printErr(`WARN ${e}`)
100
122
  // a unit file added, edited or removed in any house repo: read again,
101
- // in its child process, and the unchanged processes keep their state
123
+ // in its child process, and the unchanged processes keep their state.
124
+ // Nothing moved, no child
125
+ const watch = new PmConfigWatch(() => readPmConfig(root))
126
+ watch.seed(c)
102
127
  let reading = false
103
128
  const reread = setInterval(() => {
104
129
  if (reading) return
105
130
  reading = true
106
- void readPmConfig(root)
107
- .then((n) => pm.resources.sync(n.processes, n.errors))
131
+ void watch.poll()
132
+ .then((n) => { if (n != null) pm.resources.sync(n.processes, n.errors) })
108
133
  .catch((e) => io.printErr(`WARN pm could not read the global processes again: ${(e as Error).message}`))
109
134
  .finally(() => { reading = false })
110
135
  }, PM_RESCAN_MS)
@@ -51,8 +51,8 @@ export const REPO_ANSWER_MS = 30_000
51
51
  export const LIST_EVERY_MS = 30_000
52
52
  export const LIST_RETRY_MS = 1_000
53
53
 
54
- /** the one home answer that can wait on git: the activity page's first read of a window */
55
- export const readsGit = (path: string): boolean => path === '/api/home/activity.json'
54
+ /** the home answers that can wait on git: the activity page's first read of a window, and a repo's git page */
55
+ export const readsGit = (path: string): boolean => path === '/api/home/activity.json' || path === '/api/home/git.json'
56
56
 
57
57
  /** a run or a check run streams for minutes, and a silent one is never cut */
58
58
  export const streams = (path: string): boolean => /\/api\/(run|check\/run)$/.test(path)
@@ -29,6 +29,12 @@ export type ProcSpec = {
29
29
  /** never left down after CRASH_LIMIT crashes: it keeps retrying at the
30
30
  * longest backoff, so a code fix brings it back with no hand */
31
31
  neverGiveUp?: boolean
32
+ /** a pid that lives but has not answered its probe for this long is hung
33
+ * (a main thread stuck in one synchronous step still holds its port):
34
+ * killed, then started again after the crash backoff. None = never */
35
+ hangMs?: number
36
+ /** a line for every act the supervisor takes on its own */
37
+ say?: (line: string) => void
32
38
  }
33
39
 
34
40
  export type ProcView = {
@@ -137,6 +143,8 @@ export class Proc {
137
143
  private retryAt: number | null = null
138
144
  private killAt: number | null = null
139
145
  private probing = false
146
+ /** why the kill in flight was a hang kill, for the crash note */
147
+ private hung: string | null = null
140
148
  /** a restart: start again once the stop landed */
141
149
  private again = false
142
150
  /** it answers its probe with no pid of ours behind it */
@@ -275,6 +283,8 @@ export class Proc {
275
283
  const now = this.d.now()
276
284
  if (this.pid != null && !this.d.alive(this.pid)) {
277
285
  this.gone()
286
+ const hung = this.hung
287
+ this.hung = null
278
288
  if (this.again) {
279
289
  this.again = false
280
290
  this.want = 'up'
@@ -287,7 +297,7 @@ export class Proc {
287
297
  this.set('crashed', `exited ${this.crashes.length} times in ${Math.round(CRASH_WINDOW_MS / 60_000)} min, left down: see the log`)
288
298
  } else {
289
299
  this.retryAt = now + BACKOFF_MS[Math.min(this.crashes.length - 1, BACKOFF_MS.length - 1)]!
290
- this.set('crashed', `exited, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
300
+ this.set('crashed', `${hung != null ? `${hung}, killed` : 'exited'}, restart in ${Math.round((this.retryAt - now) / 1000)}s`)
291
301
  }
292
302
  }
293
303
  }
@@ -318,6 +328,19 @@ export class Proc {
318
328
  } finally {
319
329
  this.probing = false
320
330
  }
331
+ this.killIfHung(now)
332
+ }
333
+
334
+ /** a pid that lives and still has not answered, hangMs after it last
335
+ * changed status, is stopped: the crash path starts it again */
336
+ private killIfHung(now: number): void {
337
+ const hang = this.spec.hangMs
338
+ if (hang == null || this.status !== 'starting' || this.pid == null || this.want !== 'up' || now - this.since < hang) return
339
+ this.hung = `did not answer its probe for ${Math.round((now - this.since) / 1000)}s`
340
+ this.spec.say?.(`WARN ${this.spec.id} (pid ${this.pid}) ${this.hung}: killed, it starts again`)
341
+ this.d.kill(this.pid, 'SIGTERM')
342
+ this.killAt = now + KILL_AFTER_MS
343
+ this.set('stopping', `${this.hung}, killing it`)
321
344
  }
322
345
 
323
346
  view(): ProcView {
@@ -2,7 +2,8 @@
2
2
  // rendered by the daemon on each one's own clock and kept as html the page
3
3
  // reads at /api/home/widgets.json. The files are read again every
4
4
  // WIDGET_RESCAN_MS, a walk of every repo that costs a few tens of ms, and an
5
- // unchanged file is never imported twice.
5
+ // unchanged file is never imported twice. All of it only while a reader
6
+ // asked within WIDGET_WATCH_MS: nobody looking, nothing scans or renders.
6
7
  //
7
8
  // the one constraint a reader breaks first: a render runs in the daemon, so
8
9
  // it is bounded (WIDGET_RENDER_MS) and never awaited by the daemon's tick.
@@ -11,6 +12,8 @@ import { scanGlobalUnits, type LoadedWidget, type RepoRef } from './global-units
11
12
  export const WIDGET_EVERY_MS = 5_000
12
13
  export const WIDGET_RENDER_MS = 5_000
13
14
  export const WIDGET_RESCAN_MS = 15_000
15
+ /** a read within this long keeps the clocks running */
16
+ export const WIDGET_WATCH_MS = 30_000
14
17
 
15
18
  export type WidgetView = {
16
19
  repo: string
@@ -38,6 +41,8 @@ export class Widgets {
38
41
  private slots = new Map<string, Slot>()
39
42
  private scannedAt = -Infinity
40
43
  private scanning = false
44
+ private readAt: number | null = null
45
+ private rendering = new Set<Promise<void>>()
41
46
  /** the unit files that would not load */
42
47
  errors: string[] = []
43
48
 
@@ -47,9 +52,14 @@ export class Widgets {
47
52
  return (this.d.now ?? Date.now)()
48
53
  }
49
54
 
55
+ private get watched(): boolean {
56
+ return this.readAt != null && this.now() - this.readAt < WIDGET_WATCH_MS
57
+ }
58
+
50
59
  /** one look: read the files again when due, render every widget due. Never
51
- * waits on a render */
60
+ * waits on a render, and does nothing while no reader is watching */
52
61
  async tick(repos: RepoRef[]): Promise<void> {
62
+ if (!this.watched) return
53
63
  const now = this.now()
54
64
  if (!this.scanning && now - this.scannedAt >= WIDGET_RESCAN_MS) {
55
65
  this.scanning = true
@@ -73,7 +83,24 @@ export class Widgets {
73
83
  this.scanning = false
74
84
  }
75
85
  }
76
- for (const s of this.slots.values()) if (!s.busy && this.now() >= s.due) void this.render(s)
86
+ for (const s of this.slots.values()) {
87
+ if (s.busy || this.now() < s.due) continue
88
+ const p = this.render(s)
89
+ this.rendering.add(p)
90
+ void p.finally(() => this.rendering.delete(p))
91
+ }
92
+ }
93
+
94
+ /** a reader's views: the first one in a while waits on a scan and on the
95
+ * renders it starts (each bounded), so it never reads hours old html */
96
+ async look(repos: RepoRef[]): Promise<WidgetView[]> {
97
+ const cold = !this.watched
98
+ this.readAt = this.now()
99
+ if (cold) {
100
+ await this.tick(repos)
101
+ await Promise.all(this.rendering)
102
+ }
103
+ return this.views()
77
104
  }
78
105
 
79
106
  /** one render, bounded, its result or its error kept */
@@ -96,7 +123,9 @@ export class Widgets {
96
123
  }
97
124
  }
98
125
 
126
+ /** the last html of each; a read that keeps the clocks running */
99
127
  views(): WidgetView[] {
128
+ this.readAt = this.now()
100
129
  return [...this.slots.values()].map((s) => s.view)
101
130
  }
102
131
  }
@@ -1,6 +1,5 @@
1
- // the commit-message predicate, shared by the commit-msg hook (blocks at
2
- // write time) and SK009 (audits history). one implementation, or the two
3
- // drift and the drift only shows when a repo goes public.
1
+ // the commit-message predicate of the commit-msg hook, which blocks at write
2
+ // time: the one guard against AI attribution (SK009 checks it is armed).
4
3
  //
5
4
  // mechanical text rules only, so this stays publishable.
6
5