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/home/resources.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// the machine's global processes (ComfyUI, a TTS server, an app panel), each
|
|
2
2
|
// declared once in a repo's `<name>.shipkit.ts` as `globalProcess({...})`
|
|
3
3
|
// (shipkit-files.ts) and owned by pm: a switch, a restart, its log, its
|
|
4
|
-
// action buttons, and
|
|
4
|
+
// action buttons, and its url opened in the default browser, in Chrome, or
|
|
5
|
+
// as its own app (home/gui-app.ts builds that app in its repo on first use).
|
|
5
6
|
//
|
|
6
7
|
// two kinds: with no `down`, the command IS the service, supervised like a
|
|
7
8
|
// hub (restarted on a crash, signalled on stop). With `down`, the command is
|
|
8
9
|
// a bring-up that exits (an idempotent ladder script), `down` its reverse,
|
|
9
10
|
// and the probe is the only truth.
|
|
10
|
-
import { mkdirSync, openSync
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync } from 'fs'
|
|
12
|
+
import { homedir } from 'os'
|
|
11
13
|
import { dirname, join } from 'path'
|
|
12
14
|
import { fileURLToPath } from 'url'
|
|
13
15
|
import type { GlobalProcessDecl } from './global-units.ts'
|
|
16
|
+
import { iconImageType } from '../shipkit-files.ts'
|
|
14
17
|
import { Proc, realDeps, tailLines, type ProcStatus, type SuperviseDeps } from './supervise.ts'
|
|
15
18
|
import { resourceLog, resourcePid } from './home-paths.ts'
|
|
16
19
|
import { groupOf, localPort, portHolders } from '../core/port-holders.ts'
|
|
@@ -21,6 +24,11 @@ export type ResourceView = {
|
|
|
21
24
|
description: string | null
|
|
22
25
|
where: string | null
|
|
23
26
|
status: ProcStatus
|
|
27
|
+
/** the pid pm holds for it, null for a bring-up or one started elsewhere */
|
|
28
|
+
pid: number | null
|
|
29
|
+
/** epoch ms its uptime counts from while up (its pid's start when it has
|
|
30
|
+
* one, else its last status change), its crash time when crashed */
|
|
31
|
+
since: number
|
|
24
32
|
/** what the owner last asked for */
|
|
25
33
|
want: 'up' | 'down'
|
|
26
34
|
note: string | null
|
|
@@ -35,13 +43,26 @@ export type ResourceView = {
|
|
|
35
43
|
repo: string
|
|
36
44
|
file: string
|
|
37
45
|
url: string | null
|
|
38
|
-
/**
|
|
39
|
-
|
|
46
|
+
/** what its card wears, as declared: null leaves the card its repo's icon */
|
|
47
|
+
icon: ProcessIcon | null
|
|
40
48
|
actions: { id: string; label: string }[]
|
|
41
49
|
/** the action running now, or null */
|
|
42
50
|
running: string | null
|
|
51
|
+
/** its own app is being built or opened: the page shows a spinner on
|
|
52
|
+
* that button. Absent from an older pm */
|
|
53
|
+
appBusy?: boolean
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/** where a url opens: the default browser, Chrome, or the process's own app */
|
|
57
|
+
export type OpenHow = 'browser' | 'chrome' | 'app'
|
|
58
|
+
|
|
59
|
+
/** an emoji, or the url pm serves a declared image file at */
|
|
60
|
+
export type ProcessIcon = { text: string } | { src: string }
|
|
61
|
+
|
|
62
|
+
/** the page's url for a process's declared icon */
|
|
63
|
+
export const processIconOf = (d: GlobalProcessDecl): ProcessIcon | null =>
|
|
64
|
+
d.icon == null ? null : iconImageType(d.icon) != null ? { src: `/api/pm/icon/${encodeURIComponent(d.id)}` } : { text: d.icon }
|
|
65
|
+
|
|
45
66
|
export type HttpProbe = (url: string) => Promise<boolean>
|
|
46
67
|
|
|
47
68
|
export const probeUrl: HttpProbe = async (url) => {
|
|
@@ -69,17 +90,19 @@ export const runOnce: RunOnce = async (cmd, cwd, log) => {
|
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
|
|
72
|
-
/** opens a
|
|
73
|
-
|
|
93
|
+
/** opens a process's own app, building it first when it is missing or
|
|
94
|
+
* stale; what it did lands in the log. Resolves with the exit code */
|
|
95
|
+
export type OpenApp = (d: GlobalProcessDecl, log: string) => Promise<number>
|
|
74
96
|
|
|
75
97
|
/** its own process: what building the app needs never loads in pm */
|
|
76
98
|
const OPEN_APP = fileURLToPath(new URL('./open-app.ts', import.meta.url))
|
|
77
99
|
|
|
78
|
-
const openApp: OpenApp = (
|
|
100
|
+
const openApp: OpenApp = async (d, log) => {
|
|
79
101
|
mkdirSync(dirname(log), { recursive: true })
|
|
80
102
|
const fd = openSync(log, 'a')
|
|
81
103
|
try {
|
|
82
|
-
|
|
104
|
+
const spec = { id: d.id, label: d.label, url: d.url, icon: d.icon, root: d.root }
|
|
105
|
+
return await Bun.spawn([process.execPath, OPEN_APP, '--process', JSON.stringify(spec)], { stdio: ['ignore', fd, fd] }).exited
|
|
83
106
|
} finally {
|
|
84
107
|
closeSync(fd)
|
|
85
108
|
}
|
|
@@ -99,6 +122,26 @@ const browse: Browse = (url, log) => {
|
|
|
99
122
|
}
|
|
100
123
|
}
|
|
101
124
|
|
|
125
|
+
const CHROME_APPS = ['/Applications/Google Chrome.app', join(homedir(), 'Applications', 'Google Chrome.app')]
|
|
126
|
+
|
|
127
|
+
/** the command that opens a url in Chrome, else in the default browser */
|
|
128
|
+
export function chromeCmd(url: string, o: { platform?: string; has?: (path: string) => boolean } = {}): string[] {
|
|
129
|
+
const platform = o.platform ?? process.platform
|
|
130
|
+
const has = o.has ?? existsSync
|
|
131
|
+
if (platform === 'darwin') return CHROME_APPS.some(has) ? ['open', '-a', 'Google Chrome', url] : ['open', url]
|
|
132
|
+
return platform === 'win32' ? ['cmd', '/c', 'start', 'chrome', url] : ['xdg-open', url]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const chrome: Browse = (url, log) => {
|
|
136
|
+
mkdirSync(dirname(log), { recursive: true })
|
|
137
|
+
const fd = openSync(log, 'a')
|
|
138
|
+
try {
|
|
139
|
+
Bun.spawn(chromeCmd(url), { stdio: ['ignore', fd, fd] })
|
|
140
|
+
} finally {
|
|
141
|
+
closeSync(fd)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
102
145
|
/** the function action runner: imports the unit file, calls the action */
|
|
103
146
|
const ACTION_RUNNER = fileURLToPath(new URL('./run-unit-action.ts', import.meta.url))
|
|
104
147
|
|
|
@@ -106,10 +149,12 @@ export type ResourceOpts = {
|
|
|
106
149
|
probe?: HttpProbe
|
|
107
150
|
run?: RunOnce
|
|
108
151
|
deps?: SuperviseDeps
|
|
109
|
-
/** the app
|
|
152
|
+
/** the process's own app */
|
|
110
153
|
open?: OpenApp
|
|
111
154
|
/** the default browser */
|
|
112
155
|
browse?: Browse
|
|
156
|
+
/** Chrome */
|
|
157
|
+
chrome?: Browse
|
|
113
158
|
/** the pids listening on a local port, and the process group of a pid */
|
|
114
159
|
holders?: (port: number) => number[]
|
|
115
160
|
groupOf?: (pid: number) => number | null
|
|
@@ -136,16 +181,26 @@ export class Resource {
|
|
|
136
181
|
private busy = false
|
|
137
182
|
/** the last bring-up exited non zero */
|
|
138
183
|
private failed = false
|
|
139
|
-
/** open it once it answers
|
|
140
|
-
private openWhenUp:
|
|
184
|
+
/** open it once it answers */
|
|
185
|
+
private openWhenUp: OpenHow | null = null
|
|
186
|
+
/** its own app is being built or opened */
|
|
187
|
+
appBusy = false
|
|
188
|
+
/** the status a tick last saw, and since when, for the bring-up kind */
|
|
189
|
+
private seen: ProcStatus = 'down'
|
|
190
|
+
private seenAt: number
|
|
141
191
|
readonly log: string
|
|
142
192
|
|
|
143
193
|
constructor(readonly decl: GlobalProcessDecl, dir: string, private o: ResourceOpts = {}) {
|
|
144
194
|
this.log = resourceLog(dir, decl.id)
|
|
195
|
+
this.seenAt = this.now()
|
|
145
196
|
if (decl.down == null)
|
|
146
197
|
this.proc = new Proc({ id: decl.id, cmd: decl.cmd, cwd: decl.cwd, log: this.log, pidKey: resourcePid(dir, decl.id), probe: decl.probe == null ? undefined : () => this.probe() }, o.deps ?? realDeps)
|
|
147
198
|
}
|
|
148
199
|
|
|
200
|
+
private now(): number {
|
|
201
|
+
return this.o.deps?.now() ?? Date.now()
|
|
202
|
+
}
|
|
203
|
+
|
|
149
204
|
private probe(): Promise<boolean> {
|
|
150
205
|
return this.decl.probe == null ? Promise.resolve(true) : (this.o.probe ?? probeUrl)(this.decl.probe)
|
|
151
206
|
}
|
|
@@ -226,12 +281,10 @@ export class Resource {
|
|
|
226
281
|
await this.set('up')
|
|
227
282
|
}
|
|
228
283
|
|
|
229
|
-
/** its url in the default browser, or as
|
|
230
|
-
*
|
|
231
|
-
|
|
232
|
-
async open(how: 'browser' | 'app'): Promise<void> {
|
|
284
|
+
/** its url in the default browser, in Chrome, or as its own app: at once
|
|
285
|
+
* while it answers, else it starts and opens once it does */
|
|
286
|
+
async open(how: OpenHow): Promise<void> {
|
|
233
287
|
if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
|
|
234
|
-
if (how === 'app' && !this.decl.app) throw new Error(`${this.decl.id} does not declare app: true, so it has no app window: open it in the browser`)
|
|
235
288
|
if (this.status === 'up') {
|
|
236
289
|
this.show(how, this.decl.url)
|
|
237
290
|
return
|
|
@@ -240,9 +293,27 @@ export class Resource {
|
|
|
240
293
|
await this.set('up')
|
|
241
294
|
}
|
|
242
295
|
|
|
243
|
-
private show(how:
|
|
244
|
-
if (how === '
|
|
245
|
-
else (this.o.
|
|
296
|
+
private show(how: OpenHow, url: string): void {
|
|
297
|
+
if (how === 'browser') (this.o.browse ?? browse)(url, this.log)
|
|
298
|
+
else if (how === 'chrome') (this.o.chrome ?? chrome)(url, this.log)
|
|
299
|
+
else if (!this.appBusy) void this.showApp()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** off the request path: the build takes seconds the first time, and
|
|
303
|
+
* the page follows `appBusy` on its poll */
|
|
304
|
+
private async showApp(): Promise<void> {
|
|
305
|
+
this.appBusy = true
|
|
306
|
+
let failed: string | null = null
|
|
307
|
+
try {
|
|
308
|
+
const code = await (this.o.open ?? openApp)(this.decl, this.log)
|
|
309
|
+
if (code !== 0) failed = `opening ${this.decl.label} as an app exited ${code}: see the log`
|
|
310
|
+
} catch (e) {
|
|
311
|
+
failed = `opening ${this.decl.label} as an app: ${(e as Error).message}`
|
|
312
|
+
} finally {
|
|
313
|
+
this.appBusy = false
|
|
314
|
+
}
|
|
315
|
+
// kept beside what the supervisor says, like an action's outcome
|
|
316
|
+
if (failed != null) this.note = this.actionNote = failed
|
|
246
317
|
}
|
|
247
318
|
|
|
248
319
|
/** one declared button. A function action runs in its own bun process
|
|
@@ -275,6 +346,10 @@ export class Resource {
|
|
|
275
346
|
const up = await this.probe()
|
|
276
347
|
this.status = up ? 'up' : this.failed && this.want === 'up' ? 'crashed' : 'down'
|
|
277
348
|
}
|
|
349
|
+
if (this.status !== this.seen) {
|
|
350
|
+
this.seen = this.status
|
|
351
|
+
this.seenAt = this.now()
|
|
352
|
+
}
|
|
278
353
|
if (this.openWhenUp != null && this.status === 'up' && this.decl.url != null) {
|
|
279
354
|
const how = this.openWhenUp
|
|
280
355
|
this.openWhenUp = null
|
|
@@ -290,6 +365,9 @@ export class Resource {
|
|
|
290
365
|
description: d.description,
|
|
291
366
|
where: d.where,
|
|
292
367
|
status: this.status,
|
|
368
|
+
pid: this.proc?.pid ?? null,
|
|
369
|
+
// up with a pid: since that pid started, so a pm reload never resets its uptime
|
|
370
|
+
since: (this.status === 'up' ? this.proc?.startedAt() : null) ?? this.proc?.since ?? this.seenAt,
|
|
293
371
|
want: this.want,
|
|
294
372
|
note: this.note,
|
|
295
373
|
canStop: this.canStop,
|
|
@@ -298,9 +376,10 @@ export class Resource {
|
|
|
298
376
|
repo: d.repo,
|
|
299
377
|
file: d.file,
|
|
300
378
|
url: d.url,
|
|
301
|
-
|
|
379
|
+
icon: processIconOf(d),
|
|
302
380
|
actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
|
|
303
381
|
running: this.running,
|
|
382
|
+
appBusy: this.appBusy,
|
|
304
383
|
}
|
|
305
384
|
}
|
|
306
385
|
}
|
package/src/home/run-daemon.ts
CHANGED
|
@@ -20,8 +20,10 @@ import { daemonPort, homeDir, homePort, homeUp, hubBase, WORKER_ENV } from './ho
|
|
|
20
20
|
import { HomeState } from './home-state.ts'
|
|
21
21
|
import { Hubs } from './hubs.ts'
|
|
22
22
|
import { Planner } from '../plan/planner.ts'
|
|
23
|
+
import { machineCatchUp } from '../plan/catch-up.ts'
|
|
23
24
|
import { serveDaemon } from './serve-daemon.ts'
|
|
24
25
|
import { startSweeps } from './machine-stores.ts'
|
|
26
|
+
import { landMotherhome } from '../core/motherhome.ts'
|
|
25
27
|
import { Widgets } from './widgets.ts'
|
|
26
28
|
|
|
27
29
|
const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
@@ -58,10 +60,11 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
|
|
|
58
60
|
if (reclaimed.killed.length > 0) io.print(`ok: the daemon restarted in place: killed ${reclaimed.killed.length} check process(es) its previous image left running`)
|
|
59
61
|
const c = await loadRepoConfig(root)
|
|
60
62
|
const dir = homeDir()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// the machine's one plan: every repo and check pair, ticked by the daemon.
|
|
64
|
+
// Its catch up window lives in the machine db, so a restart in place keeps it
|
|
65
|
+
const plan = new Planner({ catchUp: machineCatchUp, onError: (repo, e) => io.printErr(`WARN the plan of ${repo} broke: ${e.message}`) })
|
|
66
|
+
// a repo's colour counts only the checks its plan holds today
|
|
67
|
+
const gutter = projectGutter({ root, warn: (l) => io.printErr(l), declared: (name) => plan.declared(name) })
|
|
65
68
|
const hubs = new Hubs({
|
|
66
69
|
dir,
|
|
67
70
|
port,
|
|
@@ -84,8 +87,6 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
|
|
|
84
87
|
fresh: o.fresh,
|
|
85
88
|
// every gate is a detached job: a reload of this process never kills one
|
|
86
89
|
jobs: { cli: CLI },
|
|
87
|
-
// a lane click of the live strip opens that repo's page, on the page's own origin
|
|
88
|
-
repoHub: async (other) => (names.has(other) ? `${hubBase(other)}/` : null),
|
|
89
90
|
})
|
|
90
91
|
hubIo.print(`${rt.name} hub → http://localhost:${port}${hubBase(repo.name)}/`)
|
|
91
92
|
return { ...rt, uses: rt.deps.uses ?? [] }
|
|
@@ -109,7 +110,11 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
|
|
|
109
110
|
// nothing grows unbound: every store of every repo it loads, and the
|
|
110
111
|
// machine's, swept on boot and hourly (core/retention.ts)
|
|
111
112
|
const only = onlyRepos()
|
|
112
|
-
const sweeps = startSweeps({
|
|
113
|
+
const sweeps = startSweeps({
|
|
114
|
+
roots: () => house.repos.filter((r) => only == null || only.has(r.name)).map((r) => r.path), dir, printErr: io.printErr,
|
|
115
|
+
// the rows every gate filed land here, one commit a day at most
|
|
116
|
+
also: (now) => { for (const n of landMotherhome({ now })) io.printErr(n) },
|
|
117
|
+
})
|
|
113
118
|
const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, io })
|
|
114
119
|
io.print(` ${house.repos.length} repos, state in ${dir}, reloads on ${code.files} lib files`)
|
|
115
120
|
for (const sig of ['SIGINT', 'SIGTERM'] as const)
|
package/src/home/run-pm.ts
CHANGED
|
@@ -11,16 +11,37 @@ import { reloadOnOwnCode } from './lib-watch.ts'
|
|
|
11
11
|
import { Pm, PM_TICK_MS, type Role } from './pm.ts'
|
|
12
12
|
import { Resources } from './resources.ts'
|
|
13
13
|
import type { HomeIo } from './run-daemon.ts'
|
|
14
|
-
import type
|
|
14
|
+
import { stampOf, type PmConfig } from './pm-inputs.ts'
|
|
15
15
|
|
|
16
16
|
const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
17
17
|
const PM_CONFIG = fileURLToPath(new URL('./pm-config.ts', import.meta.url))
|
|
18
18
|
|
|
19
19
|
const RUN_PM = fileURLToPath(import.meta.url)
|
|
20
20
|
|
|
21
|
-
/** how often pm
|
|
21
|
+
/** how often pm stats what its last read of the house side saw */
|
|
22
22
|
export const PM_RESCAN_MS = 15_000
|
|
23
23
|
|
|
24
|
+
/** pm's reread of the house side: the child runs again only once a stat of
|
|
25
|
+
* a path the last answer read moved. A few ms of stats per poll, against a
|
|
26
|
+
* child that imports every unit file */
|
|
27
|
+
export class PmConfigWatch {
|
|
28
|
+
private last: PmConfig | null = null
|
|
29
|
+
|
|
30
|
+
constructor(private readonly read: () => Promise<PmConfig>) {}
|
|
31
|
+
|
|
32
|
+
seed(c: PmConfig): void {
|
|
33
|
+
this.last = c
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** the new answer, or null when nothing it read moved */
|
|
37
|
+
async poll(): Promise<PmConfig | null> {
|
|
38
|
+
if (this.last != null && stampOf(this.last.inputs) === this.last.stamp) return null
|
|
39
|
+
const c = await this.read()
|
|
40
|
+
this.last = c
|
|
41
|
+
return c
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
/** replace this process's image with pm run from this file: same pid, same
|
|
25
46
|
* cwd, same env, none of cli.ts loaded. False when the runtime has no
|
|
26
47
|
* execve, and the caller runs pm in process */
|
|
@@ -75,6 +96,7 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
|
|
|
75
96
|
const pm = new Pm({
|
|
76
97
|
dir,
|
|
77
98
|
root,
|
|
99
|
+
port,
|
|
78
100
|
cmd: (role) => [process.execPath, CLI, role],
|
|
79
101
|
probe: roleAnswers,
|
|
80
102
|
resources: new Resources(c.processes, dir),
|
|
@@ -97,13 +119,16 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
|
|
|
97
119
|
pm.resources.errors = c.errors
|
|
98
120
|
for (const e of c.errors) io.printErr(`WARN ${e}`)
|
|
99
121
|
// a unit file added, edited or removed in any house repo: read again,
|
|
100
|
-
// in its child process, and the unchanged processes keep their state
|
|
122
|
+
// in its child process, and the unchanged processes keep their state.
|
|
123
|
+
// Nothing moved, no child
|
|
124
|
+
const watch = new PmConfigWatch(() => readPmConfig(root))
|
|
125
|
+
watch.seed(c)
|
|
101
126
|
let reading = false
|
|
102
127
|
const reread = setInterval(() => {
|
|
103
128
|
if (reading) return
|
|
104
129
|
reading = true
|
|
105
|
-
void
|
|
106
|
-
.then((n) => pm.resources.sync(n.processes, n.errors))
|
|
130
|
+
void watch.poll()
|
|
131
|
+
.then((n) => { if (n != null) pm.resources.sync(n.processes, n.errors) })
|
|
107
132
|
.catch((e) => io.printErr(`WARN pm could not read the global processes again: ${(e as Error).message}`))
|
|
108
133
|
.finally(() => { reading = false })
|
|
109
134
|
}, PM_RESCAN_MS)
|
package/src/home/serve-web.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// the web: ONE Bun.serve on the home port. It serves the one page bundle (the
|
|
2
|
-
// overview at `/`, the plan at `/planned`, a repo's hub page at `/r/<repo>/`,
|
|
2
|
+
// overview at `/`, the plan at `/planned`, the activity at `/activity`, a repo's hub page at `/r/<repo>/`,
|
|
3
3
|
// all views of one app) and proxies every api,
|
|
4
4
|
// graph and media request to the daemon, streams included, and /api/pm/* to
|
|
5
5
|
// pm. A browser only ever sees this origin.
|
|
@@ -14,7 +14,7 @@ import { hubBase } from './home-paths.ts'
|
|
|
14
14
|
/** the page routes: ONE app for the overview, the plan, and the hub page of
|
|
15
15
|
* each known repo, which moves between them in place (home-st.ts) */
|
|
16
16
|
export function pageRoutes<T>(names: string[], page: T): Record<string, T> {
|
|
17
|
-
const out: Record<string, T> = { '/': page, '/planned': page }
|
|
17
|
+
const out: Record<string, T> = { '/': page, '/planned': page, '/activity': page, '/archived': page }
|
|
18
18
|
for (const n of names) out[`${hubBase(n)}/`] = page
|
|
19
19
|
return out
|
|
20
20
|
}
|
|
@@ -51,6 +51,9 @@ export const REPO_ANSWER_MS = 30_000
|
|
|
51
51
|
export const LIST_EVERY_MS = 30_000
|
|
52
52
|
export const LIST_RETRY_MS = 1_000
|
|
53
53
|
|
|
54
|
+
/** the one home answer that can wait on git: the activity page's first read of a window */
|
|
55
|
+
export const readsGit = (path: string): boolean => path === '/api/home/activity.json'
|
|
56
|
+
|
|
54
57
|
/** a run or a check run streams for minutes, and a silent one is never cut */
|
|
55
58
|
export const streams = (path: string): boolean => /\/api\/(run|check\/run)$/.test(path)
|
|
56
59
|
|
|
@@ -94,7 +97,7 @@ export function serveWeb<T extends Bun.HTMLBundle | Response>(d: WebDeps<T>) {
|
|
|
94
97
|
const path = url.pathname
|
|
95
98
|
const pm = toPm(path)
|
|
96
99
|
const target = pm ? d.pm : d.daemon
|
|
97
|
-
const wait = streams(path) ? null : path.startsWith('/r/') ? (d.repoMs ?? REPO_ANSWER_MS) : (d.machineMs ?? MACHINE_ANSWER_MS)
|
|
100
|
+
const wait = streams(path) ? null : path.startsWith('/r/') || readsGit(path) ? (d.repoMs ?? REPO_ANSWER_MS) : (d.machineMs ?? MACHINE_ANSWER_MS)
|
|
98
101
|
// the wait covers the answer headers only: a body already flowing is never cut
|
|
99
102
|
const ctl = new AbortController()
|
|
100
103
|
const timer = wait == null ? null : setTimeout(() => ctl.abort(new Error(`no answer in ${wait / 1000}s: it runs but hangs`)), wait)
|
package/src/home/supervise.ts
CHANGED
|
@@ -143,19 +143,33 @@ export class Proc {
|
|
|
143
143
|
external = false
|
|
144
144
|
/** the pid came from the pid record an earlier image wrote, not from a start */
|
|
145
145
|
adopted = false
|
|
146
|
+
/** when the pid started, kept per pid: a pid's start never moves, so the
|
|
147
|
+
* OS is asked once for an adopted pid and never for one this image spawned */
|
|
148
|
+
private started: { pid: number; at: number | null } | null = null
|
|
146
149
|
|
|
147
150
|
constructor(readonly spec: ProcSpec, private d: SuperviseDeps = realDeps) {
|
|
148
151
|
this.since = d.now()
|
|
149
152
|
const rec = readPid(spec.pidKey)
|
|
150
153
|
// an earlier image started it and it still runs: adopt, never double
|
|
151
|
-
|
|
154
|
+
const at = rec != null && d.alive(rec.pid) ? (d.startedAt?.(rec.pid) ?? null) : undefined
|
|
155
|
+
if (rec != null && at !== undefined && !((at ?? 0) > rec.at + RECORD_SLACK_MS)) {
|
|
152
156
|
this.pid = rec.pid
|
|
157
|
+
this.started = { pid: rec.pid, at }
|
|
153
158
|
this.adopted = true
|
|
154
159
|
this.want = 'up'
|
|
155
160
|
this.set('starting')
|
|
156
161
|
} else if (rec != null) dropPid(spec.pidKey)
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
/** epoch ms its pid started, what its uptime counts from; null without
|
|
165
|
+
* a pid, or when the OS cannot say */
|
|
166
|
+
startedAt(): number | null {
|
|
167
|
+
const pid = this.pid
|
|
168
|
+
if (pid == null) return null
|
|
169
|
+
if (this.started?.pid !== pid) this.started = { pid, at: this.d.startedAt?.(pid) ?? null }
|
|
170
|
+
return this.started.at
|
|
171
|
+
}
|
|
172
|
+
|
|
159
173
|
private set(s: ProcStatus, note: string | null = null): void {
|
|
160
174
|
if (s !== this.status) this.since = this.d.now()
|
|
161
175
|
this.status = s
|
|
@@ -185,6 +199,7 @@ export class Proc {
|
|
|
185
199
|
try {
|
|
186
200
|
const pid = this.d.spawn(this.spec)
|
|
187
201
|
this.pid = pid
|
|
202
|
+
this.started = { pid, at: this.d.now() }
|
|
188
203
|
this.adopted = false
|
|
189
204
|
writePid(this.spec.pidKey, { pid, cmd: this.spec.cmd, at: this.d.now() })
|
|
190
205
|
this.set('starting')
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// a widget's html as console lines, for `shipkit overview`: the html the
|
|
2
|
+
// daemon rendered for the page, tags stripped, a table's cells aligned in
|
|
3
|
+
// columns, a block element on its own line, and a `.wbar` meter (its width
|
|
4
|
+
// in `--p:NN%`) drawn as `█████░░░░░`. It reads, never runs: no script, no
|
|
5
|
+
// style, no layout, so a widget that draws only with css reads as its text.
|
|
6
|
+
|
|
7
|
+
const BLOCK = new Set(['div', 'p', 'section', 'article', 'header', 'footer', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'hr', 'dl', 'dt', 'dd', 'figure', 'figcaption', 'blockquote'])
|
|
8
|
+
const SKIP = new Set(['script', 'style', 'template', 'svg'])
|
|
9
|
+
const BAR_CELLS = 10
|
|
10
|
+
|
|
11
|
+
const ENTITY: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·', deg: '°', hellip: '…', times: '×' }
|
|
12
|
+
|
|
13
|
+
export function decodeEntities(s: string): string {
|
|
14
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => {
|
|
15
|
+
if (e[0] === '#') {
|
|
16
|
+
const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))
|
|
17
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : m
|
|
18
|
+
}
|
|
19
|
+
return ENTITY[e.toLowerCase()] ?? m
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** a meter of BAR_CELLS cells, `p` in 0..100 */
|
|
24
|
+
export function barText(p: number): string {
|
|
25
|
+
const on = Math.round((Math.min(100, Math.max(0, p)) / 100) * BAR_CELLS)
|
|
26
|
+
return '█'.repeat(on) + '░'.repeat(BAR_CELLS - on)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Block = { kind: 'line'; text: string } | { kind: 'table'; rows: string[][] }
|
|
30
|
+
|
|
31
|
+
const attr = (attrs: string, name: string): string | null => {
|
|
32
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(attrs)
|
|
33
|
+
return m == null ? null : (m[2] ?? m[3] ?? m[4] ?? '')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const squash = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
37
|
+
|
|
38
|
+
/** a table's rows as aligned lines: each column as wide as its widest cell */
|
|
39
|
+
export function alignRows(rows: string[][]): string[] {
|
|
40
|
+
const w: number[] = []
|
|
41
|
+
for (const r of rows) r.forEach((c, i) => { w[i] = Math.max(w[i] ?? 0, c.length) })
|
|
42
|
+
return rows.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(w[i]!))).join(' ').trimEnd())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** a widget's leading `<header>`: the words it puts on its card's title
|
|
46
|
+
* line (state, age), apart from the body drawn under that line */
|
|
47
|
+
export function splitWidgetHead(html: string): { head: string | null; body: string } {
|
|
48
|
+
const m = /^\s*<header\b[^>]*>([\s\S]*?)<\/header>/i.exec(html)
|
|
49
|
+
return m == null ? { head: null, body: html } : { head: m[1]!, body: html.slice(m[0].length) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the html as lines, no empty one */
|
|
53
|
+
export function widgetText(html: string): string[] {
|
|
54
|
+
const blocks: Block[] = []
|
|
55
|
+
let line = ''
|
|
56
|
+
let table: string[][] | null = null
|
|
57
|
+
let cell: string | null = null
|
|
58
|
+
let skip: string | null = null
|
|
59
|
+
const put = (s: string): void => {
|
|
60
|
+
if (cell != null) cell += s
|
|
61
|
+
else line += s
|
|
62
|
+
}
|
|
63
|
+
const flush = (): void => {
|
|
64
|
+
for (const part of line.split('\n')) if (squash(part) !== '') blocks.push({ kind: 'line', text: squash(part) })
|
|
65
|
+
line = ''
|
|
66
|
+
}
|
|
67
|
+
const endCell = (): void => {
|
|
68
|
+
if (cell != null && table != null) table.at(-1)?.push(squash(cell))
|
|
69
|
+
cell = null
|
|
70
|
+
}
|
|
71
|
+
const tokens = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>|([^<]+)|</g
|
|
72
|
+
for (const m of html.matchAll(tokens)) {
|
|
73
|
+
const [whole, close, rawTag, attrs = '', text] = m
|
|
74
|
+
if (text != null || whole === '<') {
|
|
75
|
+
if (skip == null) put(decodeEntities(text ?? '<'))
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (rawTag == null) continue
|
|
79
|
+
const tag = rawTag.toLowerCase()
|
|
80
|
+
if (skip != null) {
|
|
81
|
+
if (close === '/' && tag === skip) skip = null
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (SKIP.has(tag) && close !== '/' && !attrs.trim().endsWith('/')) {
|
|
85
|
+
skip = tag
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (tag === 'table') {
|
|
89
|
+
endCell()
|
|
90
|
+
if (close === '/') {
|
|
91
|
+
if (table != null) blocks.push({ kind: 'table', rows: table.filter((r) => r.some((c) => c !== '')) })
|
|
92
|
+
table = null
|
|
93
|
+
} else {
|
|
94
|
+
flush()
|
|
95
|
+
table = []
|
|
96
|
+
}
|
|
97
|
+
} else if (tag === 'tr' && table != null) {
|
|
98
|
+
endCell()
|
|
99
|
+
if (close !== '/') table.push([])
|
|
100
|
+
} else if ((tag === 'td' || tag === 'th') && table != null) {
|
|
101
|
+
endCell()
|
|
102
|
+
if (close !== '/') {
|
|
103
|
+
if (table.length === 0) table.push([])
|
|
104
|
+
cell = ''
|
|
105
|
+
}
|
|
106
|
+
} else if (tag === 'br') put(cell != null ? ' ' : '\n')
|
|
107
|
+
else if (BLOCK.has(tag)) {
|
|
108
|
+
if (cell != null) put(' ')
|
|
109
|
+
else flush()
|
|
110
|
+
} else if (close !== '/' && /\bwbar\b/.test(attr(attrs, 'class') ?? '')) {
|
|
111
|
+
const p = /--p\s*:\s*(-?\d+(?:\.\d+)?)%/.exec(attr(attrs, 'style') ?? '')
|
|
112
|
+
if (p != null) put(` ${barText(Number(p[1]))} `)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
endCell()
|
|
116
|
+
if (table != null) blocks.push({ kind: 'table', rows: table })
|
|
117
|
+
flush()
|
|
118
|
+
return blocks.flatMap((b) => (b.kind === 'line' ? [b.text] : alignRows(b.rows)))
|
|
119
|
+
}
|
package/src/home/widgets.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// rendered by the daemon on each one's own clock and kept as html the page
|
|
3
3
|
// reads at /api/home/widgets.json. The files are read again every
|
|
4
4
|
// WIDGET_RESCAN_MS, a walk of every repo that costs a few tens of ms, and an
|
|
5
|
-
// unchanged file is never imported twice.
|
|
5
|
+
// unchanged file is never imported twice. All of it only while a reader
|
|
6
|
+
// asked within WIDGET_WATCH_MS: nobody looking, nothing scans or renders.
|
|
6
7
|
//
|
|
7
8
|
// the one constraint a reader breaks first: a render runs in the daemon, so
|
|
8
9
|
// it is bounded (WIDGET_RENDER_MS) and never awaited by the daemon's tick.
|
|
@@ -11,6 +12,8 @@ import { scanGlobalUnits, type LoadedWidget, type RepoRef } from './global-units
|
|
|
11
12
|
export const WIDGET_EVERY_MS = 5_000
|
|
12
13
|
export const WIDGET_RENDER_MS = 5_000
|
|
13
14
|
export const WIDGET_RESCAN_MS = 15_000
|
|
15
|
+
/** a read within this long keeps the clocks running */
|
|
16
|
+
export const WIDGET_WATCH_MS = 30_000
|
|
14
17
|
|
|
15
18
|
export type WidgetView = {
|
|
16
19
|
repo: string
|
|
@@ -38,6 +41,8 @@ export class Widgets {
|
|
|
38
41
|
private slots = new Map<string, Slot>()
|
|
39
42
|
private scannedAt = -Infinity
|
|
40
43
|
private scanning = false
|
|
44
|
+
private readAt: number | null = null
|
|
45
|
+
private rendering = new Set<Promise<void>>()
|
|
41
46
|
/** the unit files that would not load */
|
|
42
47
|
errors: string[] = []
|
|
43
48
|
|
|
@@ -47,9 +52,14 @@ export class Widgets {
|
|
|
47
52
|
return (this.d.now ?? Date.now)()
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
private get watched(): boolean {
|
|
56
|
+
return this.readAt != null && this.now() - this.readAt < WIDGET_WATCH_MS
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
/** one look: read the files again when due, render every widget due. Never
|
|
51
|
-
* waits on a render */
|
|
60
|
+
* waits on a render, and does nothing while no reader is watching */
|
|
52
61
|
async tick(repos: RepoRef[]): Promise<void> {
|
|
62
|
+
if (!this.watched) return
|
|
53
63
|
const now = this.now()
|
|
54
64
|
if (!this.scanning && now - this.scannedAt >= WIDGET_RESCAN_MS) {
|
|
55
65
|
this.scanning = true
|
|
@@ -73,7 +83,24 @@ export class Widgets {
|
|
|
73
83
|
this.scanning = false
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
|
-
for (const s of this.slots.values())
|
|
86
|
+
for (const s of this.slots.values()) {
|
|
87
|
+
if (s.busy || this.now() < s.due) continue
|
|
88
|
+
const p = this.render(s)
|
|
89
|
+
this.rendering.add(p)
|
|
90
|
+
void p.finally(() => this.rendering.delete(p))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** a reader's views: the first one in a while waits on a scan and on the
|
|
95
|
+
* renders it starts (each bounded), so it never reads hours old html */
|
|
96
|
+
async look(repos: RepoRef[]): Promise<WidgetView[]> {
|
|
97
|
+
const cold = !this.watched
|
|
98
|
+
this.readAt = this.now()
|
|
99
|
+
if (cold) {
|
|
100
|
+
await this.tick(repos)
|
|
101
|
+
await Promise.all(this.rendering)
|
|
102
|
+
}
|
|
103
|
+
return this.views()
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
/** one render, bounded, its result or its error kept */
|
|
@@ -96,7 +123,9 @@ export class Widgets {
|
|
|
96
123
|
}
|
|
97
124
|
}
|
|
98
125
|
|
|
126
|
+
/** the last html of each; a read that keeps the clocks running */
|
|
99
127
|
views(): WidgetView[] {
|
|
128
|
+
this.readAt = this.now()
|
|
100
129
|
return [...this.slots.values()].map((s) => s.view)
|
|
101
130
|
}
|
|
102
131
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// the commit-message predicate
|
|
2
|
-
//
|
|
3
|
-
// drift and the drift only shows when a repo goes public.
|
|
1
|
+
// the commit-message predicate of the commit-msg hook, which blocks at write
|
|
2
|
+
// time: the one guard against AI attribution (SK009 checks it is armed).
|
|
4
3
|
//
|
|
5
4
|
// mechanical text rules only, so this stays publishable.
|
|
6
5
|
|