rvlib-shipkit 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +20 -6
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  18. package/src/checks/SK024-identity-colors.check.ts +5 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Ci.ts +5 -0
  25. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  26. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  27. package/src/cli/cmd_Keys.ts +17 -3
  28. package/src/cli/cmd_Overview.ts +98 -0
  29. package/src/cli/cmd_Planned.ts +117 -0
  30. package/src/cli.ts +9 -1
  31. package/src/collections/checks.ts +3 -2
  32. package/src/collections/providers.ts +1 -1
  33. package/src/collections/test-time.ts +10 -1
  34. package/src/core/agent-spawn.ts +46 -0
  35. package/src/core/archived.ts +82 -0
  36. package/src/core/canonical-scripts.ts +12 -3
  37. package/src/core/check-runner.ts +40 -12
  38. package/src/core/discover-checks.ts +2 -0
  39. package/src/core/file-stamp.ts +20 -0
  40. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  41. package/src/core/identity-color.ts +1 -1
  42. package/src/core/repo-facts.ts +14 -0
  43. package/src/core/retention.ts +14 -5
  44. package/src/core/retired-checks.ts +7 -0
  45. package/src/core/run-check.ts +6 -2
  46. package/src/core/run-ci.ts +43 -12
  47. package/src/core/run-fix.ts +7 -2
  48. package/src/core/run-fixers.ts +10 -6
  49. package/src/core/wire.ts +11 -0
  50. package/src/fleet/scan-fleet.ts +2 -1
  51. package/src/home/activity-shape.ts +135 -0
  52. package/src/home/activity.ts +249 -0
  53. package/src/home/global-units.ts +3 -2
  54. package/src/home/home-routes.ts +32 -5
  55. package/src/home/pm.ts +37 -4
  56. package/src/home/process-rows.ts +205 -0
  57. package/src/home/resources.ts +35 -8
  58. package/src/home/run-daemon.ts +6 -6
  59. package/src/home/run-pm.ts +1 -0
  60. package/src/home/serve-web.ts +6 -3
  61. package/src/home/supervise.ts +16 -1
  62. package/src/home/widget-text.ts +119 -0
  63. package/src/hooks/run-hooks.ts +20 -3
  64. package/src/ledger/db.ts +1 -1
  65. package/src/ledger/gate-checks.ts +17 -3
  66. package/src/ledger/house-runs.ts +43 -0
  67. package/src/ledger/live-timeline.ts +4 -19
  68. package/src/ledger/run-log.ts +39 -10
  69. package/src/ledger/store.ts +19 -2
  70. package/src/ledger/world-cache.ts +51 -4
  71. package/src/plan/cadence.ts +21 -5
  72. package/src/plan/catch-up.ts +37 -0
  73. package/src/plan/plan-timeline.ts +99 -0
  74. package/src/plan/planner.ts +138 -13
  75. package/src/plan/repo-source.ts +120 -8
  76. package/src/release/run-release.ts +9 -2
  77. package/src/release/unit-resolve.ts +8 -12
  78. package/src/repo-config.ts +11 -3
  79. package/src/serve/house-runs.ts +53 -0
  80. package/src/serve/hub-app/activity-st.ts +168 -0
  81. package/src/serve/hub-app/activity-view.tsx +218 -0
  82. package/src/serve/hub-app/activity.css +78 -0
  83. package/src/serve/hub-app/admin-svc.ts +41 -0
  84. package/src/serve/hub-app/admin-view.tsx +42 -0
  85. package/src/serve/hub-app/chart-view.ts +12 -5
  86. package/src/serve/hub-app/check-words.ts +2 -0
  87. package/src/serve/hub-app/controls.css +32 -0
  88. package/src/serve/hub-app/cycle-button.tsx +36 -0
  89. package/src/serve/hub-app/dock-anim.ts +25 -0
  90. package/src/serve/hub-app/dock-st.ts +329 -0
  91. package/src/serve/hub-app/dock-view.tsx +68 -0
  92. package/src/serve/hub-app/dock.css +35 -0
  93. package/src/serve/hub-app/hash-route.ts +7 -1
  94. package/src/serve/hub-app/header-bar.tsx +79 -104
  95. package/src/serve/hub-app/home-st.ts +49 -12
  96. package/src/serve/hub-app/home-strip.css +65 -22
  97. package/src/serve/hub-app/home-strip.tsx +228 -97
  98. package/src/serve/hub-app/home.html +4 -0
  99. package/src/serve/hub-app/hub-app.tsx +96 -160
  100. package/src/serve/hub-app/hub.css +77 -37
  101. package/src/serve/hub-app/icons.tsx +23 -3
  102. package/src/serve/hub-app/index.html +4 -0
  103. package/src/serve/hub-app/keys.ts +42 -7
  104. package/src/serve/hub-app/live-svc.ts +24 -64
  105. package/src/serve/hub-app/machine-st.ts +64 -1
  106. package/src/serve/hub-app/md.ts +38 -5
  107. package/src/serve/hub-app/next-run.ts +1 -1
  108. package/src/serve/hub-app/palette-st.ts +11 -7
  109. package/src/serve/hub-app/palette-view.tsx +47 -0
  110. package/src/serve/hub-app/pill-row.tsx +65 -0
  111. package/src/serve/hub-app/planned-st.ts +21 -85
  112. package/src/serve/hub-app/planned-view.tsx +12 -7
  113. package/src/serve/hub-app/planned.css +5 -2
  114. package/src/serve/hub-app/project-list.css +43 -21
  115. package/src/serve/hub-app/project-list.tsx +122 -23
  116. package/src/serve/hub-app/projects-svc.ts +120 -12
  117. package/src/serve/hub-app/readme-svc.ts +38 -0
  118. package/src/serve/hub-app/readme-view.tsx +19 -0
  119. package/src/serve/hub-app/readme.css +11 -0
  120. package/src/serve/hub-app/repo-sections.tsx +61 -0
  121. package/src/serve/hub-app/repo-st.ts +25 -6
  122. package/src/serve/hub-app/runs-tabs.ts +1 -1
  123. package/src/serve/hub-app/sidebar.tsx +84 -0
  124. package/src/serve/hub-app/tests-view.tsx +31 -4
  125. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  126. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  127. package/src/serve/hub-app/timeline-svc.ts +2 -2
  128. package/src/serve/hub-app/timeline-view.tsx +20 -3
  129. package/src/serve/hub-runtime.ts +17 -9
  130. package/src/serve/other-repos.ts +8 -45
  131. package/src/serve/project-gutter-route.ts +100 -20
  132. package/src/serve/project-gutter.ts +33 -11
  133. package/src/serve/readme-route.ts +57 -0
  134. package/src/serve/serve-hub.ts +13 -8
  135. package/src/serve/tracked-watch.ts +33 -1
  136. package/src/shipkit-files.ts +20 -4
  137. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  138. package/src/checks/contracts.check.ts +0 -20
@@ -0,0 +1,205 @@
1
+ // the processes as rows: shipkit's own first (pm, the daemon, the web: the
2
+ // core group), then one group per declaring repo, one row per process, in the
3
+ // order the page and `shipkit global-processes` both show.
4
+ // The page's table and the command's text and json are all drawn from these
5
+ // rows, so the two can never disagree. A row carries no clock: ages are words
6
+ // computed at render time from `since`.
7
+ import type { GlobalProcessDecl } from './global-units.ts'
8
+ import type { CorePorts, PmAnswer } from './pm.ts'
9
+ import type { ProcessIcon, ResourceView } from './resources.ts'
10
+ import type { ProcStatus } from './supervise.ts'
11
+
12
+ /** a status as pm reports it, or unknown while pm does not answer */
13
+ export type RowStatus = ProcStatus | 'unknown'
14
+
15
+ /** the dot a row wears: green up, red crashed, grey stopped or unknown, orange on its way */
16
+ export type RowDot = 'up' | 'crashed' | 'down' | 'starting' | 'unknown'
17
+
18
+ export type ProcessRow = {
19
+ id: string
20
+ label: string
21
+ repo: string
22
+ file: string
23
+ status: RowStatus
24
+ dot: RowDot
25
+ url: string | null
26
+ /** the url without its scheme, what the table shows dimmed */
27
+ host: string | null
28
+ pid: number | null
29
+ /** epoch ms its state counts from (up: its pid's start when it has one), null when pm never said */
30
+ since: number | null
31
+ note: string | null
32
+ description: string | null
33
+ where: string | null
34
+ /** the last line of its log, null when it has none */
35
+ lastLog: string | null
36
+ tail: string[]
37
+ canStop: boolean
38
+ /** the page offers restart: a global process pm runs and can stop, while
39
+ * up; the daemon and the web always, through pm; never pm itself */
40
+ canRestart: boolean
41
+ /** one of shipkit's own: always on, no stop, no open */
42
+ core: boolean
43
+ /** the shipkit version it runs, when known (core rows only) */
44
+ version: string | null
45
+ takeOver: ResourceView['takeOver']
46
+ actions: { id: string; label: string }[]
47
+ running: string | null
48
+ icon: ProcessIcon | null
49
+ }
50
+
51
+ /** a repo's processes, or shipkit's own (`core`, always the first group) */
52
+ export type ProcessGroup = { repo: string; core?: true; rows: ProcessRow[] }
53
+
54
+ /** the core group's name */
55
+ export const CORE_GROUP = 'shipkit'
56
+
57
+ export const dotOf = (s: RowStatus): RowDot =>
58
+ s === 'up' ? 'up' : s === 'crashed' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
59
+
60
+ const hostOf = (url: string | null): string | null => (url == null ? null : url.replace(/^https?:\/\//, '').replace(/\/$/, ''))
61
+
62
+ /** a process as pm serves it. A pm older than pid and since reads them as null */
63
+ export function rowOf(r: ResourceView): ProcessRow {
64
+ return {
65
+ id: r.id,
66
+ label: r.label,
67
+ repo: r.repo,
68
+ file: r.file,
69
+ status: r.status,
70
+ dot: dotOf(r.status),
71
+ url: r.url,
72
+ host: hostOf(r.url),
73
+ pid: r.pid ?? null,
74
+ since: r.since ?? null,
75
+ note: r.note,
76
+ description: r.description,
77
+ where: r.where,
78
+ lastLog: r.tail.at(-1) ?? null,
79
+ tail: r.tail,
80
+ canStop: r.canStop,
81
+ canRestart: r.status === 'up' && r.canStop,
82
+ core: false,
83
+ version: null,
84
+ takeOver: r.takeOver,
85
+ actions: r.actions,
86
+ running: r.running,
87
+ icon: r.icon,
88
+ }
89
+ }
90
+
91
+ /** a declared process while pm does not answer: everything but its state */
92
+ export function declaredRow(d: GlobalProcessDecl): ProcessRow {
93
+ return {
94
+ id: d.id,
95
+ label: d.label,
96
+ repo: d.repo,
97
+ file: d.file,
98
+ status: 'unknown',
99
+ dot: 'unknown',
100
+ url: d.url,
101
+ host: hostOf(d.url),
102
+ pid: null,
103
+ since: null,
104
+ note: null,
105
+ description: d.description,
106
+ where: d.where,
107
+ lastLog: null,
108
+ tail: [],
109
+ canStop: false,
110
+ canRestart: false,
111
+ core: false,
112
+ version: null,
113
+ takeOver: null,
114
+ actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
115
+ running: null,
116
+ // an image is pm's to serve, and pm does not answer
117
+ icon: d.icon == null || /\.[a-z0-9]+$/i.test(d.icon) ? null : { text: d.icon },
118
+ }
119
+ }
120
+
121
+ /** one group per repo, in `order` (the project list), the repos it does not
122
+ * list after it by name; a group keeps pm's order */
123
+ export function groupRows(rows: ProcessRow[], order: string[]): ProcessGroup[] {
124
+ const by = new Map<string, ProcessRow[]>()
125
+ for (const r of rows) by.set(r.repo, [...(by.get(r.repo) ?? []), r])
126
+ const rank = (repo: string): number => {
127
+ const i = order.indexOf(repo)
128
+ return i < 0 ? order.length : i
129
+ }
130
+ return [...by.keys()]
131
+ .sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
132
+ .map((repo) => ({ repo, rows: by.get(repo)! }))
133
+ }
134
+
135
+ export const CORE_ABOUT = {
136
+ pm: 'the process manager, the LaunchAgent com.shipkit.pm: launchd starts it at login and again when it dies, so the page has no restart for it (from a terminal: launchctl kickstart -k gui/$(id -u)/com.shipkit.pm)',
137
+ daemon: 'loads every house repo hub in one process and serves their api, restarted through pm',
138
+ web: 'serves this page and passes every api request to the daemon, restarted through pm',
139
+ } as const
140
+
141
+ export type CoreOpts = {
142
+ /** the daemon's own version, asked of the daemon; null when it does not answer */
143
+ daemonVersion?: string | null
144
+ /** where each listens, when pm does not say (an older pm, or pm down) */
145
+ ports?: CorePorts | null
146
+ }
147
+
148
+ 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 {
149
+ const url = o.port == null ? null : `http://127.0.0.1:${o.port}`
150
+ return {
151
+ 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
+ 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,
154
+ }
155
+ }
156
+
157
+ /** shipkit's own processes, one row each: pm as it answers (down when it does
158
+ * not), then the daemon and the web as pm reports them (unknown without pm) */
159
+ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
160
+ const ports = pm?.ports ?? o.ports ?? null
161
+ const rows: ProcessRow[] = [pm == null
162
+ ? coreRow('pm', { status: 'down', pid: null, since: null, note: 'pm does not answer', tail: [], version: null, port: ports?.pm ?? null, restart: false })
163
+ : coreRow('pm', { status: 'up', pid: pm.pid, since: pm.since ?? null, note: null, tail: pm.tail ?? [], version: pm.version, port: ports?.pm ?? null, restart: false })]
164
+ for (const id of ['daemon', 'web'] as const) {
165
+ const c = pm?.children.find((x) => x.id === id)
166
+ const port = ports?.[id] ?? null
167
+ const version = id === 'daemon' ? (o.daemonVersion ?? null) : null
168
+ if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: null, tail: [], version, port, restart: false }))
169
+ else {
170
+ const note = [c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
171
+ 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
+ }
173
+ }
174
+ return { repo: CORE_GROUP, core: true, rows }
175
+ }
176
+
177
+ /** what the page's table holds, from pm's answer: the core group, then the
178
+ * global processes by repo */
179
+ export const processGroups = (pm: PmAnswer | null, order: string[], o: CoreOpts = {}): ProcessGroup[] =>
180
+ [coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map(rowOf), order)]
181
+
182
+ /** the declared icon, else the repo's own, else none */
183
+ export const processIcon = (r: { icon: ProcessIcon | null }, repoIcon: string | null): ProcessIcon | null =>
184
+ r.icon ?? (repoIcon == null ? null : { src: repoIcon })
185
+
186
+ /** a span in its largest whole unit: 45s, 3m, 2h, 4d */
187
+ export function spanWords(ms: number): string {
188
+ const s = Math.max(0, Math.floor(ms / 1000))
189
+ if (s < 60) return `${s}s`
190
+ if (s < 3600) return `${Math.floor(s / 60)}m`
191
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
192
+ return `${Math.floor(s / 86400)}d`
193
+ }
194
+
195
+ /** the state in words: `up 2h`, `crashed 3m ago`, `stopped`, `starting` */
196
+ export function stateWords(r: Pick<ProcessRow, 'status' | 'since'>, now: number): string {
197
+ const age = r.since == null ? null : spanWords(now - r.since)
198
+ switch (r.status) {
199
+ case 'up': return age == null ? 'up' : `up ${age}`
200
+ case 'crashed': return age == null ? 'crashed' : `crashed ${age} ago`
201
+ case 'down': return 'stopped'
202
+ case 'unknown': return 'unknown (pm down)'
203
+ default: return r.status
204
+ }
205
+ }
@@ -1,7 +1,8 @@
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 or as a shipkit
5
+ // app window.
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
@@ -11,6 +12,7 @@ import { mkdirSync, openSync, closeSync } from 'fs'
11
12
  import { dirname, join } from 'path'
12
13
  import { fileURLToPath } from 'url'
13
14
  import type { GlobalProcessDecl } from './global-units.ts'
15
+ import { iconImageType } from '../shipkit-files.ts'
14
16
  import { Proc, realDeps, tailLines, type ProcStatus, type SuperviseDeps } from './supervise.ts'
15
17
  import { resourceLog, resourcePid } from './home-paths.ts'
16
18
  import { groupOf, localPort, portHolders } from '../core/port-holders.ts'
@@ -21,6 +23,11 @@ export type ResourceView = {
21
23
  description: string | null
22
24
  where: string | null
23
25
  status: ProcStatus
26
+ /** the pid pm holds for it, null for a bring-up or one started elsewhere */
27
+ pid: number | null
28
+ /** epoch ms its uptime counts from while up (its pid's start when it has
29
+ * one, else its last status change), its crash time when crashed */
30
+ since: number
24
31
  /** what the owner last asked for */
25
32
  want: 'up' | 'down'
26
33
  note: string | null
@@ -35,13 +42,20 @@ export type ResourceView = {
35
42
  repo: string
36
43
  file: string
37
44
  url: string | null
38
- /** opens as a shipkit app window on its url */
39
- app: boolean
45
+ /** what its card wears, as declared: null leaves the card its repo's icon */
46
+ icon: ProcessIcon | null
40
47
  actions: { id: string; label: string }[]
41
48
  /** the action running now, or null */
42
49
  running: string | null
43
50
  }
44
51
 
52
+ /** an emoji, or the url pm serves a declared image file at */
53
+ export type ProcessIcon = { text: string } | { src: string }
54
+
55
+ /** the page's url for a process's declared icon */
56
+ export const processIconOf = (d: GlobalProcessDecl): ProcessIcon | null =>
57
+ d.icon == null ? null : iconImageType(d.icon) != null ? { src: `/api/pm/icon/${encodeURIComponent(d.id)}` } : { text: d.icon }
58
+
45
59
  export type HttpProbe = (url: string) => Promise<boolean>
46
60
 
47
61
  export const probeUrl: HttpProbe = async (url) => {
@@ -138,14 +152,22 @@ export class Resource {
138
152
  private failed = false
139
153
  /** open it once it answers: in the default browser, or as an app window */
140
154
  private openWhenUp: 'browser' | 'app' | null = null
155
+ /** the status a tick last saw, and since when, for the bring-up kind */
156
+ private seen: ProcStatus = 'down'
157
+ private seenAt: number
141
158
  readonly log: string
142
159
 
143
160
  constructor(readonly decl: GlobalProcessDecl, dir: string, private o: ResourceOpts = {}) {
144
161
  this.log = resourceLog(dir, decl.id)
162
+ this.seenAt = this.now()
145
163
  if (decl.down == null)
146
164
  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
165
  }
148
166
 
167
+ private now(): number {
168
+ return this.o.deps?.now() ?? Date.now()
169
+ }
170
+
149
171
  private probe(): Promise<boolean> {
150
172
  return this.decl.probe == null ? Promise.resolve(true) : (this.o.probe ?? probeUrl)(this.decl.probe)
151
173
  }
@@ -226,12 +248,10 @@ export class Resource {
226
248
  await this.set('up')
227
249
  }
228
250
 
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 */
251
+ /** its url in the default browser, or as a shipkit app window: at once
252
+ * while it answers, else it starts and opens once it does */
232
253
  async open(how: 'browser' | 'app'): Promise<void> {
233
254
  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
255
  if (this.status === 'up') {
236
256
  this.show(how, this.decl.url)
237
257
  return
@@ -275,6 +295,10 @@ export class Resource {
275
295
  const up = await this.probe()
276
296
  this.status = up ? 'up' : this.failed && this.want === 'up' ? 'crashed' : 'down'
277
297
  }
298
+ if (this.status !== this.seen) {
299
+ this.seen = this.status
300
+ this.seenAt = this.now()
301
+ }
278
302
  if (this.openWhenUp != null && this.status === 'up' && this.decl.url != null) {
279
303
  const how = this.openWhenUp
280
304
  this.openWhenUp = null
@@ -290,6 +314,9 @@ export class Resource {
290
314
  description: d.description,
291
315
  where: d.where,
292
316
  status: this.status,
317
+ pid: this.proc?.pid ?? null,
318
+ // up with a pid: since that pid started, so a pm reload never resets its uptime
319
+ since: (this.status === 'up' ? this.proc?.startedAt() : null) ?? this.proc?.since ?? this.seenAt,
293
320
  want: this.want,
294
321
  note: this.note,
295
322
  canStop: this.canStop,
@@ -298,7 +325,7 @@ export class Resource {
298
325
  repo: d.repo,
299
326
  file: d.file,
300
327
  url: d.url,
301
- app: d.app,
328
+ icon: processIconOf(d),
302
329
  actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
303
330
  running: this.running,
304
331
  }
@@ -20,6 +20,7 @@ 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'
25
26
  import { Widgets } from './widgets.ts'
@@ -58,10 +59,11 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
58
59
  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
60
  const c = await loadRepoConfig(root)
60
61
  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}`) })
62
+ // the machine's one plan: every repo and check pair, ticked by the daemon.
63
+ // Its catch up window lives in the machine db, so a restart in place keeps it
64
+ const plan = new Planner({ catchUp: machineCatchUp, onError: (repo, e) => io.printErr(`WARN the plan of ${repo} broke: ${e.message}`) })
65
+ // a repo's colour counts only the checks its plan holds today
66
+ const gutter = projectGutter({ root, warn: (l) => io.printErr(l), declared: (name) => plan.declared(name) })
65
67
  const hubs = new Hubs({
66
68
  dir,
67
69
  port,
@@ -84,8 +86,6 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
84
86
  fresh: o.fresh,
85
87
  // every gate is a detached job: a reload of this process never kills one
86
88
  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
89
  })
90
90
  hubIo.print(`${rt.name} hub → http://localhost:${port}${hubBase(repo.name)}/`)
91
91
  return { ...rt, uses: rt.deps.uses ?? [] }
@@ -75,6 +75,7 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
75
75
  const pm = new Pm({
76
76
  dir,
77
77
  root,
78
+ port,
78
79
  cmd: (role) => [process.execPath, CLI, role],
79
80
  probe: roleAnswers,
80
81
  resources: new Resources(c.processes, dir),
@@ -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
+ }
@@ -43,7 +43,10 @@ export type HooksConfig = {
43
43
  * hooks/leak-guard.ts */
44
44
  leakAllow?: LeakAllowEntry[]
45
45
  /** staged-path prefixes that can never enter git (local state like
46
- * '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not. */
46
+ * '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not.
47
+ * A repo declaring `visibility: 'private'` still commits
48
+ * '.comfy-ts/drafts/' through a broader prefix. Public and undeclared
49
+ * repos block it. */
47
50
  blockedPaths?: string[]
48
51
  /** words the commit-message language gate never counts as French
49
52
  * (proper nouns that carry accents without being a language choice) */
@@ -191,6 +194,20 @@ function waivedLines(waived: LeakWaiver[]): string[] {
191
194
  )
192
195
  }
193
196
 
197
+ const COMFY_DRAFTS = '.comfy-ts/drafts/'
198
+
199
+ /** the staged files the `blocked-paths` gate refuses. A repo DECLARED private
200
+ * (never by default) commits its comfy drafts through a
201
+ * broader block: a draft names private loras, which a private tree may hold.
202
+ * A prefix naming the drafts folder itself keeps them blocked. */
203
+ export function blockedStaged(staged: string[], config: RepoConfig): string[] {
204
+ const prefixes = config.hooks?.blockedPaths ?? []
205
+ const open = draftsOpen(config)
206
+ return staged.filter((f) => prefixes.some((p) => f.startsWith(p) && !(open && f.startsWith(COMFY_DRAFTS) && !p.startsWith(COMFY_DRAFTS))))
207
+ }
208
+
209
+ const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
210
+
194
211
  /** the pre-commit gate: built-in leak guard, then the repo's own commands.
195
212
  * A commit takes ~1s (build/shipkit.md): the gate times itself, and going
196
213
  * over budget WARNs naming the fix, so drift back to slow commits is loud. */
@@ -226,7 +243,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
226
243
  if ((h.blockedPaths ?? []).length > 0) {
227
244
  const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
228
245
  const staged = names.stdout.toString().split('\n').filter(Boolean)
229
- const blocked = staged.filter((f) => h.blockedPaths!.some((p) => f.startsWith(p)))
246
+ const blocked = blockedStaged(staged, config)
230
247
  out.push(blocked.length === 0
231
248
  ? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
232
249
  : {
@@ -327,7 +344,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
327
344
  const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
328
345
  return [
329
346
  `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
330
- ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
347
+ ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
331
348
  `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
332
349
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
333
350
  `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
package/src/ledger/db.ts CHANGED
@@ -287,7 +287,7 @@ function migrate(db: Database): void {
287
287
  /** give the file back its free pages once more than half of it is free and
288
288
  * that is worth it: a DELETE frees pages for reuse but never shrinks the
289
289
  * file. VACUUM holds the write lock while it runs: a gate that asks then
290
- * measures instead of waiting. The daemon's sweep and `check --fix SK023`
290
+ * measures instead of waiting. The daemon's sweep and `check --fix stores-under-cap` (house repo)
291
291
  * call it. True when it ran */
292
292
  export function compactDb(path: string, o: { minFreeBytes?: number } = {}): boolean {
293
293
  const opened = openDb(path, { create: false })