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,135 @@
1
+ // the activity page's shapes and its few pure rules, apart from the git
2
+ // reader so the page bundle takes them without node's modules. The words
3
+ // here are what the page and `shipkit cross-repo-activity` both print.
4
+ import type { CheckState } from '../serve/project-gutter.ts'
5
+
6
+ /** the windows the page offers, in minutes */
7
+ export const WINDOW_MINUTES = [10, 30, 60, 180, 720, 1440, 4320, 10080] as const
8
+ export const DEFAULT_MINUTES = 30
9
+ /** the most commits the daemon lists per repo, and the most uncommitted paths it names */
10
+ export const MAX_COMMITS = 100
11
+ export const MAX_FILES = 80
12
+
13
+ export type FileKind = 'staged' | 'modified' | 'untracked'
14
+
15
+ export type ActivityCommit = {
16
+ sha: string
17
+ /** committer time, epoch ms */
18
+ at: number
19
+ title: string
20
+ author: string
21
+ /** the branch it sits on when it is not on the checked out one, else null */
22
+ branch: string | null
23
+ /** a remote holds it; false on a branch with no upstream */
24
+ pushed: boolean
25
+ }
26
+
27
+ export type ActivityRow = {
28
+ name: string
29
+ /** the checked out branch; null when detached (see `detached`) */
30
+ branch: string | null
31
+ /** the short sha HEAD sits on when detached */
32
+ detached: string | null
33
+ /** the upstream as configured, null when the branch has none */
34
+ upstream: string | null
35
+ /** the upstream is configured but no longer exists */
36
+ gone: boolean
37
+ /** commits ahead of and behind the upstream, null without one */
38
+ ahead: number | null
39
+ behind: number | null
40
+ counts: Record<FileKind, number>
41
+ /** the uncommitted paths, at most MAX_FILES; a path both staged and
42
+ * modified is listed once, as modified */
43
+ files: { kind: FileKind; path: string }[]
44
+ /** in the window, newest first */
45
+ commits: ActivityCommit[]
46
+ /** HEAD's latest commit when nothing was committed in the window, shown
47
+ * grey; null when the window holds a commit, or the repo has none */
48
+ last: ActivityCommit | null
49
+ /** its checks as its run record last saw them, only the ids it declares
50
+ * today when that is known; null when shipkit never ran there */
51
+ status: CheckState
52
+ /** the checks behind a red or orange status, failing first: what the
53
+ * status column lists on hover and on click */
54
+ failing?: StatusCheck[]
55
+ /** why git could not answer, else null */
56
+ error: string | null
57
+ }
58
+
59
+ /** one failing or warning check as the run record last saw it */
60
+ export type StatusCheck = {
61
+ id: string
62
+ /** its title as the repo declares it, else its id */
63
+ title: string
64
+ status: 'fail' | 'warn'
65
+ /** when that outcome was recorded, epoch ms */
66
+ at: number | null
67
+ /** the first line it printed about the problem, '' when it printed none */
68
+ line: string
69
+ }
70
+
71
+ export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
72
+
73
+ export type ActivityRepo = { name: string; path: string }
74
+
75
+ /** the window a request names, when it is one the page offers */
76
+ export function windowMinutes(v: string | null): number {
77
+ const n = Number(v)
78
+ return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
79
+ }
80
+
81
+ /** every house repo ships from main: shown green, any other orange, a
82
+ * leftover master included so the repo still to rename stands out */
83
+ export const isMainBranch = (b: string | null): boolean => b === 'main'
84
+
85
+ /** nothing committed in the window and nothing uncommitted: it sits below the busy repos */
86
+ export const idleRow = (r: ActivityRow): boolean =>
87
+ r.error == null && r.commits.length === 0 && r.counts.staged + r.counts.modified + r.counts.untracked === 0
88
+
89
+ /** the most commits a repo shows: a number from CAP_MIN to CAP_MAX */
90
+ export const CAP_MIN = 1
91
+ export const CAP_MAX = 15
92
+ export const DEFAULT_CAP = 3
93
+
94
+ /** a window's label on the toggle: `10m`, `3h`, `3d` */
95
+ export const windowWords = (m: number): string => (m < 60 ? `${m}m` : m < 4320 ? `${m / 60}h` : `${m / 1440}d`)
96
+
97
+ /** a typed cap, as a whole number in CAP_MIN..CAP_MAX; the default when it is no number */
98
+ export function clampCap(v: unknown): number {
99
+ const n = Math.round(Number(v))
100
+ return Number.isFinite(n) && String(v).trim() !== '' ? Math.min(CAP_MAX, Math.max(CAP_MIN, n)) : DEFAULT_CAP
101
+ }
102
+
103
+ /** an age in one short unit: `now`, `4m`, `2h`, `3d` */
104
+ export function ageShort(at: number, now: number): string {
105
+ const s = Math.max(0, Math.round((now - at) / 1000))
106
+ if (s < 60) return 'now'
107
+ if (s < 3600) return `${Math.floor(s / 60)}m`
108
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
109
+ return `${Math.floor(s / 86400)}d`
110
+ }
111
+
112
+ /** the uncommitted work in one line: `3 modified, 1 untracked`; '' when clean */
113
+ export function dirtWords(r: ActivityRow): string {
114
+ const c = r.counts
115
+ return [c.staged > 0 ? `${c.staged} staged` : '', c.modified > 0 ? `${c.modified} modified` : '', c.untracked > 0 ? `${c.untracked} untracked` : '']
116
+ .filter((x) => x !== '')
117
+ .join(', ')
118
+ }
119
+
120
+ /** where the branch stands against its upstream, for the branch's hover */
121
+ export function upstreamWords(r: ActivityRow): string {
122
+ if (r.upstream == null) return 'no upstream: nothing on this branch is pushed'
123
+ if (r.gone) return `${r.upstream} is gone`
124
+ const a = r.ahead ?? 0
125
+ const b = r.behind ?? 0
126
+ if (a === 0 && b === 0) return `even with ${r.upstream}`
127
+ return [a > 0 ? `${a} unpushed` : '', b > 0 ? `${b} behind` : ''].filter((x) => x !== '').join(', ') + ` against ${r.upstream}`
128
+ }
129
+
130
+ /** the uncommitted paths, one per line, for the hover */
131
+ export const filesWords = (r: ActivityRow): string => {
132
+ const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
133
+ const shown = r.files.map((f) => `${mark[f.kind]} ${f.path}`)
134
+ return (r.files.length >= MAX_FILES ? [...shown, '…'] : shown).join('\n')
135
+ }
@@ -0,0 +1,249 @@
1
+ // the activity page's data: for every house repo, its branch, how far it is
2
+ // from its upstream, what it holds uncommitted and what it committed in a
3
+ // window. Plain local git, never a fetch, so "ahead" is against the upstream
4
+ // as this disk last saw it.
5
+ //
6
+ // the one constraint a reader breaks first: this runs only when the page asks
7
+ // (no tick). A pass reads ACTIVITY_CONCURRENCY repos at once, and an answer is
8
+ // shared for ACTIVITY_TTL_MS per window, never served past it, so a page left
9
+ // open costs about one pass a minute however many tabs show it, and a closed
10
+ // one costs nothing.
11
+ import { gitBin } from '../core/git-bin.ts'
12
+ import { readLastStatusReadonly } from '../ledger/run-log.ts'
13
+ import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
14
+ import type { Line } from '../core/wire.ts'
15
+ import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
16
+
17
+ export * from './activity-shape.ts'
18
+
19
+ /** how long one window's answer is shared between readers: half the page's
20
+ * 60s cadence, so tabs a few seconds apart share a pass and none is shown
21
+ * an answer older than this */
22
+ export const ACTIVITY_TTL_MS = 30_000
23
+
24
+ export type ParsedStatus = Pick<ActivityRow, 'branch' | 'detached' | 'upstream' | 'gone' | 'ahead' | 'behind' | 'counts' | 'files'>
25
+
26
+ /** `git status --porcelain=v2 --branch`, read */
27
+ export function parseStatus(text: string): ParsedStatus {
28
+ const out: ParsedStatus = { branch: null, detached: null, upstream: null, gone: false, ahead: null, behind: null, counts: { staged: 0, modified: 0, untracked: 0 }, files: [] }
29
+ let oid = ''
30
+ const add = (kind: FileKind, path: string): void => {
31
+ if (out.files.length < MAX_FILES) out.files.push({ kind, path })
32
+ }
33
+ for (const line of text.split('\n')) {
34
+ if (line.startsWith('# branch.oid ')) oid = line.slice(13)
35
+ else if (line.startsWith('# branch.head ')) {
36
+ const h = line.slice(14)
37
+ if (h !== '(detached)') out.branch = h
38
+ } else if (line.startsWith('# branch.upstream ')) out.upstream = line.slice(18)
39
+ else if (line.startsWith('# branch.ab ')) {
40
+ const m = /^\+(\d+) -(\d+)$/.exec(line.slice(12))
41
+ if (m != null) {
42
+ out.ahead = Number(m[1])
43
+ out.behind = Number(m[2])
44
+ }
45
+ } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
46
+ // `1 XY sub mH mI mW hH hI path`, `2 … Xscore path<TAB>orig`
47
+ const f = line.split(' ')
48
+ const xy = f[1] ?? '..'
49
+ const path = f.slice(line.startsWith('1 ') ? 8 : 9).join(' ').split('\t')[0]!
50
+ if (xy[0] !== '.') out.counts.staged++
51
+ if (xy[1] !== '.') out.counts.modified++
52
+ add(xy[1] !== '.' ? 'modified' : 'staged', path)
53
+ } else if (line.startsWith('u ')) {
54
+ out.counts.modified++
55
+ add('modified', line.split(' ').slice(10).join(' '))
56
+ } else if (line.startsWith('? ')) {
57
+ out.counts.untracked++
58
+ add('untracked', line.slice(2))
59
+ }
60
+ }
61
+ if (out.branch == null && oid !== '' && oid !== '(initial)') out.detached = oid.slice(0, 7)
62
+ if (out.upstream != null && out.ahead == null) out.gone = true
63
+ return out
64
+ }
65
+
66
+ const SEP = '\x1f'
67
+ const END = '\x1e'
68
+ const FORMAT = `--format=%h${SEP}%ct${SEP}%an${SEP}%S${SEP}%s${SEP}%H${END}`
69
+
70
+ /** `git log` in FORMAT, read; `unpushed` holds the full shas no remote has */
71
+ export function parseLog(text: string, onHead: boolean, unpushed: (full: string) => boolean = () => false): ActivityCommit[] {
72
+ const out: ActivityCommit[] = []
73
+ for (const rec of text.split(END)) {
74
+ const f = rec.replace(/^\n/, '').split(SEP)
75
+ if (f.length < 6) continue
76
+ const src = f[3]!.replace(/^refs\/heads\//, '')
77
+ out.push({ sha: f[0]!, at: Number(f[1]) * 1000, author: f[2]!, branch: onHead || src === '' ? null : src, title: f[4]!, pushed: !unpushed(f[5]!) })
78
+ }
79
+ return out
80
+ }
81
+
82
+ async function git(cwd: string, args: string[]): Promise<{ code: number; out: string; err: string }> {
83
+ // no optional locks: a look never takes the index lock an agent's commit needs
84
+ const p = Bun.spawn([gitBin(), ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
85
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
86
+ return { code, out, err: err.trim() }
87
+ }
88
+
89
+ /** which check ids a repo declares today: the daemon's plan; null = not
90
+ * known, every recorded check counts */
91
+ export type Declared = (name: string) => ReadonlySet<string> | null
92
+
93
+ /** each declared check's title by id; null = not known, the id stands in */
94
+ export type Titles = (name: string) => ReadonlyMap<string, string> | null
95
+
96
+ /** the line a check printed about its problem: its first line of the same
97
+ * weight (a fail for a failure, a warning for a warning), else its first
98
+ * line that is not a pass, without the id its line leads with */
99
+ export function findingOf(lines: string, status: 'fail' | 'warn', id: string): string {
100
+ let parsed: Line[]
101
+ try {
102
+ parsed = JSON.parse(lines) as Line[]
103
+ } catch {
104
+ return ''
105
+ }
106
+ if (!Array.isArray(parsed)) return ''
107
+ const kinds = status === 'fail' ? ['fail', 'fix-fail'] : ['warn', 'fix-warn']
108
+ const hit = parsed.find((l) => kinds.includes(l.kind)) ?? parsed.find((l) => l.kind !== 'ok' && l.kind !== 'skip')
109
+ if (hit == null) return ''
110
+ const t = hit.text.trim()
111
+ // `[ID] text`, and a runner line's `ID — text`: the list names the id already
112
+ for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
113
+ return t
114
+ }
115
+
116
+ /** a repo's checks as its run record last saw them, counted over what it
117
+ * declares, and the failing and warning ones with what each said */
118
+ export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
119
+ const record = recordOf(repo.path)
120
+ if (record == null) return { status: null, failing: [] }
121
+ const rows = readLastStatusReadonly(record, { lines: true })
122
+ const status = checkStateOf(rows, declared?.(repo.name) ?? null)
123
+ if (status == null) return { status, failing: [] }
124
+ const byId = new Map(rows.map((r) => [r.id, r]))
125
+ const named = titles?.(repo.name) ?? null
126
+ const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
127
+ const r = byId.get(id)
128
+ return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: r == null ? '' : findingOf(r.lines, st, id) }
129
+ }
130
+ return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
131
+ }
132
+
133
+ /** one repo, from `since` (epoch ms) to now */
134
+ export async function repoActivity(repo: ActivityRepo, since: number, declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityRow> {
135
+ const empty = parseStatus('')
136
+ const row: ActivityRow = { name: repo.name, ...empty, commits: [], last: null, ...statusOf(repo, declared, titles), error: null }
137
+ try {
138
+ const iso = new Date(since).toISOString()
139
+ const range = [`--since=${iso}`, `--max-count=${MAX_COMMITS}`, '--no-show-signature', FORMAT]
140
+ // every ask that needs no other answer goes at once: git calls are
141
+ // ~5ms each but queue behind a busy daemon, so waiting for them one
142
+ // by one multiplies the wait. The upstream diff and the last commit
143
+ // are cheap enough to ask even when the answer goes unused
144
+ const [st, up, head, refs, one] = await Promise.all([
145
+ git(repo.path, ['status', '--porcelain=v2', '--branch']),
146
+ git(repo.path, ['rev-list', `--max-count=${MAX_COMMITS}`, '@{upstream}..HEAD']),
147
+ // a repo with no commit yet has no HEAD to walk: nothing committed
148
+ git(repo.path, ['log', ...range, 'HEAD']),
149
+ // the other local branches (an agent's worktree) whose tip moved in
150
+ // the window, minus what HEAD has: a repo with hundreds of branches
151
+ // walks only the few that moved
152
+ git(repo.path, ['for-each-ref', '--format=%(refname:short)%09%(committerdate:unix)%09%(upstream:short)%09%(upstream:track)', 'refs/heads']),
153
+ git(repo.path, ['log', '-1', '--no-show-signature', FORMAT, 'HEAD']),
154
+ ])
155
+ if (st.code !== 0) return { ...row, error: st.err || `git status exited ${st.code}` }
156
+ Object.assign(row, parseStatus(st.out))
157
+ // HEAD's unpushed commits: all of them without an upstream, else the
158
+ // `ahead` ones
159
+ const headAway = row.upstream == null || row.gone
160
+ const away = !headAway && (row.ahead ?? 0) > 0 && up.code === 0 ? new Set(up.out.split('\n').filter((l) => l !== '')) : new Set<string>()
161
+ const own = head.code === 0 ? parseLog(head.out, true, (h) => headAway || away.has(h)) : []
162
+ const moved = (refs.code === 0 ? refs.out.split('\n') : [])
163
+ .map((l) => l.split('\t'))
164
+ .filter((f) => f.length >= 2 && f[0] !== row.branch && Number(f[1]) * 1000 >= since)
165
+ // an upstream that is gone holds nothing
166
+ .map((f) => (f[3] === '[gone]' ? [f[0]!, f[1]!, ''] : f))
167
+ let other: ActivityCommit[] = []
168
+ if (moved.length > 0) {
169
+ const side = await git(repo.path, ['log', ...range, '--source', ...moved.map((f) => `refs/heads/${f[0]}`), '--not', 'HEAD', '--'])
170
+ // a side branch's commit is pushed once its own upstream holds it;
171
+ // on a failed ask every one reads unpushed, never pushed
172
+ const tracked = moved.filter((f) => (f[2] ?? '') !== '')
173
+ let sideAway = new Set<string>()
174
+ if (tracked.length > 0) {
175
+ const r = await git(repo.path, ['rev-list', `--since=${iso}`, ...tracked.map((f) => `refs/heads/${f[0]}`), '--not', ...tracked.map((f) => f[2]!), '--'])
176
+ if (r.code === 0) sideAway = new Set(r.out.split('\n').filter((l) => l !== ''))
177
+ else sideAway = new Set(['*'])
178
+ }
179
+ const untracked = new Set(moved.filter((f) => (f[2] ?? '') === '').map((f) => f[0]!))
180
+ other = side.code === 0 ? parseLog(side.out, false, (h) => sideAway.has('*') || sideAway.has(h)).map((c) => (c.branch != null && untracked.has(c.branch) ? { ...c, pushed: false } : c)) : []
181
+ }
182
+ row.commits = [...own, ...other].sort((a, b) => b.at - a.at).slice(0, MAX_COMMITS)
183
+ // an empty window still says what came last on HEAD
184
+ if (row.commits.length === 0) {
185
+ row.last = one.code === 0 ? parseLog(one.out, true, (h) => headAway || away.has(h))[0] ?? null : null
186
+ }
187
+ return row
188
+ } catch (e) {
189
+ return { ...row, error: (e as Error).message }
190
+ }
191
+ }
192
+
193
+ /** how many repos a pass reads at once: five git calls each, so a pass
194
+ * never has more than 30 git processes alive */
195
+ export const ACTIVITY_CONCURRENCY = 6
196
+
197
+ /** every repo, ACTIVITY_CONCURRENCY at a time, rows in the order given.
198
+ * THE computation: the daemon's route and `shipkit cross-repo-activity`
199
+ * both call it, and only render it differently */
200
+ export async function activityOf(repos: ActivityRepo[], minutes: number, now = Date.now(), declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityAnswer> {
201
+ const t0 = performance.now()
202
+ const rows: ActivityRow[] = new Array(repos.length)
203
+ let next = 0
204
+ const worker = async (): Promise<void> => {
205
+ while (next < repos.length) {
206
+ const i = next++
207
+ rows[i] = await repoActivity(repos[i]!, now - minutes * 60_000, declared, titles)
208
+ }
209
+ }
210
+ await Promise.all(Array.from({ length: Math.min(ACTIVITY_CONCURRENCY, repos.length) }, worker))
211
+ return { now, minutes, rows, ms: Math.round(performance.now() - t0) }
212
+ }
213
+
214
+ /** one answer per window, shared for ttlMs. Past it a read waits for a new
215
+ * pass: an old answer is never served as current. Callers during a pass
216
+ * share it. */
217
+ export class ActivityCache {
218
+ private kept = new Map<number, ActivityAnswer>()
219
+ private running = new Map<number, Promise<ActivityAnswer>>()
220
+ private ttlMs: number
221
+ private now: () => number
222
+ private declared: Declared | null
223
+ private titles: Titles | null
224
+
225
+ constructor(o: { ttlMs?: number; now?: () => number; declared?: Declared; titles?: Titles } = {}) {
226
+ this.ttlMs = o.ttlMs ?? ACTIVITY_TTL_MS
227
+ this.now = o.now ?? Date.now
228
+ this.declared = o.declared ?? null
229
+ this.titles = o.titles ?? null
230
+ }
231
+
232
+ private pass(repos: ActivityRepo[], minutes: number): Promise<ActivityAnswer> {
233
+ const going = this.running.get(minutes)
234
+ if (going != null) return going
235
+ const p = activityOf(repos, minutes, this.now(), this.declared, this.titles)
236
+ .then((a) => { this.kept.set(minutes, a); return a })
237
+ .finally(() => this.running.delete(minutes))
238
+ this.running.set(minutes, p)
239
+ return p
240
+ }
241
+
242
+ /** the kept answer while it is younger than the ttl, else a new pass.
243
+ * `fresh` always waits for a pass: the page's refresh button */
244
+ get(repos: ActivityRepo[], minutes: number, o: { fresh?: boolean } = {}): Promise<ActivityAnswer> {
245
+ const k = this.kept.get(minutes)
246
+ if (k == null || o.fresh === true || this.now() - k.now >= this.ttlMs) return this.pass(repos, minutes)
247
+ return Promise.resolve(k)
248
+ }
249
+ }
@@ -21,7 +21,8 @@ export type GlobalProcessDecl = {
21
21
  cwd: string
22
22
  url: string | null
23
23
  probe: string | null
24
- app: boolean
24
+ /** as declared: an emoji, or an image path relative to root */
25
+ icon: string | null
25
26
  actions: ActionDecl[]
26
27
  /** the repo that declares it, and the file, repo-relative */
27
28
  repo: string
@@ -51,7 +52,7 @@ export function toDecl(u: GlobalProcessUnit, repo: RepoRef, file: string): Globa
51
52
  cwd: resolve(repo.path, u.cwd ?? '.'),
52
53
  url: u.url ?? null,
53
54
  probe: u.probe ?? u.url ?? null,
54
- app: u.app === true,
55
+ icon: u.icon ?? null,
55
56
  actions: Object.entries(u.actions ?? {}).map(([id, a]) => ({ id, label: a.label, cmd: a.cmd ?? null })),
56
57
  repo: repo.name,
57
58
  root: repo.path,
@@ -16,6 +16,9 @@ import { tailLines } from './supervise.ts'
16
16
  import { Hubs, type WorkerView } from './hubs.ts'
17
17
  import type { Planner, PlanRow } from '../plan/planner.ts'
18
18
  import type { Widgets, WidgetView } from './widgets.ts'
19
+ import { ActivityCache, windowMinutes } from './activity.ts'
20
+ import { HouseRuns } from '../serve/house-runs.ts'
21
+ import type { HouseView } from '../ledger/house-runs.ts'
19
22
 
20
23
  /** how often the daemon looks at every repo */
21
24
  export const TICK_MS = 2_000
@@ -76,10 +79,13 @@ export const runModernize = async (repo: WiredRepo, root: string, log: string):
76
79
  }
77
80
  }
78
81
 
79
- /** active = an agent works in it, VS Code has it open, or it is flagged by hand */
80
- export function activeSet(rows: ProjectRow[], flagged: string[]): Set<string> {
82
+ /** active = an agent works in it, VS Code has it open, or it is flagged by
83
+ * hand. The house repo is active whenever any repo is: its checks serve
84
+ * every repo (a login, a shared key), so they follow the machine's work */
85
+ export function activeSet(rows: ProjectRow[], flagged: string[], house?: string): Set<string> {
81
86
  const out = new Set(flagged)
82
87
  for (const r of rows) if ((r.sessions != null && r.sessions > 0) || r.vscode === true) out.add(r.name)
88
+ if (house != null && out.size > 0) out.add(house)
83
89
  return out
84
90
  }
85
91
 
@@ -87,13 +93,24 @@ export function activeSet(rows: ProjectRow[], flagged: string[]): Set<string> {
87
93
  export type WidgetsAnswer = { widgets: WidgetView[]; errors: string[] }
88
94
 
89
95
  /** the planned page's data: every row of the machine's plan */
90
- export type PlannedAnswer = { now: number; rows: PlanRow[]; errors: { repo: string; error: string }[]; running: boolean }
96
+ export type PlannedAnswer = {
97
+ now: number
98
+ rows: PlanRow[]
99
+ errors: { repo: string; error: string }[]
100
+ running: boolean
101
+ /** what holds work back, one line each (Planner.held) */
102
+ held: string[]
103
+ }
91
104
 
92
105
  export function homeRoutes(d: HomeDeps) {
93
106
  let repos: WiredRepo[] = []
94
107
  let rows: ProjectRow[] = []
95
108
  const modernizing = new Map<string, ModernizeView & { log: string }>()
109
+ // read only while the activity page asks, never on the tick
110
+ const activity = new ActivityCache({ declared: (name) => d.plan?.declared(name) ?? null, titles: (name) => d.plan?.titles(name) ?? null })
96
111
  const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
112
+ // the dock: every loaded repo's recent runs, read only while a page asks
113
+ const house = new HouseRuns(() => repos)
97
114
 
98
115
  async function refresh(): Promise<void> {
99
116
  const a = await d.gutter.answer()
@@ -107,7 +124,7 @@ export function homeRoutes(d: HomeDeps) {
107
124
 
108
125
  async function tick(): Promise<void> {
109
126
  await refresh()
110
- active = activeSet(rows, d.state.data.active)
127
+ active = activeSet(rows, d.state.data.active, rows.find((r) => r.path === d.root)?.name ?? d.name)
111
128
  // the plan's clock: one look per tick, and it starts at most one batch
112
129
  d.plan?.tick()
113
130
  // a widget render never holds the tick back
@@ -148,9 +165,19 @@ export function homeRoutes(d: HomeDeps) {
148
165
  }
149
166
  if (path === '/api/home/planned.json') {
150
167
  if (repos.length === 0) await refresh()
151
- const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true }
168
+ const out: PlannedAnswer = { now: Date.now(), rows: d.plan?.rows() ?? [], errors: d.plan?.errors() ?? [], running: d.plan?.busy() === true, held: d.plan?.held() ?? [] }
152
169
  return Response.json(out)
153
170
  }
171
+ if (path === '/api/home/activity.json') {
172
+ if (repos.length === 0) await refresh()
173
+ const q = new URL(req.url).searchParams
174
+ return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
175
+ }
176
+ if (path === '/api/house-runs.json') {
177
+ if (repos.length === 0) await refresh()
178
+ const view: HouseView = { ...house.view(), held: d.plan?.held() ?? [] }
179
+ return Response.json(view, { headers: { 'cache-control': 'no-store' } })
180
+ }
154
181
  if (path === '/api/home/widgets.json') {
155
182
  const out: WidgetsAnswer = { widgets: d.widgets?.views() ?? [], errors: d.widgets?.errors ?? [] }
156
183
  return Response.json(out)
package/src/home/pm.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  // the one constraint a reader breaks first: pm never takes a child down with
8
8
  // it. A reload of pm, or pm killed, leaves both running, and the next pm
9
9
  // adopts them from their pid records. Only a stop asked of pm stops them.
10
+ import { existsSync } from 'fs'
11
+ import { join } from 'path'
10
12
  import { LIB_VERSION } from '../core/lib-version.ts'
13
+ import { iconImageType } from '../shipkit-files.ts'
11
14
  import { sameOrigin } from '../serve/same-origin.ts'
12
- import { childLog, childPid } from './home-paths.ts'
15
+ import { childLog, childPid, daemonPort, homePort, pmLog, pmPort } from './home-paths.ts'
13
16
  import type { Resources, ResourceView } from './resources.ts'
14
17
  import { capLog, Proc, realDeps, tailLines, type ProcView, type SuperviseDeps } from './supervise.ts'
15
18
 
@@ -21,10 +24,22 @@ export const PM_TICK_MS = 2_000
21
24
 
22
25
  export type ChildView = ProcView & { tail: string[] }
23
26
 
27
+ /** where pm, the daemon and the web listen */
28
+ export type CorePorts = { pm: number; daemon: number; web: number }
29
+
30
+ /** how many log lines each of shipkit's own processes hands the page */
31
+ const CORE_TAIL = 20
32
+
24
33
  export type PmAnswer = {
25
34
  pm: 'shipkit-pm'
26
35
  pid: number
36
+ /** epoch ms this pm started; absent from a pm older than the field */
37
+ since?: number
27
38
  version: string
39
+ /** where pm and its children listen; absent from a pm older than the field */
40
+ ports?: CorePorts
41
+ /** the last lines of pm's own log; absent from a pm older than the field */
42
+ tail?: string[]
28
43
  children: ChildView[]
29
44
  resources: ResourceView[]
30
45
  /** the `.shipkit.ts` files that would not load, as pm last read them */
@@ -35,6 +50,8 @@ export type PmDeps = {
35
50
  dir: string
36
51
  /** the house repo: where the children run */
37
52
  root: string
53
+ /** the port pm serves on (default: SHIPKIT_PM_PORT, else 4797) */
54
+ port?: number
38
55
  /** the command that runs one role, e.g. [bun, cli.ts, 'daemon'] */
39
56
  cmd: (role: Role) => string[]
40
57
  /** true once the role serves */
@@ -50,6 +67,7 @@ const WEB_ANSWER_MS = 200
50
67
 
51
68
  export class Pm {
52
69
  readonly children: Map<Role, Proc>
70
+ private readonly startedAt = Date.now()
53
71
 
54
72
  constructor(private d: PmDeps) {
55
73
  this.children = new Map(ROLES.map((role) => [role, new Proc({
@@ -90,14 +108,30 @@ export class Pm {
90
108
  }
91
109
 
92
110
  answer(): PmAnswer {
93
- const children = [...this.children.values()].map((p) => ({ ...p.view(), tail: p.status === 'up' ? [] : tailLines(p.spec.log, 20) }))
94
- return { pm: 'shipkit-pm', pid: process.pid, version: LIB_VERSION, children, resources: this.d.resources.views(), unitErrors: this.d.resources.errors }
111
+ // up: since its pid started, so a pm reload never resets its uptime
112
+ const children = [...this.children.values()].map((p) => {
113
+ const v = p.view()
114
+ return { ...v, since: (v.status === 'up' ? p.startedAt() : null) ?? v.since, tail: tailLines(p.spec.log, CORE_TAIL) }
115
+ })
116
+ const ports = { pm: this.d.port ?? pmPort(), daemon: daemonPort(), web: homePort() }
117
+ return { pm: 'shipkit-pm', pid: process.pid, since: this.startedAt, version: LIB_VERSION, ports, tail: tailLines(pmLog(this.d.dir), CORE_TAIL), children, resources: this.d.resources.views(), unitErrors: this.d.resources.errors }
118
+ }
119
+
120
+ /** the image a global process declares as its icon, and no other file */
121
+ private icon(id: string): Response {
122
+ const d = this.d.resources.get(id)?.decl
123
+ const type = d?.icon == null ? null : iconImageType(d.icon)
124
+ if (d?.icon == null || type == null) return new Response(`no image icon for '${id}'`, { status: 404 })
125
+ const file = join(d.root, d.icon)
126
+ if (!existsSync(file)) return new Response(`${d.repo}/${d.icon} does not exist: the icon of '${id}'`, { status: 404 })
127
+ return new Response(Bun.file(file), { headers: { 'content-type': type, 'cache-control': 'no-cache' } })
95
128
  }
96
129
 
97
130
  async handle(req: Request): Promise<Response> {
98
131
  const path = new URL(req.url).pathname
99
132
  if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
100
133
  if (path === '/api/pm/status.json') return Response.json(this.answer())
134
+ if (path.startsWith('/api/pm/icon/') && req.method === 'GET') return this.icon(decodeURIComponent(path.slice('/api/pm/icon/'.length)))
101
135
  if (req.method !== 'POST') return new Response('not found', { status: 404 })
102
136
  const b = (await req.json().catch(() => ({}))) as Record<string, unknown>
103
137
  if (path === '/api/pm/child') {
@@ -122,7 +156,6 @@ export class Pm {
122
156
  else if (act === 'restart') report(r.restart())
123
157
  else if (act === 'open' || act === 'app') {
124
158
  if (r.decl.url == null) return new Response(`${r.decl.id} declares no url to open`, { status: 400 })
125
- if (act === 'app' && !r.decl.app) return new Response(`${r.decl.id} does not declare app: true, so it has no app window`, { status: 400 })
126
159
  report(r.open(act === 'app' ? 'app' : 'browser'))
127
160
  } else if (act === 'takeover') {
128
161
  const v = r.takeOverView