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.
- package/CHANGELOG.md +8 -0
- package/package.json +2 -2
- package/src/cli/cmd_Commands.ts +26 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +16 -1
- package/src/cli/cmd_Trailer.ts +4 -1
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -135
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/review/model.ts +19 -0
- package/src/review/paste.ts +30 -0
- package/src/review/routes.ts +10 -3
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/review/review-answer.tsx +16 -37
- package/src/serve/hub-app/review/review-page.tsx +8 -1
- package/src/serve/hub-app/review/review-st.ts +51 -3
- package/src/serve/hub-app/review/review-views.tsx +1 -1
- package/src/serve/hub-app/review.css +4 -4
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +8 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/trailer/build.ts +13 -5
- package/src/trailer/ffmpeg.ts +21 -1
package/src/serve/run-serve.ts
CHANGED
|
@@ -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()
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -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()
|
package/src/trailer/build.ts
CHANGED
|
@@ -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 }, {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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}` : ''}`)
|
package/src/trailer/ffmpeg.ts
CHANGED
|
@@ -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' })
|