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.
- package/CHANGELOG.md +67 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +20 -6
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- 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 +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- 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 +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Ci.ts +5 -0
- package/src/cli/cmd_CrossRepoActivity.ts +116 -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/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-runner.ts +40 -12
- package/src/core/discover-checks.ts +2 -0
- 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/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 +6 -2
- package/src/core/run-ci.ts +43 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/global-units.ts +3 -2
- package/src/home/home-routes.ts +32 -5
- package/src/home/pm.ts +37 -4
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +35 -8
- package/src/home/run-daemon.ts +6 -6
- package/src/home/run-pm.ts +1 -0
- 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/hooks/run-hooks.ts +20 -3
- package/src/ledger/db.ts +1 -1
- package/src/ledger/gate-checks.ts +17 -3
- 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 +19 -2
- package/src/ledger/world-cache.ts +51 -4
- 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 +11 -3
- 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/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 +49 -12
- package/src/serve/hub-app/home-strip.css +65 -22
- package/src/serve/hub-app/home-strip.tsx +228 -97
- package/src/serve/hub-app/home.html +4 -0
- package/src/serve/hub-app/hub-app.tsx +96 -160
- package/src/serve/hub-app/hub.css +77 -37
- package/src/serve/hub-app/icons.tsx +23 -3
- package/src/serve/hub-app/index.html +4 -0
- package/src/serve/hub-app/keys.ts +42 -7
- package/src/serve/hub-app/live-svc.ts +24 -64
- package/src/serve/hub-app/machine-st.ts +64 -1
- 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 +11 -7
- 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 +25 -6
- 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 +20 -3
- 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 +33 -11
- 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/shipkit-files.ts +20 -4
- package/src/checks/SK023-stores-under-cap.check.ts +0 -49
- package/src/checks/contracts.check.ts +0 -20
package/src/plan/planner.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// calls tick() on its own beat (the daemon's tick, or one interval in a hub of
|
|
9
9
|
// its own), and only the save debounce holds a timer, injected. It decides
|
|
10
10
|
// WHEN, never what is true: every verdict comes from the repo's own runner.
|
|
11
|
+
//
|
|
12
|
+
// what keeps it off the cpu: a save burst is asked about once the repo is
|
|
13
|
+
// quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
|
|
14
|
+
// save moved waits COST_FACTOR times its last measured run, and nothing it
|
|
15
|
+
// starts on its own starts while the load per core is above LOAD_MAX.
|
|
16
|
+
import { availableParallelism, loadavg } from 'os'
|
|
17
|
+
import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
|
|
11
18
|
import { nextDue, resolveCadence, resolveWhenInactive, stablePoint, type EveryValue, type LastResult, type PlanDecl } from './cadence.ts'
|
|
12
19
|
|
|
13
20
|
export type PlanReason = 'changed' | 'scheduled' | 'late' | 'asked'
|
|
@@ -15,6 +22,8 @@ export type PlanReason = 'changed' | 'scheduled' | 'late' | 'asked'
|
|
|
15
22
|
/** one check as the plan sees it */
|
|
16
23
|
export type PlanCheck = {
|
|
17
24
|
id: string
|
|
25
|
+
/** the name an operator reads, when the host knows one */
|
|
26
|
+
title?: string
|
|
18
27
|
plan: PlanDecl
|
|
19
28
|
/** a save can make it due: its answer is keyed on the tree, or it is a
|
|
20
29
|
* gate check nothing can key */
|
|
@@ -34,6 +43,10 @@ export type PlanSource = {
|
|
|
34
43
|
changed: () => Promise<string[]>
|
|
35
44
|
/** run these checks now, in the background; resolves to the exit code */
|
|
36
45
|
run: (ids: string[], why: PlanReason) => Promise<number>
|
|
46
|
+
/** at join: did the owner edit something after the repo's last run. A
|
|
47
|
+
* yes counts as a save, so a burst a restart in place cut off settles
|
|
48
|
+
* and is asked about like any other */
|
|
49
|
+
pending?: () => Promise<boolean>
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
export type PlanState = 'running' | 'due' | 'late' | 'next' | 'idle'
|
|
@@ -41,6 +54,8 @@ export type PlanState = 'running' | 'due' | 'late' | 'next' | 'idle'
|
|
|
41
54
|
export type PlanRow = {
|
|
42
55
|
repo: string
|
|
43
56
|
check: string
|
|
57
|
+
/** the name an operator reads, when the host knows one */
|
|
58
|
+
title: string | null
|
|
44
59
|
state: PlanState
|
|
45
60
|
/** why it is due or planned, null for a check that waits for a save */
|
|
46
61
|
reason: PlanReason | null
|
|
@@ -61,8 +76,32 @@ export type PlanTimers = { set: (fn: () => void, ms: number) => unknown; clear:
|
|
|
61
76
|
|
|
62
77
|
const realTimers: PlanTimers = { set: (fn, ms) => setTimeout(fn, ms), clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) }
|
|
63
78
|
|
|
64
|
-
/** how long a
|
|
65
|
-
|
|
79
|
+
/** how long a repo must be quiet (no tracked write) before the plan asks
|
|
80
|
+
* what a save burst moved: an agent's edit, test, edit loop stays inside it,
|
|
81
|
+
* so the loop costs one question and one run */
|
|
82
|
+
export const QUIET_MS = 60_000
|
|
83
|
+
|
|
84
|
+
/** a repo written to without a pause is still asked this often */
|
|
85
|
+
export const MAX_WAIT_MS = 10 * 60_000
|
|
86
|
+
|
|
87
|
+
/** a check a save moved runs at most once per this many times its last
|
|
88
|
+
* measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
|
|
89
|
+
export const COST_FACTOR = 20
|
|
90
|
+
|
|
91
|
+
/** above this 1 minute load average per core, the plan starts nothing on its
|
|
92
|
+
* own: every core already has a process waiting for it */
|
|
93
|
+
export const LOAD_MAX = 1
|
|
94
|
+
|
|
95
|
+
/** the machine's 1 minute load average per core */
|
|
96
|
+
export const machineLoad = (): number => loadavg()[0]! / Math.max(1, availableParallelism())
|
|
97
|
+
|
|
98
|
+
/** a decided fact whose probe could not answer retries after this, doubled
|
|
99
|
+
* per skip in a row, up to ONCE_RETRY_MAX_MS. From its run on record, so a
|
|
100
|
+
* restart changes nothing */
|
|
101
|
+
export const ONCE_RETRY_MS = 3_600_000
|
|
102
|
+
export const ONCE_RETRY_MAX_MS = 24 * 3_600_000
|
|
103
|
+
|
|
104
|
+
export const onceRetry = (streak: number): number => Math.min(ONCE_RETRY_MAX_MS, ONCE_RETRY_MS * 2 ** Math.max(0, Math.min(10, streak - 1)))
|
|
66
105
|
|
|
67
106
|
/** how often the last results are read again when nothing ran here */
|
|
68
107
|
export const LAST_EVERY_MS = 15_000
|
|
@@ -76,6 +115,10 @@ type Repo = {
|
|
|
76
115
|
lastRead: number
|
|
77
116
|
marks: Map<string, Mark>
|
|
78
117
|
timer: unknown
|
|
118
|
+
/** when the save burst the timer waits on began */
|
|
119
|
+
burstAt: number | null
|
|
120
|
+
/** when that timer fires */
|
|
121
|
+
settleAt: number | null
|
|
79
122
|
detecting: Promise<void> | null
|
|
80
123
|
again: boolean
|
|
81
124
|
error: string | null
|
|
@@ -92,17 +135,30 @@ export class Planner {
|
|
|
92
135
|
private readonly now: () => number
|
|
93
136
|
private readonly timers: PlanTimers
|
|
94
137
|
private readonly quietMs: number
|
|
138
|
+
private readonly maxWaitMs: number
|
|
139
|
+
private readonly load: () => number
|
|
140
|
+
private readonly cores: number
|
|
141
|
+
private holdReason: string | null = null
|
|
142
|
+
private readonly catchUp: CatchUpStore | null
|
|
143
|
+
private beatAt: number
|
|
95
144
|
/** is this repo active: an agent works in it, VS Code has it open, or it is flagged */
|
|
96
145
|
active: (repo: string) => boolean = () => true
|
|
97
146
|
|
|
98
|
-
/** when this plan started: what was overdue then is
|
|
147
|
+
/** when this plan's catch up window started: what was overdue then is
|
|
148
|
+
* spread, never fired at once. With a store, a restart in place keeps
|
|
149
|
+
* the window it had (catch-up.ts) */
|
|
99
150
|
private readonly bootAt: number
|
|
100
151
|
|
|
101
|
-
constructor(private o: { now?: () => number; timers?: PlanTimers; quietMs?: number; onError?: (repo: string, e: Error) => void } = {}) {
|
|
152
|
+
constructor(private o: { now?: () => number; timers?: PlanTimers; quietMs?: number; maxWaitMs?: number; load?: () => number; cores?: number; catchUp?: CatchUpStore; onError?: (repo: string, e: Error) => void } = {}) {
|
|
102
153
|
this.now = o.now ?? Date.now
|
|
103
|
-
this.
|
|
154
|
+
this.catchUp = o.catchUp ?? null
|
|
155
|
+
this.bootAt = this.catchUp != null ? resumeCatchUp(this.now(), this.catchUp) : this.now()
|
|
156
|
+
this.beatAt = this.now()
|
|
157
|
+
this.cores = o.cores ?? availableParallelism()
|
|
104
158
|
this.timers = o.timers ?? realTimers
|
|
105
159
|
this.quietMs = o.quietMs ?? QUIET_MS
|
|
160
|
+
this.maxWaitMs = o.maxWaitMs ?? MAX_WAIT_MS
|
|
161
|
+
this.load = o.load ?? machineLoad
|
|
106
162
|
}
|
|
107
163
|
|
|
108
164
|
private fail(repo: string, e: unknown): void {
|
|
@@ -113,9 +169,14 @@ export class Planner {
|
|
|
113
169
|
async add(src: PlanSource): Promise<void> {
|
|
114
170
|
const old = this.repos.get(src.repo)
|
|
115
171
|
if (old?.timer != null) this.timers.clear(old.timer)
|
|
116
|
-
const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, detecting: null, again: false, error: null }
|
|
172
|
+
const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
|
|
117
173
|
this.repos.set(src.repo, r)
|
|
118
174
|
await this.reload(src.repo)
|
|
175
|
+
try {
|
|
176
|
+
if (this.repos.get(src.repo) === r && (await src.pending?.()) === true) this.touched(src.repo)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
this.fail(src.repo, e)
|
|
179
|
+
}
|
|
119
180
|
}
|
|
120
181
|
|
|
121
182
|
/** read its checks again, after a config edit */
|
|
@@ -142,6 +203,20 @@ export class Planner {
|
|
|
142
203
|
return this.repos.has(repo)
|
|
143
204
|
}
|
|
144
205
|
|
|
206
|
+
/** the check ids a repo declares today; null while the plan does not
|
|
207
|
+
* hold them (not loaded, still loading, or its checks failed to load) */
|
|
208
|
+
declared(repo: string): ReadonlySet<string> | null {
|
|
209
|
+
const r = this.repos.get(repo)
|
|
210
|
+
if (r == null || r.error != null || r.checks.length === 0) return null
|
|
211
|
+
return new Set(r.checks.map((c) => c.id))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** each declared check's title by id, null while `declared` is */
|
|
215
|
+
titles(repo: string): ReadonlyMap<string, string> | null {
|
|
216
|
+
if (this.declared(repo) == null) return null
|
|
217
|
+
return new Map(this.repos.get(repo)!.checks.map((c) => [c.id, c.title ?? c.id]))
|
|
218
|
+
}
|
|
219
|
+
|
|
145
220
|
private readLast(r: Repo, force = false): void {
|
|
146
221
|
const now = this.now()
|
|
147
222
|
if (!force && now - r.lastRead < LAST_EVERY_MS) return
|
|
@@ -163,13 +238,22 @@ export class Planner {
|
|
|
163
238
|
}
|
|
164
239
|
|
|
165
240
|
/** a file moved in this repo: once the burst is quiet, the checks whose
|
|
166
|
-
* answer it moved turn `changed
|
|
241
|
+
* answer it moved turn `changed`. A burst past the wait cap keeps the
|
|
242
|
+
* timer it has, so a repo never quiet is still asked */
|
|
167
243
|
touched(repo: string): void {
|
|
168
244
|
const r = this.repos.get(repo)
|
|
169
245
|
if (r == null) return
|
|
170
|
-
|
|
246
|
+
const now = this.now()
|
|
247
|
+
r.burstAt ??= now
|
|
248
|
+
if (r.timer != null) {
|
|
249
|
+
if (now - r.burstAt >= this.maxWaitMs) return
|
|
250
|
+
this.timers.clear(r.timer)
|
|
251
|
+
}
|
|
252
|
+
r.settleAt = now + this.quietMs
|
|
171
253
|
r.timer = this.timers.set(() => {
|
|
172
254
|
r.timer = null
|
|
255
|
+
r.burstAt = null
|
|
256
|
+
r.settleAt = null
|
|
173
257
|
void this.detect(repo)
|
|
174
258
|
}, this.quietMs)
|
|
175
259
|
}
|
|
@@ -224,12 +308,20 @@ export class Planner {
|
|
|
224
308
|
const cad = resolveCadence(c.plan, c.fallback, last, s, `${r.src.repo}\0${c.id}`)
|
|
225
309
|
const wi = resolveWhenInactive(c.plan, last, s)
|
|
226
310
|
const error = cad.error ?? wi.error
|
|
227
|
-
const base = { repo: r.src.repo, check: c.id, rule: cad.rule, inactive: wi.rule, error, last, active }
|
|
311
|
+
const base = { repo: r.src.repo, check: c.id, title: c.title ?? null, rule: cad.rule, inactive: wi.rule, error, last, active }
|
|
228
312
|
if (this.running?.repo === r.src.repo && this.running.ids.has(c.id)) return { ...base, state: 'running', reason: null, dueAt: this.running.since }
|
|
229
313
|
if (m.askedAt != null) return { ...base, state: 'due', reason: 'asked', dueAt: m.askedAt }
|
|
230
|
-
if (m.changedAt != null)
|
|
314
|
+
if (m.changedAt != null) {
|
|
315
|
+
// a check pays for its own runs: one save cannot run it again
|
|
316
|
+
// before COST_FACTOR times its last measured run has passed
|
|
317
|
+
const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
|
|
318
|
+
const at = Math.max(m.changedAt, spaced)
|
|
319
|
+
return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
|
|
320
|
+
}
|
|
231
321
|
const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
|
|
232
|
-
|
|
322
|
+
// a decided fact that could not answer: its retry comes from its run
|
|
323
|
+
// on record, never from a boot
|
|
324
|
+
const raw = cad.cadence.kind === 'once' && last?.unknown === true ? Math.max(last.at + onceRetry(last.streak), m.attemptAt != null ? m.attemptAt + onceRetry(last.streak) : -Infinity) : nextDue(cad.cadence, Number.isFinite(lastAt) ? lastAt : null, now)
|
|
233
325
|
// overdue before the plan started (a machine asleep, a day off): each
|
|
234
326
|
// pair lands at its own stable point of the catch up window
|
|
235
327
|
const due = raw != null && raw < this.bootAt ? this.bootAt + Math.round(stablePoint(`catch up\0${r.src.repo}\0${c.id}`) * CATCH_UP_MS) : raw
|
|
@@ -257,6 +349,17 @@ export class Planner {
|
|
|
257
349
|
return [...this.repos.values()].filter((r) => r.error != null).map((r) => ({ repo: r.src.repo, error: r.error! }))
|
|
258
350
|
}
|
|
259
351
|
|
|
352
|
+
/** what holds work back right now, one line each: the repos whose save
|
|
353
|
+
* burst is settling, and the load that held the last look */
|
|
354
|
+
held(): string[] {
|
|
355
|
+
const now = this.now()
|
|
356
|
+
const out: string[] = []
|
|
357
|
+
for (const r of this.repos.values())
|
|
358
|
+
if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
|
|
359
|
+
if (this.holdReason != null) out.push(this.holdReason)
|
|
360
|
+
return out
|
|
361
|
+
}
|
|
362
|
+
|
|
260
363
|
/** a batch is in flight */
|
|
261
364
|
busy(): boolean {
|
|
262
365
|
return this.running != null
|
|
@@ -266,9 +369,23 @@ export class Planner {
|
|
|
266
369
|
* runs every row of its that is due, as one background run. Resolves when
|
|
267
370
|
* that batch is started, never when it ends */
|
|
268
371
|
tick(): { repo: string; ids: string[]; reason: PlanReason } | null {
|
|
372
|
+
this.beat()
|
|
269
373
|
if (this.running != null) return null
|
|
270
|
-
|
|
271
|
-
if (due.length === 0)
|
|
374
|
+
let due = this.rows().filter((x) => x.state === 'due')
|
|
375
|
+
if (due.length === 0) {
|
|
376
|
+
this.holdReason = null
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
// a loaded machine runs only what someone asked for
|
|
380
|
+
const load = this.load()
|
|
381
|
+
if (load > LOAD_MAX) {
|
|
382
|
+
due = due.filter((x) => x.reason === 'asked')
|
|
383
|
+
if (due.length === 0) {
|
|
384
|
+
this.holdReason = `held: load ${(load * this.cores).toFixed(1)} on ${this.cores} cores`
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
this.holdReason = null
|
|
272
389
|
due.sort((a, b) => ORDER[a.reason!] - ORDER[b.reason!] || (a.dueAt ?? 0) - (b.dueAt ?? 0))
|
|
273
390
|
const first = due[0]!
|
|
274
391
|
const r = this.repos.get(first.repo)!
|
|
@@ -292,6 +409,14 @@ export class Planner {
|
|
|
292
409
|
return { repo: first.repo, ids, reason: first.reason! }
|
|
293
410
|
}
|
|
294
411
|
|
|
412
|
+
/** the catch up window's last beat, at most once a minute */
|
|
413
|
+
private beat(): void {
|
|
414
|
+
const now = this.now()
|
|
415
|
+
if (this.catchUp == null || now - this.beatAt < CATCH_UP_BEAT_MS) return
|
|
416
|
+
this.beatAt = now
|
|
417
|
+
this.catchUp.set({ start: this.bootAt, seen: now })
|
|
418
|
+
}
|
|
419
|
+
|
|
295
420
|
/** resolves once no batch is in flight */
|
|
296
421
|
async idle(): Promise<void> {
|
|
297
422
|
while (this.running != null) await new Promise((res) => setTimeout(res, 5))
|
package/src/plan/repo-source.ts
CHANGED
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
// save elsewhere never re-runs it.
|
|
10
10
|
import { gateInventory, type CheckRow } from '../ledger/gate-checks.ts'
|
|
11
11
|
import { Ledger } from '../ledger/ledger.ts'
|
|
12
|
+
import { answersDb } from '../ledger/store.ts'
|
|
13
|
+
import { hasWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
|
|
14
|
+
import { FOREVER, FOREVER_MS } from '../core/wire.ts'
|
|
15
|
+
import { ONCE, type LastResult } from './cadence.ts'
|
|
16
|
+
import { openDb } from '../ledger/db.ts'
|
|
12
17
|
import { readLastResults } from '../ledger/run-log.ts'
|
|
13
18
|
import { gateBinary } from '../ledger/toolchain-identity.ts'
|
|
14
19
|
import { populationOf } from '../problems/problem-line.ts'
|
|
20
|
+
import { movedSince } from '../serve/tracked-watch.ts'
|
|
15
21
|
import type { RepoConfig } from '../repo-config.ts'
|
|
16
22
|
import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
|
|
17
23
|
|
|
@@ -19,30 +25,100 @@ import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
|
|
|
19
25
|
* check, an off one and an opt in build step wait for whoever names them */
|
|
20
26
|
export function plannable(row: CheckRow): boolean {
|
|
21
27
|
if (row.when.at !== 'always' && row.when.at !== 'health') return false
|
|
28
|
+
// nothing to do in this repo on a clock (check.ts planOff)
|
|
29
|
+
if (row.planOff != null) return false
|
|
22
30
|
if (row.optIn) return false
|
|
23
31
|
return !(row.build && row.when.at === 'health')
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* checks never fire together */
|
|
30
|
-
/** a check that declares no cadence runs about once a day, never in step */
|
|
34
|
+
/** a REPO check nothing can key and that declares no cadence runs about
|
|
35
|
+
* once a day, never in step, until it declares what it depends on. No
|
|
36
|
+
* built-in may land here (tests/plan-default-range.test.ts) */
|
|
31
37
|
const DEFAULT_EVERY: [string, string] = ['20h', '28h']
|
|
32
38
|
|
|
39
|
+
/** why the plan cannot tell when this check's answer moves, or null when it
|
|
40
|
+
* can: a key, a ttl, a build step or a declared cadence says it */
|
|
41
|
+
export function planGap(row: CheckRow): string | null {
|
|
42
|
+
const pop = populationOf(row.decl)
|
|
43
|
+
if (pop === 'content' || pop === 'reads' || pop === 'world' || row.build) return null
|
|
44
|
+
if (row.plan?.every !== undefined || row.plan?.cron != null) return null
|
|
45
|
+
const why = row.decl.never != null && row.decl.never !== '' ? `never cached (${row.decl.never})` : (row.decl.uncacheable ?? 'declares neither inputs nor ttl')
|
|
46
|
+
return `${why}, and no every: nothing says when its answer moves`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** one inventory row as a plan check. A world check keeps its ttl as its
|
|
50
|
+
* cadence; a build step and a check keyed on its files wait for a save (its
|
|
51
|
+
* answer replays until a file moves, so a clock re-asks nothing); a repo
|
|
52
|
+
* check with no cadence runs at its own stable point of the default range,
|
|
53
|
+
* and a built-in with none waits for a save and shows on the burn-down */
|
|
33
54
|
export function planCheckOf(row: CheckRow): PlanCheck {
|
|
34
55
|
const pop = populationOf(row.decl)
|
|
35
56
|
const keyed = pop === 'content' || pop === 'reads'
|
|
36
57
|
const ttl = row.decl.ttl != null && row.decl.ttl !== '' ? row.decl.ttl : null
|
|
37
|
-
const fallback =
|
|
58
|
+
const fallback =
|
|
59
|
+
ttl === FOREVER ? { value: ONCE, why: 'a decided fact: ttl forever' }
|
|
60
|
+
: ttl != null ? { value: ttl, why: 'its ttl' }
|
|
61
|
+
: row.build ? { value: null, why: 'a build step' }
|
|
62
|
+
: keyed ? { value: null, why: 'keyed on its files' }
|
|
63
|
+
: row.from === 'repo' ? { value: DEFAULT_EVERY, why: 'the default range' }
|
|
64
|
+
: { value: null, why: 'a built-in declaring nothing, see shipkit keys --burn-down' }
|
|
65
|
+
// a gate check nothing can key waits for a save only when no cadence
|
|
66
|
+
// says when to run it: a range or an every it declares is the rule
|
|
67
|
+
const clocked = fallback.value != null || row.plan?.every !== undefined || row.plan?.cron != null
|
|
38
68
|
return {
|
|
39
69
|
id: row.decl.id,
|
|
70
|
+
title: row.title,
|
|
40
71
|
plan: row.plan ?? {},
|
|
41
|
-
|
|
72
|
+
// a decided fact is keyed on its declaration, so a save can move it
|
|
73
|
+
onChange: isForever(row) || (pop !== 'world' && (keyed || (row.gate && !clocked))),
|
|
42
74
|
fallback,
|
|
43
75
|
}
|
|
44
76
|
}
|
|
45
77
|
|
|
78
|
+
const isForever = (row: CheckRow): boolean => row.decl.ttl === FOREVER
|
|
79
|
+
|
|
80
|
+
/** the key a decided fact's answer is stored under: its declared value */
|
|
81
|
+
const factKey = (r: CheckRow): string => worldKeyOf({ ttlMs: FOREVER_MS, toolchain: '', code: '', rotation: r.decl.extra ?? '', frozen: r.decl.writes === true ? false : null })
|
|
82
|
+
|
|
83
|
+
/** a decided fact with no answer stored for its current key: its declaration
|
|
84
|
+
* moved, it never ran, or the db was lost. The plan runs these once */
|
|
85
|
+
export function unknownFacts(rows: CheckRow[], cache: WorldCache): Set<string> {
|
|
86
|
+
const out = new Set<string>()
|
|
87
|
+
for (const r of rows) if (isForever(r) && !hasWorld(cache, r.decl.id, factKey(r), FOREVER_MS)) out.add(r.decl.id)
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** each check's last MEASURED run from the record: when it finished and how
|
|
92
|
+
* long it took. A replayed run is left out, it cost nothing */
|
|
93
|
+
export function readLastCosts(path: string): Record<string, { at: number; ms: number }> {
|
|
94
|
+
const opened = openDb(path, { create: false })
|
|
95
|
+
if (opened == null) return {}
|
|
96
|
+
const out: Record<string, { at: number; ms: number }> = {}
|
|
97
|
+
try {
|
|
98
|
+
const rows = opened.db.query(`select id, max(at) as at, ms from runs where replayed = 0 group by id`).all() as { id: string; at: number; ms: number }[]
|
|
99
|
+
for (const r of rows) out[r.id] = { at: Number(r.at), ms: Number(r.ms) }
|
|
100
|
+
} catch {
|
|
101
|
+
return {}
|
|
102
|
+
} finally {
|
|
103
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** when the latest run on record started, whoever ran it, or null */
|
|
109
|
+
export function lastStarted(path: string): number | null {
|
|
110
|
+
const opened = openDb(path, { create: false })
|
|
111
|
+
if (opened == null) return null
|
|
112
|
+
try {
|
|
113
|
+
const r = opened.db.query('select max(started) as at from invocations').get() as { at: number | null } | null
|
|
114
|
+
return r?.at == null ? null : Number(r.at)
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
} finally {
|
|
118
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
46
122
|
export type RepoSourceDeps = {
|
|
47
123
|
repo: string
|
|
48
124
|
root: string
|
|
@@ -59,13 +135,38 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
|
|
|
59
135
|
/** the content key each check had at the last question, so a red one
|
|
60
136
|
* counts as changed only when its key moves */
|
|
61
137
|
const seen = new Map<string, string>()
|
|
138
|
+
/** the decided facts with no answer for their key, asked again at every
|
|
139
|
+
* load and save: their last result counts as none, so they are due */
|
|
140
|
+
let unknown = new Set<string>()
|
|
141
|
+
/** the decided facts whose declaration moved since the last load */
|
|
142
|
+
const moved = new Set<string>()
|
|
143
|
+
const facts = new Map<string, string>()
|
|
144
|
+
const cache = (): WorldCache => ({ db: answersDb(d.root), ns: d.config().name, now: Date.now })
|
|
62
145
|
return {
|
|
63
146
|
repo: d.repo,
|
|
64
147
|
checks: async () => {
|
|
65
148
|
rows = (await gateInventory(d.root, d.config(), { tier: 'quick' })).filter(plannable)
|
|
149
|
+
unknown = rows.some(isForever) ? unknownFacts(rows, cache()) : new Set()
|
|
150
|
+
for (const r of rows.filter(isForever)) {
|
|
151
|
+
const k = factKey(r)
|
|
152
|
+
const before = facts.get(r.decl.id)
|
|
153
|
+
if (before != null && before !== k) moved.add(r.decl.id)
|
|
154
|
+
facts.set(r.decl.id, k)
|
|
155
|
+
}
|
|
66
156
|
return rows.map(planCheckOf)
|
|
67
157
|
},
|
|
68
|
-
last: () =>
|
|
158
|
+
last: () => {
|
|
159
|
+
const all: Record<string, LastResult> = d.record === '' ? {} : readLastResults(d.record)
|
|
160
|
+
// a fact with no answer runs; one whose probe could not answer
|
|
161
|
+
// retries on a backoff from its skip, never at each boot
|
|
162
|
+
for (const id of unknown) {
|
|
163
|
+
if (all[id]?.status === 'skip') all[id] = { ...all[id]!, unknown: true }
|
|
164
|
+
else delete all[id]
|
|
165
|
+
}
|
|
166
|
+
const costs = d.record === '' ? {} : readLastCosts(d.record)
|
|
167
|
+
for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
|
|
168
|
+
return all
|
|
169
|
+
},
|
|
69
170
|
changed: async () => {
|
|
70
171
|
const decls = rows.map((r) => r.decl)
|
|
71
172
|
if (decls.length === 0) return []
|
|
@@ -74,6 +175,13 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
|
|
|
74
175
|
for (const r of rows) {
|
|
75
176
|
const c = planCheckOf(r)
|
|
76
177
|
if (!c.onChange) continue
|
|
178
|
+
if (isForever(r)) {
|
|
179
|
+
// only an edited declaration: a probe that could not answer
|
|
180
|
+
// (gh down) must not run again at every save
|
|
181
|
+
if (moved.has(c.id) && unknown.has(c.id)) out.push(c.id)
|
|
182
|
+
moved.delete(c.id)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
77
185
|
const k = ledger.layer.keyFor(c.id)
|
|
78
186
|
if (k.key == null) {
|
|
79
187
|
out.push(c.id)
|
|
@@ -87,6 +195,10 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
|
|
|
87
195
|
}
|
|
88
196
|
return out
|
|
89
197
|
},
|
|
198
|
+
pending: async () => {
|
|
199
|
+
const since = d.record === '' ? null : lastStarted(d.record)
|
|
200
|
+
return since != null && (await movedSince(d.root, since))
|
|
201
|
+
},
|
|
90
202
|
run: async (ids, why) => {
|
|
91
203
|
const byId = new Map(rows.map((r) => [r.decl.id, r]))
|
|
92
204
|
const gate = ids.filter((id) => byId.get(id)?.gate === true)
|
|
@@ -27,8 +27,11 @@ export type ReleaseConfig = {
|
|
|
27
27
|
tag?: string
|
|
28
28
|
/** the ROOT unit's versioned changelog (default 'CHANGELOG.md') */
|
|
29
29
|
changelog?: string
|
|
30
|
-
/** publish the ROOT unit to npm
|
|
31
|
-
*
|
|
30
|
+
/** publish the ROOT unit to npm, declared and never inferred: a root whose
|
|
31
|
+
* package.json is not private refuses to release until this says true or
|
|
32
|
+
* false. A private package NEVER publishes, whatever this says. True makes
|
|
33
|
+
* the root package.json a public contract. What each check does with it:
|
|
34
|
+
* wiki/build/shipkit/built-in-checks.md */
|
|
32
35
|
npm?: boolean
|
|
33
36
|
/** argv printing an npm auth token on stdout, as in a secret-manager read.
|
|
34
37
|
* Absent = rely on ambient npm auth. Never put a token in config.
|
|
@@ -122,6 +125,10 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
|
|
|
122
125
|
// real repositories) are exactly the ones a publish must not be wrong
|
|
123
126
|
// about. Publishing behind a partial suite is a green nobody measured.
|
|
124
127
|
tests: 'full',
|
|
128
|
+
// a release log is read once and kept as evidence: the tier it asked
|
|
129
|
+
// for, the cache reads it refused and the units it left to their own
|
|
130
|
+
// release are all part of what it proves
|
|
131
|
+
verbose: true,
|
|
125
132
|
// a check that cannot block cannot change the outcome, so a release
|
|
126
133
|
// never probes the world: publishing a lib does not depend on whether
|
|
127
134
|
// a DNS provider answers, and asking spends an API call per publish.
|
|
@@ -165,9 +165,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
|
|
|
165
165
|
tagTemplate: rc.tag ?? ROOT_TAG,
|
|
166
166
|
changelogRel: rc.changelog ?? 'CHANGELOG.md',
|
|
167
167
|
npm: rc.npm,
|
|
168
|
-
|
|
169
|
-
// declaration, and a public root package publishes by default
|
|
170
|
-
npmWhenUndeclared: 'from-private-flag',
|
|
168
|
+
undeclaredFix: 'add `release: { npm: true }` to repo.config.ts to publish it',
|
|
171
169
|
githubRelease: rc.githubRelease ?? true,
|
|
172
170
|
tsconfigRel: rc.tsconfig,
|
|
173
171
|
checkFolderName: false,
|
|
@@ -204,7 +202,7 @@ export function resolveUnits(root: string, config: RepoConfig, io: UnitReader):
|
|
|
204
202
|
tagTemplate: decl.tag ?? SUB_TAG,
|
|
205
203
|
changelogRel: decl.changelog ?? `${decl.dir}/CHANGELOG.md`,
|
|
206
204
|
npm: decl.npm,
|
|
207
|
-
|
|
205
|
+
undeclaredFix: 'add `npm: true` to this units[] entry to publish it',
|
|
208
206
|
githubRelease: decl.githubRelease ?? false,
|
|
209
207
|
tsconfigRel: decl.tsconfig,
|
|
210
208
|
// only the DEFAULT tag renders the package name into a git tag, so
|
|
@@ -226,11 +224,9 @@ function build(p: {
|
|
|
226
224
|
tagTemplate: string
|
|
227
225
|
changelogRel: string
|
|
228
226
|
npm: boolean | undefined
|
|
229
|
-
/**
|
|
230
|
-
* package
|
|
231
|
-
|
|
232
|
-
* ambiguous and REFUSES. */
|
|
233
|
-
npmWhenUndeclared: 'from-private-flag' | 'refuse'
|
|
227
|
+
/** where this unit's npm declaration goes, for the refusal a non-private
|
|
228
|
+
* package with no `npm` key gets */
|
|
229
|
+
undeclaredFix: string
|
|
234
230
|
githubRelease: boolean
|
|
235
231
|
tsconfigRel: string | undefined
|
|
236
232
|
checkFolderName: boolean
|
|
@@ -256,10 +252,10 @@ function build(p: {
|
|
|
256
252
|
// about must refuse, not quietly resolve to no-publish. A silent false
|
|
257
253
|
// here is a lib that bumps, tags and cuts a release while publishing
|
|
258
254
|
// nothing, and the only trace is one note line in a long log.
|
|
259
|
-
if (p.npm == null && !isPrivate
|
|
260
|
-
errors.push(at(
|
|
255
|
+
if (p.npm == null && !isPrivate)
|
|
256
|
+
errors.push(at(`package.json is not private and no npm target is declared: ${p.undeclaredFix}, or \`private: true\` to its package.json to keep it internal`))
|
|
261
257
|
if (errors.length > 0) return { error: errors }
|
|
262
|
-
const declaredNpm = p.npm
|
|
258
|
+
const declaredNpm = p.npm === true
|
|
263
259
|
const tsconfig = p.tsconfigRel != null ? join(p.root, p.tsconfigRel) : join(p.dir, 'tsconfig.json')
|
|
264
260
|
return {
|
|
265
261
|
unit: {
|
package/src/repo-config.ts
CHANGED
|
@@ -185,8 +185,16 @@ export type RepoConfig = {
|
|
|
185
185
|
* lint and prints a skip line carrying the reason. `defineRepo` throws on
|
|
186
186
|
* an unknown id or a blank reason */
|
|
187
187
|
ruleExempt?: Record<string, string>
|
|
188
|
-
/**
|
|
189
|
-
*
|
|
188
|
+
/** the repo is done for now: nothing processes it. The house discovery
|
|
189
|
+
* leaves it out, so it has no rail row, no hub (no file watch, no plan
|
|
190
|
+
* rows, no automatic gates), no processes or widgets, no identity fill,
|
|
191
|
+
* no activity row, and fleet skips it. Only the archived page lists it.
|
|
192
|
+
* The button in the repo header and the page's unarchive toggle it, and
|
|
193
|
+
* only this key moves (core/archived.ts) */
|
|
194
|
+
archived?: boolean
|
|
195
|
+
/** declared github visibility INTENT (absent = 'private'): public means
|
|
196
|
+
* strangers read the tracked tree. What each check does with it:
|
|
197
|
+
* wiki/build/shipkit/built-in-checks.md */
|
|
190
198
|
visibility?: 'private' | 'public'
|
|
191
199
|
/** the reviewer agent type this repo declares: `.claude/agents/<name>.md`,
|
|
192
200
|
* whose system prompt points at `brief` (an md file holding the standing
|
|
@@ -287,7 +295,7 @@ export type RepoConfig = {
|
|
|
287
295
|
logDir?: string
|
|
288
296
|
}
|
|
289
297
|
|
|
290
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
|
|
298
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
|
|
291
299
|
|
|
292
300
|
const KEBAB = /^[a-z0-9][a-z0-9-]*$/
|
|
293
301
|
const words = (v: unknown): boolean => Array.isArray(v) && v.length > 0 && v.every((w) => typeof w === 'string')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The dock's data on the server: every house repo's record, read for the
|
|
2
|
+
// live window and shaped into one view (ledger/house-runs.ts). One instance
|
|
3
|
+
// serves every page that asks, the home's daemon and a hub serving on its own
|
|
4
|
+
// alike.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: this is asked every few seconds
|
|
7
|
+
// while a dock is open, so nothing here may cost a record read when nothing
|
|
8
|
+
// moved. A repo's record is opened only when its db or its write ahead log
|
|
9
|
+
// changed since the last read (the window only slides, and the shaping drops
|
|
10
|
+
// what slid out), and one shaped answer serves every request inside SHARE_MS.
|
|
11
|
+
import { existsSync } from 'fs'
|
|
12
|
+
import { parallelCap } from '../core/parallel.ts'
|
|
13
|
+
import { shapeHouse, type HouseRecord, type HouseView } from '../ledger/house-runs.ts'
|
|
14
|
+
import { LIVE_HISTORY_MS } from '../ledger/live-timeline.ts'
|
|
15
|
+
import { readWindowReadonly } from '../ledger/run-log.ts'
|
|
16
|
+
import { recordOf, recordStamp } from './project-gutter.ts'
|
|
17
|
+
|
|
18
|
+
/** how long one shaped answer serves every page that asks */
|
|
19
|
+
export const SHARE_MS = 1_000
|
|
20
|
+
|
|
21
|
+
export type HouseRepo = { name: string; path: string }
|
|
22
|
+
|
|
23
|
+
export class HouseRuns {
|
|
24
|
+
private records = new Map<string, { stamp: string; rec: HouseRecord }>()
|
|
25
|
+
private shaped: { at: number; view: HouseView } | null = null
|
|
26
|
+
/** records opened since this instance was built: what a test measures */
|
|
27
|
+
reads = 0
|
|
28
|
+
|
|
29
|
+
constructor(private list: () => HouseRepo[], private now: () => number = Date.now, private cap: () => number = parallelCap) {}
|
|
30
|
+
|
|
31
|
+
view(): HouseView {
|
|
32
|
+
const now = this.now()
|
|
33
|
+
if (this.shaped != null && now - this.shaped.at < SHARE_MS) return this.shaped.view
|
|
34
|
+
const since = now - LIVE_HISTORY_MS
|
|
35
|
+
const next = new Map<string, { stamp: string; rec: HouseRecord }>()
|
|
36
|
+
for (const r of this.list()) {
|
|
37
|
+
const path = recordOf(r.path)
|
|
38
|
+
if (path == null || !existsSync(path)) continue
|
|
39
|
+
const stamp = recordStamp(path)
|
|
40
|
+
let hit = this.records.get(r.path)
|
|
41
|
+
if (hit == null || hit.stamp !== stamp) {
|
|
42
|
+
this.reads++
|
|
43
|
+
hit = { stamp, rec: { repo: r.name, ...readWindowReadonly(path, since) } }
|
|
44
|
+
} else if (hit.rec.repo !== r.name) hit = { stamp, rec: { ...hit.rec, repo: r.name } }
|
|
45
|
+
next.set(r.path, hit)
|
|
46
|
+
}
|
|
47
|
+
// a repo gone from the list takes its rows with it
|
|
48
|
+
this.records = next
|
|
49
|
+
const view = shapeHouse({ now, cap: this.cap(), repos: [...next.values()].map((x) => x.rec) })
|
|
50
|
+
this.shaped = { at: now, view }
|
|
51
|
+
return view
|
|
52
|
+
}
|
|
53
|
+
}
|