rvlib-shipkit 2.3.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 +19 -2
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +30 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +52 -11
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +11 -3
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -133
- package/src/core/app-icon.ts +10 -6
- 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/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +66 -5
- package/src/review/paste.ts +30 -0
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +22 -4
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- 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/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +67 -58
- package/src/serve/hub-app/review/review-page.tsx +17 -15
- package/src/serve/hub-app/review/review-st.ts +191 -20
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +73 -13
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +15 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +182 -33
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/home/home-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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()
|
package/src/home/lib-watch.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
11
|
-
import {
|
|
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 =
|
|
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
|
|
97
|
-
* outlive the image (its server); what it started
|
|
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:
|
|
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
|
|
108
|
-
const
|
|
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:
|
|
113
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/home/process-rows.ts
CHANGED
|
@@ -146,6 +146,8 @@ export const CORE_ABOUT = {
|
|
|
146
146
|
export type CoreOpts = {
|
|
147
147
|
/** the daemon's own version, asked of the daemon; null when it does not answer */
|
|
148
148
|
daemonVersion?: string | null
|
|
149
|
+
/** the daemon runs older code than its tree, and why; null when it does not */
|
|
150
|
+
daemonHeld?: string | null
|
|
149
151
|
/** where each listens, when pm does not say (an older pm, or pm down) */
|
|
150
152
|
ports?: CorePorts | null
|
|
151
153
|
/** one of shipkit's own not answering the page, and why: its row says
|
|
@@ -174,9 +176,9 @@ export function coreGroup(pm: PmAnswer | null, o: CoreOpts = {}): ProcessGroup {
|
|
|
174
176
|
const c = pm?.children.find((x) => x.id === id)
|
|
175
177
|
const port = ports?.[id] ?? null
|
|
176
178
|
const version = id === 'daemon' ? (o.daemonVersion ?? null) : null
|
|
177
|
-
if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: null, tail: [], version, port, restart: false }))
|
|
179
|
+
if (c == null) rows.push(coreRow(id, { status: 'unknown', pid: null, since: null, note: id === 'daemon' ? (o.daemonHeld ?? null) : null, tail: [], version, port, restart: false }))
|
|
178
180
|
else {
|
|
179
|
-
const note = [c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
|
|
181
|
+
const note = [id === 'daemon' ? (o.daemonHeld ?? null) : null, c.note, c.restarts > 0 ? `${c.restarts} restart(s)` : null].filter((x) => x != null).join(', ')
|
|
180
182
|
rows.push(coreRow(id, { status: c.status, pid: c.pid, since: c.since, note: note === '' ? null : note, tail: c.tail, version, port, restart: true }))
|
|
181
183
|
}
|
|
182
184
|
}
|
package/src/home/run-daemon.ts
CHANGED
|
@@ -115,7 +115,7 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
|
|
|
115
115
|
// the rows every gate filed land here, one commit a day at most
|
|
116
116
|
also: (now) => { for (const n of landMotherhome({ now })) io.printErr(n) },
|
|
117
117
|
})
|
|
118
|
-
const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, io })
|
|
118
|
+
const code = reloadOnOwnCode({ role: 'daemon', entry: fileURLToPath(import.meta.url), busy: () => hubs.busy(), before: () => { sweeps.stop(); home.stop() }, keep: true, io })
|
|
119
119
|
io.print(` ${house.repos.length} repos, state in ${dir}, reloads on ${code.files} lib files`)
|
|
120
120
|
for (const sig of ['SIGINT', 'SIGTERM'] as const)
|
|
121
121
|
process.on(sig, () => {
|
package/src/repo-config.ts
CHANGED
|
@@ -57,6 +57,9 @@ export type ServeConfig = {
|
|
|
57
57
|
* one failed, green when all passed. Default: the built-in md lint
|
|
58
58
|
* (orphans and dangling refs) over the repo root */
|
|
59
59
|
lint?: string[]
|
|
60
|
+
/** the folder its node ids are relative to, where the wikis page reads
|
|
61
|
+
* a page to search and preview (default: the repo root) */
|
|
62
|
+
dir?: string
|
|
60
63
|
}
|
|
61
64
|
/** agent/ graph entrypoint (default: index.md AND README.md) */
|
|
62
65
|
agentIndex?: string
|
package/src/review/format.ts
CHANGED
|
@@ -19,7 +19,13 @@ const atText = (t: number | undefined): string => (t === undefined ? '' : ` at $
|
|
|
19
19
|
|
|
20
20
|
/** each reaction to one view or tile: its place by label, its status, its comment */
|
|
21
21
|
const reactionTexts = (x: ReviewItem): string[] =>
|
|
22
|
-
(verdictOf(x)?.reactions ?? []).map((q) => `${placeText(q, x.item.views)}${q.status ? ` ${q.status}` : ''}${q.comment ? `: ${flat(q.comment)}` : ''}`)
|
|
22
|
+
(verdictOf(x)?.reactions ?? []).map((q) => `${placeText(q, x.item.views)}${q.status ? ` ${q.status}` : ''}${q.stars ? ` ★${q.stars}` : ''}${q.comment ? `: ${flat(q.comment)}` : ''}`)
|
|
23
|
+
|
|
24
|
+
/** the view he picked on a choice card, `picked [voice 1]`; null when he picked none */
|
|
25
|
+
const pickText = (x: ReviewItem): string | null => {
|
|
26
|
+
const p = verdictOf(x)?.pick
|
|
27
|
+
return p === undefined ? null : `picked ${placeText({ view: p }, x.item.views)}`
|
|
28
|
+
}
|
|
23
29
|
|
|
24
30
|
/** one line per feedback, for a monitor: the status, the overall comment, each view's reaction, then each rectangle's comment */
|
|
25
31
|
export function line(x: ReviewItem): string {
|
|
@@ -28,7 +34,8 @@ export function line(x: ReviewItem): string {
|
|
|
28
34
|
const views = x.item.views
|
|
29
35
|
const many = views.length > 1
|
|
30
36
|
const rects = f.rects.map((q, i) => `#${i + 1}${many ? ` ${placeText(q, views)}` : ''}${q.side ? ` ${q.side}` : ''}${atText(q.t)} ${flat(q.comment) || '(no comment)'}`)
|
|
31
|
-
const
|
|
37
|
+
const pick = pickText(x)
|
|
38
|
+
const parts = [...(pick ? [pick] : []), ...reactionTexts(x), ...rects]
|
|
32
39
|
return `${x.item.id} ${f.status} "${x.item.title}": ${flat(f.comment) || '(no overall comment)'}${parts.length ? ` | ${parts.join(' | ')}` : ''}`
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -131,7 +138,8 @@ export function detail(x: ReviewItem, dir: string, full = false, followed = fals
|
|
|
131
138
|
].join('\n')
|
|
132
139
|
}
|
|
133
140
|
if (!f) return [`${r.id} NO VERDICT YET`, head, ...sub, ...replies].join('\n')
|
|
134
|
-
const
|
|
141
|
+
const pick = pickText(x)
|
|
142
|
+
const out = [`${r.id} ${f.status.toUpperCase()} ${f.at}`, head, ...sub, ...(pick ? [` ${pick}`] : []), ` comment: ${f.comment || '(none)'}`]
|
|
135
143
|
for (const t of reactionTexts(x)) out.push(` view ${t}`)
|
|
136
144
|
const px = (v: number, of: number): number => Math.round(v * of)
|
|
137
145
|
const views = r.views
|
package/src/review/model.ts
CHANGED
|
@@ -124,6 +124,8 @@ export interface MediaView {
|
|
|
124
124
|
clip?: ReviewClip
|
|
125
125
|
audio?: ReviewAudio
|
|
126
126
|
marks?: ReviewMark[]
|
|
127
|
+
/** a line on this view alone (an option's description on a choice card) */
|
|
128
|
+
note?: string
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
/** one image of a gallery view, a file of the folder (`v<n>-g<k>.<ext>`) */
|
|
@@ -168,6 +170,39 @@ export interface Item {
|
|
|
168
170
|
light?: 'review'
|
|
169
171
|
/** a draft card of a repo that shoots live: no verdict until it is ready */
|
|
170
172
|
wip?: { live: string; frame: number }
|
|
173
|
+
/** a choice: every view is an option, he scores them and picks one */
|
|
174
|
+
choice?: true
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** a choice card: he picks one of its views */
|
|
178
|
+
export const isChoice = (i: Item): boolean => i.choice === true
|
|
179
|
+
|
|
180
|
+
/** a view that plays on its own: one clip or one sound, never a pair */
|
|
181
|
+
export const isPlayable = (v: ReviewView | undefined): v is MediaView => v !== undefined && !isGallery(v) && !isPair(v.files) && (v.clip !== undefined || v.audio !== undefined)
|
|
182
|
+
|
|
183
|
+
/** a card shown as options, one thin row a view: a choice, or two views or more that each play on their own */
|
|
184
|
+
export const asOptions = (i: Item): boolean => isChoice(i) || (i.views.length >= 2 && i.views.every(isPlayable))
|
|
185
|
+
|
|
186
|
+
/** the most stars a reaction gives */
|
|
187
|
+
export const STARS = 5
|
|
188
|
+
|
|
189
|
+
/** where a card is, for a fresh agent session in any repo: the repo as `~/dev/<repo>`, the card's url where it is open
|
|
190
|
+
* now, and its phone url when the repo has a public host */
|
|
191
|
+
export interface CardWhere {
|
|
192
|
+
root: string
|
|
193
|
+
open: string
|
|
194
|
+
phone?: string | null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** one paste that hands a card to any agent session: what it is, where, the command that shows it whole, its links */
|
|
198
|
+
export function pasteBlock(i: Item, state: string, w: CardWhere): string {
|
|
199
|
+
return [
|
|
200
|
+
`review card "${i.title}" (${state}), id ${i.id}`,
|
|
201
|
+
`repo: ${w.root}`,
|
|
202
|
+
`see it whole: cd ${w.root} && sk review show ${i.id}`,
|
|
203
|
+
`open: ${w.open}`,
|
|
204
|
+
...(w.phone ? [`phone: ${w.phone}`] : []),
|
|
205
|
+
].join('\n')
|
|
171
206
|
}
|
|
172
207
|
|
|
173
208
|
/** a view's name as the page and the CLI show it: its label, or « view n » */
|
|
@@ -219,6 +254,8 @@ export interface ReviewReaction {
|
|
|
219
254
|
tile?: number
|
|
220
255
|
status?: ReviewStatus
|
|
221
256
|
comment: string
|
|
257
|
+
/** his score of the view, 1 to STARS */
|
|
258
|
+
stars?: number
|
|
222
259
|
}
|
|
223
260
|
|
|
224
261
|
/** a state change, or a read mark (`seen`, with the ids it read in `of`) */
|
|
@@ -238,6 +275,8 @@ export interface Message {
|
|
|
238
275
|
rects?: Rect[]
|
|
239
276
|
reactions?: ReviewReaction[]
|
|
240
277
|
verdict?: ReviewStatus
|
|
278
|
+
/** the view he picked on a choice card, its index */
|
|
279
|
+
pick?: number
|
|
241
280
|
/** a repo's own mark on a subject (stars, a song's mark): kept as written */
|
|
242
281
|
rate?: { stars?: number; mark?: string; part?: string }
|
|
243
282
|
op?: Op
|
|
@@ -313,6 +352,8 @@ export interface Verdict {
|
|
|
313
352
|
comment: string
|
|
314
353
|
rects: Rect[]
|
|
315
354
|
reactions?: ReviewReaction[]
|
|
355
|
+
/** the view he picked, on a choice card */
|
|
356
|
+
pick?: number
|
|
316
357
|
at: string
|
|
317
358
|
seen: boolean
|
|
318
359
|
}
|
|
@@ -338,6 +379,7 @@ export function verdictOf(x: ReviewItem): Verdict | null {
|
|
|
338
379
|
comment: text ?? '',
|
|
339
380
|
rects: rects ?? [],
|
|
340
381
|
...(m.reactions && m.reactions.length > 0 ? { reactions: m.reactions } : {}),
|
|
382
|
+
...(m.pick !== undefined ? { pick: m.pick } : {}),
|
|
341
383
|
at: m.at,
|
|
342
384
|
seen: readBy(x, 'agent').has(m.id),
|
|
343
385
|
}
|
|
@@ -677,6 +719,7 @@ export function itemProblems(v: unknown): string[] {
|
|
|
677
719
|
for (const k of ['what', 'commit', 'supersedes', 'why', 'keeps']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
|
|
678
720
|
if (v.light !== undefined && v.light !== 'review') out.push('light: review, or absent')
|
|
679
721
|
if (v.wip !== undefined && !(obj(v.wip) && typeof v.wip.live === 'string' && v.wip.live !== '' && Number.isInteger(v.wip.frame))) out.push('wip: { live, frame }, or absent')
|
|
722
|
+
if (v.choice !== undefined && !(v.choice === true && Array.isArray(v.views) && v.views.length >= 2)) out.push('choice: true on a card of two views or more, or absent')
|
|
680
723
|
return out
|
|
681
724
|
}
|
|
682
725
|
|
|
@@ -690,6 +733,7 @@ export function messageProblems(v: unknown, name: string): string[] {
|
|
|
690
733
|
if (v.op !== undefined && !OPS.includes(v.op as Op)) out.push(`op: one of ${OPS.join(', ')}`)
|
|
691
734
|
if (v.verdict !== undefined && !STATUSES.includes(v.verdict as ReviewStatus)) out.push(`verdict: one of ${STATUSES.join(', ')}`)
|
|
692
735
|
for (const k of ['text', 'image', 'note']) if (v[k] !== undefined && typeof v[k] !== 'string') out.push(`${k}: a text`)
|
|
736
|
+
if (v.pick !== undefined && !(Number.isInteger(v.pick) && (v.pick as number) >= 0 && v.verdict !== undefined)) out.push('pick: a view index, on a verdict')
|
|
693
737
|
if (v.op === 'seen' && !(Array.isArray(v.of) && v.of.every((x) => typeof x === 'string'))) out.push('of: the ids a seen message read')
|
|
694
738
|
if (v.op === undefined && v.verdict === undefined && v.rate === undefined && typeof v.text !== 'string' && !Array.isArray(v.rects))
|
|
695
739
|
out.push('a message says something: text, rects, verdict, rate or op')
|
|
@@ -713,6 +757,7 @@ function galleryProblems(v: Record<string, unknown>, at: string): string[] {
|
|
|
713
757
|
function viewProblems(v: Record<string, unknown>, at: string): string[] {
|
|
714
758
|
const out: string[] = []
|
|
715
759
|
if (typeof v.label !== 'string') out.push(`${at}label: a text`)
|
|
760
|
+
if (v.note !== undefined && typeof v.note !== 'string') out.push(`${at}note: a text`)
|
|
716
761
|
const f = v.files
|
|
717
762
|
const okImage = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && !isClipFile(n) && !isAudioFile(n)
|
|
718
763
|
const okClip = (n: unknown): boolean => typeof n === 'string' && FILE_RE.test(n) && isClipFile(n)
|
|
@@ -757,7 +802,7 @@ function viewProblems(v: Record<string, unknown>, at: string): string[] {
|
|
|
757
802
|
}
|
|
758
803
|
|
|
759
804
|
/** the verdict the page sends for an item, checked: every rectangle inside its view, a side exactly on a pair */
|
|
760
|
-
export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions'> } | { ok: false; errors: string[] } {
|
|
805
|
+
export function verdictMessageOf(body: unknown, item: Item): { ok: true; message: Pick<Message, 'verdict' | 'text' | 'rects' | 'reactions' | 'pick'> } | { ok: false; errors: string[] } {
|
|
761
806
|
if (!obj(body)) return { ok: false, errors: ['not an object'] }
|
|
762
807
|
const errors: string[] = []
|
|
763
808
|
if (!STATUSES.includes(body.status as ReviewStatus)) errors.push(`status: one of ${STATUSES.join(', ')}`)
|
|
@@ -800,7 +845,13 @@ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message
|
|
|
800
845
|
if (clip && typeof t === 'number') rect.t = t
|
|
801
846
|
out.push(rect)
|
|
802
847
|
}
|
|
803
|
-
const reactions = reactionsOf(body.reactions, views, errors)
|
|
848
|
+
const reactions = reactionsOf(body.reactions, views, errors, isChoice(item))
|
|
849
|
+
const pick = body.pick
|
|
850
|
+
if (pick !== undefined) {
|
|
851
|
+
if (!isChoice(item)) errors.push('pick: only on a choice card')
|
|
852
|
+
else if (!(typeof pick === 'number' && Number.isInteger(pick) && pick >= 0 && pick < views.length)) errors.push(`pick: the index of one of the card's ${views.length} views`)
|
|
853
|
+
else if (!isApproved(body.status as ReviewStatus)) errors.push('pick: goes with approved or approved-with-fix; rejecting every option is a plain rejected')
|
|
854
|
+
}
|
|
804
855
|
if (errors.length > 0) return { ok: false, errors }
|
|
805
856
|
const comment = typeof body.comment === 'string' ? body.comment : ''
|
|
806
857
|
return {
|
|
@@ -810,13 +861,14 @@ export function verdictMessageOf(body: unknown, item: Item): { ok: true; message
|
|
|
810
861
|
...(comment ? { text: comment } : {}),
|
|
811
862
|
...(out.length > 0 ? { rects: out } : {}),
|
|
812
863
|
...(reactions.length > 0 ? { reactions } : {}),
|
|
864
|
+
...(typeof pick === 'number' ? { pick } : {}),
|
|
813
865
|
},
|
|
814
866
|
}
|
|
815
867
|
}
|
|
816
868
|
|
|
817
869
|
/** the reactions the page sends, checked against the card's views: each on a view, a tile only on a gallery, one per view
|
|
818
870
|
* or tile; an empty one is dropped */
|
|
819
|
-
function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[]): ReviewReaction[] {
|
|
871
|
+
function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[], choice: boolean): ReviewReaction[] {
|
|
820
872
|
if (v === undefined) return []
|
|
821
873
|
if (!Array.isArray(v)) {
|
|
822
874
|
errors.push('reactions: a list')
|
|
@@ -835,12 +887,21 @@ function reactionsOf(v: unknown, views: readonly ReviewView[], errors: string[])
|
|
|
835
887
|
if (tile !== undefined && !(typeof tile === 'number' && Number.isInteger(tile) && tile >= 0 && tile < tiles)) errors.push(`reactions[${i}].tile: the index of one tile of a gallery view`)
|
|
836
888
|
if (r.status !== undefined && !STATUSES.includes(r.status as ReviewStatus)) errors.push(`reactions[${i}].status: one of ${STATUSES.join(', ')}`)
|
|
837
889
|
if (r.comment !== undefined && typeof r.comment !== 'string') errors.push(`reactions[${i}].comment: a text`)
|
|
890
|
+
const stars = r.stars
|
|
891
|
+
if (stars !== undefined && !(choice && typeof stars === 'number' && Number.isInteger(stars) && stars >= 1 && stars <= STARS))
|
|
892
|
+
errors.push(choice ? `reactions[${i}].stars: 1 to ${STARS}` : `reactions[${i}].stars: only on a choice card`)
|
|
838
893
|
const key = `${String(r.view)}/${String(tile ?? '')}`
|
|
839
894
|
if (seen.has(key)) errors.push(`reactions[${i}]: a second reaction on the same view or tile`)
|
|
840
895
|
seen.add(key)
|
|
841
896
|
const comment = typeof r.comment === 'string' ? r.comment.trim() : ''
|
|
842
|
-
if (r.status === undefined && comment === '') continue
|
|
843
|
-
out.push({
|
|
897
|
+
if (r.status === undefined && comment === '' && stars === undefined) continue
|
|
898
|
+
out.push({
|
|
899
|
+
view: r.view as number,
|
|
900
|
+
...(typeof tile === 'number' ? { tile } : {}),
|
|
901
|
+
...(r.status !== undefined ? { status: r.status as ReviewStatus } : {}),
|
|
902
|
+
comment,
|
|
903
|
+
...(typeof stars === 'number' ? { stars } : {}),
|
|
904
|
+
})
|
|
844
905
|
}
|
|
845
906
|
return out
|
|
846
907
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// where a card lives, for the paste that hands it to another agent session (model.ts pasteBlock): the repo's path as
|
|
2
|
+
// `~/dev/<repo>`, the house page's url, the phone's url through the repo's public host. Node side: the page reads it
|
|
3
|
+
// from /api/review/spec
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { homePort } from '../home/home-paths.ts'
|
|
6
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
7
|
+
import { defaultHubPort } from '../serve/hub-port.ts'
|
|
8
|
+
|
|
9
|
+
export interface PasteHosts {
|
|
10
|
+
/** the repo's path, `~` for the home folder */
|
|
11
|
+
root: string
|
|
12
|
+
/** the house page of the repo, `#page:review:<id>` goes after it */
|
|
13
|
+
open: string
|
|
14
|
+
/** the repo's hub through its public host, null when it has none */
|
|
15
|
+
phone: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function pasteHosts(root: string): Promise<PasteHosts> {
|
|
19
|
+
const c = await loadRepoConfig(root)
|
|
20
|
+
const home = homedir()
|
|
21
|
+
const host = c.serve?.publicHost
|
|
22
|
+
return {
|
|
23
|
+
root: root.startsWith(`${home}/`) ? `~${root.slice(home.length)}` : root,
|
|
24
|
+
open: `http://localhost:${homePort()}/r/${encodeURIComponent(c.name)}/`,
|
|
25
|
+
phone: host ? `http://${host}:${c.serve?.port ?? defaultHubPort(c.name)}/` : null,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** a card's url under a hub base */
|
|
30
|
+
export const cardUrl = (base: string, id: string): string => `${base}#page:review:${id}`
|
package/src/review/project.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// walked dirs' times and its text, imported, its spec bound to the store; its sources pulled, its kinds' facts and data
|
|
3
3
|
// view computed on the server, so the hub never bundles project code
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
5
|
-
import { join } from 'node:path'
|
|
5
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
6
6
|
import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
|
|
7
7
|
import { LAYOUT } from '../core/layout.ts'
|
|
8
8
|
import type { ReviewItem } from './model.ts'
|
|
9
9
|
import { bindSpec, readAll, storeDir } from './store.ts'
|
|
10
|
-
import { specOfUnit, type Fact, type Link, type ReviewUnit, type ViewText } from './unit.ts'
|
|
10
|
+
import { specOfUnit, type Fact, type Link, type ReviewUnit, type ViewPart, type ViewText } from './unit.ts'
|
|
11
11
|
|
|
12
12
|
/** the walk's answer kept in the cache: the unit files and every dir it read, each with its time; a dir whose time moved
|
|
13
13
|
* means a file came or went, so the walk runs again */
|
|
@@ -90,7 +90,12 @@ export function factLines(unit: ReviewUnit | null, x: ReviewItem): string[] {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** a kind's data view of one item, what the hub draws: its facts, links, live url and text; a function that throws says so as a fact */
|
|
93
|
-
|
|
93
|
+
/** a part as the page reads it: its media a file of the item's `part-<n>` route, with its kind */
|
|
94
|
+
export type PartData = Omit<ViewPart, 'media'> & { media?: 'image' | 'video' }
|
|
95
|
+
|
|
96
|
+
const VIDEO = /\.(mp4|webm|mov|m4v)$/i
|
|
97
|
+
|
|
98
|
+
export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fact[]; links: Link[]; live: string | null; text: ViewText | null; parts: PartData[] } {
|
|
94
99
|
const k = unit?.kinds?.[x.item.subject.kind]
|
|
95
100
|
const tryOr = <T>(what: string, f: (() => T) | undefined, none: T, errors: Fact[]): T => {
|
|
96
101
|
try {
|
|
@@ -106,5 +111,16 @@ export function viewOfItem(unit: ReviewUnit | null, x: ReviewItem): { facts: Fac
|
|
|
106
111
|
const links = tryOr('links', k?.links && (() => k.links?.(x) ?? []), [] as Link[], errors)
|
|
107
112
|
const live = tryOr<string | null>('live', k?.live && (() => k.live?.(x) ?? null), null, errors)
|
|
108
113
|
const text = tryOr<ViewText | null>('text', k?.text && (() => k.text?.(x) ?? null), null, errors)
|
|
109
|
-
|
|
114
|
+
const parts = tryOr('parts', k?.parts && (() => k.parts?.(x) ?? []), [] as ViewPart[], errors).map(({ media, ...p }): PartData => (media ? { ...p, media: VIDEO.test(media) ? 'video' : 'image' } : p))
|
|
115
|
+
return { facts: [...facts, ...source, ...errors], links, live, text, parts }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** the repo file of part `n` of item `x`'s subject, inside the repo; null when the part names none or it is not there */
|
|
119
|
+
export function partFile(root: string, unit: ReviewUnit | null, x: ReviewItem, n: number): string | null {
|
|
120
|
+
const media = unit?.kinds?.[x.item.subject.kind]?.parts?.(x)[n]?.media
|
|
121
|
+
if (!media) return null
|
|
122
|
+
const abs = resolve(root, media)
|
|
123
|
+
const rel = relative(root, abs)
|
|
124
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return null
|
|
125
|
+
return existsSync(abs) ? abs : null
|
|
110
126
|
}
|
package/src/review/routes.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// relative to the repo hub's base. null: not a path of these
|
|
4
4
|
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
|
-
import { extname, join } from 'node:path'
|
|
6
|
+
import { dirname, extname, join } from 'node:path'
|
|
7
7
|
import { isAnimFormat, isAnimLabels, isAnimMode, pairAnimation } from './anim.ts'
|
|
8
8
|
import { startAuto } from './auto.ts'
|
|
9
9
|
import { bad, KEEP, NO_STORE, ranged, TYPES } from './http.ts'
|
|
10
10
|
import type { Rect, Subject } from './model.ts'
|
|
11
|
-
import {
|
|
11
|
+
import { pasteHosts } from './paste.ts'
|
|
12
|
+
import { openReview, partFile, viewOfItem } from './project.ts'
|
|
12
13
|
import { roleOf } from './role.ts'
|
|
13
14
|
import { addMessage, imageFile, markRead, raiseItem, rateSubject, readAllCached, readItem, specOf, storeDir, storeStamp, threadImage, writeVerdict } from './store.ts'
|
|
14
15
|
import { trailerRoutes } from './trailer-routes.ts'
|
|
@@ -110,8 +111,14 @@ async function reviewRoute(req: Request, path: string, dir: string, unit: Review
|
|
|
110
111
|
}
|
|
111
112
|
if (path === '/api/review/watchers' && req.method === 'GET') return Response.json(liveWatchers(dir), { headers: NO_STORE })
|
|
112
113
|
// the unit's words, the dispatcher's holder: what the page draws the list's filters, labels and board with
|
|
113
|
-
if (path === '/api/review/spec' && req.method === 'GET')
|
|
114
|
-
|
|
114
|
+
if (path === '/api/review/spec' && req.method === 'GET') {
|
|
115
|
+
// where a card lives, for the paste that hands it to another agent session; a config that does not load leaves it out
|
|
116
|
+
const where = await pasteHosts(dirname(dirname(dir))).catch(() => null)
|
|
117
|
+
return Response.json(
|
|
118
|
+
{ ...specData(specOf(dir)), role: roleOf(dir), sources: (unit?.sources ?? []).map((s) => s.id), auto: (unit?.auto ?? []).map((r) => ({ id: r.id, every: r.every })), where },
|
|
119
|
+
{ headers: NO_STORE },
|
|
120
|
+
)
|
|
121
|
+
}
|
|
115
122
|
if ((path === '/api/review/raise' || path === '/api/review/rate') && req.method === 'POST') {
|
|
116
123
|
let img: ReturnType<typeof dataUrlFile> = null
|
|
117
124
|
try {
|
|
@@ -196,6 +203,17 @@ async function reviewRoute(req: Request, path: string, dir: string, unit: Review
|
|
|
196
203
|
return bad(e)
|
|
197
204
|
}
|
|
198
205
|
}
|
|
206
|
+
// a part's media (a draft's clip beside its text): the file the unit names for that part, never a path the page sends
|
|
207
|
+
const part = /^part-(\d+)$/.exec(name)
|
|
208
|
+
if (part) {
|
|
209
|
+
try {
|
|
210
|
+
const x = readItem(dir, id)
|
|
211
|
+
const f = x ? partFile(join(dir, '..', '..'), unit, x, Number(part[1])) : null
|
|
212
|
+
return f ? ranged(req, f) : bad(`no media for part ${part[1]} of ${id}`, 404)
|
|
213
|
+
} catch (e) {
|
|
214
|
+
return bad(e)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
199
217
|
if (name === 'anim') {
|
|
200
218
|
const q = new URL(req.url).searchParams
|
|
201
219
|
const mode = q.get('mode') ?? 'fade'
|