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
|
@@ -5,22 +5,23 @@
|
|
|
5
5
|
// the index moved, a few repos per poll, and a repo's run record is opened
|
|
6
6
|
// only when it moved, so a steady poll spawns ps and lsof and nothing else. The icon route
|
|
7
7
|
// serves only the icon files a discovered repo carries, and POST
|
|
8
|
-
// /api/projects/open opens a discovered repo in VS Code
|
|
9
|
-
// repo, never a path. The rail ORDER is one text file for the whole machine
|
|
8
|
+
// /api/projects/open opens a discovered repo in VS Code, POST
|
|
9
|
+
// /api/projects/fork in Fork: a request names a repo, never a path. The rail ORDER is one text file for the whole machine
|
|
10
10
|
// (ORDER_FILE), so every hub shows the same order and a hand edit is seen
|
|
11
11
|
// within one poll; POST /api/projects/order writes it.
|
|
12
|
-
import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
|
13
13
|
import { readFile, stat } from 'fs/promises'
|
|
14
14
|
import { homedir } from 'os'
|
|
15
15
|
import { dirname, join } from 'path'
|
|
16
16
|
import {
|
|
17
|
-
asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount,
|
|
17
|
+
asksDirOf, branchOf, countSessions, descriptionOf, dirtyCount, gateFrom, checkStateOf, gitStamp, houseRepos, iconStamp, ICON_TYPES, lastActivity,
|
|
18
18
|
lastRunAt, lastSessionAt, liveUrlOf, mergeOrder, openAsks, openFolders, parseClaudePids, parseDescribe, parseLsofCwd, parseOrder,
|
|
19
|
-
recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type Gate, type WiredRepo,
|
|
19
|
+
recordOf, recordStamp, RELEASE_TAGS, renderOrder, versionOf, vscodeStoragePath, type CheckState, type Gate, type LastRow, type WiredRepo,
|
|
20
20
|
} from './project-gutter.ts'
|
|
21
21
|
import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
22
22
|
import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
|
|
23
23
|
import { writeIdentity } from '../core/identity.ts'
|
|
24
|
+
import { writeArchived } from '../core/archived.ts'
|
|
24
25
|
|
|
25
26
|
export type { Gate }
|
|
26
27
|
|
|
@@ -62,6 +63,8 @@ export type ProjectRow = {
|
|
|
62
63
|
dirty: number | null
|
|
63
64
|
/** the last gate its own run record holds */
|
|
64
65
|
gate: Gate
|
|
66
|
+
/** every check's last outcome as one colour, warnings included */
|
|
67
|
+
checks: CheckState
|
|
65
68
|
release: Release
|
|
66
69
|
/** the deployed url `smoke.base` declares, or null */
|
|
67
70
|
live: string | null
|
|
@@ -70,8 +73,13 @@ export type ProjectRow = {
|
|
|
70
73
|
/** epoch ms of the newest of: the last commit, the last shipkit run, the
|
|
71
74
|
* newest Claude Code transcript. null when none of them exists */
|
|
72
75
|
activity: number | null
|
|
76
|
+
/** epoch ms of the last commit on HEAD, null when git cannot say */
|
|
77
|
+
commit: number | null
|
|
73
78
|
}
|
|
74
|
-
|
|
79
|
+
/** a repo whose repo.config.ts says `archived: true`: only the archived page lists it */
|
|
80
|
+
export type ArchivedRow = { name: string; icon: string | null }
|
|
81
|
+
|
|
82
|
+
export type ProjectsAnswer = { projects: ProjectRow[]; archived: ArchivedRow[]; ms: number; at: number }
|
|
75
83
|
|
|
76
84
|
/** a command's exit code and stdout, or null when its binary is missing */
|
|
77
85
|
export type Run = (cmd: string[], cwd?: string) => Promise<{ code: number; out: string } | null>
|
|
@@ -83,12 +91,18 @@ export type GutterDeps = {
|
|
|
83
91
|
run?: Run
|
|
84
92
|
/** opens a folder in VS Code; throws with the reason it could not */
|
|
85
93
|
open?: (path: string) => void
|
|
94
|
+
/** opens a folder in Fork, the git client; throws with the reason it could not */
|
|
95
|
+
fork?: (path: string) => void
|
|
86
96
|
warn?: (line: string) => void
|
|
87
97
|
now?: () => number
|
|
88
98
|
/** where the rail order lives, default ~/ORDER_FILE */
|
|
89
99
|
orderFile?: string
|
|
90
100
|
/** the Claude Code `projects` folders, default the `projects` folder of every ~/.claude config root */
|
|
91
101
|
transcriptRoots?: string[]
|
|
102
|
+
/** the check ids a repo declares today (the daemon's plan), so a check
|
|
103
|
+
* it no longer declares never colours it. Absent or null = not known,
|
|
104
|
+
* every recorded check counts */
|
|
105
|
+
declared?: (name: string) => ReadonlySet<string> | null
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
const runCmd: Run = async (cmd, cwd) => {
|
|
@@ -111,6 +125,21 @@ function openInCode(path: string): void {
|
|
|
111
125
|
throw new Error('the `code` command is not on PATH: in VS Code, run "Shell Command: Install \'code\' command in PATH"')
|
|
112
126
|
}
|
|
113
127
|
|
|
128
|
+
/** where Fork is installed, the places macOS keeps apps */
|
|
129
|
+
export const FORK_APPS = ['/Applications/Fork.app', join(homedir(), 'Applications', 'Fork.app')]
|
|
130
|
+
|
|
131
|
+
/** the command that opens a folder in Fork; throws when Fork is not installed */
|
|
132
|
+
export function forkCommand(path: string, installed: (app: string) => boolean = existsSync, platform: string = process.platform): string[] {
|
|
133
|
+
if (platform !== 'darwin') throw new Error('opening in Fork is macOS only here')
|
|
134
|
+
const app = FORK_APPS.find(installed)
|
|
135
|
+
if (app == null) throw new Error(`Fork is not installed (looked in ${FORK_APPS.join(', ')})`)
|
|
136
|
+
return ['open', '-a', app, path]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function openInFork(path: string): void {
|
|
140
|
+
Bun.spawn(forkCommand(path), { stdio: ['ignore', 'ignore', 'ignore'] }).unref()
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
const epochMs = (out: string | undefined): number | null => {
|
|
115
144
|
const secs = Number(out?.trim())
|
|
116
145
|
return Number.isFinite(secs) && secs > 0 ? secs * 1000 : null
|
|
@@ -130,6 +159,15 @@ const readText = (file: string): string => {
|
|
|
130
159
|
}
|
|
131
160
|
}
|
|
132
161
|
|
|
162
|
+
/** the house repos in the rail's order, archived ones left out: the rows of
|
|
163
|
+
* the project list without the facts the list measures, for a caller that
|
|
164
|
+
* needs only names and paths (`shipkit cross-repo-activity`) */
|
|
165
|
+
export function railRepos(root: string, userHome: string = homedir(), orderFile: string = join(userHome, ORDER_FILE)): WiredRepo[] {
|
|
166
|
+
const repos = houseRepos(root, userHome).repos
|
|
167
|
+
const by = new Map(repos.map((r) => [r.name, r]))
|
|
168
|
+
return mergeOrder(parseOrder(readText(orderFile)), repos.map((r) => r.name)).map((n) => by.get(n)!)
|
|
169
|
+
}
|
|
170
|
+
|
|
133
171
|
export function projectGutter(d: GutterDeps) {
|
|
134
172
|
let home = d.root
|
|
135
173
|
const userHome = d.home ?? homedir()
|
|
@@ -145,21 +183,25 @@ export function projectGutter(d: GutterDeps) {
|
|
|
145
183
|
}
|
|
146
184
|
|
|
147
185
|
let repos: WiredRepo[] = []
|
|
186
|
+
let archivedRepos: WiredRepo[] = []
|
|
148
187
|
let last: ProjectsAnswer | null = null
|
|
149
188
|
let inFlight: Promise<ProjectsAnswer> | null = null
|
|
150
189
|
let folders: { mtime: number; list: string[] } | null = null
|
|
151
190
|
const releases = new Map<string, { stamp: string | null; at: number; release: Release; commitAt: number | null; dirty: number | null }>()
|
|
152
|
-
const
|
|
153
|
-
/** a repo's last gate, its record opened only when it
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
191
|
+
const records = new Map<string, { stamp: string; rows: LastRow[] }>()
|
|
192
|
+
/** a repo's last gate and check colour, its record opened only when it
|
|
193
|
+
* moved, counted over the checks it declares today */
|
|
194
|
+
const gateOfRepo = (r: WiredRepo): { gate: Gate; checks: CheckState } => {
|
|
195
|
+
const record = recordOf(r.path)
|
|
196
|
+
if (record == null) return { gate: null, checks: null }
|
|
157
197
|
const stamp = recordStamp(record)
|
|
158
|
-
|
|
159
|
-
if (hit
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
198
|
+
let hit = records.get(r.path)
|
|
199
|
+
if (hit == null || hit.stamp !== stamp) {
|
|
200
|
+
hit = { stamp, rows: readLastStatusReadonly(record) }
|
|
201
|
+
records.set(r.path, hit)
|
|
202
|
+
}
|
|
203
|
+
const checks = checkStateOf(hit.rows, d.declared?.(r.name) ?? null)
|
|
204
|
+
return { gate: gateFrom(checks), checks }
|
|
163
205
|
}
|
|
164
206
|
const orderFile = d.orderFile ?? join(userHome, ORDER_FILE)
|
|
165
207
|
const transcriptRoots = (): string[] => {
|
|
@@ -251,12 +293,15 @@ export function projectGutter(d: GutterDeps) {
|
|
|
251
293
|
return paths.map((p) => ({ release: releases.get(p)?.release ?? null, commitAt: releases.get(p)?.commitAt ?? null, dirty: releases.get(p)?.dirty ?? null }))
|
|
252
294
|
}
|
|
253
295
|
|
|
296
|
+
const iconUrl = (r: WiredRepo): string | null => (r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`)
|
|
297
|
+
|
|
254
298
|
async function measure(): Promise<ProjectsAnswer> {
|
|
255
299
|
const t0 = performance.now()
|
|
256
300
|
const house = houseRepos(d.root, userHome)
|
|
257
301
|
home = house.home
|
|
258
302
|
if (house.index == null) warnOnce('house', `WARN project gutter: ${home} includes no house wiki index, so no sibling shares one and the gutter is empty`)
|
|
259
303
|
repos = house.repos
|
|
304
|
+
archivedRepos = house.archived
|
|
260
305
|
const [open, cwds, facts] = await Promise.all([vscodeFolders(), sessionCwds(), gitFacts(repos.map((r) => r.path))])
|
|
261
306
|
const transcripts = lastSessionAt(transcriptRoots(), repos.map((r) => r.path))
|
|
262
307
|
const counts = cwds == null ? null : countSessions(cwds, repos.map((r) => r.path))
|
|
@@ -266,10 +311,11 @@ export function projectGutter(d: GutterDeps) {
|
|
|
266
311
|
const asks = openAsks(join(r.path, asksDirOf(config, r.path)))
|
|
267
312
|
// the README is read only when package.json says nothing
|
|
268
313
|
const description = descriptionOf(pkg, '') ?? descriptionOf('', readText(join(r.path, 'README.md')))
|
|
314
|
+
const state = gateOfRepo(r)
|
|
269
315
|
return {
|
|
270
316
|
name: r.name,
|
|
271
317
|
path: r.path,
|
|
272
|
-
icon:
|
|
318
|
+
icon: iconUrl(r),
|
|
273
319
|
current: r.path === home,
|
|
274
320
|
vscode: open == null ? null : open.some((f) => f === r.path),
|
|
275
321
|
sessions: counts?.get(r.path) ?? null,
|
|
@@ -279,14 +325,17 @@ export function projectGutter(d: GutterDeps) {
|
|
|
279
325
|
version: versionOf(pkg),
|
|
280
326
|
branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
|
|
281
327
|
dirty: facts[i]!.dirty,
|
|
282
|
-
gate:
|
|
328
|
+
gate: state.gate,
|
|
329
|
+
checks: state.checks,
|
|
283
330
|
release: facts[i]!.release,
|
|
284
331
|
live: liveUrlOf(config),
|
|
285
332
|
identity: identityOfText(config),
|
|
286
333
|
activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
|
|
334
|
+
commit: facts[i]!.commitAt,
|
|
287
335
|
}
|
|
288
336
|
})
|
|
289
|
-
|
|
337
|
+
const archived = archivedRepos.map((r) => ({ name: r.name, icon: iconUrl(r) }))
|
|
338
|
+
return { projects: inOrder(projects, parseOrder(readText(orderFile))), archived, ms: Math.round(performance.now() - t0), at: now() }
|
|
290
339
|
}
|
|
291
340
|
|
|
292
341
|
/** the last answer while it is younger than POLL_MS, one measure otherwise,
|
|
@@ -301,6 +350,8 @@ export function projectGutter(d: GutterDeps) {
|
|
|
301
350
|
}
|
|
302
351
|
|
|
303
352
|
const find = (name: string): WiredRepo | undefined => repos.find((r) => r.name === name)
|
|
353
|
+
/** a live repo or an archived one: the icon and the archive toggle reach both */
|
|
354
|
+
const findAny = (name: string): WiredRepo | undefined => find(name) ?? archivedRepos.find((r) => r.name === name)
|
|
304
355
|
|
|
305
356
|
/** saves the rail order: the names given, then every discovered repo they
|
|
306
357
|
* leave out. Written whole through a temp file, so a reader never sees
|
|
@@ -320,7 +371,7 @@ export function projectGutter(d: GutterDeps) {
|
|
|
320
371
|
if (path === '/api/projects.json') return Response.json(await answer())
|
|
321
372
|
if (path.startsWith('/api/projects/icon/')) {
|
|
322
373
|
if (repos.length === 0) await answer()
|
|
323
|
-
const r =
|
|
374
|
+
const r = findAny(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
|
|
324
375
|
if (r?.icon == null) return new Response('no icon', { status: 404 })
|
|
325
376
|
return new Response(Bun.file(join(r.path, r.icon)), {
|
|
326
377
|
headers: { 'content-type': ICON_TYPES[r.icon.split('.').pop()!] ?? 'application/octet-stream', 'cache-control': 'max-age=86400' },
|
|
@@ -342,6 +393,21 @@ export function projectGutter(d: GutterDeps) {
|
|
|
342
393
|
return new Response((e as Error).message, { status: 409 })
|
|
343
394
|
}
|
|
344
395
|
}
|
|
396
|
+
if (path === '/api/projects/archived' && req.method === 'POST') {
|
|
397
|
+
if (repos.length === 0) await answer()
|
|
398
|
+
const b = (await req.json().catch(() => ({}))) as { name?: unknown; archived?: unknown }
|
|
399
|
+
const r = findAny(String(b.name))
|
|
400
|
+
if (r == null) return new Response(`not a house repo: ${String(b.name)}`, { status: 404 })
|
|
401
|
+
if (typeof b.archived !== 'boolean') return new Response('archived must be true or false', { status: 400 })
|
|
402
|
+
try {
|
|
403
|
+
const on = writeArchived(r.path, b.archived)
|
|
404
|
+
// the repo moves between the two lists: the next answer measures again
|
|
405
|
+
last = null
|
|
406
|
+
return Response.json({ archived: on })
|
|
407
|
+
} catch (e) {
|
|
408
|
+
return new Response((e as Error).message, { status: 409 })
|
|
409
|
+
}
|
|
410
|
+
}
|
|
345
411
|
if (path === '/api/projects/order' && req.method === 'POST') {
|
|
346
412
|
if (repos.length === 0) await answer()
|
|
347
413
|
const b = (await req.json().catch(() => ({}))) as { names?: unknown }
|
|
@@ -352,6 +418,20 @@ export function projectGutter(d: GutterDeps) {
|
|
|
352
418
|
return new Response(`the order could not be saved to ${orderFile}: ${(e as Error).message}`, { status: 500 })
|
|
353
419
|
}
|
|
354
420
|
}
|
|
421
|
+
if (path === '/api/projects/fork' && req.method === 'POST') {
|
|
422
|
+
if (repos.length === 0) await answer()
|
|
423
|
+
const b = (await req.json().catch(() => ({}))) as { name?: unknown }
|
|
424
|
+
const name = typeof b.name === 'string' ? b.name : ''
|
|
425
|
+
if (archivedRepos.some((r) => r.name === name)) return new Response(`${name} is archived: unarchive it first`, { status: 409 })
|
|
426
|
+
const r = find(name)
|
|
427
|
+
if (r == null) return new Response(`not a house repo: ${JSON.stringify(b.name ?? '')}`, { status: 404 })
|
|
428
|
+
try {
|
|
429
|
+
;(d.fork ?? openInFork)(r.path)
|
|
430
|
+
} catch (e) {
|
|
431
|
+
return new Response((e as Error).message, { status: 400 })
|
|
432
|
+
}
|
|
433
|
+
return Response.json({ ok: true })
|
|
434
|
+
}
|
|
355
435
|
if (path === '/api/projects/open' && req.method === 'POST') {
|
|
356
436
|
if (repos.length === 0) await answer()
|
|
357
437
|
const b = (await req.json().catch(() => ({}))) as { name?: string }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// read of each one: which folders VS Code has open, which Claude Code
|
|
3
3
|
// sessions run in which repo, its open asks, its distance to the last
|
|
4
4
|
// release, its declared live url, its run record and its hub port. The
|
|
5
|
-
// project gutter and the
|
|
5
|
+
// project gutter and the dock both list repos through houseRepos, so
|
|
6
6
|
// the two surfaces show the same repos by construction. The parsers read
|
|
7
7
|
// text they are handed, so sample output pins them; project-gutter-route.ts
|
|
8
8
|
// and other-repos.ts own the spawns, the reads and the caches.
|
|
@@ -13,11 +13,11 @@ import { gitRoot } from '../core/git-root.ts'
|
|
|
13
13
|
import { DB_FILE } from '../ledger/db.ts'
|
|
14
14
|
import { defaultHubPort } from './hub-port.ts'
|
|
15
15
|
import { LAYOUT } from '../core/layout.ts'
|
|
16
|
+
import { isArchived } from '../core/archived.ts'
|
|
17
|
+
import { CLAUDE_FILES } from '../home/pm-inputs.ts'
|
|
16
18
|
|
|
17
19
|
export type WiredRepo = { name: string; path: string; icon: string | null }
|
|
18
20
|
|
|
19
|
-
/** the files a repo carries its includes in */
|
|
20
|
-
const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
|
|
21
21
|
|
|
22
22
|
/** the absolute md file of every `@<path>.md` include line a repo's CLAUDE
|
|
23
23
|
* files carry, in order. A sentence that mentions a path is no include */
|
|
@@ -72,11 +72,16 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
|
|
|
72
72
|
|
|
73
73
|
/** the house repos around the repo at `root`: its main checkout (`home`),
|
|
74
74
|
* the house index its own includes name, and every sibling that includes the
|
|
75
|
-
* same index, `home` among them. No index = no repos
|
|
76
|
-
|
|
75
|
+
* same index, `home` among them. No index = no repos. An archived repo
|
|
76
|
+
* (`archived: true`, core/archived.ts) is never in `repos`, so nothing that
|
|
77
|
+
* lists through here processes it: it is in `archived`, which only the
|
|
78
|
+
* archived page reads */
|
|
79
|
+
export function houseRepos(root: string, userHome: string): { home: string; index: string | null; repos: WiredRepo[]; archived: WiredRepo[] } {
|
|
77
80
|
const home = homeRepo(root)
|
|
78
81
|
const index = houseIndex(home, userHome)
|
|
79
|
-
|
|
82
|
+
const all = index == null ? [] : wiredRepos(dirname(home), index, userHome)
|
|
83
|
+
const archived = all.filter((r) => isArchived(r.path))
|
|
84
|
+
return { home, index, repos: all.filter((r) => !archived.includes(r)), archived }
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
/** the checkout a linked worktree belongs to, so a hub started in a worktree
|
|
@@ -289,15 +294,31 @@ export function branchOf(head: string): string | null {
|
|
|
289
294
|
/** `git status --porcelain`: one line per changed or untracked path */
|
|
290
295
|
export const dirtyCount = (porcelain: string): number => porcelain.split('\n').filter((l) => l.trim() !== '').length
|
|
291
296
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
297
|
+
/** a check's last outcome as the run record keeps it */
|
|
298
|
+
export type LastRow = { id: string; status: string; at?: number }
|
|
299
|
+
|
|
300
|
+
/** every check's last outcome in one colour: red when one failed, orange
|
|
301
|
+
* when one only warned, green when all passed or skipped, with when each
|
|
302
|
+
* red or warning was recorded. Only the ids the repo declares today count
|
|
303
|
+
* (`declared`; null = not known, every row counts), so a renamed or deleted
|
|
304
|
+
* check never holds a repo red. No row left = unknown */
|
|
305
|
+
export type CheckState = { state: 'red' | 'orange' | 'green'; reds: string[]; warns: string[]; since: Record<string, number> } | null
|
|
306
|
+
export function checkStateOf(rows: LastRow[], declared: ReadonlySet<string> | null = null): CheckState {
|
|
307
|
+
const live = declared == null ? rows : rows.filter((r) => declared.has(r.id))
|
|
308
|
+
if (live.length === 0) return null
|
|
309
|
+
const reds = live.filter((r) => r.status === 'fail').map((r) => r.id).sort()
|
|
310
|
+
const warns = live.filter((r) => r.status === 'warn').map((r) => r.id).sort()
|
|
311
|
+
const since: Record<string, number> = {}
|
|
312
|
+
for (const r of live) if ((r.status === 'fail' || r.status === 'warn') && typeof r.at === 'number') since[r.id] = r.at
|
|
313
|
+
return { state: reds.length > 0 ? 'red' : warns.length > 0 ? 'orange' : 'green', reds, warns, since }
|
|
299
314
|
}
|
|
300
315
|
|
|
316
|
+
/** the last gate: green, or the checks whose last outcome failed, read off
|
|
317
|
+
* the same state. Unknown when no declared row is left */
|
|
318
|
+
export type Gate = 'green' | { reds: string[] } | null
|
|
319
|
+
export const gateFrom = (c: CheckState): Gate => (c == null ? null : c.reds.length === 0 ? 'green' : { reds: c.reds })
|
|
320
|
+
export const gateOf = (rows: LastRow[], declared: ReadonlySet<string> | null = null): Gate => gateFrom(checkStateOf(rows, declared))
|
|
321
|
+
|
|
301
322
|
/** the tag shapes a release leaves: `v1.2.3`, `name@1.2.3` (the shipkit
|
|
302
323
|
* defaults) and a bare `1.2.3`. A date or a merge marker is no release */
|
|
303
324
|
export const RELEASE_TAGS = ['v[0-9]*', '*@[0-9]*', '[0-9]*.[0-9]*']
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// the repo's README for the hub page's empty state, and the images it points
|
|
2
|
+
// at. A file answers only when it is an image AND sits inside the repo root
|
|
3
|
+
// once symlinks resolve: the route must never become a way to read the disk
|
|
4
|
+
import { existsSync, realpathSync, statSync } from 'fs'
|
|
5
|
+
import { extname, isAbsolute, join, relative, resolve } from 'path'
|
|
6
|
+
|
|
7
|
+
const README_NAMES = ['README.md', 'readme.md', 'Readme.md', 'README.markdown', 'README']
|
|
8
|
+
|
|
9
|
+
const IMAGE_TYPES: Record<string, string> = {
|
|
10
|
+
'.png': 'image/png',
|
|
11
|
+
'.jpg': 'image/jpeg',
|
|
12
|
+
'.jpeg': 'image/jpeg',
|
|
13
|
+
'.gif': 'image/gif',
|
|
14
|
+
'.webp': 'image/webp',
|
|
15
|
+
'.svg': 'image/svg+xml',
|
|
16
|
+
'.avif': 'image/avif',
|
|
17
|
+
'.ico': 'image/x-icon',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** the README file name at the root, null when the repo has none */
|
|
21
|
+
export function findReadme(root: string): string | null {
|
|
22
|
+
return README_NAMES.find((n) => existsSync(join(root, n)) && statSync(join(root, n)).isFile()) ?? null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** an image under the root, or null for anything else: another type, a
|
|
26
|
+
* missing file, `..`, an absolute path, a symlink that leaves the root */
|
|
27
|
+
export function readmeAsset(root: string, rel: string): { path: string; type: string } | null {
|
|
28
|
+
if (rel === '' || isAbsolute(rel) || rel.includes('\0')) return null
|
|
29
|
+
const type = IMAGE_TYPES[extname(rel).toLowerCase()]
|
|
30
|
+
if (type == null) return null
|
|
31
|
+
const abs = resolve(root, rel)
|
|
32
|
+
if (!existsSync(abs)) return null
|
|
33
|
+
const real = realpathSync(abs)
|
|
34
|
+
const inside = relative(realpathSync(root), real)
|
|
35
|
+
if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) return null
|
|
36
|
+
if (!statSync(real).isFile()) return null
|
|
37
|
+
return { path: real, type }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `/api/readme.json` and `/api/readme-file/<path>`, null for any other path */
|
|
41
|
+
export async function readmeRoutes(root: string, path: string): Promise<Response | null> {
|
|
42
|
+
if (path === '/api/readme.json') {
|
|
43
|
+
const file = findReadme(root)
|
|
44
|
+
return Response.json({ file, md: file == null ? null : await Bun.file(join(root, file)).text() })
|
|
45
|
+
}
|
|
46
|
+
const prefix = '/api/readme-file/'
|
|
47
|
+
if (!path.startsWith(prefix)) return null
|
|
48
|
+
let rel: string
|
|
49
|
+
try {
|
|
50
|
+
rel = decodeURIComponent(path.slice(prefix.length))
|
|
51
|
+
} catch {
|
|
52
|
+
return new Response('bad path encoding', { status: 400 })
|
|
53
|
+
}
|
|
54
|
+
const asset = readmeAsset(root, rel)
|
|
55
|
+
if (asset == null) return new Response(`not an image inside this repo: ${rel}`, { status: 404 })
|
|
56
|
+
return new Response(Bun.file(asset.path), { headers: { 'content-type': asset.type, 'cache-control': 'no-cache' } })
|
|
57
|
+
}
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { ProseView } from '../prose/prose-view.ts'
|
|
|
20
20
|
import type { Timeline } from '../ledger/run-timeline.ts'
|
|
21
21
|
import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
|
|
22
22
|
import type { LiveView } from '../ledger/live-timeline.ts'
|
|
23
|
+
import type { HouseView } from '../ledger/house-runs.ts'
|
|
23
24
|
|
|
24
25
|
export type { LiveView }
|
|
25
26
|
import type { PlanRow } from '../plan/planner.ts'
|
|
@@ -32,6 +33,7 @@ import { projectGutter } from './project-gutter-route.ts'
|
|
|
32
33
|
import { sameOrigin } from './same-origin.ts'
|
|
33
34
|
import { LAYOUT } from '../core/layout.ts'
|
|
34
35
|
import { WarmCell } from './warm-cell.ts'
|
|
36
|
+
import { readmeRoutes } from './readme-route.ts'
|
|
35
37
|
|
|
36
38
|
export { sameOrigin }
|
|
37
39
|
|
|
@@ -85,11 +87,11 @@ export type HubDeps = {
|
|
|
85
87
|
/** where the test checks' time goes: the `tests` page (check null) or one
|
|
86
88
|
* test check's page (collections/test-time.ts). Absent = 404 */
|
|
87
89
|
tests?: (check: string | null) => Promise<TestsRootView | TestCheckView>
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
+
/** this repo's live view: the recent runs and the ones in flight, shaped
|
|
91
|
+
* on the server clock */
|
|
90
92
|
live?: () => LiveView
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
+
/** the dock's view: every house repo's recent runs (house-runs.ts) */
|
|
94
|
+
house?: () => HouseView
|
|
93
95
|
/** every human-action item, red/yellow, gathered per request from the
|
|
94
96
|
* collection providers (run-serve builds it) */
|
|
95
97
|
needs: () => Promise<NeedItem[]>
|
|
@@ -323,12 +325,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
323
325
|
}
|
|
324
326
|
}
|
|
325
327
|
if (path === '/api/live.json') {
|
|
326
|
-
if (d.live == null) return Response.json({ now: Date.now(), historyMs: 0, cap: 0, busy: 0, invocations: [],
|
|
328
|
+
if (d.live == null) return Response.json({ now: Date.now(), historyMs: 0, cap: 0, busy: 0, invocations: [], runs: [], queued: 0, blocked: [] } satisfies LiveView)
|
|
327
329
|
return Response.json(d.live(), { headers: { 'cache-control': 'no-store' } })
|
|
328
330
|
}
|
|
329
|
-
if (path === '/api/
|
|
330
|
-
|
|
331
|
-
return Response.json(
|
|
331
|
+
if (path === '/api/house-runs.json') {
|
|
332
|
+
if (d.house == null) return new Response('this hub draws no house runs', { status: 404 })
|
|
333
|
+
return Response.json(d.house(), { headers: { 'cache-control': 'no-store' } })
|
|
332
334
|
}
|
|
333
335
|
if (path === '/api/timeline.json') {
|
|
334
336
|
if (timeline == null) return new Response('this hub keeps no run record', { status: 404 })
|
|
@@ -347,6 +349,9 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
347
349
|
return new Response(`the test times could not be shaped: ${(e as Error).message}`, { status: 500 })
|
|
348
350
|
}
|
|
349
351
|
}
|
|
352
|
+
// the empty page shows the README, its images served from the repo
|
|
353
|
+
const readme = await readmeRoutes(d.root, path)
|
|
354
|
+
if (readme != null) return readme
|
|
350
355
|
if (path === '/api/status.json') {
|
|
351
356
|
const p = join(d.root, LAYOUT.status)
|
|
352
357
|
return Response.json({ statusMd: existsSync(p) ? await Bun.file(p).text() : null })
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// It only says THAT something moved; the plan (plan/planner.ts) then asks the
|
|
3
3
|
// ledger which checks that moved, so a missed file event costs a stale
|
|
4
4
|
// problems pane until the next save, never a green.
|
|
5
|
-
import { watch, type FSWatcher } from 'fs'
|
|
5
|
+
import { statSync, watch, type FSWatcher } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
6
7
|
import { GENERATED_IGNORE_LINES } from '../rules/untrack-generated.ts'
|
|
7
8
|
import { gitBin } from '../core/git-bin.ts'
|
|
8
9
|
import { LAYOUT } from '../core/layout.ts'
|
|
@@ -46,6 +47,13 @@ export async function gitIgnored(root: string, paths: string[]): Promise<Set<str
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** a commit, a fetch or a tag moved a ref: the one write inside .git a check
|
|
51
|
+
* keyed on the refs (SK009) must hear. Lock files are git mid write */
|
|
52
|
+
export function isRefMove(rel: string): boolean {
|
|
53
|
+
if (rel.endsWith('.lock')) return false
|
|
54
|
+
return rel === '.git/HEAD' || rel === '.git/packed-refs' || rel.startsWith('.git/refs/')
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
/** calls onChange whenever a file the owner edits moves under root: tracked,
|
|
50
58
|
* or untracked and not ignored. What the gate itself writes (GATE_WRITES,
|
|
51
59
|
* the ledger in .git) never counts, ignored or not, so a run never triggers
|
|
@@ -74,6 +82,7 @@ export function watchTracked(root: string, onChange: () => void, o: { ignored?:
|
|
|
74
82
|
w = watch(root, { recursive: true }, (_event, name) => {
|
|
75
83
|
if (name == null) return onChange()
|
|
76
84
|
const rel = String(name).replace(/\\/g, '/')
|
|
85
|
+
if (isRefMove(rel)) return onChange()
|
|
77
86
|
if (rel === '.git' || rel.startsWith('.git/') || rel.split('/').includes('node_modules') || isGateWrite(rel)) return
|
|
78
87
|
pending.add(rel)
|
|
79
88
|
void flush()
|
|
@@ -86,3 +95,26 @@ export function watchTracked(root: string, onChange: () => void, o: { ignored?:
|
|
|
86
95
|
}
|
|
87
96
|
return { close: () => w?.close(), error }
|
|
88
97
|
}
|
|
98
|
+
|
|
99
|
+
/** did the owner edit anything after `since`: a tracked file, or an untracked
|
|
100
|
+
* one git does not ignore, written later, or a ref that moved. What the gate
|
|
101
|
+
* writes never counts. A boot asks it once per repo, so a burst the previous
|
|
102
|
+
* image had not asked about is picked up (plan/repo-source.ts pending) */
|
|
103
|
+
export async function movedSince(root: string, since: number): Promise<boolean> {
|
|
104
|
+
let out = ''
|
|
105
|
+
try {
|
|
106
|
+
const p = Bun.spawn([gitBin(), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
|
|
107
|
+
out = await new Response(p.stdout).text()
|
|
108
|
+
if ((await p.exited) !== 0) return false
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
const later = (rel: string): boolean => {
|
|
113
|
+
try { return statSync(join(root, rel)).mtimeMs > since } catch { return false }
|
|
114
|
+
}
|
|
115
|
+
for (const rel of out.split('\0')) {
|
|
116
|
+
if (rel === '' || rel.split('/').includes('node_modules') || isGateWrite(rel)) continue
|
|
117
|
+
if (later(rel)) return true
|
|
118
|
+
}
|
|
119
|
+
return later('.git/HEAD') || later('.git/packed-refs')
|
|
120
|
+
}
|
package/src/serve/warm-cell.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// one answer of a hub kept warm in memory: computed when the hub loads,
|
|
2
2
|
// recomputed in the background when something says it moved (a save, a
|
|
3
|
-
// stamp, its age), and served from memory. A
|
|
4
|
-
//
|
|
3
|
+
// stamp, its age) while a page reads it, and served from memory. A cell
|
|
4
|
+
// nobody read for WARM_WATCH_MS computes nothing on a save: its next reader
|
|
5
|
+
// waits on one compute instead, so an unwatched repo costs no cpu per save.
|
|
5
6
|
//
|
|
6
|
-
// the one constraint a reader breaks first: `get()`
|
|
7
|
-
// memory even when it is known stale. Whoever just
|
|
8
|
-
// act, the end of a run) asks for `fresh`, which waits
|
|
9
|
-
// started after its call.
|
|
7
|
+
// the one constraint a reader breaks first: `get()` from a watched cell
|
|
8
|
+
// answers what is in memory even when it is known stale. Whoever just
|
|
9
|
+
// changed the truth (an act, the end of a run) asks for `fresh`, which waits
|
|
10
|
+
// on a compute that started after its call.
|
|
11
|
+
|
|
12
|
+
/** a cell read within this long has a reader: a save recomputes it in the
|
|
13
|
+
* background. Past it, nothing recomputes until a reader asks */
|
|
14
|
+
export const WARM_WATCH_MS = 30_000
|
|
15
|
+
|
|
16
|
+
/** a background recompute waits this many times the last compute's length
|
|
17
|
+
* after it ended: a 1.5s answer at most every 15s, a 50ms one at once */
|
|
18
|
+
export const WARM_SPACING = 10
|
|
10
19
|
|
|
11
20
|
export type WarmCellOpts = {
|
|
12
21
|
/** a cheap fingerprint of what the answer reads (file stats): a new one
|
|
@@ -20,6 +29,10 @@ export type WarmCellOpts = {
|
|
|
20
29
|
|
|
21
30
|
export class WarmCell<T> {
|
|
22
31
|
private value: { v: T; at: number; stamp: string } | null = null
|
|
32
|
+
/** when a reader last asked, null before any */
|
|
33
|
+
private readAt: number | null = null
|
|
34
|
+
/** when the last compute ended and how long it took */
|
|
35
|
+
private last: { end: number; ms: number } | null = null
|
|
23
36
|
private running: Promise<T> | null = null
|
|
24
37
|
/** bumped by invalidate: a compute that started before it is stale */
|
|
25
38
|
private gen = 0
|
|
@@ -37,6 +50,7 @@ export class WarmCell<T> {
|
|
|
37
50
|
const gen = this.gen
|
|
38
51
|
const stamp = this.o.stamp?.() ?? ''
|
|
39
52
|
this.runGen = gen
|
|
53
|
+
const start = this.now()
|
|
40
54
|
const p = (async (): Promise<T> => {
|
|
41
55
|
try {
|
|
42
56
|
// never synchronously: `running` is set before this settles
|
|
@@ -44,6 +58,8 @@ export class WarmCell<T> {
|
|
|
44
58
|
this.value = { v, at: this.now(), stamp }
|
|
45
59
|
return v
|
|
46
60
|
} finally {
|
|
61
|
+
const end = this.now()
|
|
62
|
+
this.last = { end, ms: end - start }
|
|
47
63
|
this.running = null
|
|
48
64
|
// what said it moved while this ran is not in this answer
|
|
49
65
|
if (this.gen !== gen) this.kick()
|
|
@@ -53,11 +69,19 @@ export class WarmCell<T> {
|
|
|
53
69
|
return p
|
|
54
70
|
}
|
|
55
71
|
|
|
56
|
-
/** a compute in the background
|
|
72
|
+
/** a compute in the background, at most once per WARM_SPACING times the
|
|
73
|
+
* last one: a kick too soon is dropped, and the next read past the gap
|
|
74
|
+
* kicks again. On a failure the last answer stays */
|
|
57
75
|
private kick(): void {
|
|
76
|
+
if (this.last != null && this.now() < this.last.end + WARM_SPACING * this.last.ms) return
|
|
58
77
|
void this.run().catch(() => {})
|
|
59
78
|
}
|
|
60
79
|
|
|
80
|
+
/** a reader asked within WARM_WATCH_MS */
|
|
81
|
+
private get watched(): boolean {
|
|
82
|
+
return this.readAt != null && this.now() - this.readAt < WARM_WATCH_MS
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
get stale(): boolean {
|
|
62
86
|
const v = this.value
|
|
63
87
|
if (v == null) return true
|
|
@@ -68,23 +92,32 @@ export class WarmCell<T> {
|
|
|
68
92
|
/** the answer in memory, a stale one kicking a recompute behind it. With
|
|
69
93
|
* nothing in memory yet, or `fresh`, it waits on a compute */
|
|
70
94
|
async get(o: { fresh?: boolean } = {}): Promise<T> {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (this.running != null) await this.running.catch(() => {})
|
|
75
|
-
return this.run()
|
|
76
|
-
}
|
|
95
|
+
const watched = this.watched
|
|
96
|
+
this.readAt = this.now()
|
|
97
|
+
if (o.fresh === true) return this.fresh()
|
|
77
98
|
const v = this.value
|
|
78
99
|
// nothing in memory, a failed compute included: wait on a new one
|
|
79
100
|
if (v == null) return this.run()
|
|
80
|
-
|
|
101
|
+
const moved = this.stale || this.gen !== this.runGen
|
|
102
|
+
// the first reader in a while: what is in memory may be hours old,
|
|
103
|
+
// and no save recomputed it meanwhile
|
|
104
|
+
if (moved && !watched) return this.running != null && this.runGen === this.gen ? this.running : this.fresh()
|
|
105
|
+
if (this.running == null && moved) this.kick()
|
|
81
106
|
return v.v
|
|
82
107
|
}
|
|
83
108
|
|
|
84
|
-
/**
|
|
109
|
+
/** a compute that started after this call: one that started before may
|
|
110
|
+
* miss what the caller just changed, so wait for it, then for our own */
|
|
111
|
+
private async fresh(): Promise<T> {
|
|
112
|
+
if (this.running != null) await this.running.catch(() => {})
|
|
113
|
+
return this.run()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** something this answer reads moved: recompute in the background while
|
|
117
|
+
* a reader watches, else leave it to the next reader */
|
|
85
118
|
invalidate(): void {
|
|
86
119
|
this.gen++
|
|
87
|
-
if (this.running == null) this.kick()
|
|
120
|
+
if (this.running == null && this.watched) this.kick()
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
/** compute now unless an answer is already in memory: the hub's load */
|