rvlib-shipkit 2.1.1 → 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 +50 -0
- package/package.json +2 -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/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- 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 +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- 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/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- 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 +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- 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 +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// the trailer view's shapes (wiki build/trailer.md, Frame notes): what the hub answers about a repo's cuts, and a frame
|
|
2
|
+
// note as the view shows it, read from its item of the one store. Pure: the routes and the page share them
|
|
3
|
+
import { isFrame, isReply, partyOf, stateOf, type ReviewItem } from './model.ts'
|
|
4
|
+
|
|
5
|
+
export type NoteState = 'open' | 'done' | 'wontfix'
|
|
6
|
+
|
|
7
|
+
export interface NotePin {
|
|
8
|
+
/** 0 to 1 over the frame, from the left and from the top */
|
|
9
|
+
x: number
|
|
10
|
+
y: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** one note as the view shows it: an item of the one store whose subject is a frame */
|
|
14
|
+
export interface TrailerNote {
|
|
15
|
+
/** its item's id */
|
|
16
|
+
id: string
|
|
17
|
+
/** its number on the trailer, in the order written: the label of its pin */
|
|
18
|
+
n: number
|
|
19
|
+
trailer: string
|
|
20
|
+
cut: string
|
|
21
|
+
/** seconds on the cut it was written on, the frame's middle */
|
|
22
|
+
time: number
|
|
23
|
+
frame: number
|
|
24
|
+
shot: string
|
|
25
|
+
/** seconds into the shot */
|
|
26
|
+
shotTime: number
|
|
27
|
+
section: string | null
|
|
28
|
+
text: string
|
|
29
|
+
pin?: NotePin
|
|
30
|
+
state: NoteState
|
|
31
|
+
/** done: the commit or the answer; wontfix: the reason */
|
|
32
|
+
reply?: string
|
|
33
|
+
written: string
|
|
34
|
+
closed?: string
|
|
35
|
+
/** its still, a file of its item's folder */
|
|
36
|
+
still: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** a frame item as a note: its words and pin are the owner's latest, its state the lifecycle's (fixed is done); null
|
|
40
|
+
* when the item is no frame */
|
|
41
|
+
export function noteOf(x: ReviewItem, n: number): TrailerNote | null {
|
|
42
|
+
const s = x.item.subject
|
|
43
|
+
if (!isFrame(s)) return null
|
|
44
|
+
const said = x.thread.filter((m) => partyOf(m.who) === 'owner' && m.op === undefined)
|
|
45
|
+
const text = said.filter(isReply).at(-1)?.text ?? ''
|
|
46
|
+
const pinned = said.filter((m) => m.rects !== undefined).at(-1)?.rects?.[0]
|
|
47
|
+
const st = stateOf(x, false)
|
|
48
|
+
const closing = st.state === 'closed' ? x.thread.filter((m) => m.op === 'fixed' || m.op === 'wontfix').at(-1) : undefined
|
|
49
|
+
const first = x.item.views[0]
|
|
50
|
+
return {
|
|
51
|
+
id: x.item.id,
|
|
52
|
+
n,
|
|
53
|
+
trailer: s.trailer,
|
|
54
|
+
cut: s.cut,
|
|
55
|
+
time: s.time,
|
|
56
|
+
frame: s.frame,
|
|
57
|
+
shot: s.shot,
|
|
58
|
+
shotTime: s.shotTime,
|
|
59
|
+
section: s.section,
|
|
60
|
+
text,
|
|
61
|
+
...(pinned ? { pin: { x: pinned.x, y: pinned.y } } : {}),
|
|
62
|
+
state: closing?.op === 'wontfix' ? 'wontfix' : closing ? 'done' : 'open',
|
|
63
|
+
...(closing?.note ? { reply: closing.note } : {}),
|
|
64
|
+
written: x.item.created,
|
|
65
|
+
...(closing ? { closed: closing.at } : {}),
|
|
66
|
+
still: first && 'files' in first && 'image' in first.files ? first.files.image : 'image.png',
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** every frame note of one trailer, numbered in the order written */
|
|
71
|
+
export function notesOf(items: readonly ReviewItem[], trailer: string): TrailerNote[] {
|
|
72
|
+
return items
|
|
73
|
+
.filter((x) => isFrame(x.item.subject) && x.item.subject.trailer === trailer)
|
|
74
|
+
.sort((a, b) => a.item.created.localeCompare(b.item.created))
|
|
75
|
+
.flatMap((x, i) => noteOf(x, i + 1) ?? [])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CaptionSpan {
|
|
79
|
+
text: string
|
|
80
|
+
style: string
|
|
81
|
+
/** seconds on the cut */
|
|
82
|
+
start: number
|
|
83
|
+
end: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ShotSpan {
|
|
87
|
+
name: string
|
|
88
|
+
/** its line in the .trailer file */
|
|
89
|
+
line: number
|
|
90
|
+
start: number
|
|
91
|
+
/** where the next shot starts (the cut's end for the last): the span the timeline draws */
|
|
92
|
+
end: number
|
|
93
|
+
/** its whole length, the cut into the next included */
|
|
94
|
+
length: number
|
|
95
|
+
section: string | null
|
|
96
|
+
captions: CaptionSpan[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SectionSpan {
|
|
100
|
+
name: string
|
|
101
|
+
start: number
|
|
102
|
+
end: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Timeline {
|
|
106
|
+
total: number
|
|
107
|
+
fps: number
|
|
108
|
+
size: [number, number]
|
|
109
|
+
shots: ShotSpan[]
|
|
110
|
+
sections: SectionSpan[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** one cut of a trailer folder: a `.trailer` file and its render `out/<name>.mp4`, or a render with no file beside it */
|
|
114
|
+
export interface CutInfo {
|
|
115
|
+
/** the `.trailer` file's stem, else the render's */
|
|
116
|
+
cut: string
|
|
117
|
+
/** the render's stem: the file's `trailer <name>` line */
|
|
118
|
+
name: string
|
|
119
|
+
/** repo relative: the render, its strip, its montage page */
|
|
120
|
+
files: { trailer: string | null; video: string; strip: string; montage: string | null }
|
|
121
|
+
/** the timeline the render on screen was made from, else the .trailer file's as it stands; null when neither parses */
|
|
122
|
+
timeline: Timeline | null
|
|
123
|
+
/** the timeline is the render's own (the file was saved before it) */
|
|
124
|
+
timelineFromRender: boolean
|
|
125
|
+
errors: string[]
|
|
126
|
+
/** the render on screen: its version (mtime in ms), length and rate; null when there is none */
|
|
127
|
+
video: { mtime: number; duration: number; fps: number } | null
|
|
128
|
+
/** a new render is being written: `video` is still the last finished one */
|
|
129
|
+
rendering: boolean
|
|
130
|
+
/** the .trailer file changed since that render */
|
|
131
|
+
edited: boolean
|
|
132
|
+
/** the render's contact strip (one frame a shot, `cols` a row), null when absent or not of this timeline */
|
|
133
|
+
strip: { cols: number; tileW: number; tileH: number; pad: number } | null
|
|
134
|
+
/** the .trailer file's mtime in ms, else the render's */
|
|
135
|
+
fileMtime: number
|
|
136
|
+
/** a kept version (`versions/<v>.trailer` and `versions/<v>/`): it never changes */
|
|
137
|
+
version: boolean
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface TrailerInfo {
|
|
141
|
+
id: string
|
|
142
|
+
cuts: CutInfo[]
|
|
143
|
+
notes: TrailerNote[]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface TrailerDesk {
|
|
147
|
+
trailers: TrailerInfo[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** the shot on screen at `t`: the last one started */
|
|
151
|
+
export function shotAt(tl: Pick<Timeline, 'shots'>, t: number): ShotSpan | null {
|
|
152
|
+
let out: ShotSpan | null = null
|
|
153
|
+
for (const s of tl.shots) {
|
|
154
|
+
if (s.start > t + 1e-6) break
|
|
155
|
+
out = s
|
|
156
|
+
}
|
|
157
|
+
return out ?? tl.shots[0] ?? null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** where a note sits on a cut: its shot's start there plus its time into it, clamped to the shot; null when the shot is
|
|
161
|
+
* not in it */
|
|
162
|
+
export function noteTime(n: Pick<TrailerNote, 'shot' | 'shotTime'>, tl: Pick<Timeline, 'shots'>): number | null {
|
|
163
|
+
const s = tl.shots.find((x) => x.name === n.shot)
|
|
164
|
+
if (!s) return null
|
|
165
|
+
return s.start + Math.max(0, Math.min(s.length, n.shotTime))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** the cuts newest first, by the later of the file's edit and its render; the kept versions after them, newest first too */
|
|
169
|
+
export function cutsByRecent<C extends Pick<CutInfo, 'fileMtime' | 'video' | 'version'>>(cuts: readonly C[]): C[] {
|
|
170
|
+
const last = (c: C): number => Math.max(c.fileMtime, c.video?.mtime ?? 0)
|
|
171
|
+
return [...cuts].sort((a, b) => Number(a.version) - Number(b.version) || last(b) - last(a))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `m:ss.cc` */
|
|
175
|
+
export function clock(t: number): string {
|
|
176
|
+
const s = Math.max(0, t)
|
|
177
|
+
const m = Math.floor(s / 60)
|
|
178
|
+
const r = s - m * 60
|
|
179
|
+
return `${m}:${r < 10 ? '0' : ''}${r.toFixed(2)}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const TRAILER_ID_RE = /^[a-z0-9][a-z0-9-]*$/
|
|
183
|
+
export const CUT_RE = /^[a-z0-9][a-z0-9-]*$/
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// the hub's trailer routes (wiki build/trailer.md, Frame notes): a repo's cuts with their timelines and notes, a kept
|
|
2
|
+
// render with byte ranges, and the owner's frame notes, each an item of the one review store. Paths are relative to the
|
|
3
|
+
// repo hub's base. null: not a path of these
|
|
4
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join, relative } from 'node:path'
|
|
7
|
+
import { bad, NO_STORE, ranged } from './http.ts'
|
|
8
|
+
import { ID_RE, isFrame } from './model.ts'
|
|
9
|
+
import { act, appendMessage, deleteRaised, raiseItem, readAllCached, readItem } from './store.ts'
|
|
10
|
+
import { cutOf, frameNote, montagePage, trailerDir, trailerIds, trailerInfo, videoOf, viewStrip, viewVideo } from './trailer-desk.ts'
|
|
11
|
+
import { CUT_RE, noteOf, TRAILER_ID_RE, type NotePin, type NoteState, type TrailerDesk } from './trailer-model.ts'
|
|
12
|
+
|
|
13
|
+
const clamp = (v: number): number => Number(Math.min(1, Math.max(0, v)).toFixed(4))
|
|
14
|
+
|
|
15
|
+
const pinOf = (v: unknown): NotePin | undefined => {
|
|
16
|
+
const p = v as { x?: unknown; y?: unknown } | null | undefined
|
|
17
|
+
return p && typeof p.x === 'number' && typeof p.y === 'number' ? { x: p.x, y: p.y } : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STATES: readonly NoteState[] = ['open', 'done', 'wontfix']
|
|
21
|
+
|
|
22
|
+
/** a note's title in the list: its first words */
|
|
23
|
+
const titleOf = (text: string): string => {
|
|
24
|
+
const t = text.trim().split('\n')[0] ?? ''
|
|
25
|
+
return t.length > 80 ? `${t.slice(0, 79).trimEnd()}…` : t
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** the commit the tree stands at, for the record of a note; none when git does not answer */
|
|
29
|
+
function headCommit(root: string): string | undefined {
|
|
30
|
+
const r = Bun.spawnSync(['git', 'rev-parse', '--short=10', 'HEAD'], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
|
|
31
|
+
const sha = r.stdout.toString().trim()
|
|
32
|
+
return r.exitCode === 0 && /^[0-9a-f]{7,40}$/.test(sha) ? sha : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** a note as the view shows it, after a change: numbered among its trailer's notes */
|
|
36
|
+
function noteAfter(dir: string, nid: string): Response {
|
|
37
|
+
const x = readItem(dir, nid)
|
|
38
|
+
if (!x || !isFrame(x.item.subject)) return bad(`no note ${nid}`, 404)
|
|
39
|
+
const trailer = x.item.subject.trailer
|
|
40
|
+
const n = readAllCached(dir).items.filter((y) => isFrame(y.item.subject) && y.item.subject.trailer === trailer && y.item.created <= x.item.created).length
|
|
41
|
+
const note = noteOf(x, Math.max(1, n))
|
|
42
|
+
return note ? Response.json(note, { headers: NO_STORE }) : bad(`no note ${nid}`, 404)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function trailerRoutes(req: Request, path: string, root: string, dir: string): Promise<Response | null> {
|
|
46
|
+
const API = '/api/trailer'
|
|
47
|
+
if (path !== API && !path.startsWith(`${API}/`)) return null
|
|
48
|
+
try {
|
|
49
|
+
if (path === API && req.method === 'GET') {
|
|
50
|
+
const desk: TrailerDesk = { trailers: trailerIds(root).flatMap((id) => trailerInfo(root, id) ?? []) }
|
|
51
|
+
return Response.json(desk, { headers: NO_STORE })
|
|
52
|
+
}
|
|
53
|
+
const media = /^\/api\/trailer\/(video|strip)\/([^/]+)\/([^/]+)\/(\d+)\.(mp4|png)$/.exec(path)
|
|
54
|
+
if (media && req.method === 'GET') {
|
|
55
|
+
const [, kind, id = '', cut = '', version = ''] = media
|
|
56
|
+
if (!TRAILER_ID_RE.test(id) || !CUT_RE.test(cut)) return bad(`${path}?`, 404)
|
|
57
|
+
return ranged(req, kind === 'video' ? viewVideo(root, id, cut, Number(version)) : viewStrip(root, id, cut, Number(version)))
|
|
58
|
+
}
|
|
59
|
+
const mont = /^\/api\/trailer\/montage\/([^/]+)\/([^/]+)$/.exec(path)
|
|
60
|
+
if (mont && req.method === 'GET') {
|
|
61
|
+
const page = montagePage(root, mont[1] ?? '', mont[2] ?? '')
|
|
62
|
+
return page === null ? bad('no montage page: sk trailer writes it with the render', 404) : new Response(page, { headers: { 'Content-Type': 'text/markdown; charset=utf-8', ...NO_STORE } })
|
|
63
|
+
}
|
|
64
|
+
if (path === `${API}/reveal` && req.method === 'POST') {
|
|
65
|
+
// the render on screen shown in the Finder, so a cut already on this disk is never downloaded
|
|
66
|
+
const b = (await req.json()) as Record<string, unknown>
|
|
67
|
+
const dir = trailerDir(root, String(b.trailer ?? ''))
|
|
68
|
+
const c = dir ? cutOf(dir, String(b.cut ?? '')) : null
|
|
69
|
+
if (!dir || !c) return bad(`no cut ${String(b.trailer)}/${String(b.cut)}`, 404)
|
|
70
|
+
const video = videoOf(dir, c)
|
|
71
|
+
if (!existsSync(video)) return bad(`${c.cut} is not rendered yet`, 404)
|
|
72
|
+
if (process.platform !== 'darwin') return bad('the Finder is macOS only', 501)
|
|
73
|
+
const r = Bun.spawnSync(['open', '-R', video], { stdout: 'ignore', stderr: 'pipe' })
|
|
74
|
+
if (r.exitCode !== 0) return bad(`open -R: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`, 500)
|
|
75
|
+
return Response.json({ ok: true, file: relative(root, video) }, { headers: NO_STORE })
|
|
76
|
+
}
|
|
77
|
+
if (path === `${API}/notes` && req.method === 'POST') {
|
|
78
|
+
const b = (await req.json()) as Record<string, unknown>
|
|
79
|
+
const id = String(b.trailer ?? '')
|
|
80
|
+
if (!trailerDir(root, id)) return bad(`no trailer ${id}`)
|
|
81
|
+
const text = String(b.text ?? '')
|
|
82
|
+
if (text.trim() === '') return bad('a note says something')
|
|
83
|
+
const pin = pinOf(b.pin)
|
|
84
|
+
const tmp = mkdtempSync(join(tmpdir(), 'trailer-note-'))
|
|
85
|
+
try {
|
|
86
|
+
const still = join(tmp, 'still.png')
|
|
87
|
+
const subject = await frameNote(root, id, { cut: String(b.cut ?? ''), version: Number(b.version), time: Number(b.time) || 0 }, still)
|
|
88
|
+
const commit = headCommit(root)
|
|
89
|
+
const x = await raiseItem(dir, {
|
|
90
|
+
subject,
|
|
91
|
+
origin: { by: 'owner', from: 'trailer' },
|
|
92
|
+
topic: 'film',
|
|
93
|
+
title: titleOf(text),
|
|
94
|
+
text,
|
|
95
|
+
...(pin ? { rects: [{ x: clamp(pin.x), y: clamp(pin.y), w: 0, h: 0, comment: '' }] } : {}),
|
|
96
|
+
image: still,
|
|
97
|
+
...(commit ? { commit } : {}),
|
|
98
|
+
})
|
|
99
|
+
return noteAfter(dir, x.item.id)
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const one = /^\/api\/trailer\/notes\/([^/]+)\/([^/]+)$/.exec(path)
|
|
105
|
+
if (one) {
|
|
106
|
+
const [, id = '', nid = ''] = one
|
|
107
|
+
const x = trailerDir(root, id) && ID_RE.test(nid) ? readItem(dir, nid) : null
|
|
108
|
+
if (!x || !isFrame(x.item.subject) || x.item.subject.trailer !== id) return bad(`no note ${id}/${nid}`, 404)
|
|
109
|
+
if (req.method === 'DELETE') {
|
|
110
|
+
deleteRaised(dir, nid)
|
|
111
|
+
return Response.json({ ok: true }, { headers: NO_STORE })
|
|
112
|
+
}
|
|
113
|
+
if (req.method !== 'POST') return null
|
|
114
|
+
const b = (await req.json()) as Record<string, unknown>
|
|
115
|
+
if (typeof b.state === 'string' && !STATES.includes(b.state as NoteState)) return bad(`state ${b.state}?`)
|
|
116
|
+
const text = typeof b.text === 'string' ? b.text.trim() : undefined
|
|
117
|
+
if (text === '') return bad('a note says something')
|
|
118
|
+
const pin = b.pin === null ? null : pinOf(b.pin)
|
|
119
|
+
if (text !== undefined || pin !== undefined)
|
|
120
|
+
appendMessage(dir, nid, {
|
|
121
|
+
who: 'owner',
|
|
122
|
+
...(text !== undefined ? { text } : {}),
|
|
123
|
+
...(pin !== undefined ? { rects: pin ? [{ x: clamp(pin.x), y: clamp(pin.y), w: 0, h: 0, comment: '' }] : [] } : {}),
|
|
124
|
+
})
|
|
125
|
+
const reply = typeof b.reply === 'string' ? b.reply : undefined
|
|
126
|
+
if (b.state === 'open') act(dir, nid, 'reopen', 'owner')
|
|
127
|
+
else if (b.state === 'done') act(dir, nid, 'fixed', 'owner', reply?.trim() ? { note: reply } : {})
|
|
128
|
+
else if (b.state === 'wontfix') act(dir, nid, 'wontfix', 'owner', { note: reply?.trim() || 'not done' })
|
|
129
|
+
return noteAfter(dir, nid)
|
|
130
|
+
}
|
|
131
|
+
return bad(`${req.method} ${path}?`, 404)
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.error(`FAIL: ${req.method} ${path}: ${(e as Error).message}`)
|
|
134
|
+
return bad(e)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// a repo's parts of the one store (wiki build/shipkit/review.md, A project's parts): its subject kinds, topics, sources,
|
|
2
|
+
// auto rules and the dispatcher switch, declared once in a `review({...})` unit of any `<name>.shipkit.ts`. Every unit
|
|
3
|
+
// file imports this module, so it stays light: the store reads the pure part through specOf(dir), project.ts finds,
|
|
4
|
+
// imports and serves the rest
|
|
5
|
+
import { AGENT_RE, type ReviewItem, type ReviewMark, type Spec, type SpecKind, type SpecTopic, type Subject } from './model.ts'
|
|
6
|
+
import type { AddInput, ViewInput } from './store.ts'
|
|
7
|
+
|
|
8
|
+
/** one line of a kind's data view: a fact the hub and `show` print */
|
|
9
|
+
export interface Fact {
|
|
10
|
+
label: string
|
|
11
|
+
value: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** a link the hub opens */
|
|
15
|
+
export interface Link {
|
|
16
|
+
label: string
|
|
17
|
+
url: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** a text the hub shows, or a change of one shown as a diff */
|
|
21
|
+
export type ViewText = { text: string } | { before: string; after: string }
|
|
22
|
+
|
|
23
|
+
/** one subject kind: its words, its key, near rule and problems (the model's), and its data view, all server side */
|
|
24
|
+
export interface KindDef<S extends Subject = Subject> extends SpecKind {
|
|
25
|
+
key?(s: S): string | null
|
|
26
|
+
near?(a: S, b: S): boolean
|
|
27
|
+
problems?(s: S): string[]
|
|
28
|
+
facts?(x: ReviewItem): Fact[]
|
|
29
|
+
links?(x: ReviewItem): Link[]
|
|
30
|
+
/** a url the hub opens in a frame: the subject live */
|
|
31
|
+
live?(x: ReviewItem): string | null
|
|
32
|
+
text?(x: ReviewItem): ViewText | null
|
|
33
|
+
/** the `sk review add` flags this kind reads, each with its help line: `--where <zone>:<x>,<y>` */
|
|
34
|
+
flags?: Record<string, string>
|
|
35
|
+
/** the subject (and its topic) a card of this kind gets from those flags, every flag as the list of its values;
|
|
36
|
+
* throws with the reason a flag is wrong */
|
|
37
|
+
parse?(flags: Record<string, string[]>): { subject: S; topic?: string }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** what a rule or a source raises: an item to a lane, open from the start; `key` names what it is about */
|
|
41
|
+
export interface Raise {
|
|
42
|
+
title: string
|
|
43
|
+
what: string
|
|
44
|
+
lane: string
|
|
45
|
+
subject: Subject
|
|
46
|
+
key?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AutoCtx {
|
|
50
|
+
items: readonly ReviewItem[]
|
|
51
|
+
now: Date
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** a rule on a clock: its answer is raised as an item, one open item a rule and key */
|
|
55
|
+
export interface AutoRule {
|
|
56
|
+
id: string
|
|
57
|
+
every: `${number}m`
|
|
58
|
+
when(ctx: AutoCtx): Raise | readonly Raise[] | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** what the repo adds to a view: its rings, the review light */
|
|
62
|
+
export interface ViewExtra {
|
|
63
|
+
marks?: ReviewMark[]
|
|
64
|
+
light?: 'review'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** what a verb of the repo gets: its store and root, and its two outputs */
|
|
68
|
+
export interface VerbCtx {
|
|
69
|
+
dir: string
|
|
70
|
+
root: string
|
|
71
|
+
say(line: string): void
|
|
72
|
+
err(line: string): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** the live shots a draft card follows: its frames now, and the shot stopped when the card is ready */
|
|
76
|
+
export interface LiveShots {
|
|
77
|
+
frames(name: string): { image: string; history: { file: string; label: string }[] }
|
|
78
|
+
stop?(name: string, why: string): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** where items come from beyond this machine: `pull` writes each new one through raiseItem and returns their ids */
|
|
82
|
+
export interface ReviewSource {
|
|
83
|
+
id: string
|
|
84
|
+
pull(o: { dir: string; items: readonly ReviewItem[]; say: (line: string) => void }): Promise<string[]>
|
|
85
|
+
/** a test report deleted at its source; the store's folder goes after it */
|
|
86
|
+
remove?(x: ReviewItem): Promise<void>
|
|
87
|
+
/** what `show` prints of an item this source raised, beyond its words */
|
|
88
|
+
facts?(x: ReviewItem): Fact[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ReviewDef {
|
|
92
|
+
kinds?: Record<string, KindDef>
|
|
93
|
+
topics?: Record<string, SpecTopic>
|
|
94
|
+
dispatcher?: boolean
|
|
95
|
+
sources?: readonly ReviewSource[]
|
|
96
|
+
auto?: readonly AutoRule[]
|
|
97
|
+
/** a repo relative font file the animation's labels are drawn in */
|
|
98
|
+
anim?: { font?: string }
|
|
99
|
+
/** the `sk review add` and `add-view` flags of any kind (a ring over the subject), each with its help line */
|
|
100
|
+
flags?: Record<string, string>
|
|
101
|
+
/** what the repo checks and fills on a card before it is written (marks read from its own shots, the review light,
|
|
102
|
+
* a lane from a commit, a refused contact sheet); throws to refuse it */
|
|
103
|
+
add?(input: AddInput, flags: Record<string, string[]>): AddInput | Promise<AddInput>
|
|
104
|
+
/** the same on a view added to an open card */
|
|
105
|
+
addView?(view: ViewInput, flags: Record<string, string[]>): ViewExtra | Promise<ViewExtra>
|
|
106
|
+
/** a draft card's live shots: `sk review add --wip --live <name>`, `ready` stops the shot */
|
|
107
|
+
live?: LiveShots
|
|
108
|
+
/** the repo's own verbs under `sk review <verb>`: a report replayed in the game, the town composer */
|
|
109
|
+
verbs?: Record<string, { description: string; usage?: string; run(args: string[], ctx: VerbCtx): number | Promise<number> }>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type ReviewUnit = ReviewDef & { kind: 'review' }
|
|
113
|
+
|
|
114
|
+
const WORD = /^[a-z][a-z0-9-]*$/
|
|
115
|
+
|
|
116
|
+
/** why a review unit cannot be read, or null */
|
|
117
|
+
export function reviewProblem(d: ReviewDef): string | null {
|
|
118
|
+
for (const [k, v] of Object.entries(d.kinds ?? {})) {
|
|
119
|
+
if (!WORD.test(k)) return `review: kind '${k}' is a lowercase word`
|
|
120
|
+
if (typeof v.label !== 'string' || v.label.trim() === '') return `review: kind '${k}' has its label`
|
|
121
|
+
for (const f of ['key', 'near', 'problems', 'facts', 'links', 'live', 'text'] as const)
|
|
122
|
+
if (v[f] !== undefined && typeof v[f] !== 'function') return `review: kind '${k}'.${f} is a function`
|
|
123
|
+
}
|
|
124
|
+
for (const [k, v] of Object.entries(d.topics ?? {})) {
|
|
125
|
+
if (!WORD.test(k)) return `review: topic '${k}' is a lowercase word`
|
|
126
|
+
if (typeof v.label !== 'string' || v.label.trim() === '') return `review: topic '${k}' has its label`
|
|
127
|
+
}
|
|
128
|
+
const ids = new Set<string>()
|
|
129
|
+
for (const r of d.auto ?? []) {
|
|
130
|
+
if (!AGENT_RE.test(r.id ?? '')) return `review: auto rule '${String(r.id)}' is a word (${AGENT_RE.source})`
|
|
131
|
+
if (ids.has(r.id)) return `review: two auto rules named '${r.id}'`
|
|
132
|
+
ids.add(r.id)
|
|
133
|
+
if (!/^\d+(\.\d+)?m$/.test(r.every ?? '')) return `review: auto rule '${r.id}' runs every '<minutes>m', like '10m'`
|
|
134
|
+
if (typeof r.when !== 'function') return `review: auto rule '${r.id}'.when is a function`
|
|
135
|
+
}
|
|
136
|
+
const core = new Set(['ls', 'add', 'add-view', 'show', 'inbox', 'seen', 'reply', 'withdraw', 'reopen', 'fixed', 'wontfix', 'claim', 'release', 'blocked', 'done', 'dispatcher', 'dispatch', 'ask', 'drop', 'reassign', 'next', 'topics', 'board', 'ready', 'delete', 'marks', 'anim', 'auto', 'watch'])
|
|
137
|
+
for (const [v, def] of Object.entries(d.verbs ?? {})) {
|
|
138
|
+
if (!WORD.test(v)) return `review: verb '${v}' is a lowercase word`
|
|
139
|
+
if (core.has(v)) return `review: verb '${v}' is one of sk review's own`
|
|
140
|
+
if (typeof def.run !== 'function') return `review: verb '${v}'.run is a function`
|
|
141
|
+
}
|
|
142
|
+
for (const [k, v] of Object.entries(d.kinds ?? {})) if (v.flags && typeof v.parse !== 'function') return `review: kind '${k}' reads flags, so it has a parse`
|
|
143
|
+
if (d.live && typeof d.live.frames !== 'function') return 'review: live.frames is a function'
|
|
144
|
+
for (const s of d.sources ?? []) {
|
|
145
|
+
if (!AGENT_RE.test(s.id ?? '')) return `review: source '${String(s.id)}' is a word`
|
|
146
|
+
if (typeof s.pull !== 'function') return `review: source '${s.id}'.pull is a function`
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function review(d: ReviewDef): ReviewUnit {
|
|
152
|
+
const why = reviewProblem(d)
|
|
153
|
+
if (why !== null) throw new Error(why)
|
|
154
|
+
return { ...d, kind: 'review' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** the model's part of a unit: its kinds, topics and dispatcher switch */
|
|
158
|
+
export const specOfUnit = (u: ReviewDef | null): Spec => ({ kinds: u?.kinds ?? {}, topics: u?.topics ?? {}, dispatcher: u?.dispatcher === true })
|
|
159
|
+
|
|
160
|
+
/** the words of a spec the browser reads: no function crosses */
|
|
161
|
+
export const specData = (spec: Spec): Spec => ({
|
|
162
|
+
kinds: Object.fromEntries(Object.entries(spec.kinds).map(([k, v]) => [k, { label: v.label, ...(v.icon ? { icon: v.icon } : {}) }])),
|
|
163
|
+
topics: spec.topics,
|
|
164
|
+
dispatcher: spec.dispatcher,
|
|
165
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// who listens: each `sk review watch` registers one file per process beside the store (`.shipkit/review-watchers/`),
|
|
2
|
+
// out of its watch, so a registration never wakes the watchers. A file alone is never trusted: its pid is probed
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { WatchFilter, Watcher } from './model.ts'
|
|
6
|
+
|
|
7
|
+
const watchersDir = (dir: string): string => join(dir, '..', 'review-watchers')
|
|
8
|
+
|
|
9
|
+
/** the process is there: a signal 0 reaches it, or it exists under another user */
|
|
10
|
+
export function pidAlive(pid: number): boolean {
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0)
|
|
13
|
+
return true
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return (e as NodeJS.ErrnoException).code === 'EPERM'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** this process watches what `filter` keeps until it exits: its file goes on exit and on a signal */
|
|
20
|
+
export function registerWatch(dir: string, filter: WatchFilter, now = new Date()): void {
|
|
21
|
+
const folder = watchersDir(dir)
|
|
22
|
+
mkdirSync(folder, { recursive: true })
|
|
23
|
+
const file = join(folder, `${process.pid}.json`)
|
|
24
|
+
const w: Watcher = { pid: process.pid, session: filter.session, lane: filter.lane, started: now.toISOString() }
|
|
25
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify(w)}\n`)
|
|
26
|
+
renameSync(`${file}.tmp`, file)
|
|
27
|
+
const gone = (): void => rmSync(file, { force: true })
|
|
28
|
+
process.on('exit', gone)
|
|
29
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const)
|
|
30
|
+
process.on(sig, () => {
|
|
31
|
+
gone()
|
|
32
|
+
process.exit(0)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** every live watcher, by pid; a file whose process is gone is deleted */
|
|
37
|
+
export function liveWatchers(dir: string, alive: (pid: number) => boolean = pidAlive): Watcher[] {
|
|
38
|
+
const folder = watchersDir(dir)
|
|
39
|
+
if (!existsSync(folder)) return []
|
|
40
|
+
const out: Watcher[] = []
|
|
41
|
+
for (const name of readdirSync(folder)) {
|
|
42
|
+
if (!name.endsWith('.json')) continue
|
|
43
|
+
const file = join(folder, name)
|
|
44
|
+
let w: Watcher | null = null
|
|
45
|
+
try {
|
|
46
|
+
w = JSON.parse(readFileSync(file, 'utf8')) as Watcher
|
|
47
|
+
} catch {
|
|
48
|
+
w = null
|
|
49
|
+
}
|
|
50
|
+
if (w && Number.isInteger(w.pid) && alive(w.pid)) out.push({ pid: w.pid, session: w.session ?? null, lane: w.lane ?? null, started: w.started })
|
|
51
|
+
else rmSync(file, { force: true })
|
|
52
|
+
}
|
|
53
|
+
return out.sort((a, b) => a.pid - b.pid)
|
|
54
|
+
}
|