rvlib-shipkit 2.2.0 → 2.3.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 +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
// the one feedback store on disk (wiki build/shipkit/review.md): `.shipkit/review/<id>/`, gitignored: `item.json`, the
|
|
2
|
+
// media, and `thread/`, one file per message, never rewritten. An item is built in a dot folder and renamed, every file
|
|
3
|
+
// written beside its place and renamed, so no reader sees half of one. The format is pkm-multi's verbatim. A folder of
|
|
4
|
+
// the old shape (request.json, feedback.json, a message with `seen`) fails loud
|
|
5
|
+
import * as fs from 'node:fs'
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync, watch } from 'node:fs'
|
|
7
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
8
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
9
|
+
import { ffmpeg } from '../trailer/ffmpeg.ts'
|
|
10
|
+
import { probe, sizeOf, stillAt } from './media.ts'
|
|
11
|
+
import {
|
|
12
|
+
AGENT_RE,
|
|
13
|
+
allowed,
|
|
14
|
+
FILE_RE,
|
|
15
|
+
ID_RE,
|
|
16
|
+
idOf,
|
|
17
|
+
isApproved,
|
|
18
|
+
isAudioFile,
|
|
19
|
+
isClipFile,
|
|
20
|
+
isMarkItem,
|
|
21
|
+
itemProblems,
|
|
22
|
+
MESSAGE_FILE_RE,
|
|
23
|
+
MESSAGE_RE,
|
|
24
|
+
messageIdOf,
|
|
25
|
+
messageProblems,
|
|
26
|
+
NO_SPEC,
|
|
27
|
+
specProblems,
|
|
28
|
+
stateOf,
|
|
29
|
+
subjectKey,
|
|
30
|
+
laneOf,
|
|
31
|
+
textOf,
|
|
32
|
+
toAcknowledge,
|
|
33
|
+
verdictMessageOf,
|
|
34
|
+
verdictOf,
|
|
35
|
+
withdrawnOf,
|
|
36
|
+
type GalleryTile,
|
|
37
|
+
type Item,
|
|
38
|
+
type Message,
|
|
39
|
+
type Op,
|
|
40
|
+
type Origin,
|
|
41
|
+
type Party,
|
|
42
|
+
type Rect,
|
|
43
|
+
type ReviewClip,
|
|
44
|
+
type ReviewFiles,
|
|
45
|
+
type ReviewItem,
|
|
46
|
+
type ReviewMark,
|
|
47
|
+
type ReviewView,
|
|
48
|
+
type Spec,
|
|
49
|
+
type Subject,
|
|
50
|
+
} from './model.ts'
|
|
51
|
+
import { beatRole, roleOf } from './role.ts'
|
|
52
|
+
import { chainOf, coveringHead, followedIds, isFollowed } from './threads.ts'
|
|
53
|
+
import { liveWatchers } from './watchers.ts'
|
|
54
|
+
|
|
55
|
+
/** the store of a repo */
|
|
56
|
+
export const storeDir = (root: string): string => join(root, LAYOUT.review)
|
|
57
|
+
|
|
58
|
+
/** each store's spec, the model's part of its repo's review unit (unit.ts), bound by whoever loaded the unit: the cli,
|
|
59
|
+
* the hub. A store no one bound reads with NO_SPEC */
|
|
60
|
+
const specs = new Map<string, Spec>()
|
|
61
|
+
|
|
62
|
+
export const bindSpec = (dir: string, spec: Spec): void => void specs.set(resolve(dir), spec)
|
|
63
|
+
export const specOf = (dir: string): Spec => specs.get(resolve(dir)) ?? NO_SPEC
|
|
64
|
+
|
|
65
|
+
const IMAGE_EXT = /^\.(png|jpe?g|webp)$/
|
|
66
|
+
const CLIP_EXT = /^\.(webm|mp4)$/
|
|
67
|
+
|
|
68
|
+
/** this process's writes to a store, counted at once: the listing tag moves before the watcher reports them */
|
|
69
|
+
let wrote = 0
|
|
70
|
+
const renameSync = (a: string, b: string): void => (fs.renameSync(a, b), touched(a), touched(b))
|
|
71
|
+
const rmSync = (p: string, o?: fs.RmOptions): void => (fs.rmSync(p, o), touched(p))
|
|
72
|
+
const copyFileSync = (a: string, b: string): void => (fs.copyFileSync(a, b), touched(b))
|
|
73
|
+
const writeFileSync = (p: string, d: string): void => (fs.writeFileSync(p, d), touched(p))
|
|
74
|
+
const mkdirSync = (p: string, o?: fs.MakeDirectoryOptions): void => (fs.mkdirSync(p, o), touched(p))
|
|
75
|
+
|
|
76
|
+
function writeJson(file: string, v: unknown): void {
|
|
77
|
+
const tmp = `${file}.tmp`
|
|
78
|
+
writeFileSync(tmp, `${JSON.stringify(v, null, 4)}\n`)
|
|
79
|
+
renameSync(tmp, file)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** one view as given: a label, and an image, a clip, a sound or a pair (a clip or a sound goes anywhere an image goes,
|
|
83
|
+
* by its extension), or a gallery of images */
|
|
84
|
+
export interface ViewInput {
|
|
85
|
+
label: string
|
|
86
|
+
image?: string
|
|
87
|
+
video?: string
|
|
88
|
+
audio?: string
|
|
89
|
+
before?: string
|
|
90
|
+
after?: string
|
|
91
|
+
/** a list of small images shown as a wrapping grid, each with its label; never the first view */
|
|
92
|
+
gallery?: { file: string; label: string }[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AddInput {
|
|
96
|
+
title: string
|
|
97
|
+
what: string
|
|
98
|
+
/** what is judged; a plain shot by default */
|
|
99
|
+
subject?: Subject
|
|
100
|
+
topic?: string
|
|
101
|
+
image?: string
|
|
102
|
+
video?: string
|
|
103
|
+
audio?: string
|
|
104
|
+
before?: string
|
|
105
|
+
after?: string
|
|
106
|
+
commit?: string
|
|
107
|
+
/** the lane that made it; else SK_AGENT */
|
|
108
|
+
lane?: string
|
|
109
|
+
supersedes?: string
|
|
110
|
+
/** the raised items (a report, his note) this card answers, by id */
|
|
111
|
+
answers?: string[]
|
|
112
|
+
/** what a new version of an approved look keeps of it: required then */
|
|
113
|
+
keeps?: string
|
|
114
|
+
/** another subject on purpose, next to an active card: needs `why` */
|
|
115
|
+
fresh?: boolean
|
|
116
|
+
why?: string
|
|
117
|
+
/** the first view's label, and the other views of the same subject */
|
|
118
|
+
label?: string
|
|
119
|
+
views?: ViewInput[]
|
|
120
|
+
/** the rings over the subject, per view, as the repo read them from its own shots */
|
|
121
|
+
marks?: ReviewMark[][]
|
|
122
|
+
/** the shots used the review light over a dark place */
|
|
123
|
+
light?: 'review'
|
|
124
|
+
/** a draft card on a live shot: its image a live frame, no verdict until it is ready */
|
|
125
|
+
wip?: { live: string }
|
|
126
|
+
now?: Date
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** a view whose files were checked: its sources by name, its one size, and on a clip its length, its rate and the file
|
|
130
|
+
* its poster comes from */
|
|
131
|
+
interface Loaded {
|
|
132
|
+
label: string
|
|
133
|
+
sources: [name: 'image' | 'video' | 'audio' | 'before' | 'after', path: string][]
|
|
134
|
+
size: { w: number; h: number }
|
|
135
|
+
clip?: { duration: number; fps: number; from: string }
|
|
136
|
+
/** a sound: its length, and the files its waveforms are drawn from (the after's or the one's, then the before's) */
|
|
137
|
+
audio?: { duration: number; from: string; before?: string }
|
|
138
|
+
tiles?: { path: string; label: string; size: { w: number; h: number } }[]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** a view's files checked and sized, a pair of one size (and one length, two clips); throws with the reason, naming the view */
|
|
142
|
+
async function loadView(v: ViewInput, which: string): Promise<Loaded> {
|
|
143
|
+
if (v.gallery !== undefined) {
|
|
144
|
+
if (v.image !== undefined || v.video !== undefined || v.audio !== undefined || v.before !== undefined || v.after !== undefined)
|
|
145
|
+
throw new Error(`${which}: a gallery is a list of images, no --image, --video or pair beside it`)
|
|
146
|
+
if (v.gallery.length === 0) throw new Error(`${which}: a gallery needs at least one image`)
|
|
147
|
+
const tiles = await Promise.all(
|
|
148
|
+
v.gallery.map(async (t) => {
|
|
149
|
+
const path = resolve(t.file)
|
|
150
|
+
if (!existsSync(path)) throw new Error(`${path}: no such file`)
|
|
151
|
+
if (!IMAGE_EXT.test(extname(path).toLowerCase())) throw new Error(`${which}: ${path}: a gallery holds png, jpg or webp images, never a clip`)
|
|
152
|
+
return { path, label: t.label.trim(), size: await sizeOf(path) }
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
return { label: v.label.trim(), sources: [], size: tiles[0]?.size ?? { w: 0, h: 0 }, tiles }
|
|
156
|
+
}
|
|
157
|
+
if ([v.image, v.video, v.audio].filter((x) => x !== undefined).length > 1) throw new Error(`${which}: give one of an image, a clip or a sound`)
|
|
158
|
+
const one = v.image ?? v.video ?? v.audio
|
|
159
|
+
const pair = v.before !== undefined || v.after !== undefined
|
|
160
|
+
if ((one !== undefined) === pair || (pair && (v.before === undefined || v.after === undefined))) throw new Error(`${which}: give one image, clip or sound, or a before and an after`)
|
|
161
|
+
const sources: Loaded['sources'] =
|
|
162
|
+
one !== undefined
|
|
163
|
+
? [[isClipFile(one) ? 'video' : isAudioFile(one) ? 'audio' : 'image', resolve(one)]]
|
|
164
|
+
: [
|
|
165
|
+
['before', resolve(v.before ?? '')],
|
|
166
|
+
['after', resolve(v.after ?? '')],
|
|
167
|
+
]
|
|
168
|
+
for (const [, p] of sources) {
|
|
169
|
+
if (!existsSync(p)) throw new Error(`${p}: no such file`)
|
|
170
|
+
const ext = extname(p).toLowerCase()
|
|
171
|
+
if (v.video !== undefined && !CLIP_EXT.test(ext)) throw new Error(`--video ${p}: a webm or mp4`)
|
|
172
|
+
if (v.audio !== undefined && !isAudioFile(p)) throw new Error(`--audio ${p}: a wav, mp3, flac, ogg, opus or m4a`)
|
|
173
|
+
if (!IMAGE_EXT.test(ext) && !CLIP_EXT.test(ext) && !isAudioFile(p)) throw new Error(`${p}: a png, jpg or webp, a webm or mp4 clip, or a wav, mp3, flac, ogg, opus or m4a sound`)
|
|
174
|
+
}
|
|
175
|
+
const clips = sources.filter(([, p]) => isClipFile(p)).length
|
|
176
|
+
const sounds = sources.filter(([, p]) => isAudioFile(p)).length
|
|
177
|
+
if ((clips > 0 && clips < sources.length) || (sounds > 0 && sounds < sources.length)) throw new Error(`${which}: a pair is two images, two clips or two sounds, never one of each`)
|
|
178
|
+
if (sounds > 0) {
|
|
179
|
+
const lengths = sources.map(([, p]) => soundLength(p))
|
|
180
|
+
const main = sources[sources.length - 1] as Loaded['sources'][number]
|
|
181
|
+
return {
|
|
182
|
+
label: v.label.trim(),
|
|
183
|
+
sources,
|
|
184
|
+
size: WAVE_SIZE,
|
|
185
|
+
audio: { duration: Math.max(...lengths), from: main[1], ...(sources.length > 1 ? { before: sources[0]?.[1] ?? '' } : {}) },
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (clips === 0) {
|
|
189
|
+
const sizes = await Promise.all(sources.map(([, p]) => sizeOf(p)))
|
|
190
|
+
const size = sizes[0] ?? { w: 0, h: 0 }
|
|
191
|
+
const odd = sizes.find((s) => s.w !== size.w || s.h !== size.h)
|
|
192
|
+
if (odd) throw new Error(`${which}: before is ${size.w}x${size.h}, after is ${odd.w}x${odd.h}: a pair shares one size (shoot both at the same size)`)
|
|
193
|
+
return { label: v.label.trim(), sources, size }
|
|
194
|
+
}
|
|
195
|
+
const infos = sources.map(([, p]) => clipInfo(p))
|
|
196
|
+
const a = infos[0] as (typeof infos)[number]
|
|
197
|
+
const b = infos[infos.length - 1] as (typeof infos)[number]
|
|
198
|
+
if (a.w !== b.w || a.h !== b.h || a.fps !== b.fps || Math.abs(a.duration - b.duration) > 1 / a.fps + 1e-3)
|
|
199
|
+
throw new Error(
|
|
200
|
+
`${which}: before is ${a.w}x${a.h}, ${a.duration} s at ${a.fps} fps, after is ${b.w}x${b.h}, ${b.duration} s at ${b.fps} fps: a pair of clips shares one size and one length`,
|
|
201
|
+
)
|
|
202
|
+
const main = sources[sources.length - 1] as Loaded['sources'][number]
|
|
203
|
+
return { label: v.label.trim(), sources, size: { w: b.w, h: b.h }, clip: { duration: b.duration, fps: b.fps, from: main[1] } }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** what a card records of a clip: its size, its length and its rate, to the millisecond */
|
|
207
|
+
function clipInfo(file: string): { w: number; h: number; duration: number; fps: number } {
|
|
208
|
+
let p: ReturnType<typeof probe>
|
|
209
|
+
try {
|
|
210
|
+
p = probe(file)
|
|
211
|
+
} catch (e) {
|
|
212
|
+
throw new Error(`${file}: not a clip ffprobe reads (${(e as Error).message})`)
|
|
213
|
+
}
|
|
214
|
+
if (!p.video || !(p.video.fps > 0)) throw new Error(`${file}: no video stream with a size, a length and a frame rate`)
|
|
215
|
+
const ms = (n: number): number => Math.round(n * 1000) / 1000
|
|
216
|
+
return { w: p.video.width, h: p.video.height, duration: ms(p.duration), fps: ms(p.video.fps) }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** a sound's length in seconds, to the millisecond */
|
|
220
|
+
function soundLength(file: string): number {
|
|
221
|
+
try {
|
|
222
|
+
return Math.round(probe(file).duration * 1000) / 1000
|
|
223
|
+
} catch (e) {
|
|
224
|
+
throw new Error(`${file}: not a sound ffprobe reads (${(e as Error).message})`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** the waveform a sound view shows, one size for every sound so a pair reads side by side */
|
|
229
|
+
const WAVE_SIZE = { w: 1600, h: 240 }
|
|
230
|
+
|
|
231
|
+
/** view `n`'s waveforms, drawn into `folder` beside their place and renamed */
|
|
232
|
+
async function writeWaves(v: Loaded, n: number, folder: string): Promise<void> {
|
|
233
|
+
if (!v.audio) return
|
|
234
|
+
const draw = async (from: string, name: string): Promise<void> => {
|
|
235
|
+
const to = join(folder, `${prefix(n)}${name}.png`)
|
|
236
|
+
await ffmpeg(['-i', from, '-filter_complex', `showwavespic=s=${WAVE_SIZE.w}x${WAVE_SIZE.h}:split_channels=0:colors=0x8fb3ff`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', `${to}.tmp`])
|
|
237
|
+
renameSync(`${to}.tmp`, to)
|
|
238
|
+
}
|
|
239
|
+
await draw(v.audio.from, 'wave')
|
|
240
|
+
if (v.audio.before) await draw(v.audio.before, 'before-wave')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const prefix = (n: number): string => (n > 0 ? `v${n}-` : '')
|
|
244
|
+
|
|
245
|
+
/** the file name of tile `k` of gallery view `n` */
|
|
246
|
+
const tileName = (n: number, k: number, path: string): string => `v${n}-g${k}${extname(path).toLowerCase()}`
|
|
247
|
+
|
|
248
|
+
function viewOf(v: Loaded, n: number, marks: readonly ReviewMark[] = []): ReviewView {
|
|
249
|
+
if (v.tiles) return { label: v.label, gallery: v.tiles.map((t, k): GalleryTile => ({ file: tileName(n, k, t.path), label: t.label, size: t.size })) }
|
|
250
|
+
const audio = v.audio ? { audio: { duration: v.audio.duration, wave: `${prefix(n)}wave.png`, ...(v.audio.before ? { waveBefore: `${prefix(n)}before-wave.png` } : {}) } } : {}
|
|
251
|
+
return { label: v.label, files: filesOf(v, n), size: v.size, ...clipOf(v, n), ...audio, ...(marks.length > 0 ? { marks: [...marks] } : {}) }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** the file names of view `n` in the folder: `image.png` on the first, `v2-before.png` on a later one */
|
|
255
|
+
const filesOf = (v: Loaded, n: number): ReviewFiles => Object.fromEntries(v.sources.map(([name, p]) => [name, `${prefix(n)}${name}${extname(p).toLowerCase()}`])) as ReviewFiles
|
|
256
|
+
|
|
257
|
+
const clipOf = (v: Loaded, n: number): { clip: ReviewClip } | Record<string, never> =>
|
|
258
|
+
v.clip ? { clip: { poster: `${prefix(n)}poster.png`, duration: v.clip.duration, fps: v.clip.fps } } : {}
|
|
259
|
+
|
|
260
|
+
/** the first frame of view `n`'s clip, written into `folder` beside its place and renamed */
|
|
261
|
+
async function writePoster(v: Loaded, n: number, folder: string): Promise<void> {
|
|
262
|
+
if (!v.clip) return
|
|
263
|
+
const to = join(folder, `${prefix(n)}poster.png`)
|
|
264
|
+
await stillAt(v.clip.from, 0, `${to}.tmp`)
|
|
265
|
+
renameSync(`${to}.tmp`, to)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** why an active card refuses a new one, and every way out */
|
|
269
|
+
function coveredText(c: { head: ReviewItem; by: 'subject' | 'near' | 'title' }): string {
|
|
270
|
+
const r = c.head.item
|
|
271
|
+
const by = c.by === 'subject' ? 'the same subject' : c.by === 'near' ? 'the same topic and a subject near it' : 'a title this close'
|
|
272
|
+
return [
|
|
273
|
+
`one subject is one card: the active card ${r.id} "${r.title}" (${r.topic ?? 'no topic'}) already covers it, ${by}.`,
|
|
274
|
+
` a new version of it: --supersedes ${r.id}`,
|
|
275
|
+
` another angle of it: sk review add-view ${r.id} "<label>" <image> [<after>]`,
|
|
276
|
+
` truly another subject: --new --why "<reason>"`,
|
|
277
|
+
].join('\n')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** the Claude Code session that runs this process, null outside one */
|
|
281
|
+
export const sessionId = (): string | null => process.env.CLAUDE_CODE_SESSION_ID?.trim() || null
|
|
282
|
+
|
|
283
|
+
/** the agent that acts: its lane name (SK_AGENT), else `agent` */
|
|
284
|
+
export const agentName = (): string => process.env.SK_AGENT?.trim() || 'agent'
|
|
285
|
+
|
|
286
|
+
/** copies the media in and writes item.json; throws with the reason on a bad input */
|
|
287
|
+
export async function addItem(dir: string, input: AddInput): Promise<Item> {
|
|
288
|
+
const spec = specOf(dir)
|
|
289
|
+
if (!input.title.trim()) throw new Error('--title: required')
|
|
290
|
+
if (!input.what.trim()) throw new Error('--what: required, what changed and why')
|
|
291
|
+
const first = input.image !== undefined || input.video !== undefined || input.audio !== undefined || input.before !== undefined || input.after !== undefined
|
|
292
|
+
const given: ViewInput[] = [...(first ? [{ label: input.label ?? '', image: input.image, video: input.video, audio: input.audio, before: input.before, after: input.after }] : []), ...(input.views ?? [])].map(
|
|
293
|
+
(v) => Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined)) as unknown as ViewInput,
|
|
294
|
+
)
|
|
295
|
+
if (given.length === 0) throw new Error('give --image, --video or --audio, or --before and --after, or --view "<label>" <image>')
|
|
296
|
+
for (const [i, v] of given.entries()) if (i > 0 && !v.label.trim()) throw new Error('--view: every view after the first needs its label')
|
|
297
|
+
if (given[0]?.gallery) throw new Error('a gallery is never the first view: give the card an --image (or a pair, or a clip) first')
|
|
298
|
+
const loaded = await Promise.all(given.map((v, i) => loadView(v, i === 0 ? 'the first view' : `--view "${v.label}"`)))
|
|
299
|
+
if (input.supersedes !== undefined && !existsSync(join(dir, input.supersedes, 'item.json'))) throw new Error(`--supersedes ${input.supersedes}: no such card in ${dir}`)
|
|
300
|
+
if (input.supersedes !== undefined && readItem(dir, input.supersedes)?.item.origin.by !== 'agent')
|
|
301
|
+
throw new Error(`--supersedes ${input.supersedes}: a report or a note is answered, never superseded: --answers ${input.supersedes}`)
|
|
302
|
+
const answers = [...new Set(input.answers ?? [])]
|
|
303
|
+
for (const a of answers) {
|
|
304
|
+
const y = ID_RE.test(a) ? readItem(dir, a) : null
|
|
305
|
+
if (!y) throw new Error(`--answers ${a}: no such item in ${dir}`)
|
|
306
|
+
if (y.item.origin.by === 'agent') throw new Error(`--answers ${a}: an agent's card; a new version of it passes --supersedes ${a}`)
|
|
307
|
+
}
|
|
308
|
+
const subject: Subject = input.subject ?? { kind: 'shot' }
|
|
309
|
+
const topic = input.topic?.trim() || undefined
|
|
310
|
+
const wrong = specProblems({ subject, ...(topic ? { topic } : {}) } as Item, spec)
|
|
311
|
+
if (wrong.length > 0) throw new Error(wrong.join('; '))
|
|
312
|
+
if (input.wip && (given.length > 2 || given.slice(1).some((v) => !v.gallery))) throw new Error('--wip: the card shows the live frame, and its history as a gallery, nothing else')
|
|
313
|
+
if (input.marks && input.marks.length > given.length) throw new Error(`marks: ${input.marks.length} lists for ${given.length} views`)
|
|
314
|
+
if (input.supersedes !== undefined && !input.keeps?.trim()) {
|
|
315
|
+
const all = readAll(dir).items
|
|
316
|
+
const old = all.find((x) => x.item.id === input.supersedes)
|
|
317
|
+
const answered = old ? chainOf(old, all).find((x) => verdictOf(x)) : undefined
|
|
318
|
+
const v = answered ? verdictOf(answered) : null
|
|
319
|
+
if (answered && v && isApproved(v.status))
|
|
320
|
+
throw new Error(
|
|
321
|
+
`${answered.item.id} "${answered.item.title}" was approved: an approval locks the look. Keep what he approved, say what stays with --keeps "<what of the approved version stays>", and say the change in --what`,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
if (input.supersedes === undefined) {
|
|
325
|
+
if (input.fresh === true) {
|
|
326
|
+
if (!input.why?.trim()) throw new Error('--new needs --why "<reason>": why this is another subject than the active card next to it')
|
|
327
|
+
} else {
|
|
328
|
+
const others = readAll(dir).items.filter((x) => !answers.includes(x.item.id))
|
|
329
|
+
const c = coveringHead({ title: input.title, ...(topic ? { topic } : {}), subject }, others, spec)
|
|
330
|
+
if (c) throw new Error(coveredText(c))
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const lane = input.lane?.trim() || process.env.SK_AGENT?.trim() || undefined
|
|
334
|
+
if (lane !== undefined && !AGENT_RE.test(lane)) throw new Error(`--lane: « ${lane} » is no lane id (${AGENT_RE.source})`)
|
|
335
|
+
mkdirSync(dir, { recursive: true })
|
|
336
|
+
const now = input.now ?? new Date()
|
|
337
|
+
const base = idOf(now, input.title)
|
|
338
|
+
let id = base
|
|
339
|
+
for (let n = 2; existsSync(join(dir, id)); n++) id = `${base}-${n}`
|
|
340
|
+
const session = sessionId()
|
|
341
|
+
const item: Item = {
|
|
342
|
+
id,
|
|
343
|
+
created: now.toISOString(),
|
|
344
|
+
subject,
|
|
345
|
+
origin: { by: 'agent', ...(lane ? { lane } : {}), ...(session ? { session } : {}) },
|
|
346
|
+
title: input.title.trim(),
|
|
347
|
+
what: input.what.trim(),
|
|
348
|
+
...(topic ? { topic } : {}),
|
|
349
|
+
...(input.commit ? { commit: input.commit } : {}),
|
|
350
|
+
views: loaded.map((v, n) => viewOf(v, n, input.marks?.[n])),
|
|
351
|
+
...(input.supersedes ? { supersedes: input.supersedes } : {}),
|
|
352
|
+
...(answers.length > 0 ? { answers } : {}),
|
|
353
|
+
...(input.fresh && input.why ? { why: input.why.trim() } : {}),
|
|
354
|
+
...(input.supersedes && input.keeps?.trim() ? { keeps: input.keeps.trim() } : {}),
|
|
355
|
+
...(input.light ? { light: input.light } : {}),
|
|
356
|
+
...(input.wip ? { wip: { live: input.wip.live, frame: 1 } } : {}),
|
|
357
|
+
}
|
|
358
|
+
const problems = itemProblems(item)
|
|
359
|
+
if (problems.length > 0) throw new Error(problems.join('; '))
|
|
360
|
+
const tmp = join(dir, `.${id}.tmp`)
|
|
361
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
362
|
+
mkdirSync(tmp)
|
|
363
|
+
loaded.forEach((v, n) => copyFiles(v, n, tmp))
|
|
364
|
+
await Promise.all(loaded.map((v, n) => Promise.all([writePoster(v, n, tmp), writeWaves(v, n, tmp)])))
|
|
365
|
+
writeJson(join(tmp, 'item.json'), item)
|
|
366
|
+
renameSync(tmp, join(dir, id))
|
|
367
|
+
return item
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** every file of view `n` into the folder, each as `<name>.tmp` renamed, so no reader sees half of one */
|
|
371
|
+
function copyFiles(v: Loaded, n: number, into: string): void {
|
|
372
|
+
const files = filesOf(v, n) as Record<string, string>
|
|
373
|
+
const copies: [string, string][] = [...v.sources.map(([name, p]): [string, string] => [p, files[name] ?? name]), ...(v.tiles ?? []).map((t, k): [string, string] => [t.path, tileName(n, k, t.path)])]
|
|
374
|
+
for (const [from, name] of copies) {
|
|
375
|
+
const to = join(into, name)
|
|
376
|
+
copyFileSync(from, `${to}.tmp`)
|
|
377
|
+
renameSync(`${to}.tmp`, to)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** another angle of an open card's subject, appended as its next view; refused once he answered */
|
|
382
|
+
export async function addView(dir: string, id: string, v: ViewInput, o: { marks?: ReviewMark[]; light?: 'review' } = {}): Promise<Item> {
|
|
383
|
+
const x = readItem(dir, id)
|
|
384
|
+
if (!x) throw new Error(`no card ${id}`)
|
|
385
|
+
if (verdictOf(x)) throw new Error(`${id} has his answer already: a new version passes --supersedes ${id}`)
|
|
386
|
+
if (!v.label.trim()) throw new Error('add-view: the view needs its label')
|
|
387
|
+
const loaded = await loadView(v, `the view "${v.label}"`)
|
|
388
|
+
const n = x.item.views.length
|
|
389
|
+
const folder = join(dir, id)
|
|
390
|
+
// the files land before the head names them, so no reader sees a view without its images
|
|
391
|
+
copyFiles(loaded, n, folder)
|
|
392
|
+
await writePoster(loaded, n, folder)
|
|
393
|
+
await writeWaves(loaded, n, folder)
|
|
394
|
+
const item: Item = { ...x.item, views: [...x.item.views, viewOf(loaded, n, o.marks)], ...(x.item.light || o.light ? { light: 'review' as const } : {}) }
|
|
395
|
+
writeJson(join(folder, 'item.json'), item)
|
|
396
|
+
return item
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface ReadAll {
|
|
400
|
+
items: ReviewItem[]
|
|
401
|
+
/** a folder whose item.json or a message does not read, or still of the old shape: loud, never skipped in silence */
|
|
402
|
+
broken: { id: string; error: string }[]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const OLD_FILE = /^(request|feedback(\..+)?)\.json$/
|
|
406
|
+
|
|
407
|
+
/** one item as its repo's unit reads it: a subject or a topic the unit refuses throws, like a broken file */
|
|
408
|
+
export function readItem(dir: string, id: string): ReviewItem | null {
|
|
409
|
+
const x = readRaw(dir, id)
|
|
410
|
+
const wrong = x ? specProblems(x.item, specOf(dir)) : []
|
|
411
|
+
if (wrong.length > 0) throw new Error(`item.json: ${wrong.join('; ')}`)
|
|
412
|
+
return x
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** one item as the file says, whatever the unit: what the index keeps */
|
|
416
|
+
function readRaw(dir: string, id: string): ReviewItem | null {
|
|
417
|
+
if (!ID_RE.test(id)) return null
|
|
418
|
+
const folder = join(dir, id)
|
|
419
|
+
if (!existsSync(folder)) return null
|
|
420
|
+
const old = readdirSync(folder).filter((n) => OLD_FILE.test(n))
|
|
421
|
+
if (old.length > 0) throw new Error(`the old shape (${old.join(', ')}): written by a process older than the one store`)
|
|
422
|
+
const f = join(folder, 'item.json')
|
|
423
|
+
if (!existsSync(f)) return null
|
|
424
|
+
const item = JSON.parse(readFileSync(f, 'utf8')) as unknown
|
|
425
|
+
const problems = itemProblems(item)
|
|
426
|
+
if (problems.length > 0) throw new Error(`item.json: ${problems.join('; ')}`)
|
|
427
|
+
return { item: item as Item, thread: readThread(dir, id) }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** the messages of an item, oldest first; a message file that does not read throws, never skipped in silence */
|
|
431
|
+
function readThread(dir: string, id: string): Message[] {
|
|
432
|
+
const t = join(dir, id, 'thread')
|
|
433
|
+
if (!existsSync(t)) return []
|
|
434
|
+
const names = readdirSync(t).filter((n) => n.endsWith('.json'))
|
|
435
|
+
const odd = names.find((n) => !MESSAGE_RE.test(n.slice(0, -5)))
|
|
436
|
+
if (odd) throw new Error(`thread/${odd}: not a message name`)
|
|
437
|
+
// by id, never by file name: `.json` sorts after `-`, so `…-owner-2.json` would come before `…-owner.json`
|
|
438
|
+
return names
|
|
439
|
+
.sort((a, b) => (a.slice(0, -5) < b.slice(0, -5) ? -1 : 1))
|
|
440
|
+
.map((n) => {
|
|
441
|
+
const m = JSON.parse(readFileSync(join(t, n), 'utf8')) as unknown
|
|
442
|
+
const problems = messageProblems(m, n.slice(0, -5))
|
|
443
|
+
if (problems.length > 0) throw new Error(`thread/${n}: ${problems.join('; ')}`)
|
|
444
|
+
return m as Message
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** appends one message to an item's thread, its image copied in beside it; the id never collides */
|
|
449
|
+
export function appendMessage(dir: string, id: string, m: Omit<Message, 'id' | 'at'>, o: { image?: string; now?: Date } = {}): Message {
|
|
450
|
+
const now = o.now ?? new Date()
|
|
451
|
+
if (o.image !== undefined) {
|
|
452
|
+
if (!existsSync(o.image)) throw new Error(`${o.image}: no such file`)
|
|
453
|
+
if (!IMAGE_EXT.test(extname(o.image).toLowerCase())) throw new Error(`${o.image}: a png, jpg or webp`)
|
|
454
|
+
}
|
|
455
|
+
const folder = join(dir, id, 'thread')
|
|
456
|
+
mkdirSync(folder, { recursive: true })
|
|
457
|
+
const base = messageIdOf(now, m.who)
|
|
458
|
+
let mid = base
|
|
459
|
+
for (let n = 2; existsSync(join(folder, `${mid}.json`)); n++) mid = `${base}-${n}`
|
|
460
|
+
const file = o.image === undefined ? undefined : `${mid}${extname(o.image).toLowerCase()}`
|
|
461
|
+
if (o.image !== undefined && file) copyFileSync(o.image, join(folder, file))
|
|
462
|
+
const out: Message = { id: mid, at: now.toISOString(), ...m, ...(file ? { image: file } : {}) }
|
|
463
|
+
writeJson(join(folder, `${mid}.json`), out)
|
|
464
|
+
return out
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** a reply on an item from `who` (the owner, or an agent by its name), with an optional image; throws with the reason */
|
|
468
|
+
export function addMessage(dir: string, id: string, who: string, text: unknown, image?: string, now = new Date()): Message {
|
|
469
|
+
if (!readItem(dir, id)) throw new Error(`no card ${id}`)
|
|
470
|
+
const t = textOf(text)
|
|
471
|
+
if ('error' in t) throw new Error(t.error)
|
|
472
|
+
return appendMessage(dir, id, { who, text: t.text }, { ...(image !== undefined ? { image } : {}), now })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** `party` read what the other party wrote on an item: one `seen` message naming them; how many it read */
|
|
476
|
+
export function markRead(dir: string, id: string, party: Party, who: string = party === 'owner' ? 'owner' : agentName(), now = new Date()): number {
|
|
477
|
+
const x = ID_RE.test(id) ? readItem(dir, id) : null
|
|
478
|
+
if (!x) return 0
|
|
479
|
+
const of = toAcknowledge(x, party)
|
|
480
|
+
if (of.length > 0) appendMessage(dir, id, { who, op: 'seen', of }, { now })
|
|
481
|
+
return of.length
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** the path of a message's image, null for any other name */
|
|
485
|
+
export function threadImage(dir: string, id: string, name: string): string | null {
|
|
486
|
+
if (!ID_RE.test(id) || !MESSAGE_FILE_RE.test(name)) return null
|
|
487
|
+
const f = join(dir, id, 'thread', name)
|
|
488
|
+
return existsSync(f) ? f : null
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** an item's stamp: its folder and its thread. Every write renames a file into one of them (the head into the folder, a
|
|
492
|
+
* message into thread/), which moves that directory's change time, so any write moves it. Two stats an item, never
|
|
493
|
+
* three: on a 3000 item store each stat is 10 ms of every read */
|
|
494
|
+
function stampOf(folder: string): string {
|
|
495
|
+
const one = (f: string): string => {
|
|
496
|
+
const s = statSync(f, { bigint: true, throwIfNoEntry: false })
|
|
497
|
+
return s ? `${s.ctimeNs}.${s.size}.${s.ino}` : '-'
|
|
498
|
+
}
|
|
499
|
+
return `${one(folder)}:${one(`${folder}/thread`)}`
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** the index of a store: beside it, never inside it. Its own name, never `.review-index.json`: a second code that reads
|
|
503
|
+
* the same store keys its index on its own code, and two codes sharing one file each throw the other's away */
|
|
504
|
+
export const indexFile = (dir: string): string => join(dirname(resolve(dir)), `.${basename(dir)}-index.shipkit.json`)
|
|
505
|
+
|
|
506
|
+
type Read = { x: ReviewItem } | { error: string }
|
|
507
|
+
|
|
508
|
+
interface StoreIndex {
|
|
509
|
+
/** the code that reads and checks an item: another code reads every item again */
|
|
510
|
+
code: string
|
|
511
|
+
items: Record<string, { stamp: string; read: Read }>
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let codeKey: string | null = null
|
|
515
|
+
const indexCode = (): string => (codeKey ??= new Bun.CryptoHasher('sha1').update(readFileSync(import.meta.path)).update(readFileSync(join(import.meta.dir, 'model.ts'))).digest('hex'))
|
|
516
|
+
|
|
517
|
+
function readOne(dir: string, id: string): Read {
|
|
518
|
+
try {
|
|
519
|
+
const x = readRaw(dir, id)
|
|
520
|
+
return x ? { x } : { error: ID_RE.test(id) ? 'no item.json' : 'not a review id' }
|
|
521
|
+
} catch (e) {
|
|
522
|
+
return { error: (e as Error).message }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** every item of the store as its unit reads it: an item its unit refuses (a subject kind or a topic it does not
|
|
527
|
+
* declare, a subject's problems) is broken; the index keeps the raw reads, so a unit's change never invalidates it */
|
|
528
|
+
export const readAll = (dir: string): ReadAll => checked(readRaws(dir), specOf(dir))
|
|
529
|
+
|
|
530
|
+
const checks = new WeakMap<ReadAll, { spec: Spec; all: ReadAll }>()
|
|
531
|
+
|
|
532
|
+
function checked(raw: ReadAll, spec: Spec): ReadAll {
|
|
533
|
+
if (spec === NO_SPEC) return raw
|
|
534
|
+
const held = checks.get(raw)
|
|
535
|
+
if (held?.spec === spec) return held.all
|
|
536
|
+
const all: ReadAll = { items: [], broken: [...raw.broken] }
|
|
537
|
+
for (const x of raw.items) {
|
|
538
|
+
const wrong = specProblems(x.item, spec)
|
|
539
|
+
if (wrong.length === 0) all.items.push(x)
|
|
540
|
+
else all.broken.push({ id: x.item.id, error: `item.json: ${wrong.join('; ')}` })
|
|
541
|
+
}
|
|
542
|
+
all.broken.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
543
|
+
checks.set(raw, { spec, all })
|
|
544
|
+
return all
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** every item of the store, through its index: an item whose stamp moved is read again */
|
|
548
|
+
function readRaws(dir: string): ReadAll {
|
|
549
|
+
const out: ReadAll = { items: [], broken: [] }
|
|
550
|
+
if (!existsSync(dir)) return out
|
|
551
|
+
const file = indexFile(dir)
|
|
552
|
+
const code = indexCode()
|
|
553
|
+
let was: StoreIndex['items'] = {}
|
|
554
|
+
try {
|
|
555
|
+
const ix = JSON.parse(readFileSync(file, 'utf8')) as StoreIndex
|
|
556
|
+
if (ix.code === code) was = ix.items
|
|
557
|
+
} catch {
|
|
558
|
+
// no index yet, or one of another shape: read every item
|
|
559
|
+
}
|
|
560
|
+
const next: StoreIndex = { code, items: {} }
|
|
561
|
+
let moved = false
|
|
562
|
+
for (const id of readdirSync(dir).sort()) {
|
|
563
|
+
if (id.startsWith('.')) continue
|
|
564
|
+
const stamp = stampOf(join(dir, id))
|
|
565
|
+
const held = was[id]
|
|
566
|
+
const read = held && held.stamp === stamp ? held.read : readOne(dir, id)
|
|
567
|
+
if (read !== held?.read) moved = true
|
|
568
|
+
next.items[id] = { stamp, read }
|
|
569
|
+
if ('x' in read) out.items.push(read.x)
|
|
570
|
+
else out.broken.push({ id, error: read.error })
|
|
571
|
+
}
|
|
572
|
+
if (moved || Object.keys(was).length !== Object.keys(next.items).length)
|
|
573
|
+
try {
|
|
574
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
575
|
+
fs.writeFileSync(tmp, JSON.stringify(next))
|
|
576
|
+
fs.renameSync(tmp, file)
|
|
577
|
+
} catch {
|
|
578
|
+
// a read only store: the next read pays the whole read again
|
|
579
|
+
}
|
|
580
|
+
return out
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** the owner's verdict, checked against its item, appended as his message */
|
|
584
|
+
export function writeVerdict(dir: string, id: string, body: unknown, now = new Date()): { ok: true; message: Message } | { ok: false; errors: string[] } {
|
|
585
|
+
const x = readItem(dir, id)
|
|
586
|
+
if (!x) return { ok: false, errors: [`no card ${id}`] }
|
|
587
|
+
if (x.item.wip) return { ok: false, errors: [`${id} is a draft card: the agent makes it ready before a verdict; a comment goes in a reply`] }
|
|
588
|
+
if (x.item.origin.by !== 'agent') return { ok: false, errors: [`${id} was raised, not shown by an agent: a verdict goes on the card that shows its fix; a word goes in a reply`] }
|
|
589
|
+
const r = verdictMessageOf(body, x.item)
|
|
590
|
+
if (!r.ok) return r
|
|
591
|
+
return { ok: true, message: appendMessage(dir, id, { who: 'owner', ...r.message }, { now }) }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** an agent acted on the verdict and the owner's replies; false when the item has neither */
|
|
595
|
+
export function markSeen(dir: string, id: string, now = new Date()): boolean {
|
|
596
|
+
const x = ID_RE.test(id) ? readItem(dir, id) : null
|
|
597
|
+
if (!x) return false
|
|
598
|
+
const read = markRead(dir, id, 'agent', agentName(), now)
|
|
599
|
+
return verdictOf(x) !== null || read > 0
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** takes an unanswered card back: false when it is unknown or already answered */
|
|
603
|
+
export function withdraw(dir: string, id: string, why: string, now = new Date()): boolean {
|
|
604
|
+
const x = ID_RE.test(id) ? readItem(dir, id) : null
|
|
605
|
+
if (!x || verdictOf(x) || withdrawnOf(x) !== undefined) return false
|
|
606
|
+
appendMessage(dir, id, { who: agentName(), op: 'withdraw', note: why }, { now })
|
|
607
|
+
return true
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** an answered card waits for his answer again: its verdict stays in the thread, before the reopen; false when it has none */
|
|
611
|
+
export function reopen(dir: string, id: string, now = new Date()): boolean {
|
|
612
|
+
const x = ID_RE.test(id) ? readItem(dir, id) : null
|
|
613
|
+
if (!x || !verdictOf(x)) return false
|
|
614
|
+
appendMessage(dir, id, { who: agentName(), op: 'reopen' }, { now })
|
|
615
|
+
return true
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** the path of one image, clip or poster of an item, null for any other name */
|
|
619
|
+
export function imageFile(dir: string, id: string, name: string): string | null {
|
|
620
|
+
if (!ID_RE.test(id) || !FILE_RE.test(name)) return null
|
|
621
|
+
const f = join(dir, id, name)
|
|
622
|
+
return existsSync(f) ? f : null
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export interface RaiseInput {
|
|
626
|
+
/** its id when the caller names it (a player's report: by its server and number); else from its time and title */
|
|
627
|
+
id?: string
|
|
628
|
+
subject: Subject
|
|
629
|
+
origin: Exclude<Origin, { by: 'agent' }>
|
|
630
|
+
title: string
|
|
631
|
+
/** an auto item's markdown, what its rule saw: shown as a card's `what` */
|
|
632
|
+
what?: string
|
|
633
|
+
topic?: string
|
|
634
|
+
commit?: string
|
|
635
|
+
/** his or the player's words: the first message; none on an item opened for marks or raised by a rule */
|
|
636
|
+
text?: string
|
|
637
|
+
/** the rectangles or pins drawn with them, on the image */
|
|
638
|
+
rects?: Rect[]
|
|
639
|
+
/** the image the item shows, copied in as its first view */
|
|
640
|
+
image?: string
|
|
641
|
+
now?: Date
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** a record the owner, a player or a rule raises: its image the first view, the words and rectangles the first message;
|
|
645
|
+
* built in a dot folder and renamed, so two pullers of one report write it once */
|
|
646
|
+
export async function raiseItem(dir: string, input: RaiseInput): Promise<ReviewItem> {
|
|
647
|
+
const text = input.text?.trim()
|
|
648
|
+
if (input.text !== undefined && !text) throw new Error('a note says something')
|
|
649
|
+
const lane = input.origin.by === 'player' ? undefined : input.origin.lane
|
|
650
|
+
if (lane !== undefined && !AGENT_RE.test(lane)) throw new Error(`origin.lane: « ${lane} » is no lane id (${AGENT_RE.source})`)
|
|
651
|
+
if (input.origin.by === 'auto' && input.text !== undefined) throw new Error('an auto item says what its rule saw in `what`, never as a message')
|
|
652
|
+
if (input.image !== undefined && (!existsSync(input.image) || !IMAGE_EXT.test(extname(input.image).toLowerCase()))) throw new Error(`${input.image}: a png, jpg or webp`)
|
|
653
|
+
const now = input.now ?? new Date()
|
|
654
|
+
mkdirSync(dir, { recursive: true })
|
|
655
|
+
if (input.id !== undefined && (!ID_RE.test(input.id) || existsSync(join(dir, input.id)))) throw new Error(`${input.id}: no item id, or taken`)
|
|
656
|
+
const base = input.id ?? idOf(now, input.title)
|
|
657
|
+
let id = base
|
|
658
|
+
for (let n = 2; existsSync(join(dir, id)); n++) id = `${base}-${n}`
|
|
659
|
+
const image = input.image === undefined ? undefined : `image${extname(input.image).toLowerCase()}`
|
|
660
|
+
const item: Item = {
|
|
661
|
+
id,
|
|
662
|
+
created: now.toISOString(),
|
|
663
|
+
subject: input.subject,
|
|
664
|
+
origin: input.origin,
|
|
665
|
+
title: input.title.trim(),
|
|
666
|
+
...(input.what?.trim() ? { what: input.what.trim() } : {}),
|
|
667
|
+
...(input.topic ? { topic: input.topic } : {}),
|
|
668
|
+
...(input.commit ? { commit: input.commit } : {}),
|
|
669
|
+
views: input.image !== undefined && image ? [{ label: '', files: { image }, size: await sizeOf(input.image) }] : [],
|
|
670
|
+
}
|
|
671
|
+
const problems = [...itemProblems(item), ...specProblems(item, specOf(dir))]
|
|
672
|
+
if (problems.length > 0) throw new Error(problems.join('; '))
|
|
673
|
+
const tmp = join(dir, `.${id}.tmp`)
|
|
674
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
675
|
+
mkdirSync(tmp)
|
|
676
|
+
if (input.image !== undefined && image) copyFileSync(input.image, join(tmp, image))
|
|
677
|
+
writeJson(join(tmp, 'item.json'), item)
|
|
678
|
+
renameSync(tmp, join(dir, id))
|
|
679
|
+
const who = input.origin.by === 'player' ? 'player' : 'owner'
|
|
680
|
+
if (text) appendMessage(dir, id, { who, text, ...(input.rects && input.rects.length > 0 ? { rects: input.rects } : {}) }, { now })
|
|
681
|
+
return readItem(dir, id) as ReviewItem
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
/** the owner's mark on a subject (stars, a mark and a note): one `rate` message on the subject's marks item, opened on
|
|
687
|
+
* the first mark; the older marks stay as its history */
|
|
688
|
+
export async function rateSubject(
|
|
689
|
+
dir: string,
|
|
690
|
+
o: { subject: Subject; from: string; title: string; topic?: string; rate: NonNullable<Message['rate']>; text?: string; now?: Date },
|
|
691
|
+
): Promise<Message> {
|
|
692
|
+
const spec = specOf(dir)
|
|
693
|
+
const key = subjectKey(o.subject, spec)
|
|
694
|
+
if (key === null) throw new Error(`a mark names its subject: kind « ${o.subject.kind} » names none (its key)`)
|
|
695
|
+
const mine = readAllCached(dir)
|
|
696
|
+
.items.filter((x) => isMarkItem(x) && subjectKey(x.item.subject, spec) === key)
|
|
697
|
+
.sort((a, b) => b.item.created.localeCompare(a.item.created))[0]
|
|
698
|
+
const x = mine ?? (await raiseItem(dir, { subject: o.subject, origin: { by: 'owner', from: o.from }, title: o.title, ...(o.topic ? { topic: o.topic } : {}), ...(o.now ? { now: o.now } : {}) }))
|
|
699
|
+
const text = o.text?.trim()
|
|
700
|
+
return appendMessage(dir, x.item.id, { who: 'owner', rate: o.rate, ...(text ? { text } : {}) }, o.now ? { now: o.now } : {})
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** the owner approves a subject he raised from a tool (an entry he checked), or takes it back: a verdict with the commit, or a
|
|
704
|
+
* reopen, on the newest item he raised on that subject from that tool */
|
|
705
|
+
export async function ownerVerdict(dir: string, o: { subject: Subject; from: string; title: string; ok: boolean; commit?: string; now?: Date }): Promise<void> {
|
|
706
|
+
const spec = specOf(dir)
|
|
707
|
+
const key = subjectKey(o.subject, spec)
|
|
708
|
+
if (key === null) throw new Error(`a verdict on a subject names it: kind « ${o.subject.kind} » names none (its key)`)
|
|
709
|
+
const mine = readAllCached(dir)
|
|
710
|
+
.items.filter((x) => x.item.origin.by === 'owner' && x.item.origin.from === o.from && subjectKey(x.item.subject, spec) === key)
|
|
711
|
+
.sort((a, b) => b.item.created.localeCompare(a.item.created))[0]
|
|
712
|
+
if (!o.ok && (!mine || !verdictOf(mine))) return
|
|
713
|
+
const x = mine ?? (await raiseItem(dir, { subject: o.subject, origin: { by: 'owner', from: o.from }, title: o.title, ...(o.now ? { now: o.now } : {}) }))
|
|
714
|
+
const at = o.now ? { now: o.now } : {}
|
|
715
|
+
if (o.ok) appendMessage(dir, x.item.id, { who: 'owner', verdict: 'approved', ...(o.commit ? { fix: { commit: o.commit } } : {}) }, at)
|
|
716
|
+
else appendMessage(dir, x.item.id, { who: 'owner', op: 'reopen' }, at)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** the lock of one item while an act is checked and written: a folder made atomically, waited on for at most 2 s */
|
|
720
|
+
function locked<T>(dir: string, id: string, fn: () => T): T {
|
|
721
|
+
const lock = join(dir, id, '.lock')
|
|
722
|
+
const until = Date.now() + 2000
|
|
723
|
+
for (;;) {
|
|
724
|
+
try {
|
|
725
|
+
mkdirSync(lock)
|
|
726
|
+
break
|
|
727
|
+
} catch {
|
|
728
|
+
if (Date.now() > until) {
|
|
729
|
+
// a lock left by a dead process: older than the wait itself
|
|
730
|
+
if (Date.now() - statSync(lock).mtimeMs > 2000) {
|
|
731
|
+
rmSync(lock, { recursive: true, force: true })
|
|
732
|
+
continue
|
|
733
|
+
}
|
|
734
|
+
throw new Error(`${id}: locked by another process`)
|
|
735
|
+
}
|
|
736
|
+
Bun.sleepSync(10)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
return fn()
|
|
741
|
+
} finally {
|
|
742
|
+
rmSync(lock, { recursive: true, force: true })
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** a state change on an item by `who`, checked by `allowed` under the item's lock, so two workers claiming at once
|
|
747
|
+
* never both win; throws with the reason it is refused. The dispatcher's ops need the repo's unit to declare one, and
|
|
748
|
+
* its holder; a topic is one the unit declares */
|
|
749
|
+
export function act(dir: string, id: string, op: Exclude<Op, 'seen'>, who: string, o: { note?: string; text?: string; commit?: string; topic?: string; now?: Date } = {}): Message {
|
|
750
|
+
const spec = specOf(dir)
|
|
751
|
+
if (who !== 'owner' && who !== 'agent' && !AGENT_RE.test(who)) throw new Error(`« ${who} » is no agent name (${AGENT_RE.source})`)
|
|
752
|
+
if (!ID_RE.test(id) || !readItem(dir, id)) throw new Error(`no item ${id}`)
|
|
753
|
+
if ((op === 'wontfix' || op === 'blocked' || op === 'drop') && !o.note?.trim()) throw new Error(`${op} says why (--note)`)
|
|
754
|
+
if ((op === 'ask' || op === 'answer') && !o.text?.trim()) throw new Error(`${op} says something`)
|
|
755
|
+
if (op === 'dispatch' && !o.topic) throw new Error('dispatch names a topic')
|
|
756
|
+
if (o.topic !== undefined && Object.keys(spec.topics).length > 0 && !spec.topics[o.topic]) throw new Error(`no topic « ${o.topic} »: ${Object.keys(spec.topics).join(', ')}`)
|
|
757
|
+
if (o.commit !== undefined && !/^[0-9a-f]{7,40}$/.test(o.commit)) throw new Error(`« ${o.commit} » is no commit`)
|
|
758
|
+
const now = o.now ?? new Date()
|
|
759
|
+
return locked(dir, id, () => {
|
|
760
|
+
const x = readItem(dir, id) as ReviewItem
|
|
761
|
+
const superseded = isFollowed(id, readAll(dir).items)
|
|
762
|
+
const role = spec.dispatcher ? (roleOf(dir)?.name ?? null) : null
|
|
763
|
+
const why = op === 'claim' && superseded && x.item.origin.by === 'agent' ? 'a newer card redoes it: claim that one' : allowed(stateOf(x, superseded), op, who, role, spec.dispatcher)
|
|
764
|
+
if (why) throw new Error(`${id}: ${why}`)
|
|
765
|
+
const m = appendMessage(
|
|
766
|
+
dir,
|
|
767
|
+
id,
|
|
768
|
+
{
|
|
769
|
+
who,
|
|
770
|
+
op,
|
|
771
|
+
...(o.text?.trim() ? { text: o.text.trim() } : {}),
|
|
772
|
+
...(o.note?.trim() ? { note: o.note.trim() } : {}),
|
|
773
|
+
...(o.commit ? { fix: { commit: o.commit } } : {}),
|
|
774
|
+
...(o.topic ? { topic: o.topic } : {}),
|
|
775
|
+
},
|
|
776
|
+
{ now },
|
|
777
|
+
)
|
|
778
|
+
if (role !== null && role === who) beatRole(dir, now)
|
|
779
|
+
return m
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** `who` claims the oldest open item raised to a lane, or of a topic, and gets it read back; null when none is open.
|
|
784
|
+
* Each claim runs under the item's lock, so two workers never take one item */
|
|
785
|
+
export function claimNext(dir: string, by: { lane: string } | { topic: string }, who: string): ReviewItem | null {
|
|
786
|
+
const store = readAll(dir).items
|
|
787
|
+
const followed = followedIds(store)
|
|
788
|
+
const routed = specOf(dir).dispatcher
|
|
789
|
+
const open = store
|
|
790
|
+
.filter((x) => x.item.origin.by !== 'agent' && ('lane' in by ? laneOf(x) === by.lane : true))
|
|
791
|
+
.filter((x) => {
|
|
792
|
+
const st = stateOf(x, followed.has(x.item.id))
|
|
793
|
+
return (st.state === 'open' || (!routed && st.state === 'new')) && ('topic' in by ? st.topic === by.topic : true)
|
|
794
|
+
})
|
|
795
|
+
.sort((a, b) => a.item.created.localeCompare(b.item.created) || a.item.id.localeCompare(b.item.id))
|
|
796
|
+
for (const x of open) {
|
|
797
|
+
try {
|
|
798
|
+
act(dir, x.item.id, 'claim', who)
|
|
799
|
+
return readItem(dir, x.item.id)
|
|
800
|
+
} catch {
|
|
801
|
+
// taken by another worker meanwhile: the next one
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return null
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** the owner deletes what he raised himself (a note he takes back): its folder goes; an agent's card is never deleted
|
|
808
|
+
* this way */
|
|
809
|
+
export function deleteRaised(dir: string, id: string): void {
|
|
810
|
+
const x = ID_RE.test(id) ? readItem(dir, id) : null
|
|
811
|
+
if (!x) throw new Error(`no item ${id}`)
|
|
812
|
+
if (x.item.origin.by !== 'owner') throw new Error(`${id} was not raised by the owner: never deleted from the hub`)
|
|
813
|
+
rmSync(join(dir, id), { recursive: true, force: true })
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** a player's report removed from the store, once its source deleted it there */
|
|
817
|
+
export function deleteReport(dir: string, id: string): void {
|
|
818
|
+
const x = ID_RE.test(id) ? readItem(dir, id) : null
|
|
819
|
+
if (!x) throw new Error(`no item ${id}`)
|
|
820
|
+
if (x.item.origin.by !== 'player') throw new Error(`${id} is no player's report`)
|
|
821
|
+
rmSync(join(dir, id), { recursive: true, force: true })
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** a player's report by its ref (`live#3`: its server and number), or an item by its id. A pulled report's item is named
|
|
825
|
+
* after it (`…-live-3`): its name first, the whole store only when no name holds it */
|
|
826
|
+
export function itemOfRef(dir: string, ref: string): ReviewItem | null {
|
|
827
|
+
const m = /^([a-z][a-z0-9-]*)#(\d+)$/.exec(ref.trim())
|
|
828
|
+
if (!m) return ID_RE.test(ref) ? readItem(dir, ref) : null
|
|
829
|
+
const holds = (x: ReviewItem | null): x is ReviewItem => x !== null && x.item.origin.by === 'player' && x.item.origin.server === m[1] && x.item.origin.serverId === Number(m[2])
|
|
830
|
+
const tail = `-${m[1]}-${m[2]}`
|
|
831
|
+
for (const n of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(tail)).sort() : []) {
|
|
832
|
+
try {
|
|
833
|
+
const x = readItem(dir, n)
|
|
834
|
+
if (holds(x)) return x
|
|
835
|
+
} catch {
|
|
836
|
+
// a broken folder of that name: the whole store next
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return readAll(dir).items.find(holds) ?? null
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** a player's report as the owner names it: `live#85`; any other item by its id */
|
|
843
|
+
export const refOf = (x: ReviewItem): string => (x.item.origin.by === 'player' ? `${x.item.origin.server}#${x.item.origin.serverId}` : x.item.id)
|
|
844
|
+
|
|
845
|
+
/** a draft card's live frame and its history again: the first view's image replaced, the history a gallery view; the
|
|
846
|
+
* draft's frame count moves */
|
|
847
|
+
export async function refreshDraft(dir: string, id: string, o: { image: string; history?: { file: string; label: string }[] }): Promise<Item> {
|
|
848
|
+
const x = readItem(dir, id)
|
|
849
|
+
if (!x) throw new Error(`no card ${id}`)
|
|
850
|
+
const r = x.item
|
|
851
|
+
if (!r.wip || verdictOf(x)) throw new Error(`${id} is not a draft card waiting`)
|
|
852
|
+
const f0 = r.views[0]
|
|
853
|
+
if (!f0 || !('files' in f0) || !('image' in f0.files)) throw new Error(`${id}: its first view is no image`)
|
|
854
|
+
const folder = join(dir, id)
|
|
855
|
+
const main = await loadView({ label: f0.label, image: o.image }, 'the live frame')
|
|
856
|
+
const image = f0.files.image
|
|
857
|
+
copyFileSync(main.sources[0]?.[1] ?? '', join(folder, `${image}.tmp`))
|
|
858
|
+
renameSync(join(folder, `${image}.tmp`), join(folder, image))
|
|
859
|
+
// a draft's one gallery is its history
|
|
860
|
+
const label = r.views.find((v) => 'gallery' in v)?.label ?? HISTORY
|
|
861
|
+
let views: ReviewView[] = [{ ...f0, size: main.size }, ...r.views.slice(1).filter((v) => !('gallery' in v))]
|
|
862
|
+
if (o.history && o.history.length > 0) {
|
|
863
|
+
const n = views.length
|
|
864
|
+
const loaded = await loadView({ label, gallery: o.history }, label)
|
|
865
|
+
for (const f of readdirSync(folder)) if (new RegExp(`^v${n}-g\\d+\\.`).test(f)) rmSync(join(folder, f))
|
|
866
|
+
copyFiles(loaded, n, folder)
|
|
867
|
+
views = [...views, viewOf(loaded, n)]
|
|
868
|
+
}
|
|
869
|
+
const next: Item = { ...r, views, wip: { live: r.wip.live, frame: r.wip.frame + 1 } }
|
|
870
|
+
writeJson(join(folder, 'item.json'), next)
|
|
871
|
+
return next
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** the label of a draft card's history view */
|
|
875
|
+
export const HISTORY = 'history'
|
|
876
|
+
|
|
877
|
+
/** a draft card made a card he answers: its image the last frame or `image`, the draft state gone */
|
|
878
|
+
export async function readyItem(dir: string, id: string, o: { image?: string; now?: Date } = {}): Promise<Item> {
|
|
879
|
+
const x = readItem(dir, id)
|
|
880
|
+
if (!x) throw new Error(`no card ${id}`)
|
|
881
|
+
const { wip, ...rest } = x.item
|
|
882
|
+
if (!wip) throw new Error(`${id} is not a draft card: it already takes a verdict`)
|
|
883
|
+
let item: Item = rest
|
|
884
|
+
if (o.image !== undefined) {
|
|
885
|
+
const f0 = rest.views[0]
|
|
886
|
+
if (!f0 || !('files' in f0)) throw new Error(`${id} has no image to replace`)
|
|
887
|
+
const loaded = await loadView({ label: f0.label, image: o.image }, '--image')
|
|
888
|
+
const folder = join(dir, id)
|
|
889
|
+
const old = 'image' in f0.files ? f0.files.image : undefined
|
|
890
|
+
copyFiles(loaded, 0, folder)
|
|
891
|
+
const files = filesOf(loaded, 0)
|
|
892
|
+
const now = 'image' in files ? files.image : undefined
|
|
893
|
+
if (old && old !== now) rmSync(join(folder, old), { force: true })
|
|
894
|
+
item = { ...rest, views: [{ ...f0, files, size: loaded.size }, ...rest.views.slice(1)] }
|
|
895
|
+
}
|
|
896
|
+
writeJson(join(dir, id, 'item.json'), item)
|
|
897
|
+
appendMessage(dir, id, { who: agentName(), op: 'ready' }, o.now ? { now: o.now } : {})
|
|
898
|
+
return item
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** a store watched by this process: `moved` counts the writes under it, by any process; `dirty` names the items they
|
|
902
|
+
* touched since the last read, `held` every item as last read (null until a first whole read); null once the watch failed */
|
|
903
|
+
interface Watched {
|
|
904
|
+
moved: number
|
|
905
|
+
dirty: Set<string>
|
|
906
|
+
held: Map<string, Read> | null
|
|
907
|
+
all: ReadAll | null
|
|
908
|
+
}
|
|
909
|
+
const watched = new Map<string, Watched | null>()
|
|
910
|
+
const BOOT = Date.now().toString(36)
|
|
911
|
+
|
|
912
|
+
/** a write by this process: the item it touched is dirty at once, before the watcher reports it */
|
|
913
|
+
function touched(path: string): void {
|
|
914
|
+
wrote++
|
|
915
|
+
const p = resolve(path)
|
|
916
|
+
for (const [key, w] of watched) if (w && p.startsWith(`${key}/`)) w.dirty.add(p.slice(key.length + 1).split('/')[0] ?? '')
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function watchOf(dir: string): Watched | null {
|
|
920
|
+
const key = resolve(dir)
|
|
921
|
+
if (watched.has(key)) return watched.get(key) ?? null
|
|
922
|
+
const w: Watched = { moved: 0, dirty: new Set(), held: null, all: null }
|
|
923
|
+
try {
|
|
924
|
+
// a thumbnail or an item's lock is no change of the list
|
|
925
|
+
const watcher = watch(key, { recursive: true }, (_, name) => {
|
|
926
|
+
if (name && (name.startsWith('.thumbs') || name.endsWith('.lock'))) return
|
|
927
|
+
w.moved++
|
|
928
|
+
if (name) w.dirty.add(name.split('/')[0] ?? '')
|
|
929
|
+
else w.held = null
|
|
930
|
+
})
|
|
931
|
+
watcher.on('error', () => watched.set(key, null))
|
|
932
|
+
watcher.unref()
|
|
933
|
+
watched.set(key, w)
|
|
934
|
+
return w
|
|
935
|
+
} catch {
|
|
936
|
+
watched.set(key, null)
|
|
937
|
+
return null
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** changes when anything the list is built from changes: watched, a count of writes; else a scan. The list's ETag */
|
|
942
|
+
export function storeStamp(dir: string): string {
|
|
943
|
+
if (!existsSync(dir)) return 'none'
|
|
944
|
+
const w = watchOf(dir)
|
|
945
|
+
return w ? `${BOOT}.${w.moved}.${wrote}` : scanStamp(dir)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** the store's stamp and who listens (the live watchers' pids), so a watch that starts or dies moves it too */
|
|
949
|
+
export const listingStamp = (dir: string): string => `${storeStamp(dir)}~${liveWatchers(dir).map((w) => w.pid).join(',')}`
|
|
950
|
+
|
|
951
|
+
/** the stamp from a scan of every item's files: the safety net of a watcher that may miss an event */
|
|
952
|
+
export function scanStamp(dir: string): string {
|
|
953
|
+
const h = new Bun.CryptoHasher('sha1')
|
|
954
|
+
if (!existsSync(dir)) return 'none'
|
|
955
|
+
const mark = (f: string): void => {
|
|
956
|
+
const s = statSync(f, { throwIfNoEntry: false })
|
|
957
|
+
h.update(`${f}:${s ? `${s.mtimeMs}:${s.size}` : '-'}\n`)
|
|
958
|
+
}
|
|
959
|
+
for (const id of readdirSync(dir).sort()) {
|
|
960
|
+
if (id.startsWith('.')) continue
|
|
961
|
+
for (const f of ['', 'item.json']) mark(join(dir, id, f))
|
|
962
|
+
const thread = join(dir, id, 'thread')
|
|
963
|
+
if (existsSync(thread)) for (const n of readdirSync(thread).sort()) mark(join(thread, n))
|
|
964
|
+
}
|
|
965
|
+
return h.digest('hex').slice(0, 16)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
let cached: { dir: string; tag: string; all: ReadAll } | null = null
|
|
969
|
+
|
|
970
|
+
/** readAll kept while nothing it reads changed: watched, only the items written since are read again */
|
|
971
|
+
export const readAllCached = (dir: string): ReadAll => checked(readRawsCached(dir), specOf(dir))
|
|
972
|
+
|
|
973
|
+
function readRawsCached(dir: string): ReadAll {
|
|
974
|
+
const w = existsSync(dir) ? watchOf(dir) : null
|
|
975
|
+
if (!w) {
|
|
976
|
+
const tag = listingStamp(dir)
|
|
977
|
+
if (cached?.dir !== dir || cached.tag !== tag) cached = { dir, tag, all: readRaws(dir) }
|
|
978
|
+
return cached.all
|
|
979
|
+
}
|
|
980
|
+
if (!w.held || !w.all) {
|
|
981
|
+
w.dirty.clear()
|
|
982
|
+
const all = readRaws(dir)
|
|
983
|
+
w.held = new Map<string, Read>([...all.items.map((x) => [x.item.id, { x }] as const), ...all.broken.map((b) => [b.id, { error: b.error }] as const)])
|
|
984
|
+
w.all = all
|
|
985
|
+
return all
|
|
986
|
+
}
|
|
987
|
+
if (w.dirty.size === 0) return w.all
|
|
988
|
+
for (const id of w.dirty) {
|
|
989
|
+
if (id === '' || id.startsWith('.')) continue
|
|
990
|
+
if (existsSync(join(dir, id))) w.held.set(id, readOne(dir, id))
|
|
991
|
+
else w.held.delete(id)
|
|
992
|
+
}
|
|
993
|
+
w.dirty.clear()
|
|
994
|
+
const out: ReadAll = { items: [], broken: [] }
|
|
995
|
+
for (const id of [...w.held.keys()].sort()) {
|
|
996
|
+
const read = w.held.get(id) as Read
|
|
997
|
+
if ('x' in read) out.items.push(read.x)
|
|
998
|
+
else out.broken.push({ id, error: read.error })
|
|
999
|
+
}
|
|
1000
|
+
w.all = out
|
|
1001
|
+
return out
|
|
1002
|
+
}
|