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
@@ -5,7 +5,11 @@
5
5
  import { basename } from 'path'
6
6
  import { reclaimPreviousImage } from '../core/instance.ts'
7
7
  import { isLinkedWorktree } from '../core/git-root.ts'
8
+ import { heldNow, heldText, OWN_LIB, setHeld } from '../core/last-good.ts'
8
9
  import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
10
+ import { bootEntries, probeLoad, TreeProbe } from '../core/load-probe.ts'
11
+ import { LibWatch } from '../home/lib-watch.ts'
12
+ import type { LoadVerdict } from './stale-restart.ts'
9
13
  import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
10
14
  import { attachToHome } from '../home/attach.ts'
11
15
  import { daemonUp, WORKER_ENV } from '../home/home-paths.ts'
@@ -21,6 +25,14 @@ export type { ServeIo }
21
25
  /** how often a hub of its own looks at its plan */
22
26
  export const PLAN_TICK_MS = 2_000
23
27
 
28
+ /** does the lib this hub would restart onto load: probed again after every
29
+ * write to its tree, not only when the version moves */
30
+ export function serveProbe(lib: string): { loads: () => LoadVerdict; stop: () => void } {
31
+ const w = new LibWatch(lib)
32
+ const p = new TreeProbe(() => probeLoad(lib, bootEntries(lib, 'serve')), () => `${w.seq}|${diskLibVersion()}`)
33
+ return { loads: () => p.loads(), stop: () => w.close() }
34
+ }
35
+
24
36
  /** what a `shipkit serve` does with its hub. On a machine with a home, no
25
37
  * hub runs outside it: a home that answers takes it, and an installed one
26
38
  * that does not is started first. `serve` only inside the home, a linked
@@ -104,6 +116,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
104
116
  // a file table the watch restarts leaked: it re-executes itself in
105
117
  // place (same pid, argv, port) once no run is in flight. Only a
106
118
  // restart that throws is said, in the hub's own status
119
+ const probe = serveProbe(OWN_LIB)
107
120
  const stale = new StaleRestart({
108
121
  boot: LIB_VERSION,
109
122
  disk: diskLibVersion,
@@ -114,6 +127,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
114
127
  if (blocked != null) throw new Error(blocked)
115
128
  io.print(`ok: ${why}: restarting in place`)
116
129
  stale.stop()
130
+ probe.stop()
117
131
  clearInterval(planClock)
118
132
  rt.stop()
119
133
  hub.stop()
@@ -126,6 +140,16 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
126
140
  }
127
141
  },
128
142
  say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
143
+ // a version move restarts only onto code that loads; the held line is the hub's (serve-hub.ts)
144
+ loads: () => probe.loads(),
145
+ hold: (v) => {
146
+ if (v == null) return setHeld(null)
147
+ const was = heldNow()
148
+ if (was?.file === v.file && was.error === v.error) return
149
+ const h = { since: was?.since ?? Date.now(), file: v.file, error: v.error, from: OWN_LIB, copyAt: null }
150
+ io.printErr(`WARN ${heldText(h)}`)
151
+ setHeld(h)
152
+ },
129
153
  })
130
154
  stale.start()
131
155
  // a watch restart that leaked sheds it before the first run spawns anything
@@ -140,6 +164,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
140
164
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
141
165
  process.on(sig, () => {
142
166
  stale.stop()
167
+ probe.stop()
143
168
  clearInterval(planClock)
144
169
  plan.stop()
145
170
  rt.stop()
@@ -31,6 +31,7 @@ import { RUN_EXIT } from '../home/home-words.ts'
31
31
  import { projectGutter } from './project-gutter-route.ts'
32
32
  import { sameOrigin } from './same-origin.ts'
33
33
  import { LAYOUT } from '../core/layout.ts'
34
+ import { heldNow, heldText } from '../core/last-good.ts'
34
35
  import { WarmCell } from './warm-cell.ts'
35
36
  import { readmeRoutes } from './readme-route.ts'
36
37
  import { reviewRoutes } from '../review/routes.ts'
@@ -246,7 +247,7 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
246
247
  collections: r.collections,
247
248
  resources: r.resources,
248
249
  planned: d.planned?.() ?? null,
249
- hub: d.hubProblems?.list() ?? [],
250
+ hub: [...(d.hubProblems?.list() ?? []), ...heldProblem()],
250
251
  })
251
252
  }
252
253
  // the previous address, answered while pages opened before the
@@ -427,6 +428,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
427
428
  }
428
429
  }
429
430
 
431
+ /** the machine runs older code than its tree: every hub's header says so */
432
+ const heldProblem = (): { id: string; text: string; severity: 'yellow' }[] => {
433
+ const h = heldNow()
434
+ return h == null ? [] : [{ id: 'last-good', text: heldText(h), severity: 'yellow' }]
435
+ }
436
+
430
437
  export function serveHub(d: HubDeps, h: HubHandler = hubHandler(d)): Hub {
431
438
  const server: Server = Bun.serve({
432
439
  hostname: '127.0.0.1',
@@ -7,10 +7,14 @@
7
7
  // watch fds, and a full table fails every spawn with EBADF. Either way it
8
8
  // re-executes in place (core/reexec.ts) once no run is in flight, which is a
9
9
  // clean table too. Only a restart that throws reaches a human, once, through
10
- // `say`.
10
+ // `say`. With `loads`, it restarts only onto code that loads: while the tree
11
+ // fails, it keeps the running image and tells `hold` why.
11
12
  import { assertLeakHealable, FD_HEALED_ENV, fdTableLeak } from '../core/fd-table.ts'
12
13
 
13
- export type StaleTick = 'fresh' | 'busy' | 'restarted' | 'impossible'
14
+ export type StaleTick = 'fresh' | 'busy' | 'probing' | 'held' | 'restarted' | 'impossible'
15
+
16
+ /** does the code a restart would run load: `pending` while a probe runs */
17
+ export type LoadVerdict = 'ok' | 'pending' | { file: string; error: string }
14
18
 
15
19
  export type StaleRestartOpts = {
16
20
  /** the version this process booted with */
@@ -30,6 +34,10 @@ export type StaleRestartOpts = {
30
34
  /** the hub's status line: a text when the restart is impossible, null
31
35
  * once there is nothing to say */
32
36
  say: (text: string | null) => void
37
+ /** asked once a restart is due, never before (core/load-probe.ts) */
38
+ loads?: () => LoadVerdict
39
+ /** why the due restart waits: a file of the tree fails to load. Null once it loads */
40
+ hold?: (why: { file: string; error: string } | null) => void
33
41
  everyMs?: number
34
42
  }
35
43
 
@@ -47,6 +55,15 @@ export class StaleRestart {
47
55
  }
48
56
  if (this.failed != null) return 'impossible'
49
57
  if (this.o.busy()) return 'busy'
58
+ if (this.o.loads != null) {
59
+ const v = this.o.loads()
60
+ if (v === 'pending') return 'probing'
61
+ if (v !== 'ok') {
62
+ this.o.hold?.(v)
63
+ return 'held'
64
+ }
65
+ this.o.hold?.(null)
66
+ }
50
67
  try {
51
68
  if (stale.leak) {
52
69
  assertLeakHealable()
@@ -504,7 +504,7 @@ export async function scoreFile(l: Loaded, seconds: number, say: Say): Promise<s
504
504
  /** everything the stitch reads but its output: sources, titles, sounds, the look */
505
505
  async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Shot[], silent: boolean, say: Say = () => undefined) {
506
506
  const { gridMask, splitMasks } = await import('./stitch.ts')
507
- const [{ cardPng, titlePng }, { lookOf }, { duration }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
507
+ const [{ cardPng, titlePng }, { lookOf }, { durationOf }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
508
508
  import('./titles.ts'),
509
509
  import('./look.ts'),
510
510
  import('./ffmpeg.ts'),
@@ -534,7 +534,7 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
534
534
  if (f.kind === 'image') return { image: fileOf(f.path, l)! }
535
535
  if (f.kind === 'clip') {
536
536
  const file = fileOf(f.path, l)!
537
- return { footage: file, seconds: duration(file) }
537
+ return { footage: file, seconds: await durationOf(file) }
538
538
  }
539
539
  const tk = takeOf(s)!
540
540
  return tk.stamp!.seconds === 0 ? { image: tk.file } : { footage: tk.file, seconds: tk.stamp!.seconds }
@@ -556,11 +556,11 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
556
556
  )
557
557
  const sfx = await Promise.all(shots.map((s) => Promise.all(s.sfx.map(async (x) => {
558
558
  const file = isFileSound(x.id, x) ? fileOf(x.id, l)! : await resolve(ask('sfx', x.id, x))
559
- return { file, seconds: duration(file) }
559
+ return { file, seconds: await durationOf(file) }
560
560
  }))))
561
561
  const voices = await Promise.all(shots.map((s) => Promise.all(s.voices.map(async (v) => {
562
562
  const file = v.file ? fileOf(v.file, l)! : await resolve(ask('voice', v.text ?? 'run', v))
563
- return { file, seconds: duration(file) }
563
+ return { file, seconds: await durationOf(file) }
564
564
  }))))
565
565
  return { ...base, sources, titles: drawn, music, sfx, voices }
566
566
  }
@@ -655,7 +655,15 @@ export async function build(l: Loaded, o: BuildOptions): Promise<string> {
655
655
  const done = await stitch({ ...input, t: l.t, out: cut, cache: trailerCache(l.root, 'pieces'), encoder: o.final ? 'final' : 'draft' })
656
656
  const finished = l.t.vertical ? await finishVertical(l, input, done, cut, out, o.final) : null
657
657
  const stripFile = join(outDir(l), `${l.t.name}.strip.png`)
658
- await strip(done.pieces, stripFile)
658
+ // the strip is drawn from the pieces' thumbs: the same pieces draw the same strip, so a build that rendered no
659
+ // section keeps it
660
+ const stripKey = join(trailerCache(l.root, 'pieces'), `${l.t.name}.strip.key`)
661
+ const key = done.pieces.map((p) => p.thumbs).join('\n')
662
+ const drawn = (): string => `${key}\n${statSync(stripFile).mtimeMs}`
663
+ if (!existsSync(stripFile) || !existsSync(stripKey) || readFileSync(stripKey, 'utf8') !== drawn()) {
664
+ await strip(done.pieces, stripFile)
665
+ writeFileSync(stripKey, drawn())
666
+ }
659
667
  writeFileSync(join(outDir(l), `${l.t.name}.montage.md`), montage(l.t, basename(l.file)))
660
668
  for (const line of timeline(l, takes, world, o.cwd)) o.say(line)
661
669
  o.say(`ok: ${out} (${(finished?.seconds ?? total(l.t)).toFixed(2)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s: filming ${((tFilm - t0) / 1000).toFixed(1)} s, ${rendered(done)}${finished ? `, ${finished.note}` : ''}`)
@@ -1,7 +1,7 @@
1
1
  // the one way the trailer spawns ffmpeg and ffprobe: a run that fails loud with ffmpeg's own words, a probe, the nine
2
2
  // frame sheet. The binaries resolve once, with the homebrew path for processes started without it
3
3
  import { spawn } from 'node:child_process'
4
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
5
5
  import { basename } from 'node:path'
6
6
 
7
7
  const FFMPEG = Bun.which('ffmpeg') ?? '/opt/homebrew/bin/ffmpeg'
@@ -38,6 +38,26 @@ export function duration(file: string): number {
38
38
  return d
39
39
  }
40
40
 
41
+ const probed = new Map<string, Promise<number>>()
42
+
43
+ /** a media file's length like `duration`, probed once per file and state in this process, the probes side by side: a
44
+ * cut that plays one sfx on many lines asks once */
45
+ export function durationOf(file: string): Promise<number> {
46
+ const st = statSync(file)
47
+ const k = `${file}\0${st.size}\0${st.mtimeMs}`
48
+ const had = probed.get(k)
49
+ if (had) return had
50
+ const p = (async () => {
51
+ const r = Bun.spawn([FFPROBE, '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
52
+ const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited])
53
+ const d = Number(out.trim())
54
+ if (code !== 0 || !(d > 0)) throw new Error(`ffprobe ${file}: ${err.trim() || 'no length'}`)
55
+ return d
56
+ })()
57
+ probed.set(k, p)
58
+ return p
59
+ }
60
+
41
61
  /** the media file carries a sound stream */
42
62
  export function hasAudio(file: string): boolean {
43
63
  const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })