rvlib-shipkit 2.4.0 → 2.5.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +2 -2
  3. package/src/cli/cmd_Commands.ts +26 -5
  4. package/src/cli/cmd_FleetPush.ts +47 -0
  5. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  6. package/src/cli/cmd_Review.ts +16 -1
  7. package/src/cli/cmd_Trailer.ts +4 -1
  8. package/src/cli/family-pages.ts +49 -0
  9. package/src/cli/kit/builtins.ts +1 -0
  10. package/src/cli/registry.ts +137 -0
  11. package/src/cli.ts +47 -135
  12. package/src/core/last-good.ts +217 -0
  13. package/src/core/load-probe.ts +71 -0
  14. package/src/core/reexec.ts +5 -3
  15. package/src/fleet/push-fleet.ts +80 -0
  16. package/src/home/home-routes.ts +12 -2
  17. package/src/home/lib-watch.ts +110 -20
  18. package/src/home/process-rows.ts +4 -2
  19. package/src/home/run-daemon.ts +1 -1
  20. package/src/review/model.ts +19 -0
  21. package/src/review/paste.ts +30 -0
  22. package/src/review/routes.ts +10 -3
  23. package/src/scaffold/files/agent-index.md +2 -2
  24. package/src/scaffold/project-scaffold.ts +2 -2
  25. package/src/serve/hub-app/activity-st.ts +31 -0
  26. package/src/serve/hub-app/activity-view.tsx +9 -0
  27. package/src/serve/hub-app/activity.css +6 -0
  28. package/src/serve/hub-app/home-strip.tsx +3 -1
  29. package/src/serve/hub-app/machine-st.ts +1 -1
  30. package/src/serve/hub-app/review/review-answer.tsx +16 -37
  31. package/src/serve/hub-app/review/review-page.tsx +8 -1
  32. package/src/serve/hub-app/review/review-st.ts +51 -3
  33. package/src/serve/hub-app/review/review-views.tsx +1 -1
  34. package/src/serve/hub-app/review.css +4 -4
  35. package/src/serve/run-serve.ts +25 -0
  36. package/src/serve/serve-hub.ts +8 -1
  37. package/src/serve/stale-restart.ts +19 -2
  38. package/src/trailer/build.ts +13 -5
  39. package/src/trailer/ffmpeg.ts +21 -1
@@ -0,0 +1,217 @@
1
+ // the last good copy of the toolkit: what `sk` and the home run while the
2
+ // checkout fails to load. The daemon clones the source after a load that
3
+ // works (home/lib-watch.ts); the boot (cli.ts) runs the copy when the tree
4
+ // fails to load, with one line that names the file.
5
+ //
6
+ // the one constraint a reader breaks first: cli.ts imports this file
7
+ // statically, so it imports node builtins only. A lib import here would let
8
+ // a broken file stop the boot before it can fall back.
9
+ import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'fs'
10
+ import { homedir } from 'os'
11
+ import { join, relative } from 'path'
12
+
13
+ /** set on a process that runs the copy: the Held record, as json */
14
+ export const LAST_GOOD_ENV = 'SHIPKIT_LAST_GOOD'
15
+
16
+ /** why a process runs code older than its tree */
17
+ export type Held = {
18
+ /** epoch ms the tree was first seen failing */
19
+ since: number
20
+ /** the file that fails, relative to the lib (`src/trailer/graph.ts:12`) */
21
+ file: string
22
+ error: string
23
+ /** the lib root whose tree fails */
24
+ from: string
25
+ /** epoch ms the copy was taken, null when this process runs its own boot image */
26
+ copyAt: number | null
27
+ }
28
+
29
+ /** how long a replaced copy stays, from when it stopped being current: a
30
+ * process that runs it may still import a module from it lazily */
31
+ export const OLD_COPY_MS = 10 * 60_000
32
+
33
+ /** this lib's root (its package.json beside src) */
34
+ export const OWN_LIB = join(import.meta.dir, '../..')
35
+
36
+ const real = (p: string): string => {
37
+ try {
38
+ return realpathSync(p)
39
+ } catch {
40
+ return p
41
+ }
42
+ }
43
+
44
+ export const lastGoodHome = (env: Record<string, string | undefined> = process.env): string =>
45
+ env.SHIPKIT_LAST_GOOD_HOME != null && env.SHIPKIT_LAST_GOOD_HOME !== '' ? env.SHIPKIT_LAST_GOOD_HOME : join(homedir(), '.cache/shipkit/last-good')
46
+
47
+ /** one folder per lib checkout, so a worktree never runs the main checkout's copy */
48
+ export const lastGoodDir = (lib: string, home = lastGoodHome()): string => join(home, Bun.hash(real(lib)).toString(16).padStart(16, '0'))
49
+
50
+ const STAMP = 'stamp.json'
51
+ /** in a copy that is no longer current: when it stopped being */
52
+ const RETIRED = 'retired'
53
+
54
+ /** a copy carries a stamp: the lib it was taken from and when */
55
+ export function copyStamp(root: string): { at: number; from: string } | null {
56
+ try {
57
+ const j = JSON.parse(readFileSync(join(root, STAMP), 'utf8')) as { at?: number; from?: string }
58
+ return typeof j.at === 'number' && typeof j.from === 'string' ? { at: j.at, from: j.from } : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ /** the copy's cli and when it was taken, null when the lib has none */
65
+ export function lastGood(lib: string, home = lastGoodHome()): { cli: string; at: number } | null {
66
+ const cur = join(lastGoodDir(lib, home), 'current')
67
+ const s = copyStamp(cur)
68
+ const cli = join(cur, 'src/cli.ts')
69
+ return s == null || !existsSync(cli) ? null : { cli, at: s.at }
70
+ }
71
+
72
+ /** clone the lib's source as the last good copy (APFS clone: about 0.2 s for
73
+ * the whole source), its node_modules linked, probe it in its own folder,
74
+ * then point `current` at it. Throws when the clone does not load */
75
+ export async function takeLastGood(lib: string, o: { home?: string; now?: number } = {}): Promise<{ dir: string; ms: number }> {
76
+ const t0 = performance.now()
77
+ const now = o.now ?? Date.now()
78
+ const base = lastGoodDir(lib, o.home)
79
+ const name = `v${now}`
80
+ const dir = join(base, name)
81
+ mkdirSync(dir, { recursive: true })
82
+ const cp = Bun.spawn(['cp', '-c', '-R', join(lib, 'src'), join(dir, 'src')], { stdout: 'ignore', stderr: 'pipe' })
83
+ if ((await cp.exited) !== 0) {
84
+ const err = (await new Response(cp.stderr).text()).trim()
85
+ rmSync(dir, { recursive: true, force: true })
86
+ throw new Error(`could not clone ${lib}/src: ${err}`)
87
+ }
88
+ for (const f of ['package.json', 'tsconfig.json']) if (existsSync(join(lib, f))) copyFileSync(join(lib, f), join(dir, f), constants.COPYFILE_FICLONE)
89
+ symlinkSync(join(real(lib), 'node_modules'), join(dir, 'node_modules'))
90
+ writeFileSync(join(dir, STAMP), `${JSON.stringify({ at: now, from: real(lib) })}\n`)
91
+ // a write that landed during the clone is in it: the copy loads in its own folder, or never becomes current
92
+ const { probeLoad, bootEntries } = await import('./load-probe.ts')
93
+ const a = await probeLoad(dir, bootEntries(dir, 'all'))
94
+ if (!a.ok) {
95
+ rmSync(dir, { recursive: true, force: true })
96
+ throw new Error(`the clone fails to load (${a.file}: ${a.error}), so the copy before it stays current`)
97
+ }
98
+ // a link renamed over the old one: a reader sees the old copy or the new, never none
99
+ const link = join(base, `.current-${process.pid}`)
100
+ rmSync(link, { force: true })
101
+ symlinkSync(name, link)
102
+ let was: string | null = null
103
+ try { was = readlinkSync(join(base, 'current')) } catch { /* the first copy */ }
104
+ renameSync(link, join(base, 'current'))
105
+ // a copy ages from when it stopped being current: a process that runs it keeps its modules
106
+ if (was != null && was !== name && existsSync(join(base, was))) writeFileSync(join(base, was, RETIRED), `${now}\n`)
107
+ for (const e of readdirSync(base)) {
108
+ const at = /^v(\d+)$/.exec(e)
109
+ if (at == null || e === name) continue
110
+ let since = Number(at[1])
111
+ try { since = Number(readFileSync(join(base, e, RETIRED), 'utf8')) } catch { /* never current: a clone that failed or was cut */ }
112
+ if (now - since > OLD_COPY_MS) rmSync(join(base, e), { recursive: true, force: true })
113
+ }
114
+ return { dir, ms: performance.now() - t0 }
115
+ }
116
+
117
+ /** the lib file that imports `name` from `module`, read only on a failure */
118
+ function importerOf(lib: string, name: string, module: string): string | null {
119
+ const want = real(module).replace(/\.[cm]?[jt]sx?$/, '')
120
+ const re = new RegExp(`(?:import|export)\\s*(?:type\\s*)?\\{[^}]*\\b${name.replace(/[^\w$]/g, '')}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, 'g')
121
+ for (const f of new Bun.Glob('src/**/*.{ts,tsx}').scanSync(lib)) {
122
+ const full = join(lib, f)
123
+ let text: string
124
+ try { text = readFileSync(full, 'utf8') } catch { continue }
125
+ if (!text.includes(name)) continue
126
+ for (const m of text.matchAll(re)) {
127
+ if (!m[1]!.startsWith('.')) continue
128
+ if (real(join(full, '..', m[1]!)).replace(/\.[cm]?[jt]sx?$/, '') === want) return full
129
+ }
130
+ }
131
+ return null
132
+ }
133
+
134
+ /** the file a load error names, relative to `lib`, and its first line */
135
+ export function loadFailure(e: unknown, lib: string): { file: string; error: string } {
136
+ const x = (e ?? {}) as { message?: string; position?: { file?: string; line?: number } | null; referrer?: string; stack?: string }
137
+ const rel = (p: string): string => {
138
+ const r = relative(real(lib), real(p))
139
+ return r.startsWith('..') ? p : r
140
+ }
141
+ const error = String(x.message ?? e).split('\n')[0]!.slice(0, 200)
142
+ // bun names the module a missing export was asked of, never the file that asked
143
+ const miss = /Export named '([^']+)' not found in module '([^']+)'/.exec(error)
144
+ if (miss != null) {
145
+ const importer = importerOf(lib, miss[1]!, miss[2]!)
146
+ return { file: rel(importer ?? miss[2]!), error: `imports '${miss[1]}', which ${rel(miss[2]!)} does not export` }
147
+ }
148
+ if (x.position?.file != null) return { file: `${rel(x.position.file)}${x.position.line != null ? `:${x.position.line}` : ''}`, error }
149
+ if (typeof x.referrer === 'string' && x.referrer !== '') return { file: rel(x.referrer), error }
150
+ const at = /\(?((?:\/[^\s():]+)+\.[cm]?[jt]sx?):(\d+)/.exec(x.stack ?? '')
151
+ return { file: at == null ? '?' : `${rel(at[1]!)}:${at[2]}`, error }
152
+ }
153
+
154
+ const hhmm = (t: number): string => new Date(t).toTimeString().slice(0, 5)
155
+
156
+ /** when a copy was taken: HH:MM within the last hour of today, else its
157
+ * date, time and age, so an old copy is never mistaken for a fresh one */
158
+ export function copyWhen(at: number, now = Date.now()): string {
159
+ const d = new Date(at)
160
+ if (d.toDateString() === new Date(now).toDateString() && now - at < 3_600_000) return hhmm(at)
161
+ const age = now - at
162
+ const old = age < 48 * 3_600_000 ? `${Math.round(age / 3_600_000)} h old` : `${Math.floor(age / 86_400_000)} d old`
163
+ const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
164
+ return `${day} ${hhmm(at)}, ${old}`
165
+ }
166
+
167
+ /** the one line every surface shows (the hub, the overview, a log) */
168
+ export const heldText = (h: Held): string =>
169
+ `running last good code since ${hhmm(h.since)} because ${h.file} fails to load: ${h.error}${h.copyAt == null ? '' : ` (the copy from ${copyWhen(h.copyAt)})`}`
170
+
171
+ let held: Held | null = null
172
+
173
+ /** this process's own state: a refused restart set it, or the boot that ran
174
+ * the copy left it in the env */
175
+ export function heldNow(): Held | null {
176
+ if (held != null) return held
177
+ const raw = process.env[LAST_GOOD_ENV]
178
+ if (raw == null || raw === '' || copyStamp(OWN_LIB) == null) return null
179
+ try {
180
+ return JSON.parse(raw) as Held
181
+ } catch {
182
+ return null
183
+ }
184
+ }
185
+
186
+ export function setHeld(h: Held | null): void {
187
+ held = h
188
+ }
189
+
190
+ /** the tree this process should go back to: its own, or the one its copy stands in for */
191
+ export const homeLib = (): string => (copyStamp(OWN_LIB)?.from ?? OWN_LIB)
192
+
193
+ /** the boot of a tree that loads: an env a copy left is not this process's */
194
+ export function bootLoaded(): void {
195
+ if (copyStamp(OWN_LIB) == null) delete process.env[LAST_GOOD_ENV]
196
+ }
197
+
198
+ /** the boot of a tree that failed to load: runs the copy (never returns), or
199
+ * returns when there is none so the caller throws the error itself */
200
+ export function runLastGood(e: unknown, argv: string[]): void {
201
+ const f = loadFailure(e, OWN_LIB)
202
+ // the copy itself failing is the error to show, never a loop
203
+ if (copyStamp(OWN_LIB) != null) return
204
+ const copy = lastGood(OWN_LIB)
205
+ if (copy == null) {
206
+ process.stderr.write(`FAIL shipkit: ${f.file} fails to load (${f.error}), and this checkout has no last good copy: the home's daemon keeps one\n`)
207
+ return
208
+ }
209
+ const h: Held = { since: Date.now(), file: f.file, error: f.error, from: real(OWN_LIB), copyAt: copy.at }
210
+ process.stderr.write(`WARN shipkit: ${f.file} fails to load (${f.error}), so this runs the last good copy from ${copyWhen(copy.at)}\n`)
211
+ const env = Object.fromEntries(Object.entries({ ...process.env, [LAST_GOOD_ENV]: JSON.stringify(h) }).filter((x): x is [string, string] => x[1] != null))
212
+ const args = [process.execPath, ...process.execArgv, copy.cli, ...argv]
213
+ const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
214
+ if (typeof execve === 'function') execve.call(process, process.execPath, args, env)
215
+ const child = Bun.spawnSync(args, { cwd: process.cwd(), stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env })
216
+ process.exit(child.exitCode ?? 1)
217
+ }
@@ -0,0 +1,71 @@
1
+ // does a tree load: a child bun imports its entries and says which file
2
+ // fails. Asked before a machine process restarts onto new code, and before
3
+ // the daemon keeps a copy as the last good one. Run as a child so a module
4
+ // that fails, or one whose top level runs, never touches the asking process.
5
+ import { join } from 'path'
6
+ import { loadFailure } from './last-good.ts'
7
+ import type { LoadVerdict } from '../serve/stale-restart.ts'
8
+
9
+ export type LoadAnswer = { ok: true; ms: number } | { ok: false; ms: number; file: string; error: string }
10
+
11
+ export type Role = 'daemon' | 'pm' | 'web' | 'serve'
12
+
13
+ const RUNNER: Record<Role, string> = { daemon: 'src/home/run-daemon.ts', pm: 'src/home/run-pm.ts', web: 'src/home/run-web.ts', serve: 'src/serve/run-serve.ts' }
14
+
15
+ /** the modules a restart through the cli loads: the boot's (cli.ts
16
+ * `loadBoot`: the registry and what runs before it) and the role's runner;
17
+ * `all`: every role's, what a copy every process may run must load */
18
+ export const bootEntries = (lib: string, role?: Role | 'all'): string[] => [
19
+ join(lib, 'src/cli.ts'),
20
+ ...(role == null ? [] : (role === 'all' ? Object.values(RUNNER) : [RUNNER[role]]).map((r) => join(lib, r))),
21
+ ]
22
+
23
+ /** import `entries` in a child bun from `lib`'s own probe file */
24
+ export async function probeLoad(lib: string, entries: string[]): Promise<LoadAnswer> {
25
+ const t0 = performance.now()
26
+ const p = Bun.spawn([process.execPath, join(lib, 'src/core/load-probe.ts'), ...entries], { cwd: lib, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, SHIPKIT_NO_HOME: '1' } })
27
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
28
+ const ms = performance.now() - t0
29
+ if (code === 0) return { ok: true, ms }
30
+ try {
31
+ const j = JSON.parse(out.trim().split('\n').at(-1) ?? '') as { file: string; error: string }
32
+ return { ok: false, ms, file: j.file, error: j.error }
33
+ } catch {
34
+ // the probe file itself did not load
35
+ const line = err.split('\n').find((l) => /error/i.test(l)) ?? err.trim().split('\n')[0] ?? `exit ${code}`
36
+ return { ok: false, ms, file: 'src/core/load-probe.ts', error: line.trim().slice(0, 200) }
37
+ }
38
+ }
39
+
40
+ /** one probe per state of the tree: asked again only once the key moves */
41
+ export class TreeProbe {
42
+ private key: string | null = null
43
+ private verdict: LoadVerdict = 'pending'
44
+
45
+ constructor(private readonly run: () => Promise<LoadAnswer>, private readonly keyOf: () => string) {}
46
+
47
+ loads(): LoadVerdict {
48
+ const k = this.keyOf()
49
+ if (k !== this.key) {
50
+ this.key = k
51
+ this.verdict = 'pending'
52
+ const land = (v: LoadVerdict): void => { if (this.key === k) this.verdict = v }
53
+ void this.run().then((a) => land(a.ok ? 'ok' : { file: a.file, error: a.error }), (e: Error) => land({ file: '?', error: e.message }))
54
+ }
55
+ return this.verdict
56
+ }
57
+ }
58
+
59
+ if (import.meta.main) {
60
+ const lib = join(import.meta.dir, '../..')
61
+ for (const e of process.argv.slice(2)) {
62
+ try {
63
+ const m = (await import(e)) as { loadBoot?: () => Promise<unknown> }
64
+ if (typeof m.loadBoot === 'function') await m.loadBoot()
65
+ } catch (x) {
66
+ console.log(JSON.stringify(loadFailure(x, lib)))
67
+ process.exit(3)
68
+ }
69
+ }
70
+ process.exit(0)
71
+ }
@@ -5,7 +5,9 @@
5
5
  // code, which keeps the terminal but leaves this process waiting under it.
6
6
  import { existsSync } from 'fs'
7
7
 
8
- const argvOf = (): string[] => [process.execPath, ...process.execArgv, ...process.argv.slice(1)]
8
+ /** `entry` in place of the running script: a process on the last good copy
9
+ * goes back to the tree's own cli (core/last-good.ts) */
10
+ const argvOf = (entry?: string): string[] => [process.execPath, ...process.execArgv, ...(entry == null ? process.argv.slice(1) : [entry, ...process.argv.slice(2)])]
9
11
 
10
12
  /** why this invocation cannot run again, null when it can. Asked BEFORE a
11
13
  * caller tears anything down, so a refusal leaves the process serving */
@@ -16,8 +18,8 @@ export function reexecBlocker(): string | null {
16
18
  }
17
19
 
18
20
  /** never returns. `env` defaults to this process's own */
19
- export function reexecSelf(env: Record<string, string | undefined> = process.env): never {
20
- const argv = argvOf()
21
+ export function reexecSelf(env: Record<string, string | undefined> = process.env, entry?: string): never {
22
+ const argv = argvOf(entry)
21
23
  const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] != null))
22
24
  const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
23
25
  if (typeof execve === 'function') execve.call(process, process.execPath, argv, clean)
@@ -0,0 +1,80 @@
1
+ // push every house repo that holds commits its remote does not: `sk fleet
2
+ // push` and the activity page's push button run this one path. A plain
3
+ // `git push`, never a force: a branch that also fell behind its remote waits
4
+ // for a pull, a branch with no upstream goes to its one remote with -u, and
5
+ // git itself refuses anything that is not a fast-forward. Local git only, no
6
+ // fetch first: "behind" is as of the last fetch, and the push is what finds
7
+ // out for sure.
8
+ import { parseStatus } from '../home/activity.ts'
9
+
10
+ export type PushAction = 'push' | 'push-upstream' | 'none' | 'skip'
11
+ export type PushPlan = { name: string; path: string; branch: string | null; remote: string | null; ahead: number; action: PushAction; why: string }
12
+ export type PushResult = PushPlan & { ok: boolean; line: string }
13
+
14
+ /** at most this many pushes at once */
15
+ const PARALLEL = 4
16
+ const PUSH_MS = 60_000
17
+
18
+ async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
19
+ // a push that would ask for a password fails instead of hanging the page
20
+ const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
21
+ const timer = setTimeout(() => p.kill(), ms)
22
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
23
+ clearTimeout(timer)
24
+ return { code, out, err }
25
+ }
26
+
27
+ /** what to do with one repo, from `git status --porcelain=v2 --branch`, its
28
+ * remotes and its commit count. Pure: the tests feed it text */
29
+ export function planOf(repo: { name: string; path: string }, status: string, remotes: string[], commits: number): PushPlan {
30
+ const s = parseStatus(status)
31
+ const base = { name: repo.name, path: repo.path, branch: s.branch, remote: null as string | null, ahead: s.ahead ?? 0 }
32
+ if (s.branch == null) return { ...base, action: 'skip', why: s.detached != null ? `detached at ${s.detached}: no branch to push` : 'no commit yet' }
33
+ if (s.upstream == null) {
34
+ if (commits === 0) return { ...base, action: 'none', why: 'no commit yet' }
35
+ const remote = remotes.length === 1 ? remotes[0]! : remotes.includes('origin') ? 'origin' : null
36
+ if (remote == null) return { ...base, action: 'skip', why: remotes.length === 0 ? 'no remote to push to' : `no upstream, and ${remotes.length} remotes: set one with git push -u <remote> ${s.branch}` }
37
+ return { ...base, remote, ahead: commits, action: 'push-upstream', why: `no upstream: push -u ${remote} ${s.branch}` }
38
+ }
39
+ if (s.gone) return { ...base, action: 'skip', why: `its upstream ${s.upstream} is gone` }
40
+ const ahead = s.ahead ?? 0
41
+ const behind = s.behind ?? 0
42
+ if (ahead === 0) return { ...base, action: 'none', why: 'pushed' }
43
+ if (behind > 0) return { ...base, action: 'skip', why: `${ahead} ahead and ${behind} behind ${s.upstream}: pull first, never a force` }
44
+ return { ...base, remote: s.upstream.split('/')[0] ?? null, action: 'push', why: `${ahead} unpushed to ${s.upstream}` }
45
+ }
46
+
47
+ export async function pushPlan(repo: { name: string; path: string }): Promise<PushPlan> {
48
+ const [status, remotes, count] = await Promise.all([
49
+ git(repo.path, ['status', '--porcelain=v2', '--branch']),
50
+ git(repo.path, ['remote']),
51
+ git(repo.path, ['rev-list', '--count', 'HEAD']),
52
+ ])
53
+ if (status.code !== 0) return { name: repo.name, path: repo.path, branch: null, remote: null, ahead: 0, action: 'skip', why: `git status failed: ${status.err.trim().split('\n')[0]}` }
54
+ return planOf(repo, status.out, remotes.out.split('\n').filter((r) => r !== ''), Number(count.out.trim()) || 0)
55
+ }
56
+
57
+ async function pushOne(p: PushPlan): Promise<PushResult> {
58
+ if (p.action !== 'push' && p.action !== 'push-upstream') return { ...p, ok: p.action === 'none', line: `${p.action === 'none' ? 'ok' : 'skip'}: ${p.name}: ${p.why}` }
59
+ const args = p.action === 'push' ? ['push'] : ['push', '-u', p.remote!, p.branch!]
60
+ const r = await git(p.path, args, PUSH_MS)
61
+ if (r.code === 0) return { ...p, ok: true, line: `ok: ${p.name}: pushed ${p.ahead} commit${p.ahead === 1 ? '' : 's'} (${p.branch})` }
62
+ const why = r.err.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('hint:')).slice(-1)[0] ?? `git push exited ${r.code}`
63
+ return { ...p, ok: false, line: `FAIL: ${p.name}: git ${args.join(' ')}: ${why}` }
64
+ }
65
+
66
+ /** every repo planned, the ones that need it pushed, a few at a time; `dry`
67
+ * plans only. Results come back in the repos' order */
68
+ export async function pushFleet(repos: { name: string; path: string }[], o: { dry?: boolean } = {}): Promise<PushResult[]> {
69
+ const plans = await Promise.all(repos.map(pushPlan))
70
+ if (o.dry === true) return plans.map((p) => ({ ...p, ok: p.action !== 'skip', line: `${p.action === 'skip' ? 'skip' : p.action === 'none' ? 'ok' : 'would push'}: ${p.name}: ${p.why}` }))
71
+ const out: PushResult[] = new Array(plans.length)
72
+ let next = 0
73
+ await Promise.all(Array.from({ length: PARALLEL }, async () => {
74
+ while (next < plans.length) {
75
+ const i = next++
76
+ out[i] = await pushOne(plans[i]!)
77
+ }
78
+ }))
79
+ return out
80
+ }
@@ -7,6 +7,7 @@ import { existsSync } from 'fs'
7
7
  import { dirname, join } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import { installedVersion, isBehind } from '../fleet/scan-fleet.ts'
10
+ import { heldNow, heldText } from '../core/last-good.ts'
10
11
  import { LIB_VERSION } from '../core/lib-version.ts'
11
12
  import { projectGutter, type ProjectRow } from '../serve/project-gutter-route.ts'
12
13
  import type { WiredRepo } from '../serve/project-gutter.ts'
@@ -20,6 +21,7 @@ import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
21
  import { devRepos, InsightsCache, type Scope } from './insights.ts'
21
22
  import { GLOBAL_CONTEXT_EVERY_MS, houseContext, writeIfChanged } from './global-context.ts'
22
23
  import { HouseRuns } from '../serve/house-runs.ts'
24
+ import { pushFleet } from '../fleet/push-fleet.ts'
23
25
 
24
26
  /** how far back a repo's git page lists its commits */
25
27
  export const GIT_PAGE_MS = 7 * 24 * 3600_000
@@ -38,6 +40,8 @@ export type HomeAnswer = {
38
40
  /** repo → its installed shipkit, for every repo behind this home's */
39
41
  behind: Record<string, string>
40
42
  modernize: Record<string, ModernizeView>
43
+ /** the daemon runs older code than its tree, and why (core/last-good.ts) */
44
+ held?: string | null
41
45
  }
42
46
 
43
47
  export type HomeDeps = {
@@ -156,7 +160,8 @@ export function homeRoutes(d: HomeDeps) {
156
160
  }
157
161
  const modernize: Record<string, ModernizeView> = {}
158
162
  for (const [k, m] of modernizing) modernize[k] = { status: m.status, code: m.code, tail: tailLines(m.log, 40) }
159
- return { version: LIB_VERSION, workers: d.hubs.views(repos), active: d.state.data.active, behind, modernize }
163
+ const h = heldNow()
164
+ return { version: LIB_VERSION, workers: d.hubs.views(repos), active: d.state.data.active, behind, modernize, held: h == null ? null : heldText(h) }
160
165
  }
161
166
 
162
167
  const find = (name: unknown): WiredRepo | null => repos.find((r) => r.name === name) ?? null
@@ -171,9 +176,14 @@ export function homeRoutes(d: HomeDeps) {
171
176
  }
172
177
  if (path === '/hub.json')
173
178
  // the identity a client probes (homeUp), through the web or here
174
- return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION }, { headers: { 'access-control-allow-origin': '*' } })
179
+ return Response.json({ hub: 'shipkit-home', name: d.name, root: d.root, version: LIB_VERSION, held: heldNow() == null ? null : heldText(heldNow()!) }, { headers: { 'access-control-allow-origin': '*' } })
175
180
  if (!sameOrigin(req)) return new Response('refused: a request that changes something must come from the home page', { status: 403 })
176
181
  if (path.startsWith('/api/projects')) return d.gutter.handle(req, path)
182
+ // the activity page's push button: every house repo with unpushed commits, never a force (fleet/push-fleet.ts)
183
+ if (path === '/api/home/push-all' && req.method === 'POST') {
184
+ if (repos.length === 0) await refresh()
185
+ return Response.json(await pushFleet(repos.map((r) => ({ name: r.name, path: r.path }))))
186
+ }
177
187
  // the web asks which page routes to serve: one per house repo
178
188
  if (path === '/api/home/repos.json') {
179
189
  if (repos.length === 0) await refresh()
@@ -5,10 +5,14 @@
5
5
  // started through cli.ts holds every command (one edit would restart all
6
6
  // three). This watch covers the lib source, filtered to the files the role's
7
7
  // own entry module reaches, and says a change once the burst of writes settles.
8
- import { existsSync, readFileSync, watch, type FSWatcher } from 'fs'
8
+ // A restart lands only on code that loads (core/load-probe.ts): while a file
9
+ // of the tree fails, the role keeps its running image and says why once. The
10
+ // daemon also keeps the last good copy of the tree (core/last-good.ts).
11
+ import { existsSync, readFileSync, realpathSync, statSync, watch, type FSWatcher } from 'fs'
9
12
  import { dirname, extname, join, relative, resolve } from 'path'
10
- import { LIB_ROOT } from '../core/lib-root.ts'
11
- import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
13
+ import { copyStamp, heldNow, homeLib, LAST_GOOD_ENV, lastGood, OWN_LIB, setHeld, takeLastGood } from '../core/last-good.ts'
14
+ import { LIB_VERSION } from '../core/lib-version.ts'
15
+ import { bootEntries, probeLoad, TreeProbe } from '../core/load-probe.ts'
12
16
  import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
13
17
  import { StaleRestart } from '../serve/stale-restart.ts'
14
18
 
@@ -56,6 +60,9 @@ export function libGraph(entry: string, o: { bundles?: boolean } = {}): Set<stri
56
60
  export class LibWatch {
57
61
  private w: FSWatcher | null = null
58
62
  private last: { file: string; at: number } | null = null
63
+ private any: { file: string; at: number } | null = null
64
+ /** every code write under src, own or not */
65
+ seq = 0
59
66
  readonly error: string | null = null
60
67
  private now: () => number
61
68
  private settleMs: number
@@ -64,11 +71,25 @@ export class LibWatch {
64
71
  constructor(private root: string, o: { settleMs?: number; now?: () => number; only?: Set<string> } = {}) {
65
72
  this.now = o.now ?? Date.now
66
73
  this.settleMs = o.settleMs ?? 1_000
74
+ // an APFS clone of the tree (the last good copy, a scratch checkout)
75
+ // fires a change on every source file it reads: only a moved mtime,
76
+ // or a file gone, is a write
77
+ const mtimes = new Map<string, number>()
78
+ const mtimeOf = (f: string): number | null => {
79
+ try { return statSync(f).mtimeMs } catch { return null }
80
+ }
81
+ for (const f of new Bun.Glob('**/*').scanSync(join(root, 'src'))) if (CODE.test(f)) mtimes.set(join(root, 'src', f), mtimeOf(join(root, 'src', f)) ?? -1)
67
82
  try {
68
83
  this.w = watch(join(root, 'src'), { recursive: true }, (_ev, file) => {
69
84
  if (file == null || !CODE.test(String(file))) return
85
+ const full = join(root, 'src', String(file))
86
+ const m = mtimeOf(full)
87
+ if (m != null && m === mtimes.get(full)) return
88
+ mtimes.set(full, m ?? -1)
89
+ this.seq++
90
+ this.any = { file: String(file), at: this.now() }
70
91
  if (o.only != null && !o.only.has(join(root, 'src', String(file)))) return
71
- this.last = { file: String(file), at: this.now() }
92
+ this.last = this.any
72
93
  })
73
94
  } catch (e) {
74
95
  this.error = (e as Error).message
@@ -86,41 +107,92 @@ export class LibWatch {
86
107
  return `the shipkit source moved (src/${this.last.file})`
87
108
  }
88
109
 
110
+ /** no write anywhere under src for settleMs */
111
+ settled(): boolean {
112
+ return this.any == null || this.now() - this.any.at >= this.settleMs
113
+ }
114
+
115
+ /** one line on the last write anywhere under src, once settled, else null */
116
+ whyAny(): string | null {
117
+ return this.any == null || !this.settled() ? null : `the shipkit source moved (src/${this.any.file})`
118
+ }
119
+
89
120
  close(): void {
90
121
  this.w?.close()
91
122
  this.w = null
92
123
  }
93
124
  }
94
125
 
126
+ /** how often the daemon may probe the tree to keep its last good copy */
127
+ export const KEEP_EVERY_MS = 60_000
128
+
129
+ const realOf = (p: string): string => {
130
+ try { return realpathSync(p) } catch { return p }
131
+ }
132
+
133
+ const versionAt = (root: string): string => {
134
+ try { return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
135
+ }
136
+
95
137
  /** a machine process restarts itself in place, same pid, once its own code
96
- * moved and nothing it runs is in flight. `before` tears down what must not
97
- * outlive the image (its server); what it started detached keeps running */
138
+ * moved, nothing it runs is in flight and the new code loads. `before`
139
+ * tears down what must not outlive the image (its server); what it started
140
+ * detached keeps running. On the last good copy it watches the tree and
141
+ * goes back to it once it loads. `keep`: this process keeps the copy */
98
142
  export function reloadOnOwnCode(o: {
99
- role: string
143
+ role: 'pm' | 'daemon' | 'web'
100
144
  /** the role's runner module: its graph is what the role reloads on */
101
145
  entry: string
102
146
  bundles?: boolean
103
147
  busy?: () => boolean
104
148
  before: () => void
149
+ keep?: boolean
105
150
  io: { print: (l: string) => void; printErr: (l: string) => void }
106
151
  }): { stop: () => void; files: number } {
107
- const graph = libGraph(o.entry, { bundles: o.bundles })
108
- const lib = new LibWatch(LIB_ROOT, { only: graph })
152
+ const onCopy = copyStamp(OWN_LIB) != null
153
+ const tree = homeLib()
154
+ const graph = libGraph(onCopy ? join(tree, relative(OWN_LIB, o.entry)) : o.entry, { bundles: o.bundles })
155
+ const lib = new LibWatch(tree, { only: onCopy ? undefined : graph })
109
156
  if (lib.error != null) o.io.printErr(`WARN no watch on the shipkit source (${lib.error}), so an edit of the ${o.role} needs a restart of it`)
157
+ const probe = new TreeProbe(() => probeLoad(tree, bootEntries(tree, o.role)), () => `${lib.seq}|${versionAt(tree)}`)
158
+ let since: number | null = null
159
+ let said = ''
160
+ const hold = (v: { file: string; error: string } | null): void => {
161
+ if (v == null) {
162
+ if (since != null) o.io.print(`ok: the shipkit source loads again`)
163
+ since = null
164
+ said = ''
165
+ if (!onCopy) setHeld(null)
166
+ return
167
+ }
168
+ since ??= heldNow()?.since ?? Date.now()
169
+ const text = `${v.file} fails to load (${v.error})`
170
+ if (text !== said) {
171
+ said = text
172
+ o.io.printErr(`FAIL: ${text}: sk runs the last good copy, and the ${o.role} keeps its running code until the tree loads again`)
173
+ }
174
+ setHeld({ since, file: v.file, error: v.error, from: tree, copyAt: lastGood(tree)?.at ?? null })
175
+ }
110
176
  const stale = new StaleRestart({
111
177
  boot: LIB_VERSION,
112
- disk: diskLibVersion,
113
- moved: () => lib.why(),
178
+ disk: () => versionAt(tree),
179
+ // on the copy, any write may heal the tree, and the first look is at once
180
+ moved: () => (onCopy ? (lib.whyAny() ?? (lib.seq === 0 ? 'this process runs the last good copy' : null)) : lib.why()),
114
181
  busy: o.busy ?? (() => false),
182
+ loads: () => probe.loads(),
183
+ hold,
115
184
  restart: (why) => {
116
185
  const blocked = reexecBlocker()
117
186
  if (blocked != null) throw new Error(blocked)
118
- o.io.print(`ok: ${why}: the ${o.role} restarts in place`)
119
- stale.stop()
120
- lib.close()
187
+ o.io.print(`ok: ${why}: the ${o.role} restarts in place${onCopy ? ' on the tree' : ''}`)
188
+ stop()
121
189
  o.before()
122
190
  try {
123
- reexecSelf()
191
+ if (!onCopy) reexecSelf()
192
+ const env = { ...process.env }
193
+ delete env[LAST_GOOD_ENV]
194
+ // the same script in the tree: cli.ts for most roles, run-pm.ts for pm
195
+ reexecSelf(env, join(tree, relative(realOf(OWN_LIB), realOf(process.argv[1] ?? join(OWN_LIB, 'src/cli.ts')))))
124
196
  } catch (e) {
125
197
  o.io.printErr(`FAIL: the ${o.role} stopped to restart and could not: ${(e as Error).message}`)
126
198
  process.exit(1)
@@ -128,12 +200,30 @@ export function reloadOnOwnCode(o: {
128
200
  },
129
201
  say: (text) => { if (text != null) o.io.printErr(`WARN ${text}`) },
130
202
  })
203
+ // the daemon keeps the copy: after a settled burst that loads, at most every KEEP_EVERY_MS
204
+ // once a minute counts from the last copy's own stamp (a restart keeps it), or from a try that failed
205
+ let kept = -1
206
+ let triedAt = 0
207
+ const keeper = o.keep === true && !onCopy
208
+ ? setInterval(() => {
209
+ if (lib.seq === kept || !lib.settled() || Date.now() - Math.max(lastGood(tree)?.at ?? 0, triedAt) < KEEP_EVERY_MS) return
210
+ const v = probe.loads()
211
+ if (v === 'pending') return
212
+ if (v !== 'ok') return hold(v)
213
+ hold(null)
214
+ kept = lib.seq
215
+ triedAt = Date.now()
216
+ void takeLastGood(tree).catch((e: Error) => o.io.printErr(`WARN the last good copy of the shipkit source could not be kept: ${e.message}`))
217
+ }, 2_000)
218
+ : null
219
+ function stop(): void {
220
+ stale.stop()
221
+ lib.close()
222
+ if (keeper != null) clearInterval(keeper)
223
+ }
131
224
  stale.start()
132
225
  return {
133
- files: [...graph].filter((f) => !relative(LIB_ROOT, f).startsWith('..')).length,
134
- stop: () => {
135
- stale.stop()
136
- lib.close()
137
- },
226
+ files: [...graph].filter((f) => !relative(tree, f).startsWith('..')).length,
227
+ stop,
138
228
  }
139
229
  }