rvlib-shipkit 1.3.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 +37 -0
- 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 +12 -2
- package/src/check.ts +6 -4
- package/src/checks/SK001-package-json-shape.check.ts +20 -2
- package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
- package/src/checks/SK028-readme.check.ts +6 -2
- package/src/cli/cmd_CrossRepoActivity.ts +4 -1
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +6 -2
- 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/budget.ts +19 -9
- package/src/core/check-runner.ts +26 -5
- package/src/core/discover-checks.ts +8 -5
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/run-check.ts +2 -5
- package/src/core/run-ci.ts +3 -3
- package/src/core/wire.ts +38 -9
- package/src/home/activity-shape.ts +4 -1
- package/src/home/activity.ts +22 -15
- 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 +2 -1
- 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 +3 -3
- package/src/home/process-rows.ts +23 -6
- package/src/home/resources.ts +67 -15
- package/src/home/run-daemon.ts +6 -1
- package/src/home/run-pm.ts +29 -5
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +12 -2
- package/src/ledger/gate-checks.ts +3 -3
- package/src/ledger/store.ts +11 -0
- package/src/ledger/world-cache.ts +17 -15
- package/src/repo-config.ts +6 -1
- package/src/serve/hub-app/activity-view.tsx +3 -3
- 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/dock.css +1 -1
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +32 -17
- package/src/serve/hub-app/home-strip.tsx +99 -89
- package/src/serve/hub-app/hub-app.tsx +7 -1
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +24 -2
- package/src/serve/hub-app/icons.tsx +4 -0
- package/src/serve/hub-app/keys.ts +6 -6
- package/src/serve/hub-app/machine-st.ts +26 -37
- package/src/serve/hub-app/palette-st.ts +5 -5
- package/src/serve/hub-app/palette-view.tsx +1 -1
- package/src/serve/hub-app/project-list.tsx +3 -3
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/timeline-view.tsx +14 -11
- package/src/serve/project-gutter.ts +1 -2
- package/src/serve/warm-cell.ts +49 -16
- 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/home/pm-config.ts
CHANGED
|
@@ -2,18 +2,14 @@
|
|
|
2
2
|
// and the global processes every house repo declares in its `.shipkit.ts`
|
|
3
3
|
// files), read in a short child process so pm itself never imports a repo
|
|
4
4
|
// file. That graph would make pm large and reload it on every edit there.
|
|
5
|
+
// The answer names the paths it read (`inputs`) and their stamp, so pm runs
|
|
6
|
+
// the child again only once a stat of one of them moved.
|
|
5
7
|
//
|
|
6
|
-
// bun pm-config.ts <root> prints
|
|
8
|
+
// bun pm-config.ts <root> prints a PmConfig as json on stdout
|
|
7
9
|
import { homedir } from 'os'
|
|
8
|
-
import type
|
|
10
|
+
import { houseInputs, stampOf, type PmConfig } from './pm-inputs.ts'
|
|
9
11
|
|
|
10
|
-
export type PmConfig
|
|
11
|
-
/** the house wiki index the repo includes, null when it includes none */
|
|
12
|
-
index: string | null
|
|
13
|
-
processes: GlobalProcessDecl[]
|
|
14
|
-
/** the unit files that would not load */
|
|
15
|
-
errors: string[]
|
|
16
|
-
}
|
|
12
|
+
export type { PmConfig }
|
|
17
13
|
|
|
18
14
|
if (import.meta.main) {
|
|
19
15
|
const root = process.argv[2]
|
|
@@ -22,14 +18,22 @@ if (import.meta.main) {
|
|
|
22
18
|
process.exit(2)
|
|
23
19
|
}
|
|
24
20
|
const { houseRepos } = await import('../serve/project-gutter.ts')
|
|
21
|
+
const { repoUnitFiles } = await import('../core/discover-checks.ts')
|
|
25
22
|
const house = houseRepos(root, homedir())
|
|
26
|
-
const
|
|
23
|
+
const repos = house.index == null ? [] : house.repos
|
|
24
|
+
const files = new Map<string, string[]>()
|
|
25
|
+
const inputs = houseInputs(house.home, house.index, repos.map((r) => r.path), (r, dirs) => {
|
|
26
|
+
const f = repoUnitFiles(r, dirs)
|
|
27
|
+
files.set(r, f)
|
|
28
|
+
return f
|
|
29
|
+
})
|
|
30
|
+
const out: PmConfig = { index: house.index, processes: [], errors: [], inputs, stamp: stampOf(inputs) }
|
|
27
31
|
if (house.index != null) {
|
|
28
32
|
// a unit file imports the lib by name: this copy, like the daemon's
|
|
29
33
|
const { useOneLib } = await import('../core/one-lib.ts')
|
|
30
34
|
const { scanGlobalUnits } = await import('./global-units.ts')
|
|
31
35
|
useOneLib()
|
|
32
|
-
const u = await scanGlobalUnits(
|
|
36
|
+
const u = await scanGlobalUnits(repos.map((r) => ({ name: r.name, path: r.path })), { files: (r) => files.get(r) ?? [] })
|
|
33
37
|
out.processes = u.processes
|
|
34
38
|
out.errors = u.errors
|
|
35
39
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// what pm stamps to know whether the house side moved, without running the
|
|
2
|
+
// child that reads it (pm-config.ts): the answer's shape, the paths it
|
|
3
|
+
// depends on, and their stamp. Imports nothing a repo file does, so pm's own
|
|
4
|
+
// graph stays small.
|
|
5
|
+
import { readdirSync, statSync } from 'fs'
|
|
6
|
+
import { dirname, join } from 'path'
|
|
7
|
+
import { UNIT_SUFFIX } from '../core/check-file-names.ts'
|
|
8
|
+
import type { GlobalProcessDecl } from './global-units.ts'
|
|
9
|
+
|
|
10
|
+
export type PmConfig = {
|
|
11
|
+
/** the house wiki index the repo includes, null when it includes none */
|
|
12
|
+
index: string | null
|
|
13
|
+
processes: GlobalProcessDecl[]
|
|
14
|
+
/** the unit files that would not load */
|
|
15
|
+
errors: string[]
|
|
16
|
+
/** every path whose stat can change this answer (houseInputs) */
|
|
17
|
+
inputs: string[]
|
|
18
|
+
/** stampOf(inputs), taken before the unit files were imported */
|
|
19
|
+
stamp: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** the files a repo carries its includes in. Here, not in project-gutter.ts
|
|
23
|
+
* which reads them, so pm stamps them without loading that graph */
|
|
24
|
+
export const CLAUDE_FILES = ['CLAUDE.md', 'CLAUDE.local.md']
|
|
25
|
+
|
|
26
|
+
/** what the answer depends on, as paths to stat: the dir holding the repos
|
|
27
|
+
* (a repo added or removed), every sibling's CLAUDE files (a repo starting
|
|
28
|
+
* to include the house), the index, each house repo's config (archived),
|
|
29
|
+
* and every dir and unit file the unit walk read. A helper a unit file
|
|
30
|
+
* imports is not in it: an edit there shows once the unit file moves */
|
|
31
|
+
export function houseInputs(home: string, index: string | null, repos: string[], unitFiles: (root: string, dirs: string[]) => string[]): string[] {
|
|
32
|
+
const parent = dirname(home)
|
|
33
|
+
const out = [parent]
|
|
34
|
+
let names: string[] = []
|
|
35
|
+
try { names = readdirSync(parent) } catch { /* the stat of parent says when it appears */ }
|
|
36
|
+
for (const n of names.sort()) if (!n.startsWith('.')) for (const f of CLAUDE_FILES) out.push(join(parent, n, f))
|
|
37
|
+
if (index != null) out.push(index)
|
|
38
|
+
for (const r of repos) {
|
|
39
|
+
out.push(join(r, 'repo.config.ts'))
|
|
40
|
+
const dirs: string[] = []
|
|
41
|
+
const files = unitFiles(r, dirs)
|
|
42
|
+
out.push(...dirs, ...files)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** a dir's entries that can change the answer: its subdirs and its unit
|
|
48
|
+
* files. A temp file coming and going moves the dir's mtime, not this */
|
|
49
|
+
const listings = new Map<string, { mtime: number; names: string }>()
|
|
50
|
+
function dirNames(dir: string, mtime: number): string {
|
|
51
|
+
const hit = listings.get(dir)
|
|
52
|
+
if (hit?.mtime === mtime) return hit.names
|
|
53
|
+
let names = ''
|
|
54
|
+
try {
|
|
55
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
56
|
+
.filter((e) => !e.name.startsWith('.') && (e.isDirectory() || e.name.endsWith(UNIT_SUFFIX)))
|
|
57
|
+
.map((e) => e.name)
|
|
58
|
+
.sort()
|
|
59
|
+
.join('/')
|
|
60
|
+
} catch { /* gone between the stat and the read: the next stat says so */ }
|
|
61
|
+
listings.set(dir, { mtime, names })
|
|
62
|
+
return names
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** every path as one string: a file by its mtime, a dir by the entries
|
|
66
|
+
* that matter (read again only when its mtime moved), -1 when missing */
|
|
67
|
+
export function stampOf(paths: string[]): string {
|
|
68
|
+
return paths.map((p) => {
|
|
69
|
+
try {
|
|
70
|
+
const st = statSync(p)
|
|
71
|
+
return st.isDirectory() ? dirNames(p, st.mtimeMs) : st.mtimeMs
|
|
72
|
+
} catch {
|
|
73
|
+
return -1
|
|
74
|
+
}
|
|
75
|
+
}).join(',')
|
|
76
|
+
}
|
package/src/home/pm.ts
CHANGED
|
@@ -154,9 +154,9 @@ export class Pm {
|
|
|
154
154
|
if (act === 'start') report(r.set('up'))
|
|
155
155
|
else if (act === 'stop') report(r.set('down'))
|
|
156
156
|
else if (act === 'restart') report(r.restart())
|
|
157
|
-
else if (act === 'open' || act === 'app') {
|
|
157
|
+
else if (act === 'open' || act === 'chrome' || act === 'app') {
|
|
158
158
|
if (r.decl.url == null) return new Response(`${r.decl.id} declares no url to open`, { status: 400 })
|
|
159
|
-
report(r.open(act === '
|
|
159
|
+
report(r.open(act === 'open' ? 'browser' : act))
|
|
160
160
|
} else if (act === 'takeover') {
|
|
161
161
|
const v = r.takeOverView
|
|
162
162
|
if (v == null) return new Response(`${r.decl.id} does not run outside the supervisor: nothing to take over`, { status: 409 })
|
|
@@ -166,7 +166,7 @@ export class Pm {
|
|
|
166
166
|
if (!r.decl.actions.some((a) => a.id === b.action)) return new Response(`${r.decl.id} declares no action '${String(b.action)}'`, { status: 404 })
|
|
167
167
|
if (r.running != null) return new Response(`${r.decl.id} is already running its action '${r.running}'`, { status: 409 })
|
|
168
168
|
report(r.action(String(b.action)))
|
|
169
|
-
} else return new Response('act must be start, stop, restart, open, app, takeover or action', { status: 400 })
|
|
169
|
+
} else return new Response('act must be start, stop, restart, open, chrome, app, takeover or action', { status: 400 })
|
|
170
170
|
return Response.json(r.view())
|
|
171
171
|
}
|
|
172
172
|
return new Response('not found', { status: 404 })
|
package/src/home/process-rows.ts
CHANGED
|
@@ -9,8 +9,9 @@ import type { CorePorts, PmAnswer } from './pm.ts'
|
|
|
9
9
|
import type { ProcessIcon, ResourceView } from './resources.ts'
|
|
10
10
|
import type { ProcStatus } from './supervise.ts'
|
|
11
11
|
|
|
12
|
-
/** a status as pm reports it,
|
|
13
|
-
|
|
12
|
+
/** a status as pm reports it, unknown while pm does not answer, silent
|
|
13
|
+
* while pm runs it but the page gets no answer from it */
|
|
14
|
+
export type RowStatus = ProcStatus | 'unknown' | 'silent'
|
|
14
15
|
|
|
15
16
|
/** the dot a row wears: green up, red crashed, grey stopped or unknown, orange on its way */
|
|
16
17
|
export type RowDot = 'up' | 'crashed' | 'down' | 'starting' | 'unknown'
|
|
@@ -46,6 +47,8 @@ export type ProcessRow = {
|
|
|
46
47
|
actions: { id: string; label: string }[]
|
|
47
48
|
running: string | null
|
|
48
49
|
icon: ProcessIcon | null
|
|
50
|
+
/** its own app is being built or opened */
|
|
51
|
+
appBusy: boolean
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/** a repo's processes, or shipkit's own (`core`, always the first group) */
|
|
@@ -55,7 +58,7 @@ export type ProcessGroup = { repo: string; core?: true; rows: ProcessRow[] }
|
|
|
55
58
|
export const CORE_GROUP = 'shipkit'
|
|
56
59
|
|
|
57
60
|
export const dotOf = (s: RowStatus): RowDot =>
|
|
58
|
-
s === 'up' ? 'up' : s === 'crashed' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
|
|
61
|
+
s === 'up' ? 'up' : s === 'crashed' || s === 'silent' ? 'crashed' : s === 'starting' || s === 'stopping' ? 'starting' : s === 'down' ? 'down' : 'unknown'
|
|
59
62
|
|
|
60
63
|
const hostOf = (url: string | null): string | null => (url == null ? null : url.replace(/^https?:\/\//, '').replace(/\/$/, ''))
|
|
61
64
|
|
|
@@ -85,6 +88,7 @@ export function rowOf(r: ResourceView): ProcessRow {
|
|
|
85
88
|
actions: r.actions,
|
|
86
89
|
running: r.running,
|
|
87
90
|
icon: r.icon,
|
|
91
|
+
appBusy: r.appBusy === true,
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -113,6 +117,7 @@ export function declaredRow(d: GlobalProcessDecl): ProcessRow {
|
|
|
113
117
|
takeOver: null,
|
|
114
118
|
actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
|
|
115
119
|
running: null,
|
|
120
|
+
appBusy: false,
|
|
116
121
|
// an image is pm's to serve, and pm does not answer
|
|
117
122
|
icon: d.icon == null || /\.[a-z0-9]+$/i.test(d.icon) ? null : { text: d.icon },
|
|
118
123
|
}
|
|
@@ -143,6 +148,10 @@ export type CoreOpts = {
|
|
|
143
148
|
daemonVersion?: string | null
|
|
144
149
|
/** where each listens, when pm does not say (an older pm, or pm down) */
|
|
145
150
|
ports?: CorePorts | null
|
|
151
|
+
/** one of shipkit's own not answering the page, and why: its row says
|
|
152
|
+
* no answer whatever pm last reported. pm silent, every other row is
|
|
153
|
+
* the last answer's with its state unknown, so the table keeps its lines */
|
|
154
|
+
silent?: Partial<Record<'pm' | 'daemon' | 'web', string>>
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
function coreRow(id: 'pm' | 'daemon' | 'web', o: { status: RowStatus; pid: number | null; since: number | null; note: string | null; tail: string[]; version: string | null; port: number | null; restart: boolean }): ProcessRow {
|
|
@@ -150,7 +159,7 @@ function coreRow(id: 'pm' | 'daemon' | 'web', o: { status: RowStatus; pid: numbe
|
|
|
150
159
|
return {
|
|
151
160
|
id, label: id, repo: CORE_GROUP, file: '', status: o.status, dot: dotOf(o.status), url, host: hostOf(url), pid: o.pid, since: o.since, note: o.note,
|
|
152
161
|
description: CORE_ABOUT[id], where: null, lastLog: o.tail.at(-1) ?? null, tail: o.tail,
|
|
153
|
-
canStop: false, canRestart: o.restart, core: true, version: o.version, takeOver: null, actions: [], running: null, icon: null,
|
|
162
|
+
canStop: false, canRestart: o.restart, core: true, version: o.version, takeOver: null, actions: [], running: null, icon: null, appBusy: false,
|
|
154
163
|
}
|
|
155
164
|
}
|
|
156
165
|
|
|
@@ -171,13 +180,20 @@ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
|
|
|
171
180
|
rows.push(coreRow(id, { status: c.status, pid: c.pid, since: c.since, note: note === '' ? null : note, tail: c.tail, version, port, restart: true }))
|
|
172
181
|
}
|
|
173
182
|
}
|
|
174
|
-
|
|
183
|
+
const s = o.silent ?? {}
|
|
184
|
+
return { repo: CORE_GROUP, core: true, rows: rows.map((r) => {
|
|
185
|
+
const why = s[r.id as 'pm' | 'daemon' | 'web']
|
|
186
|
+
return why != null && why !== '' ? { ...r, status: 'silent', dot: 'crashed', note: why } : s.pm ? stale(r) : r
|
|
187
|
+
}) }
|
|
175
188
|
}
|
|
176
189
|
|
|
177
190
|
/** what the page's table holds, from pm's answer: the core group, then the
|
|
178
191
|
* global processes by repo */
|
|
179
192
|
export const processGroups = (pm: PmAnswer | null, order: string[], o: CoreOpts = {}): ProcessGroup[] =>
|
|
180
|
-
[coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map(rowOf), order)]
|
|
193
|
+
[coreGroup(pm, o), ...groupRows((pm?.resources ?? []).map((r) => (o.silent?.pm ? stale(rowOf(r)) : rowOf(r))), order)]
|
|
194
|
+
|
|
195
|
+
/** a row from an answer pm can no longer confirm: its state unknown, nothing to click */
|
|
196
|
+
const stale = (r: ProcessRow): ProcessRow => ({ ...r, status: 'unknown', dot: 'unknown', canStop: false, canRestart: false, takeOver: null, running: null, appBusy: false })
|
|
181
197
|
|
|
182
198
|
/** the declared icon, else the repo's own, else none */
|
|
183
199
|
export const processIcon = (r: { icon: ProcessIcon | null }, repoIcon: string | null): ProcessIcon | null =>
|
|
@@ -200,6 +216,7 @@ export function stateWords(r: Pick<ProcessRow, 'status' | 'since'>, now: number)
|
|
|
200
216
|
case 'crashed': return age == null ? 'crashed' : `crashed ${age} ago`
|
|
201
217
|
case 'down': return 'stopped'
|
|
202
218
|
case 'unknown': return 'unknown (pm down)'
|
|
219
|
+
case 'silent': return 'no answer'
|
|
203
220
|
default: return r.status
|
|
204
221
|
}
|
|
205
222
|
}
|
package/src/home/resources.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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 its url opened in the default browser
|
|
5
|
-
// app
|
|
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).
|
|
6
6
|
//
|
|
7
7
|
// two kinds: with no `down`, the command IS the service, supervised like a
|
|
8
8
|
// hub (restarted on a crash, signalled on stop). With `down`, the command is
|
|
9
9
|
// a bring-up that exits (an idempotent ladder script), `down` its reverse,
|
|
10
10
|
// and the probe is the only truth.
|
|
11
|
-
import { mkdirSync, openSync
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync } from 'fs'
|
|
12
|
+
import { homedir } from 'os'
|
|
12
13
|
import { dirname, join } from 'path'
|
|
13
14
|
import { fileURLToPath } from 'url'
|
|
14
15
|
import type { GlobalProcessDecl } from './global-units.ts'
|
|
@@ -47,8 +48,14 @@ export type ResourceView = {
|
|
|
47
48
|
actions: { id: string; label: string }[]
|
|
48
49
|
/** the action running now, or null */
|
|
49
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
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
/** where a url opens: the default browser, Chrome, or the process's own app */
|
|
57
|
+
export type OpenHow = 'browser' | 'chrome' | 'app'
|
|
58
|
+
|
|
52
59
|
/** an emoji, or the url pm serves a declared image file at */
|
|
53
60
|
export type ProcessIcon = { text: string } | { src: string }
|
|
54
61
|
|
|
@@ -83,17 +90,19 @@ export const runOnce: RunOnce = async (cmd, cwd, log) => {
|
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
/** opens a
|
|
87
|
-
|
|
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>
|
|
88
96
|
|
|
89
97
|
/** its own process: what building the app needs never loads in pm */
|
|
90
98
|
const OPEN_APP = fileURLToPath(new URL('./open-app.ts', import.meta.url))
|
|
91
99
|
|
|
92
|
-
const openApp: OpenApp = (
|
|
100
|
+
const openApp: OpenApp = async (d, log) => {
|
|
93
101
|
mkdirSync(dirname(log), { recursive: true })
|
|
94
102
|
const fd = openSync(log, 'a')
|
|
95
103
|
try {
|
|
96
|
-
|
|
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
|
|
97
106
|
} finally {
|
|
98
107
|
closeSync(fd)
|
|
99
108
|
}
|
|
@@ -113,6 +122,26 @@ const browse: Browse = (url, log) => {
|
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
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
|
+
|
|
116
145
|
/** the function action runner: imports the unit file, calls the action */
|
|
117
146
|
const ACTION_RUNNER = fileURLToPath(new URL('./run-unit-action.ts', import.meta.url))
|
|
118
147
|
|
|
@@ -120,10 +149,12 @@ export type ResourceOpts = {
|
|
|
120
149
|
probe?: HttpProbe
|
|
121
150
|
run?: RunOnce
|
|
122
151
|
deps?: SuperviseDeps
|
|
123
|
-
/** the app
|
|
152
|
+
/** the process's own app */
|
|
124
153
|
open?: OpenApp
|
|
125
154
|
/** the default browser */
|
|
126
155
|
browse?: Browse
|
|
156
|
+
/** Chrome */
|
|
157
|
+
chrome?: Browse
|
|
127
158
|
/** the pids listening on a local port, and the process group of a pid */
|
|
128
159
|
holders?: (port: number) => number[]
|
|
129
160
|
groupOf?: (pid: number) => number | null
|
|
@@ -150,8 +181,10 @@ export class Resource {
|
|
|
150
181
|
private busy = false
|
|
151
182
|
/** the last bring-up exited non zero */
|
|
152
183
|
private failed = false
|
|
153
|
-
/** open it once it answers
|
|
154
|
-
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
|
|
155
188
|
/** the status a tick last saw, and since when, for the bring-up kind */
|
|
156
189
|
private seen: ProcStatus = 'down'
|
|
157
190
|
private seenAt: number
|
|
@@ -248,9 +281,9 @@ export class Resource {
|
|
|
248
281
|
await this.set('up')
|
|
249
282
|
}
|
|
250
283
|
|
|
251
|
-
/** its url in the default browser, or as
|
|
284
|
+
/** its url in the default browser, in Chrome, or as its own app: at once
|
|
252
285
|
* while it answers, else it starts and opens once it does */
|
|
253
|
-
async open(how:
|
|
286
|
+
async open(how: OpenHow): Promise<void> {
|
|
254
287
|
if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
|
|
255
288
|
if (this.status === 'up') {
|
|
256
289
|
this.show(how, this.decl.url)
|
|
@@ -260,9 +293,27 @@ export class Resource {
|
|
|
260
293
|
await this.set('up')
|
|
261
294
|
}
|
|
262
295
|
|
|
263
|
-
private show(how:
|
|
264
|
-
if (how === '
|
|
265
|
-
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
|
|
266
317
|
}
|
|
267
318
|
|
|
268
319
|
/** one declared button. A function action runs in its own bun process
|
|
@@ -328,6 +379,7 @@ export class Resource {
|
|
|
328
379
|
icon: processIconOf(d),
|
|
329
380
|
actions: d.actions.map((a) => ({ id: a.id, label: a.label })),
|
|
330
381
|
running: this.running,
|
|
382
|
+
appBusy: this.appBusy,
|
|
331
383
|
}
|
|
332
384
|
}
|
|
333
385
|
}
|
package/src/home/run-daemon.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { Planner } from '../plan/planner.ts'
|
|
|
23
23
|
import { machineCatchUp } from '../plan/catch-up.ts'
|
|
24
24
|
import { serveDaemon } from './serve-daemon.ts'
|
|
25
25
|
import { startSweeps } from './machine-stores.ts'
|
|
26
|
+
import { landMotherhome } from '../core/motherhome.ts'
|
|
26
27
|
import { Widgets } from './widgets.ts'
|
|
27
28
|
|
|
28
29
|
const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url))
|
|
@@ -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 */
|
|
@@ -98,13 +119,16 @@ export async function runPm(root: string, io: HomeIo, o: { port?: number } = {})
|
|
|
98
119
|
pm.resources.errors = c.errors
|
|
99
120
|
for (const e of c.errors) io.printErr(`WARN ${e}`)
|
|
100
121
|
// a unit file added, edited or removed in any house repo: read again,
|
|
101
|
-
// 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)
|
|
102
126
|
let reading = false
|
|
103
127
|
const reread = setInterval(() => {
|
|
104
128
|
if (reading) return
|
|
105
129
|
reading = true
|
|
106
|
-
void
|
|
107
|
-
.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) })
|
|
108
132
|
.catch((e) => io.printErr(`WARN pm could not read the global processes again: ${(e as Error).message}`))
|
|
109
133
|
.finally(() => { reading = false })
|
|
110
134
|
}, PM_RESCAN_MS)
|
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
|
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -53,7 +53,8 @@ export type HooksConfig = {
|
|
|
53
53
|
languageAllow?: string[]
|
|
54
54
|
/** disable a built-in gate, visible in `hooks list`, never silent.
|
|
55
55
|
* 'commit-language' lifts ONLY the French check, keeping the dash and
|
|
56
|
-
* AI-attribution guards armed.
|
|
56
|
+
* AI-attribution guards armed. 'commit-message' lifts the dash and French
|
|
57
|
+
* checks; the AI-attribution guard is never lifted. */
|
|
57
58
|
disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -309,6 +310,13 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
309
310
|
...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
|
|
310
311
|
],
|
|
311
312
|
})
|
|
313
|
+
} else {
|
|
314
|
+
// AI attribution is the one message rule no config lifts: this hook is
|
|
315
|
+
// the guard against it in every repo (identity/how-we-commit.md)
|
|
316
|
+
const offenses = commitMessageOffenses(message).filter((o) => o.kind === 'attribution')
|
|
317
|
+
out.push(offenses.length === 0
|
|
318
|
+
? { id: 'ai-attribution', ok: true, lines: ['ok: no AI attribution in the commit message'] }
|
|
319
|
+
: { id: 'ai-attribution', ok: false, lines: [`FAIL: commit message carries AI attribution`, ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`)] })
|
|
312
320
|
}
|
|
313
321
|
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
314
322
|
// commit body is as public as one pasted into a file
|
|
@@ -347,7 +355,9 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
347
355
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
|
|
348
356
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
349
357
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
350
|
-
|
|
358
|
+
off.has('commit-message')
|
|
359
|
+
? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
|
|
360
|
+
: `commit-msg commit-message — dash punctuation + AI attribution (never disabled) + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
|
|
351
361
|
`commit-msg leak-guard${leakState} — the message text`,
|
|
352
362
|
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
353
363
|
]
|
|
@@ -351,7 +351,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
351
351
|
owner: u.owner,
|
|
352
352
|
group: u.def.group ?? null,
|
|
353
353
|
timeoutMs: u.def.timeoutMs ?? null,
|
|
354
|
-
budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null }, c.budgets),
|
|
354
|
+
budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null || u.def.never != null }, c.budgets),
|
|
355
355
|
optIn: false,
|
|
356
356
|
fixable: u.def.fix != null,
|
|
357
357
|
when: w,
|
|
@@ -389,7 +389,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
389
389
|
owner: r.owner,
|
|
390
390
|
group: null,
|
|
391
391
|
timeoutMs: null,
|
|
392
|
-
budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null }, c.budgets),
|
|
392
|
+
budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null || r.never != null }, c.budgets),
|
|
393
393
|
optIn: false,
|
|
394
394
|
fixable: r.fix != null,
|
|
395
395
|
when: whenOfRule(c, r.id),
|
|
@@ -433,7 +433,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
433
433
|
owner: rel,
|
|
434
434
|
group: task?.group ?? null,
|
|
435
435
|
timeoutMs: task?.timeoutMs ?? null,
|
|
436
|
-
budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null }, c.budgets),
|
|
436
|
+
budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null || d.never != null }, c.budgets),
|
|
437
437
|
optIn: false,
|
|
438
438
|
// the reader is a source scanner, so it cannot carry a function
|
|
439
439
|
fixable: false,
|