rvlib-shipkit 1.2.0 → 1.4.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 (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  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 +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
@@ -1,16 +1,19 @@
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 for an app the window that opens on its url.
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).
5
6
  //
6
7
  // two kinds: with no `down`, the command IS the service, supervised like a
7
8
  // hub (restarted on a crash, signalled on stop). With `down`, the command is
8
9
  // a bring-up that exits (an idempotent ladder script), `down` its reverse,
9
10
  // and the probe is the only truth.
10
- import { mkdirSync, openSync, closeSync } from 'fs'
11
+ import { closeSync, existsSync, mkdirSync, openSync } from 'fs'
12
+ import { homedir } from 'os'
11
13
  import { dirname, join } from 'path'
12
14
  import { fileURLToPath } from 'url'
13
15
  import type { GlobalProcessDecl } from './global-units.ts'
16
+ import { iconImageType } from '../shipkit-files.ts'
14
17
  import { Proc, realDeps, tailLines, type ProcStatus, type SuperviseDeps } from './supervise.ts'
15
18
  import { resourceLog, resourcePid } from './home-paths.ts'
16
19
  import { groupOf, localPort, portHolders } from '../core/port-holders.ts'
@@ -21,6 +24,11 @@ export type ResourceView = {
21
24
  description: string | null
22
25
  where: string | null
23
26
  status: ProcStatus
27
+ /** the pid pm holds for it, null for a bring-up or one started elsewhere */
28
+ pid: number | null
29
+ /** epoch ms its uptime counts from while up (its pid's start when it has
30
+ * one, else its last status change), its crash time when crashed */
31
+ since: number
24
32
  /** what the owner last asked for */
25
33
  want: 'up' | 'down'
26
34
  note: string | null
@@ -35,13 +43,26 @@ export type ResourceView = {
35
43
  repo: string
36
44
  file: string
37
45
  url: string | null
38
- /** opens as a shipkit app window on its url */
39
- app: boolean
46
+ /** what its card wears, as declared: null leaves the card its repo's icon */
47
+ icon: ProcessIcon | null
40
48
  actions: { id: string; label: string }[]
41
49
  /** the action running now, or null */
42
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
43
54
  }
44
55
 
56
+ /** where a url opens: the default browser, Chrome, or the process's own app */
57
+ export type OpenHow = 'browser' | 'chrome' | 'app'
58
+
59
+ /** an emoji, or the url pm serves a declared image file at */
60
+ export type ProcessIcon = { text: string } | { src: string }
61
+
62
+ /** the page's url for a process's declared icon */
63
+ export const processIconOf = (d: GlobalProcessDecl): ProcessIcon | null =>
64
+ d.icon == null ? null : iconImageType(d.icon) != null ? { src: `/api/pm/icon/${encodeURIComponent(d.id)}` } : { text: d.icon }
65
+
45
66
  export type HttpProbe = (url: string) => Promise<boolean>
46
67
 
47
68
  export const probeUrl: HttpProbe = async (url) => {
@@ -69,17 +90,19 @@ export const runOnce: RunOnce = async (cmd, cwd, log) => {
69
90
  }
70
91
  }
71
92
 
72
- /** opens a shipkit app window on a url; what it did lands in the log */
73
- 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>
74
96
 
75
97
  /** its own process: what building the app needs never loads in pm */
76
98
  const OPEN_APP = fileURLToPath(new URL('./open-app.ts', import.meta.url))
77
99
 
78
- const openApp: OpenApp = (url, log) => {
100
+ const openApp: OpenApp = async (d, log) => {
79
101
  mkdirSync(dirname(log), { recursive: true })
80
102
  const fd = openSync(log, 'a')
81
103
  try {
82
- 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
83
106
  } finally {
84
107
  closeSync(fd)
85
108
  }
@@ -99,6 +122,26 @@ const browse: Browse = (url, log) => {
99
122
  }
100
123
  }
101
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
+
102
145
  /** the function action runner: imports the unit file, calls the action */
103
146
  const ACTION_RUNNER = fileURLToPath(new URL('./run-unit-action.ts', import.meta.url))
104
147
 
@@ -106,10 +149,12 @@ export type ResourceOpts = {
106
149
  probe?: HttpProbe
107
150
  run?: RunOnce
108
151
  deps?: SuperviseDeps
109
- /** the app window */
152
+ /** the process's own app */
110
153
  open?: OpenApp
111
154
  /** the default browser */
112
155
  browse?: Browse
156
+ /** Chrome */
157
+ chrome?: Browse
113
158
  /** the pids listening on a local port, and the process group of a pid */
114
159
  holders?: (port: number) => number[]
115
160
  groupOf?: (pid: number) => number | null
@@ -136,16 +181,26 @@ export class Resource {
136
181
  private busy = false
137
182
  /** the last bring-up exited non zero */
138
183
  private failed = false
139
- /** open it once it answers: in the default browser, or as an app window */
140
- 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
188
+ /** the status a tick last saw, and since when, for the bring-up kind */
189
+ private seen: ProcStatus = 'down'
190
+ private seenAt: number
141
191
  readonly log: string
142
192
 
143
193
  constructor(readonly decl: GlobalProcessDecl, dir: string, private o: ResourceOpts = {}) {
144
194
  this.log = resourceLog(dir, decl.id)
195
+ this.seenAt = this.now()
145
196
  if (decl.down == null)
146
197
  this.proc = new Proc({ id: decl.id, cmd: decl.cmd, cwd: decl.cwd, log: this.log, pidKey: resourcePid(dir, decl.id), probe: decl.probe == null ? undefined : () => this.probe() }, o.deps ?? realDeps)
147
198
  }
148
199
 
200
+ private now(): number {
201
+ return this.o.deps?.now() ?? Date.now()
202
+ }
203
+
149
204
  private probe(): Promise<boolean> {
150
205
  return this.decl.probe == null ? Promise.resolve(true) : (this.o.probe ?? probeUrl)(this.decl.probe)
151
206
  }
@@ -226,12 +281,10 @@ export class Resource {
226
281
  await this.set('up')
227
282
  }
228
283
 
229
- /** its url in the default browser, or as a shipkit app window (app:
230
- * true only): at once while it answers, else it starts and opens once
231
- * it does */
232
- async open(how: 'browser' | 'app'): Promise<void> {
284
+ /** its url in the default browser, in Chrome, or as its own app: at once
285
+ * while it answers, else it starts and opens once it does */
286
+ async open(how: OpenHow): Promise<void> {
233
287
  if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
234
- if (how === 'app' && !this.decl.app) throw new Error(`${this.decl.id} does not declare app: true, so it has no app window: open it in the browser`)
235
288
  if (this.status === 'up') {
236
289
  this.show(how, this.decl.url)
237
290
  return
@@ -240,9 +293,27 @@ export class Resource {
240
293
  await this.set('up')
241
294
  }
242
295
 
243
- private show(how: 'browser' | 'app', url: string): void {
244
- if (how === 'app') (this.o.open ?? openApp)(url, this.log)
245
- 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
246
317
  }
247
318
 
248
319
  /** one declared button. A function action runs in its own bun process
@@ -275,6 +346,10 @@ export class Resource {
275
346
  const up = await this.probe()
276
347
  this.status = up ? 'up' : this.failed && this.want === 'up' ? 'crashed' : 'down'
277
348
  }
349
+ if (this.status !== this.seen) {
350
+ this.seen = this.status
351
+ this.seenAt = this.now()
352
+ }
278
353
  if (this.openWhenUp != null && this.status === 'up' && this.decl.url != null) {
279
354
  const how = this.openWhenUp
280
355
  this.openWhenUp = null
@@ -290,6 +365,9 @@ export class Resource {
290
365
  description: d.description,
291
366
  where: d.where,
292
367
  status: this.status,
368
+ pid: this.proc?.pid ?? null,
369
+ // up with a pid: since that pid started, so a pm reload never resets its uptime
370
+ since: (this.status === 'up' ? this.proc?.startedAt() : null) ?? this.proc?.since ?? this.seenAt,
293
371
  want: this.want,
294
372
  note: this.note,
295
373
  canStop: this.canStop,
@@ -298,9 +376,10 @@ export class Resource {
298
376
  repo: d.repo,
299
377
  file: d.file,
300
378
  url: d.url,
301
- app: d.app,
379
+ icon: processIconOf(d),
302
380
  actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
303
381
  running: this.running,
382
+ appBusy: this.appBusy,
304
383
  }
305
384
  }
306
385
  }
@@ -20,8 +20,10 @@ import { daemonPort, homeDir, homePort, homeUp, hubBase, WORKER_ENV } from './ho
20
20
  import { HomeState } from './home-state.ts'
21
21
  import { Hubs } from './hubs.ts'
22
22
  import { Planner } from '../plan/planner.ts'
23
+ import { machineCatchUp } from '../plan/catch-up.ts'
23
24
  import { serveDaemon } from './serve-daemon.ts'
24
25
  import { startSweeps } from './machine-stores.ts'
26
+ import { landMotherhome } from '../core/motherhome.ts'
25
27
  import { Widgets } from './widgets.ts'
26
28
 
27
29
  const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
@@ -58,10 +60,11 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
58
60
  if (reclaimed.killed.length > 0) io.print(`ok: the daemon restarted in place: killed ${reclaimed.killed.length} check process(es) its previous image left running`)
59
61
  const c = await loadRepoConfig(root)
60
62
  const dir = homeDir()
61
- const gutter = projectGutter({ root, warn: (l) => io.printErr(l) })
62
- const names = new Set(house.repos.map((r) => r.name))
63
- // the machine's one plan: every repo and check pair, ticked by the daemon
64
- const plan = new Planner({ onError: (repo, e) => io.printErr(`WARN the plan of ${repo} broke: ${e.message}`) })
63
+ // the machine's one plan: every repo and check pair, ticked by the daemon.
64
+ // Its catch up window lives in the machine db, so a restart in place keeps it
65
+ const plan = new Planner({ catchUp: machineCatchUp, onError: (repo, e) => io.printErr(`WARN the plan of ${repo} broke: ${e.message}`) })
66
+ // a repo's colour counts only the checks its plan holds today
67
+ const gutter = projectGutter({ root, warn: (l) => io.printErr(l), declared: (name) => plan.declared(name) })
65
68
  const hubs = new Hubs({
66
69
  dir,
67
70
  port,
@@ -84,8 +87,6 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
84
87
  fresh: o.fresh,
85
88
  // every gate is a detached job: a reload of this process never kills one
86
89
  jobs: { cli: CLI },
87
- // a lane click of the live strip opens that repo's page, on the page's own origin
88
- repoHub: async (other) => (names.has(other) ? `${hubBase(other)}/` : null),
89
90
  })
90
91
  hubIo.print(`${rt.name} hub → http://localhost:${port}${hubBase(repo.name)}/`)
91
92
  return { ...rt, uses: rt.deps.uses ?? [] }
@@ -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 */
@@ -75,6 +96,7 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
75
96
  const pm = new Pm({
76
97
  dir,
77
98
  root,
99
+ port,
78
100
  cmd: (role) => [process.execPath, CLI, role],
79
101
  probe: roleAnswers,
80
102
  resources: new Resources(c.processes, dir),
@@ -97,13 +119,16 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
97
119
  pm.resources.errors = c.errors
98
120
  for (const e of c.errors) io.printErr(`WARN ${e}`)
99
121
  // a unit file added, edited or removed in any house repo: read again,
100
- // in its child process, and the unchanged processes keep their state
122
+ // in its child process, and the unchanged processes keep their state.
123
+ // Nothing moved, no child
124
+ const watch = new PmConfigWatch(() => readPmConfig(root))
125
+ watch.seed(c)
101
126
  let reading = false
102
127
  const reread = setInterval(() => {
103
128
  if (reading) return
104
129
  reading = true
105
- void readPmConfig(root)
106
- .then((n) => pm.resources.sync(n.processes, n.errors))
130
+ void watch.poll()
131
+ .then((n) => { if (n != null) pm.resources.sync(n.processes, n.errors) })
107
132
  .catch((e) => io.printErr(`WARN pm could not read the global processes again: ${(e as Error).message}`))
108
133
  .finally(() => { reading = false })
109
134
  }, PM_RESCAN_MS)
@@ -1,5 +1,5 @@
1
1
  // the web: ONE Bun.serve on the home port. It serves the one page bundle (the
2
- // overview at `/`, the plan at `/planned`, a repo's hub page at `/r/<repo>/`,
2
+ // overview at `/`, the plan at `/planned`, the activity at `/activity`, a repo's hub page at `/r/<repo>/`,
3
3
  // all views of one app) and proxies every api,
4
4
  // graph and media request to the daemon, streams included, and /api/pm/* to
5
5
  // pm. A browser only ever sees this origin.
@@ -14,7 +14,7 @@ import { hubBase } from './home-paths.ts'
14
14
  /** the page routes: ONE app for the overview, the plan, and the hub page of
15
15
  * each known repo, which moves between them in place (home-st.ts) */
16
16
  export function pageRoutes<T>(names: string[], page: T): Record<string, T> {
17
- const out: Record<string, T> = { '/': page, '/planned': page }
17
+ const out: Record<string, T> = { '/': page, '/planned': page, '/activity': page, '/archived': page }
18
18
  for (const n of names) out[`${hubBase(n)}/`] = page
19
19
  return out
20
20
  }
@@ -51,6 +51,9 @@ 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'
56
+
54
57
  /** a run or a check run streams for minutes, and a silent one is never cut */
55
58
  export const streams = (path: string): boolean => /\/api\/(run|check\/run)$/.test(path)
56
59
 
@@ -94,7 +97,7 @@ export function serveWeb<T extends Bun.HTMLBundle | Response>(d: WebDeps<T>) {
94
97
  const path = url.pathname
95
98
  const pm = toPm(path)
96
99
  const target = pm ? d.pm : d.daemon
97
- const wait = streams(path) ? null : path.startsWith('/r/') ? (d.repoMs ?? REPO_ANSWER_MS) : (d.machineMs ?? MACHINE_ANSWER_MS)
100
+ const wait = streams(path) ? null : path.startsWith('/r/') || readsGit(path) ? (d.repoMs ?? REPO_ANSWER_MS) : (d.machineMs ?? MACHINE_ANSWER_MS)
98
101
  // the wait covers the answer headers only: a body already flowing is never cut
99
102
  const ctl = new AbortController()
100
103
  const timer = wait == null ? null : setTimeout(() => ctl.abort(new Error(`no answer in ${wait / 1000}s: it runs but hangs`)), wait)
@@ -143,19 +143,33 @@ export class Proc {
143
143
  external = false
144
144
  /** the pid came from the pid record an earlier image wrote, not from a start */
145
145
  adopted = false
146
+ /** when the pid started, kept per pid: a pid's start never moves, so the
147
+ * OS is asked once for an adopted pid and never for one this image spawned */
148
+ private started: { pid: number; at: number | null } | null = null
146
149
 
147
150
  constructor(readonly spec: ProcSpec, private d: SuperviseDeps = realDeps) {
148
151
  this.since = d.now()
149
152
  const rec = readPid(spec.pidKey)
150
153
  // an earlier image started it and it still runs: adopt, never double
151
- if (rec != null && d.alive(rec.pid) && !((d.startedAt?.(rec.pid) ?? 0) > rec.at + RECORD_SLACK_MS)) {
154
+ const at = rec != null && d.alive(rec.pid) ? (d.startedAt?.(rec.pid) ?? null) : undefined
155
+ if (rec != null && at !== undefined && !((at ?? 0) > rec.at + RECORD_SLACK_MS)) {
152
156
  this.pid = rec.pid
157
+ this.started = { pid: rec.pid, at }
153
158
  this.adopted = true
154
159
  this.want = 'up'
155
160
  this.set('starting')
156
161
  } else if (rec != null) dropPid(spec.pidKey)
157
162
  }
158
163
 
164
+ /** epoch ms its pid started, what its uptime counts from; null without
165
+ * a pid, or when the OS cannot say */
166
+ startedAt(): number | null {
167
+ const pid = this.pid
168
+ if (pid == null) return null
169
+ if (this.started?.pid !== pid) this.started = { pid, at: this.d.startedAt?.(pid) ?? null }
170
+ return this.started.at
171
+ }
172
+
159
173
  private set(s: ProcStatus, note: string | null = null): void {
160
174
  if (s !== this.status) this.since = this.d.now()
161
175
  this.status = s
@@ -185,6 +199,7 @@ export class Proc {
185
199
  try {
186
200
  const pid = this.d.spawn(this.spec)
187
201
  this.pid = pid
202
+ this.started = { pid, at: this.d.now() }
188
203
  this.adopted = false
189
204
  writePid(this.spec.pidKey, { pid, cmd: this.spec.cmd, at: this.d.now() })
190
205
  this.set('starting')
@@ -0,0 +1,119 @@
1
+ // a widget's html as console lines, for `shipkit overview`: the html the
2
+ // daemon rendered for the page, tags stripped, a table's cells aligned in
3
+ // columns, a block element on its own line, and a `.wbar` meter (its width
4
+ // in `--p:NN%`) drawn as `█████░░░░░`. It reads, never runs: no script, no
5
+ // style, no layout, so a widget that draws only with css reads as its text.
6
+
7
+ const BLOCK = new Set(['div', 'p', 'section', 'article', 'header', 'footer', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'hr', 'dl', 'dt', 'dd', 'figure', 'figcaption', 'blockquote'])
8
+ const SKIP = new Set(['script', 'style', 'template', 'svg'])
9
+ const BAR_CELLS = 10
10
+
11
+ const ENTITY: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·', deg: '°', hellip: '…', times: '×' }
12
+
13
+ export function decodeEntities(s: string): string {
14
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => {
15
+ if (e[0] === '#') {
16
+ const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))
17
+ return Number.isFinite(n) ? String.fromCodePoint(n) : m
18
+ }
19
+ return ENTITY[e.toLowerCase()] ?? m
20
+ })
21
+ }
22
+
23
+ /** a meter of BAR_CELLS cells, `p` in 0..100 */
24
+ export function barText(p: number): string {
25
+ const on = Math.round((Math.min(100, Math.max(0, p)) / 100) * BAR_CELLS)
26
+ return '█'.repeat(on) + '░'.repeat(BAR_CELLS - on)
27
+ }
28
+
29
+ type Block = { kind: 'line'; text: string } | { kind: 'table'; rows: string[][] }
30
+
31
+ const attr = (attrs: string, name: string): string | null => {
32
+ const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(attrs)
33
+ return m == null ? null : (m[2] ?? m[3] ?? m[4] ?? '')
34
+ }
35
+
36
+ const squash = (s: string): string => s.replace(/\s+/g, ' ').trim()
37
+
38
+ /** a table's rows as aligned lines: each column as wide as its widest cell */
39
+ export function alignRows(rows: string[][]): string[] {
40
+ const w: number[] = []
41
+ for (const r of rows) r.forEach((c, i) => { w[i] = Math.max(w[i] ?? 0, c.length) })
42
+ return rows.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(w[i]!))).join(' ').trimEnd())
43
+ }
44
+
45
+ /** a widget's leading `<header>`: the words it puts on its card's title
46
+ * line (state, age), apart from the body drawn under that line */
47
+ export function splitWidgetHead(html: string): { head: string | null; body: string } {
48
+ const m = /^\s*<header\b[^>]*>([\s\S]*?)<\/header>/i.exec(html)
49
+ return m == null ? { head: null, body: html } : { head: m[1]!, body: html.slice(m[0].length) }
50
+ }
51
+
52
+ /** the html as lines, no empty one */
53
+ export function widgetText(html: string): string[] {
54
+ const blocks: Block[] = []
55
+ let line = ''
56
+ let table: string[][] | null = null
57
+ let cell: string | null = null
58
+ let skip: string | null = null
59
+ const put = (s: string): void => {
60
+ if (cell != null) cell += s
61
+ else line += s
62
+ }
63
+ const flush = (): void => {
64
+ for (const part of line.split('\n')) if (squash(part) !== '') blocks.push({ kind: 'line', text: squash(part) })
65
+ line = ''
66
+ }
67
+ const endCell = (): void => {
68
+ if (cell != null && table != null) table.at(-1)?.push(squash(cell))
69
+ cell = null
70
+ }
71
+ const tokens = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>|([^<]+)|</g
72
+ for (const m of html.matchAll(tokens)) {
73
+ const [whole, close, rawTag, attrs = '', text] = m
74
+ if (text != null || whole === '<') {
75
+ if (skip == null) put(decodeEntities(text ?? '<'))
76
+ continue
77
+ }
78
+ if (rawTag == null) continue
79
+ const tag = rawTag.toLowerCase()
80
+ if (skip != null) {
81
+ if (close === '/' && tag === skip) skip = null
82
+ continue
83
+ }
84
+ if (SKIP.has(tag) && close !== '/' && !attrs.trim().endsWith('/')) {
85
+ skip = tag
86
+ continue
87
+ }
88
+ if (tag === 'table') {
89
+ endCell()
90
+ if (close === '/') {
91
+ if (table != null) blocks.push({ kind: 'table', rows: table.filter((r) => r.some((c) => c !== '')) })
92
+ table = null
93
+ } else {
94
+ flush()
95
+ table = []
96
+ }
97
+ } else if (tag === 'tr' && table != null) {
98
+ endCell()
99
+ if (close !== '/') table.push([])
100
+ } else if ((tag === 'td' || tag === 'th') && table != null) {
101
+ endCell()
102
+ if (close !== '/') {
103
+ if (table.length === 0) table.push([])
104
+ cell = ''
105
+ }
106
+ } else if (tag === 'br') put(cell != null ? ' ' : '\n')
107
+ else if (BLOCK.has(tag)) {
108
+ if (cell != null) put(' ')
109
+ else flush()
110
+ } else if (close !== '/' && /\bwbar\b/.test(attr(attrs, 'class') ?? '')) {
111
+ const p = /--p\s*:\s*(-?\d+(?:\.\d+)?)%/.exec(attr(attrs, 'style') ?? '')
112
+ if (p != null) put(` ${barText(Number(p[1]))} `)
113
+ }
114
+ }
115
+ endCell()
116
+ if (table != null) blocks.push({ kind: 'table', rows: table })
117
+ flush()
118
+ return blocks.flatMap((b) => (b.kind === 'line' ? [b.text] : alignRows(b.rows)))
119
+ }
@@ -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