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.
- package/CHANGELOG.md +104 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +15 -10
- package/src/check.ts +26 -10
- package/src/checks/SK001-package-json-shape.check.ts +34 -5
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
- package/src/checks/SK024-identity-colors.check.ts +5 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
- package/src/checks/SK028-readme.check.ts +83 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +119 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +9 -1
- package/src/collections/checks.ts +3 -2
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +7 -3
- package/src/collections/test-time.ts +10 -1
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/budget.ts +19 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +66 -17
- package/src/core/discover-checks.ts +10 -5
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- package/src/core/identity-color.ts +1 -1
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/repo-facts.ts +14 -0
- package/src/core/retention.ts +14 -5
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +8 -7
- package/src/core/run-ci.ts +46 -15
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +49 -9
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +138 -0
- package/src/home/activity.ts +256 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +10 -2
- package/src/home/home-routes.ts +34 -6
- package/src/home/home-start.ts +33 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +40 -7
- package/src/home/process-rows.ts +222 -0
- package/src/home/resources.ts +99 -20
- package/src/home/run-daemon.ts +12 -7
- package/src/home/run-pm.ts +30 -5
- package/src/home/serve-web.ts +6 -3
- package/src/home/supervise.ts +16 -1
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +32 -5
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +20 -6
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +39 -10
- package/src/ledger/store.ts +30 -2
- package/src/ledger/world-cache.ts +63 -14
- package/src/plan/cadence.ts +21 -5
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +138 -13
- package/src/plan/repo-source.ts +120 -8
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +17 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-words.ts +2 -0
- package/src/serve/hub-app/checks-svc.ts +2 -1
- package/src/serve/hub-app/col-tables-view.tsx +124 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +28 -0
- package/src/serve/hub-app/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- package/src/serve/hub-app/hash-route.ts +7 -1
- package/src/serve/hub-app/header-bar.tsx +79 -104
- package/src/serve/hub-app/home-st.ts +50 -13
- package/src/serve/hub-app/home-strip.css +81 -23
- package/src/serve/hub-app/home-strip.tsx +243 -102
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +103 -161
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +101 -39
- package/src/serve/hub-app/icons.tsx +27 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +44 -9
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +65 -13
- package/src/serve/hub-app/md.ts +38 -5
- package/src/serve/hub-app/next-run.ts +1 -1
- package/src/serve/hub-app/palette-st.ts +13 -9
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +21 -85
- package/src/serve/hub-app/planned-view.tsx +12 -7
- package/src/serve/hub-app/planned.css +5 -2
- package/src/serve/hub-app/project-list.css +43 -21
- package/src/serve/hub-app/project-list.tsx +122 -23
- package/src/serve/hub-app/projects-svc.ts +120 -12
- package/src/serve/hub-app/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +26 -7
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +84 -0
- package/src/serve/hub-app/tests-view.tsx +31 -4
- package/src/serve/hub-app/timeline-chart.tsx +31 -27
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +2 -2
- package/src/serve/hub-app/timeline-view.tsx +29 -9
- package/src/serve/hub-runtime.ts +17 -9
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +100 -20
- package/src/serve/project-gutter.ts +34 -13
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/serve-hub.ts +13 -8
- package/src/serve/tracked-watch.ts +33 -1
- package/src/serve/warm-cell.ts +49 -16
- package/src/shipkit-files.ts +20 -4
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
package/src/core/run-fix.ts
CHANGED
|
@@ -17,6 +17,9 @@ export type FixOpts = {
|
|
|
17
17
|
* not wire-shaped, which the frozen output contract forbids */
|
|
18
18
|
quiet?: boolean
|
|
19
19
|
print?: (line: string) => void
|
|
20
|
+
/** where a fix that changed nothing reports, since the run after it says
|
|
21
|
+
* the same about that check. Default: print */
|
|
22
|
+
detail?: (line: string) => void
|
|
20
23
|
/** the config as it stands on disk now, when the caller holds one: a
|
|
21
24
|
* second load of a rewritten repo.config.ts can hit the module cache */
|
|
22
25
|
config?: RepoConfig
|
|
@@ -75,7 +78,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
75
78
|
// it is an ok, not a warning. It carries a frozen wire prefix like
|
|
76
79
|
// every other line the fix pass emits: `shipkit ci` promises its whole
|
|
77
80
|
// output is parseable, and a bare line breaks that for every reader.
|
|
78
|
-
|
|
81
|
+
const say = f.changed ? print : o.detail ?? print
|
|
82
|
+
say(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
|
|
79
83
|
// a fixer may rewrite repo.config.ts, and the re-run below and every
|
|
80
84
|
// fixer after it must read what is on disk now, never the config this
|
|
81
85
|
// pass loaded before the rewrite
|
|
@@ -89,7 +93,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
89
93
|
await runCheckBody(rule, after)
|
|
90
94
|
// the fix already ran: advertising it again on surviving findings
|
|
91
95
|
// would send an agent in a loop
|
|
92
|
-
|
|
96
|
+
// inside the ci fix pass the run below reports this check's end state
|
|
97
|
+
for (const l of after.lines) (o.quiet === true ? o.detail ?? print : say)(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
|
|
93
98
|
if (after.failed) exit = 1
|
|
94
99
|
}
|
|
95
100
|
const unfixable = all.filter((r) => r.fix == null).length
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -140,6 +140,9 @@ export type FixPassOpts = {
|
|
|
140
140
|
* mutate the tree they are about to ship) */
|
|
141
141
|
armHooks?: boolean
|
|
142
142
|
print: (line: string) => void
|
|
143
|
+
/** where the lines nobody acts on go (a clean pass, an unfilled slot).
|
|
144
|
+
* Default: print. `shipkit ci` shows them only under --verbose */
|
|
145
|
+
detail?: (line: string) => void
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
export type FixPassResult = {
|
|
@@ -220,12 +223,13 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
220
223
|
if (o.armHooks === true && !verify) {
|
|
221
224
|
const armed = armHooks(root, config)
|
|
222
225
|
if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail}), commit the .shipkit/hooks/ dir`)
|
|
223
|
-
else if (armed.state === 'foreign' || armed.state === 'not-root') o.print(`skip: hooks (${armed.detail})`)
|
|
226
|
+
else if (armed.state === 'foreign' || armed.state === 'not-root') (o.detail ?? o.print)(`skip: hooks (${armed.detail})`)
|
|
224
227
|
else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
|
|
225
228
|
}
|
|
229
|
+
const detail = o.detail ?? o.print
|
|
226
230
|
const { slots, unfilled, disabled } = resolveFixers(root, config)
|
|
227
|
-
for (const u of unfilled)
|
|
228
|
-
for (const d of disabled)
|
|
231
|
+
for (const u of unfilled) detail(`skip: ${u.id} (${u.reason})`)
|
|
232
|
+
for (const d of disabled) detail(`skip: ${d.id} (${d.reason})`)
|
|
229
233
|
const before = treeState(root)
|
|
230
234
|
|
|
231
235
|
for (const s of slots) {
|
|
@@ -236,7 +240,7 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
236
240
|
o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
|
|
237
241
|
}
|
|
238
242
|
// structural repo-config repair is never implicit, and never in verify mode
|
|
239
|
-
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, config })
|
|
243
|
+
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, detail: o.detail, config })
|
|
240
244
|
|
|
241
245
|
const after = treeState(root)
|
|
242
246
|
const reshaped = shapeStamp(root) !== shapeBefore
|
|
@@ -257,11 +261,11 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
|
|
|
257
261
|
}
|
|
258
262
|
const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
|
|
259
263
|
if (before == null) {
|
|
260
|
-
|
|
264
|
+
detail(`ok: fix pass ran, ${slots.length} fixer(s) (not a git repo — rewritten files cannot be listed)`)
|
|
261
265
|
return { ran: true, touched, failed: false, reshaped }
|
|
262
266
|
}
|
|
263
267
|
if (touched.length === 0) {
|
|
264
|
-
|
|
268
|
+
detail(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
|
|
265
269
|
return { ran: true, touched, failed: false, reshaped }
|
|
266
270
|
}
|
|
267
271
|
if (verify) {
|
package/src/core/wire.ts
CHANGED
|
@@ -3,22 +3,51 @@
|
|
|
3
3
|
// exit 0/1. FROZEN: consumers grep startsWith('FAIL'/'WARN'/'skip:') — a
|
|
4
4
|
// prefix change is a breaking change of the whole toolkit.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { CACHED, RAN } from './outcome-words.ts'
|
|
7
|
+
|
|
8
|
+
/** when a remembered world answer was measured, and until when it keeps
|
|
9
|
+
* (null: until its declaration changes). Kept as data, never as text, and
|
|
10
|
+
* rendered against the reader's clock: a time frozen into the line reads
|
|
11
|
+
* as a future expiry long after it passed */
|
|
12
|
+
export type Stamp = { at: number; until: number | null; cached?: boolean }
|
|
13
|
+
|
|
14
|
+
export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string; stamp?: Stamp }
|
|
15
|
+
|
|
16
|
+
/** a span in its one biggest unit: `45s`, `12m`, `3h`, `2d` */
|
|
17
|
+
export function spanShort(ms: number): string {
|
|
18
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
19
|
+
if (s < 60) return `${s}s`
|
|
20
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
21
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
22
|
+
return `${Math.floor(s / 86400)}d`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `cached, ran 3h ago, kept 57m more`, `ran 1d ago, expired 1d ago` */
|
|
26
|
+
export function stampWords(s: Stamp, now: number): string {
|
|
27
|
+
const age = now - s.at
|
|
28
|
+
const ran = age < 1000 ? `${RAN} just now` : `${RAN} ${spanShort(age)} ago`
|
|
29
|
+
const keeps = s.until == null ? 'kept until its declaration changes' : s.until > now ? `kept ${spanShort(s.until - now)} more` : `expired ${spanShort(now - s.until)} ago`
|
|
30
|
+
return `${s.cached === true ? `${CACHED}, ` : ''}${ran}, ${keeps}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** a line's text as a reader sees it now, its stamp rendered */
|
|
34
|
+
export const lineText = (l: Line, now: number = Date.now()): string => (l.stamp == null ? l.text : `${l.text} (${stampWords(l.stamp, now)})`)
|
|
7
35
|
|
|
8
36
|
const green = (s: string): string => `\x1b[32m${s}\x1b[0m`
|
|
9
37
|
const red = (s: string): string => `\x1b[31m${s}\x1b[0m`
|
|
10
38
|
const yellow = (s: string): string => `\x1b[33m${s}\x1b[0m`
|
|
11
39
|
|
|
12
|
-
export function formatLine(l: Line, color: boolean): string {
|
|
40
|
+
export function formatLine(l: Line, color: boolean, now: number = Date.now()): string {
|
|
13
41
|
const c = (f: (s: string) => string, s: string) => (color ? f(s) : s)
|
|
42
|
+
const text = lineText(l, now)
|
|
14
43
|
switch (l.kind) {
|
|
15
|
-
case 'ok': return `${c(green, 'ok:')} ${
|
|
16
|
-
case 'warn': return c(yellow, `WARN ${
|
|
17
|
-
case 'fail': return c(red, `FAIL: ${
|
|
18
|
-
case 'fix-warn': return ` ${c(yellow, `fix: ${
|
|
19
|
-
case 'fix-fail': return ` ${c(red, `fix: ${
|
|
20
|
-
case 'skip': return `skip: ${
|
|
21
|
-
case 'note': return
|
|
44
|
+
case 'ok': return `${c(green, 'ok:')} ${text}`
|
|
45
|
+
case 'warn': return c(yellow, `WARN ${text}`)
|
|
46
|
+
case 'fail': return c(red, `FAIL: ${text}`)
|
|
47
|
+
case 'fix-warn': return ` ${c(yellow, `fix: ${text}`)}`
|
|
48
|
+
case 'fix-fail': return ` ${c(red, `fix: ${text}`)}`
|
|
49
|
+
case 'skip': return `skip: ${text}`
|
|
50
|
+
case 'note': return text
|
|
22
51
|
}
|
|
23
52
|
}
|
|
24
53
|
|
|
@@ -49,6 +78,17 @@ export function parseDuration(d: string | number): number {
|
|
|
49
78
|
return Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3600_000, d: 86400_000 }[m[2] as 'ms' | 's' | 'm' | 'h' | 'd']
|
|
50
79
|
}
|
|
51
80
|
|
|
81
|
+
/** `ttl: 'forever'`: a DECIDED fact (a repo's visibility, an account that
|
|
82
|
+
* exists). It moves only when someone decides to move it, and that decision
|
|
83
|
+
* is the declared value in its key, so a clean answer never expires */
|
|
84
|
+
export const FOREVER = 'forever'
|
|
85
|
+
|
|
86
|
+
/** what a forever answer keeps for: no clock ends it, no sweep drops it */
|
|
87
|
+
export const FOREVER_MS = Number.MAX_SAFE_INTEGER
|
|
88
|
+
|
|
89
|
+
/** how long a clean answer keeps, from a declared ttl. 0 = none declared */
|
|
90
|
+
export const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === '' ? 0 : ttl === FOREVER ? FOREVER_MS : parseDuration(ttl))
|
|
91
|
+
|
|
52
92
|
/** absolute local stamp (2026-08-05 09:12:34), never a relative age: a
|
|
53
93
|
* relative render changes every run and turns committed STATUS.md
|
|
54
94
|
* checkpoints into noise diffs */
|
package/src/fleet/scan-fleet.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// `shipkit check`, so the fix travels through that one code path.
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
|
+
import { isArchived } from '../core/archived.ts'
|
|
15
16
|
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
16
17
|
import { isBreaking } from '../core/update-policy.ts'
|
|
17
18
|
|
|
@@ -84,7 +85,7 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
|
|
|
84
85
|
continue
|
|
85
86
|
}
|
|
86
87
|
const pkg = readPkg(dir)
|
|
87
|
-
if (pkg == null) continue
|
|
88
|
+
if (pkg == null || isArchived(dir)) continue
|
|
88
89
|
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
89
90
|
const names = Object.keys(all).filter((n) => n.startsWith(prefix))
|
|
90
91
|
if (names.length === 0) continue
|
|
@@ -0,0 +1,138 @@
|
|
|
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 worst line it printed about the problem, '' when it printed none */
|
|
68
|
+
line: string
|
|
69
|
+
/** every fail and warn line it printed, the worst weight first: a check
|
|
70
|
+
* reporting three findings lists three */
|
|
71
|
+
lines: string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
|
|
75
|
+
|
|
76
|
+
export type ActivityRepo = { name: string; path: string }
|
|
77
|
+
|
|
78
|
+
/** the window a request names, when it is one the page offers */
|
|
79
|
+
export function windowMinutes(v: string | null): number {
|
|
80
|
+
const n = Number(v)
|
|
81
|
+
return (WINDOW_MINUTES as readonly number[]).includes(n) ? n : DEFAULT_MINUTES
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** every house repo ships from main: shown green, any other orange, a
|
|
85
|
+
* leftover master included so the repo still to rename stands out */
|
|
86
|
+
export const isMainBranch = (b: string | null): boolean => b === 'main'
|
|
87
|
+
|
|
88
|
+
/** nothing committed in the window and nothing uncommitted: it sits below the busy repos */
|
|
89
|
+
export const idleRow = (r: ActivityRow): boolean =>
|
|
90
|
+
r.error == null && r.commits.length === 0 && r.counts.staged + r.counts.modified + r.counts.untracked === 0
|
|
91
|
+
|
|
92
|
+
/** the most commits a repo shows: a number from CAP_MIN to CAP_MAX */
|
|
93
|
+
export const CAP_MIN = 1
|
|
94
|
+
export const CAP_MAX = 15
|
|
95
|
+
export const DEFAULT_CAP = 3
|
|
96
|
+
|
|
97
|
+
/** a window's label on the toggle: `10m`, `3h`, `3d` */
|
|
98
|
+
export const windowWords = (m: number): string => (m < 60 ? `${m}m` : m < 4320 ? `${m / 60}h` : `${m / 1440}d`)
|
|
99
|
+
|
|
100
|
+
/** a typed cap, as a whole number in CAP_MIN..CAP_MAX; the default when it is no number */
|
|
101
|
+
export function clampCap(v: unknown): number {
|
|
102
|
+
const n = Math.round(Number(v))
|
|
103
|
+
return Number.isFinite(n) && String(v).trim() !== '' ? Math.min(CAP_MAX, Math.max(CAP_MIN, n)) : DEFAULT_CAP
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** an age in one short unit: `now`, `4m`, `2h`, `3d` */
|
|
107
|
+
export function ageShort(at: number, now: number): string {
|
|
108
|
+
const s = Math.max(0, Math.round((now - at) / 1000))
|
|
109
|
+
if (s < 60) return 'now'
|
|
110
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
111
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
112
|
+
return `${Math.floor(s / 86400)}d`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** the uncommitted work in one line: `3 modified, 1 untracked`; '' when clean */
|
|
116
|
+
export function dirtWords(r: ActivityRow): string {
|
|
117
|
+
const c = r.counts
|
|
118
|
+
return [c.staged > 0 ? `${c.staged} staged` : '', c.modified > 0 ? `${c.modified} modified` : '', c.untracked > 0 ? `${c.untracked} untracked` : '']
|
|
119
|
+
.filter((x) => x !== '')
|
|
120
|
+
.join(', ')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** where the branch stands against its upstream, for the branch's hover */
|
|
124
|
+
export function upstreamWords(r: ActivityRow): string {
|
|
125
|
+
if (r.upstream == null) return 'no upstream: nothing on this branch is pushed'
|
|
126
|
+
if (r.gone) return `${r.upstream} is gone`
|
|
127
|
+
const a = r.ahead ?? 0
|
|
128
|
+
const b = r.behind ?? 0
|
|
129
|
+
if (a === 0 && b === 0) return `even with ${r.upstream}`
|
|
130
|
+
return [a > 0 ? `${a} unpushed` : '', b > 0 ? `${b} behind` : ''].filter((x) => x !== '').join(', ') + ` against ${r.upstream}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** the uncommitted paths, one per line, for the hover */
|
|
134
|
+
export const filesWords = (r: ActivityRow): string => {
|
|
135
|
+
const mark = { staged: 'S', modified: 'M', untracked: '?' } as const
|
|
136
|
+
const shown = r.files.map((f) => `${mark[f.kind]} ${f.path}`)
|
|
137
|
+
return (r.files.length >= MAX_FILES ? [...shown, '…'] : shown).join('\n')
|
|
138
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
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 { lineText, 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
|
+
/** every line a check printed about its problems: its fail and warn lines,
|
|
97
|
+
* those of its own weight first (a fail for a failure, a warning for a
|
|
98
|
+
* warning), else its first line that is not a pass, each without the id it
|
|
99
|
+
* leads with */
|
|
100
|
+
export function findingsOf(lines: string, status: 'fail' | 'warn', id: string): string[] {
|
|
101
|
+
let parsed: Line[]
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(lines) as Line[]
|
|
104
|
+
} catch {
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(parsed)) return []
|
|
108
|
+
const of = (k: Line['kind']): Line[] => parsed.filter((l) => l.kind === k)
|
|
109
|
+
let hits = status === 'fail' ? [...of('fail'), ...of('warn')] : [...of('warn'), ...of('fail')]
|
|
110
|
+
if (hits.length === 0) hits = parsed.filter((l) => l.kind !== 'ok' && l.kind !== 'skip').slice(0, 1)
|
|
111
|
+
return hits.map((l) => {
|
|
112
|
+
const t = lineText(l).trim()
|
|
113
|
+
// `[ID] text`, and a runner line's `ID — text`: the list names the id already
|
|
114
|
+
for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
|
|
115
|
+
return t
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** the worst of them, '' when it printed none */
|
|
120
|
+
export const findingOf = (lines: string, status: 'fail' | 'warn', id: string): string => findingsOf(lines, status, id)[0] ?? ''
|
|
121
|
+
|
|
122
|
+
/** a repo's checks as its run record last saw them, counted over what it
|
|
123
|
+
* declares, and the failing and warning ones with what each said */
|
|
124
|
+
export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
|
|
125
|
+
const record = recordOf(repo.path)
|
|
126
|
+
if (record == null) return { status: null, failing: [] }
|
|
127
|
+
const rows = readLastStatusReadonly(record, { lines: true })
|
|
128
|
+
const status = checkStateOf(rows, declared?.(repo.name) ?? null)
|
|
129
|
+
if (status == null) return { status, failing: [] }
|
|
130
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
131
|
+
const named = titles?.(repo.name) ?? null
|
|
132
|
+
const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
|
|
133
|
+
const r = byId.get(id)
|
|
134
|
+
const lines = r == null ? [] : findingsOf(r.lines, st, id)
|
|
135
|
+
return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: lines[0] ?? '', lines }
|
|
136
|
+
}
|
|
137
|
+
return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** one repo, from `since` (epoch ms) to now */
|
|
141
|
+
export async function repoActivity(repo: ActivityRepo, since: number, declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityRow> {
|
|
142
|
+
const empty = parseStatus('')
|
|
143
|
+
const row: ActivityRow = { name: repo.name, ...empty, commits: [], last: null, ...statusOf(repo, declared, titles), error: null }
|
|
144
|
+
try {
|
|
145
|
+
const iso = new Date(since).toISOString()
|
|
146
|
+
const range = [`--since=${iso}`, `--max-count=${MAX_COMMITS}`, '--no-show-signature', FORMAT]
|
|
147
|
+
// every ask that needs no other answer goes at once: git calls are
|
|
148
|
+
// ~5ms each but queue behind a busy daemon, so waiting for them one
|
|
149
|
+
// by one multiplies the wait. The upstream diff and the last commit
|
|
150
|
+
// are cheap enough to ask even when the answer goes unused
|
|
151
|
+
const [st, up, head, refs, one] = await Promise.all([
|
|
152
|
+
git(repo.path, ['status', '--porcelain=v2', '--branch']),
|
|
153
|
+
git(repo.path, ['rev-list', `--max-count=${MAX_COMMITS}`, '@{upstream}..HEAD']),
|
|
154
|
+
// a repo with no commit yet has no HEAD to walk: nothing committed
|
|
155
|
+
git(repo.path, ['log', ...range, 'HEAD']),
|
|
156
|
+
// the other local branches (an agent's worktree) whose tip moved in
|
|
157
|
+
// the window, minus what HEAD has: a repo with hundreds of branches
|
|
158
|
+
// walks only the few that moved
|
|
159
|
+
git(repo.path, ['for-each-ref', '--format=%(refname:short)%09%(committerdate:unix)%09%(upstream:short)%09%(upstream:track)', 'refs/heads']),
|
|
160
|
+
git(repo.path, ['log', '-1', '--no-show-signature', FORMAT, 'HEAD']),
|
|
161
|
+
])
|
|
162
|
+
if (st.code !== 0) return { ...row, error: st.err || `git status exited ${st.code}` }
|
|
163
|
+
Object.assign(row, parseStatus(st.out))
|
|
164
|
+
// HEAD's unpushed commits: all of them without an upstream, else the
|
|
165
|
+
// `ahead` ones
|
|
166
|
+
const headAway = row.upstream == null || row.gone
|
|
167
|
+
const away = !headAway && (row.ahead ?? 0) > 0 && up.code === 0 ? new Set(up.out.split('\n').filter((l) => l !== '')) : new Set<string>()
|
|
168
|
+
const own = head.code === 0 ? parseLog(head.out, true, (h) => headAway || away.has(h)) : []
|
|
169
|
+
const moved = (refs.code === 0 ? refs.out.split('\n') : [])
|
|
170
|
+
.map((l) => l.split('\t'))
|
|
171
|
+
.filter((f) => f.length >= 2 && f[0] !== row.branch && Number(f[1]) * 1000 >= since)
|
|
172
|
+
// an upstream that is gone holds nothing
|
|
173
|
+
.map((f) => (f[3] === '[gone]' ? [f[0]!, f[1]!, ''] : f))
|
|
174
|
+
let other: ActivityCommit[] = []
|
|
175
|
+
if (moved.length > 0) {
|
|
176
|
+
const side = await git(repo.path, ['log', ...range, '--source', ...moved.map((f) => `refs/heads/${f[0]}`), '--not', 'HEAD', '--'])
|
|
177
|
+
// a side branch's commit is pushed once its own upstream holds it;
|
|
178
|
+
// on a failed ask every one reads unpushed, never pushed
|
|
179
|
+
const tracked = moved.filter((f) => (f[2] ?? '') !== '')
|
|
180
|
+
let sideAway = new Set<string>()
|
|
181
|
+
if (tracked.length > 0) {
|
|
182
|
+
const r = await git(repo.path, ['rev-list', `--since=${iso}`, ...tracked.map((f) => `refs/heads/${f[0]}`), '--not', ...tracked.map((f) => f[2]!), '--'])
|
|
183
|
+
if (r.code === 0) sideAway = new Set(r.out.split('\n').filter((l) => l !== ''))
|
|
184
|
+
else sideAway = new Set(['*'])
|
|
185
|
+
}
|
|
186
|
+
const untracked = new Set(moved.filter((f) => (f[2] ?? '') === '').map((f) => f[0]!))
|
|
187
|
+
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)) : []
|
|
188
|
+
}
|
|
189
|
+
row.commits = [...own, ...other].sort((a, b) => b.at - a.at).slice(0, MAX_COMMITS)
|
|
190
|
+
// an empty window still says what came last on HEAD
|
|
191
|
+
if (row.commits.length === 0) {
|
|
192
|
+
row.last = one.code === 0 ? parseLog(one.out, true, (h) => headAway || away.has(h))[0] ?? null : null
|
|
193
|
+
}
|
|
194
|
+
return row
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return { ...row, error: (e as Error).message }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** how many repos a pass reads at once: five git calls each, so a pass
|
|
201
|
+
* never has more than 30 git processes alive */
|
|
202
|
+
export const ACTIVITY_CONCURRENCY = 6
|
|
203
|
+
|
|
204
|
+
/** every repo, ACTIVITY_CONCURRENCY at a time, rows in the order given.
|
|
205
|
+
* THE computation: the daemon's route and `shipkit cross-repo-activity`
|
|
206
|
+
* both call it, and only render it differently */
|
|
207
|
+
export async function activityOf(repos: ActivityRepo[], minutes: number, now = Date.now(), declared: Declared | null = null, titles: Titles | null = null): Promise<ActivityAnswer> {
|
|
208
|
+
const t0 = performance.now()
|
|
209
|
+
const rows: ActivityRow[] = new Array(repos.length)
|
|
210
|
+
let next = 0
|
|
211
|
+
const worker = async (): Promise<void> => {
|
|
212
|
+
while (next < repos.length) {
|
|
213
|
+
const i = next++
|
|
214
|
+
rows[i] = await repoActivity(repos[i]!, now - minutes * 60_000, declared, titles)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await Promise.all(Array.from({ length: Math.min(ACTIVITY_CONCURRENCY, repos.length) }, worker))
|
|
218
|
+
return { now, minutes, rows, ms: Math.round(performance.now() - t0) }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** one answer per window, shared for ttlMs. Past it a read waits for a new
|
|
222
|
+
* pass: an old answer is never served as current. Callers during a pass
|
|
223
|
+
* share it. */
|
|
224
|
+
export class ActivityCache {
|
|
225
|
+
private kept = new Map<number, ActivityAnswer>()
|
|
226
|
+
private running = new Map<number, Promise<ActivityAnswer>>()
|
|
227
|
+
private ttlMs: number
|
|
228
|
+
private now: () => number
|
|
229
|
+
private declared: Declared | null
|
|
230
|
+
private titles: Titles | null
|
|
231
|
+
|
|
232
|
+
constructor(o: { ttlMs?: number; now?: () => number; declared?: Declared; titles?: Titles } = {}) {
|
|
233
|
+
this.ttlMs = o.ttlMs ?? ACTIVITY_TTL_MS
|
|
234
|
+
this.now = o.now ?? Date.now
|
|
235
|
+
this.declared = o.declared ?? null
|
|
236
|
+
this.titles = o.titles ?? null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private pass(repos: ActivityRepo[], minutes: number): Promise<ActivityAnswer> {
|
|
240
|
+
const going = this.running.get(minutes)
|
|
241
|
+
if (going != null) return going
|
|
242
|
+
const p = activityOf(repos, minutes, this.now(), this.declared, this.titles)
|
|
243
|
+
.then((a) => { this.kept.set(minutes, a); return a })
|
|
244
|
+
.finally(() => this.running.delete(minutes))
|
|
245
|
+
this.running.set(minutes, p)
|
|
246
|
+
return p
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** the kept answer while it is younger than the ttl, else a new pass.
|
|
250
|
+
* `fresh` always waits for a pass: the page's refresh button */
|
|
251
|
+
get(repos: ActivityRepo[], minutes: number, o: { fresh?: boolean } = {}): Promise<ActivityAnswer> {
|
|
252
|
+
const k = this.kept.get(minutes)
|
|
253
|
+
if (k == null || o.fresh === true || this.now() - k.now >= this.ttlMs) return this.pass(repos, minutes)
|
|
254
|
+
return Promise.resolve(k)
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/home/global-units.ts
CHANGED
|
@@ -21,7 +21,8 @@ export type GlobalProcessDecl = {
|
|
|
21
21
|
cwd: string
|
|
22
22
|
url: string | null
|
|
23
23
|
probe: string | null
|
|
24
|
-
|
|
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
|
-
|
|
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,
|