@vfrey/vfrey 1.0.0-beta.0 → 1.0.0-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vfrey/vfrey",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "vfrey worker. Generate, render, publish, engage, and collect metrics.",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -52,7 +52,7 @@
52
52
  "yauzl": "3.4.0"
53
53
  },
54
54
  "optionalDependencies": {
55
- "@esbuild/linux-x64": "0.28.1",
55
+ "@esbuild/linux-x64": "0.28.2",
56
56
  "@remotion/compositor-linux-x64-gnu": "4.0.537",
57
57
  "@rollup/rollup-linux-x64-gnu": "4.64.12",
58
58
  "@rspack/binding-linux-x64-gnu": "1.7.11"
@@ -20,7 +20,7 @@ The person asks in plain language; you run `npx vfrey`. Everything lives in a pr
20
20
  - "show me", "open the dashboard", "where is my video": `npx vfrey open [run id | video title or path]` (reuses the project's dashboard or starts it, prints the URL; `--no-browser` only prints). Never start the dashboard any other way.
21
21
  - "my videos": `npx vfrey videos` (newest first, with paths); "my other projects": `npx vfrey projects`.
22
22
  - "what should I make", "trending in X": `npx vfrey topics --niche X` (ids: `topics niches`, free); cite the evidence, then `tick --now --about "<title>"`. Topics need an account and credits (`login`, `whoami`); a `✗`/`Fix:` says which is missing. "find creators / KOLs in X": `creators search --niche X` (Research plan). Outreach: draft with `npx vfrey mail draft`; NEVER approve or send unless the person says so for that specific draft; mail needs the Mail add-on.
23
- - "sign in", "my plan": `npx vfrey login` (prints a link and a code to relay) or `npx vfrey whoami`. Free features never need an account.
23
+ - "sign in", "my plan": `npx vfrey login` (prints a link and a code to relay) or `npx vfrey whoami`. Free features never need an account. Team sync: `npx vfrey cloud link|push [--videos]|pull|status` (newest wins; the other copy is kept as `<file>.conflict-<time>`).
24
24
  - Old machine data, update, engines: `npx vfrey desk snapshot --json`, `npx vfrey update`, `npx vfrey engine list`. A run with `status: awaiting_review` waits for the person (series review on): tell them, then `npx vfrey desk job approve|reject --id <id> [--reason T]`. `npx vfrey --help` and `npx vfrey <command> --help` list the rest.
25
25
 
26
26
  ## Make a video
@@ -28,7 +28,7 @@ The person asks in plain language; you run `npx vfrey`. Everything lives in a pr
28
28
  You are the writer and the designer. vfrey has no model of its own; without your script the words are template filler, and without your scene the picture is a plain fallback.
29
29
 
30
30
  1. **Script.** Write `scripts/<idea>.json`: `{ "title", "hook", "lines": [...], "hashtags": [...] }` (`npx vfrey tick --script-template` prints one). 6 to 10 short spoken lines (4 to 12 allowed, at most 220 characters each), hook first, title at most 100 characters, 1 to 8 hashtags starting with `#`. Be accurate and plain: no invented statistics, numbers, quotes or sources; leave out what you are not sure of. Use the topic's key words (the quality check rejects a script that never mentions its subject). A bad file gets a `✗` line and a `Fix:` line; a rejected script prints its reason. Fix and rerun.
31
- 2. **Scene.** Every idea gets its own picture ("what's the difference?", "3 facts", "how a mutex works" look different). There are no fixed templates: never reuse one scene for a new idea.
31
+ 2. **Scene.** Always design a scene for the idea (scene init, design, `--frame` preview, then tick with `--scene` and `--engine`). Use the default template only if the person explicitly asks for a quick text-only video, and say so. Every idea gets its own picture ("what's the difference?", "3 facts", "how a mutex works" look different). There are no fixed templates: never reuse one scene for a new idea.
32
32
  - `npx vfrey scene init scenes/<idea>` writes a commented starter (`index.html`; `--engine remotion` writes `index.tsx`). Replace it. Draw SVGs yourself and `<img>` them from the folder.
33
33
  - `index.html` renders with `vfrey-framey` (or `hyperframes`), `index.tsx` with `remotion`; a mismatch stops with a `Fix:` line. Always pass `--engine` with a scene.
34
34
  - vfrey supplies in `DIR/vfrey/` (rewritten at render, do not edit): `timing.json`, `voice.wav`, `vfrey.js`, local `gsap.min.js`, `fonts/fonts.css` (Titan One, JetBrains Mono, Google Sans Flex), `assets/` (stick figures `stick-*.svg`, glyphs `glyph-*.svg`).
@@ -46,7 +46,7 @@ You are the writer and the designer. vfrey has no model of its own; without your
46
46
  - `npx vfrey scene blocks [--category X]` lists them; `npx vfrey scene add BLOCK scenes/<idea>` copies one into the scene and prints the snippet to paste into `#root`.
47
47
  - Time it with `vfrey.block("#block-<name>", t)` inside `vfrey.onLine` or `vfrey.at`; mount each block once per scene; HTML scenes only. Placement, fit modes and restyling are in `scenes/blocks.md`.
48
48
  5. **Make it.** `npx vfrey tick --now --about "<subject>" --script scripts/<idea>.json --scene scenes/<idea> --engine vfrey-framey`. Subject goes in `--about` (no feed is read; it runs as its own job). Without `--about` the default series reads the Federal Reserve feed: say so and ask once for a topic. Leave `--script` out only if they say to let vfrey write it, and then say the words are generic. Never set `--dry-run` for a real video (it skips the file). `render --subject` is only a silent, untracked preview.
49
- 6. **Tell them.** The summary has `✓ Video ready: <path>` (length and size from ffprobe: quote them), the transcript, the upload outcome, and `See it: http://127.0.0.1:<port>/runs/<id>` (live; the tick starts the dashboard). Relay the readable video path in `videos/` and the See it link; `npx vfrey open` shows it. Say `Scene: agent-written`; with the default template say so. `--json` has `{processed, jobs:[{id, series, status, mp4, transcript, durationSec, publish, engage, runUrl, scene, engine}]}`. Nothing made: the summary says why (`not due`, `paused`, `job <id> in progress` with its `npx vfrey desk job cancel --id <id>` fix). Never move or rename videos by hand; vfrey tracks the paths.
49
+ 6. **Tell them.** The summary has `✓ Video ready: <path>` (length and size from ffprobe: quote them), the transcript, the upload outcome, and `See it: http://127.0.0.1:<port>/runs/<id>`. That link is live because the tick starts the dashboard: relay it and the readable video path in `videos/` as they are, without hedging. Only if `curl` to it fails, run `npx vfrey open`. Say `Scene: agent-written`; with the default template say so. `--json` has `{processed, jobs:[{id, series, status, mp4, transcript, durationSec, publish, engage, runUrl, scene, engine}]}`. Nothing made: the summary says why (`not due`, `paused`, `job <id> in progress` with its `npx vfrey desk job cancel --id <id>` fix). Never move or rename videos by hand; vfrey tracks the paths.
50
50
 
51
51
  Voice is on (macOS `say`; `TTS_PROVIDER=openai` is the paid voice, `none` is silent). A machine without it stops with a `Fix:` line. No model key is needed. To check a dashboard link is live: `curl -s -o /dev/null -w "%{http_code}" <url>` expects `200`.
52
52
 
package/src/api/client.ts CHANGED
@@ -14,6 +14,13 @@ import {
14
14
  type NicheRequestResponse,
15
15
  type NichesResponse,
16
16
  type TopicsResponse,
17
+ type CloudConfirmResponse,
18
+ type CloudFileDecl,
19
+ type CloudFileView,
20
+ type CloudPlanResponse,
21
+ type CloudProjectView,
22
+ type CloudPullResponse,
23
+ type CloudRunBody,
17
24
  type CreatorAlertsResponse,
18
25
  type CreatorProfileView,
19
26
  type CreatorSearchResponse,
@@ -124,6 +131,10 @@ export class ApiClient {
124
131
  if (status === 429 && err?.code === "rate_limited") {
125
132
  return new ApiFailure(message || "Too many requests.", "wait a minute and try again.", 429, "rate_limited");
126
133
  }
134
+ if (status === 429 && (err as { meter?: unknown } | null)?.meter === "storage") {
135
+ const url = err?.upgradeUrl ? clean(err.upgradeUrl, token) : "";
136
+ return new ApiFailure(message || "Your plan's cloud storage is full.", `free space (delete old videos from the cloud project) or ${url ? `upgrade at ${url}` : "upgrade your plan"}. Scenes and scripts are small; push without --videos.`, 429, "limit_reached");
137
+ }
127
138
  if (status === 429) {
128
139
  const resets = err?.resetsAt ? clean(err.resetsAt, token) : "";
129
140
  const url = err?.upgradeUrl ? clean(err.upgradeUrl, token) : "";
@@ -260,6 +271,74 @@ export class ApiClient {
260
271
  return this.request<OrgSwitchResponse>("POST", "/api/v1/orgs/switch", { orgId });
261
272
  }
262
273
 
274
+ // ---------- Hosted projects and sync (spec 019 B) ----------
275
+
276
+ cloudLink(body: { localProjectId: string; name: string }): Promise<{ project: CloudProjectView }> {
277
+ return this.request("POST", "/api/v1/cloud/projects", body);
278
+ }
279
+
280
+ cloudFiles(projectId: string): Promise<{ files: CloudFileView[] }> {
281
+ return this.request("GET", `/api/v1/cloud/projects/${encodeURIComponent(projectId)}/files`);
282
+ }
283
+
284
+ /** The diff: which of these files are new or changed, and a signed upload URL for each. */
285
+ cloudPlan(projectId: string, files: CloudFileDecl[]): Promise<CloudPlanResponse> {
286
+ return this.request("POST", `/api/v1/cloud/projects/${encodeURIComponent(projectId)}/files`, { files });
287
+ }
288
+
289
+ cloudConfirm(projectId: string, files: CloudFileDecl[]): Promise<CloudConfirmResponse> {
290
+ return this.request("POST", `/api/v1/cloud/projects/${encodeURIComponent(projectId)}/files/confirm`, { files });
291
+ }
292
+
293
+ cloudPushRuns(projectId: string, runs: CloudRunBody[]): Promise<{ runs: number }> {
294
+ return this.request("POST", `/api/v1/cloud/projects/${encodeURIComponent(projectId)}/runs`, { runs });
295
+ }
296
+
297
+ cloudPull(projectId: string, query: { since?: string | null; kinds?: string }): Promise<CloudPullResponse> {
298
+ const params = new URLSearchParams();
299
+ if (query.since) params.set("since", query.since);
300
+ if (query.kinds) params.set("kinds", query.kinds);
301
+ const qs = params.toString();
302
+ return this.request("GET", `/api/v1/cloud/projects/${encodeURIComponent(projectId)}/pull${qs ? `?${qs}` : ""}`);
303
+ }
304
+
305
+ /**
306
+ * One transfer to or from a signed storage URL. The vfrey token is NEVER sent here: the signed URL is the credential,
307
+ * and it may live on another host. https only (or this machine). Throws ApiFailure; no redirects.
308
+ */
309
+ async transfer(method: "PUT" | "GET", url: string, init: { headers?: Record<string, string>; body?: BodyInit; length?: number; timeoutMs?: number } = {}): Promise<Response> {
310
+ let target: URL;
311
+ try {
312
+ target = new URL(url);
313
+ } catch {
314
+ throw new ApiFailure("The server sent a storage link that is not a web address.", "update vfrey with `npx vfrey update`, or try again.");
315
+ }
316
+ const loopback = target.hostname === "localhost" || target.hostname === "127.0.0.1" || target.hostname === "[::1]";
317
+ if (target.protocol !== "https:" && !(target.protocol === "http:" && loopback)) {
318
+ throw new ApiFailure(`The storage link (${target.host}) is not https, so vfrey will not use it.`, "check the vfrey server's storage settings.");
319
+ }
320
+ const headers: Record<string, string> = { ...(init.headers ?? {}) };
321
+ delete headers.authorization;
322
+ delete headers.Authorization;
323
+ if (init.length !== undefined) headers["content-length"] = String(init.length);
324
+ let response: Response;
325
+ try {
326
+ response = await this.fetchFn(target.href, {
327
+ method,
328
+ headers,
329
+ ...(init.body !== undefined ? { body: init.body, duplex: "half" } : {}),
330
+ redirect: "error",
331
+ signal: AbortSignal.timeout(init.timeoutMs ?? 600_000),
332
+ } as RequestInit);
333
+ } catch {
334
+ throw new ApiFailure(`Could not reach the storage host ${target.host}.`, "check your connection and run the command again. Only what changed is sent.");
335
+ }
336
+ if (!response.ok) {
337
+ throw new ApiFailure(`Storage answered ${response.status} for a ${method === "PUT" ? "upload" : "download"}.`, response.status === 403 ? "the signed link expired; run the command again." : "run the command again. Only what changed is sent.", response.status);
338
+ }
339
+ return response;
340
+ }
341
+
263
342
  /** Revokes the calling token. */
264
343
  async revokeCurrentToken(): Promise<void> {
265
344
  const { status, json } = await this.send("DELETE", "/api/v1/tokens/current");
@@ -0,0 +1,197 @@
1
+ // The local side of `vfrey cloud` (spec 019 B): which files sync, hashing, the sync state, and safe writes.
2
+ // The path rules here mirror the console's; the server checks them again, and pull checks what the server sends.
3
+ import { createHash } from "node:crypto";
4
+ import { createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
5
+ import path from "node:path";
6
+
7
+ export type Kind = "scene" | "script" | "video" | "transcript" | "meta";
8
+
9
+ export const MAX_TEXT_BYTES = 5 * 1024 * 1024;
10
+ export const MAX_VIDEO_BYTES = 500 * 1024 * 1024;
11
+ /** What one push may change, and how many files one request names (the server's caps). */
12
+ export const MAX_PUSH_BYTES = 1024 * 1024 * 1024;
13
+ export const MAX_FILES_PER_REQUEST = 200;
14
+
15
+ const SCENE_EXT = new Set(["html", "htm", "css", "js", "mjs", "ts", "tsx", "jsx", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "woff", "woff2", "txt", "md"]);
16
+ const SCRIPT_EXT = new Set(["json", "txt", "md"]);
17
+ const VIDEO_EXT = new Set(["mp4", "webm"]);
18
+ const TRANSCRIPT_EXT = new Set(["txt", "srt", "vtt"]);
19
+
20
+ export type PathCheck = { ok: true; kind: Kind } | { ok: false; reason: string };
21
+
22
+ /** Only relative paths under scenes/, scripts/ and videos/: no "..", no absolute path, no hidden or conflict files. */
23
+ export function checkSyncPath(p: unknown): PathCheck {
24
+ if (typeof p !== "string" || p.length === 0) return { ok: false, reason: "empty path" };
25
+ if (p.length > 300) return { ok: false, reason: "path too long" };
26
+ // eslint-disable-next-line no-control-regex
27
+ if (/[\u0000-\u001f\u007f]/.test(p)) return { ok: false, reason: "control characters" };
28
+ if (p.includes("\\") || p.startsWith("/") || /^[A-Za-z]:/.test(p)) return { ok: false, reason: "not a relative path" };
29
+ const parts = p.split("/");
30
+ if (parts.some((x) => x === "" || x === "." || x === "..")) return { ok: false, reason: "empty, . or .. part" };
31
+ if (parts.some((x) => x.startsWith("."))) return { ok: false, reason: "hidden file" };
32
+ if (parts.some((x) => x.length > 120)) return { ok: false, reason: "name too long" };
33
+ if (p.includes(".conflict-")) return { ok: false, reason: "conflict copy" };
34
+ if (parts.length < 2) return { ok: false, reason: "outside scenes/, scripts/, videos/" };
35
+ const last = parts[parts.length - 1]!;
36
+ const ext = last.includes(".") ? last.split(".").pop()!.toLowerCase() : "";
37
+ if (parts[0] === "scenes") return SCENE_EXT.has(ext) ? { ok: true, kind: "scene" } : { ok: false, reason: `.${ext || "(none)"} is not a scene file type` };
38
+ if (parts[0] === "scripts") return SCRIPT_EXT.has(ext) ? { ok: true, kind: "script" } : { ok: false, reason: `.${ext || "(none)"} is not a script file type` };
39
+ if (parts[0] === "videos") {
40
+ if (VIDEO_EXT.has(ext)) return { ok: true, kind: "video" };
41
+ if (TRANSCRIPT_EXT.has(ext)) return { ok: true, kind: "transcript" };
42
+ if (ext === "json") return { ok: true, kind: "meta" };
43
+ return { ok: false, reason: `.${ext || "(none)"} is not a video or transcript file type` };
44
+ }
45
+ return { ok: false, reason: "outside scenes/, scripts/, videos/" };
46
+ }
47
+
48
+ export const maxBytes = (kind: Kind): number => (kind === "video" ? MAX_VIDEO_BYTES : MAX_TEXT_BYTES);
49
+
50
+ export interface LocalFile {
51
+ path: string;
52
+ abs: string;
53
+ kind: Kind;
54
+ size: number;
55
+ sha256: string;
56
+ mtime: Date;
57
+ }
58
+ export interface Skipped {
59
+ path: string;
60
+ reason: string;
61
+ }
62
+
63
+ export function sha256File(file: string): Promise<string> {
64
+ return new Promise((resolve, reject) => {
65
+ const hash = createHash("sha256");
66
+ createReadStream(file).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
67
+ });
68
+ }
69
+
70
+ /** Walks scenes/ and scripts/ (and videos/ with `videos`). Symlinks are never followed; anything that cannot sync is listed, not hidden. */
71
+ export async function scanProject(root: string, opts: { videos: boolean }): Promise<{ files: LocalFile[]; skipped: Skipped[] }> {
72
+ const files: LocalFile[] = [];
73
+ const skipped: Skipped[] = [];
74
+ const roots = ["scenes", "scripts", ...(opts.videos ? ["videos"] : [])];
75
+ const walk = async (rel: string): Promise<void> => {
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
79
+ } catch {
80
+ return;
81
+ }
82
+ entries.sort((a, b) => (a.name < b.name ? -1 : 1));
83
+ for (const entry of entries) {
84
+ const next = `${rel}/${entry.name}`;
85
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name.includes(".conflict-")) continue;
86
+ if (entry.isSymbolicLink()) {
87
+ skipped.push({ path: next, reason: "symbolic link" });
88
+ continue;
89
+ }
90
+ if (entry.isDirectory()) {
91
+ await walk(next);
92
+ continue;
93
+ }
94
+ if (!entry.isFile()) continue;
95
+ const checked = checkSyncPath(next);
96
+ if (!checked.ok) {
97
+ skipped.push({ path: next, reason: checked.reason });
98
+ continue;
99
+ }
100
+ const abs = path.join(root, next);
101
+ const st = statSync(abs);
102
+ if (st.size > maxBytes(checked.kind)) {
103
+ skipped.push({ path: next, reason: `${Math.round(st.size / 1024 / 1024)} MB is over the ${Math.round(maxBytes(checked.kind) / 1024 / 1024)} MB limit for a ${checked.kind}` });
104
+ continue;
105
+ }
106
+ files.push({ path: next, abs, kind: checked.kind, size: st.size, sha256: await sha256File(abs), mtime: st.mtime });
107
+ }
108
+ };
109
+ for (const r of roots) {
110
+ const top = path.join(root, r);
111
+ if (existsSync(top) && lstatSync(top).isDirectory()) await walk(r);
112
+ }
113
+ return { files, skipped };
114
+ }
115
+
116
+ // ---------- sync state: what the cloud held the last time this machine pushed or pulled ----------
117
+
118
+ export interface SyncState {
119
+ version: 1;
120
+ projectId: string;
121
+ cursor: string | null;
122
+ /** path -> sha256 the cloud held when this machine last saw it. */
123
+ files: Record<string, string>;
124
+ }
125
+
126
+ const stateFile = (root: string) => path.join(root, ".vfrey", "cloud-sync.json");
127
+
128
+ export function readState(root: string, projectId: string): SyncState {
129
+ const fresh: SyncState = { version: 1, projectId, cursor: null, files: {} };
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(stateFile(root), "utf8")) as Partial<SyncState>;
132
+ if (parsed.version !== 1 || parsed.projectId !== projectId || typeof parsed.files !== "object" || parsed.files === null) return fresh;
133
+ return { version: 1, projectId, cursor: typeof parsed.cursor === "string" ? parsed.cursor : null, files: { ...parsed.files } };
134
+ } catch {
135
+ return fresh;
136
+ }
137
+ }
138
+
139
+ export function writeState(root: string, state: SyncState): void {
140
+ mkdirSync(path.dirname(stateFile(root)), { recursive: true });
141
+ const tmp = `${stateFile(root)}.tmp`;
142
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
143
+ renameSync(tmp, stateFile(root));
144
+ }
145
+
146
+ // ---------- classification ----------
147
+
148
+ export type Verdict = "same" | "new" | "push" | "pull" | "conflict";
149
+
150
+ /**
151
+ * One file, three facts: its hash here, its hash in the cloud, and the hash the cloud had when we last synced.
152
+ * Changed only here -> push. Changed only in the cloud -> pull. Changed in both (or never synced and different) -> conflict.
153
+ */
154
+ export function classify(local: string | undefined, cloud: string | undefined, base: string | undefined): Verdict {
155
+ if (local !== undefined && cloud === undefined) return "new";
156
+ if (local === undefined) return "pull";
157
+ if (local === cloud) return "same";
158
+ if (base === cloud) return "push";
159
+ if (base === local) return "pull";
160
+ return "conflict";
161
+ }
162
+
163
+ /** `<file>.conflict-<ISO time>`, with colons and dots made safe for every filesystem. A clash gets a counter. */
164
+ export function conflictPath(abs: string, at: Date): string {
165
+ const stamp = at.toISOString().replace(/[:.]/g, "-");
166
+ let candidate = `${abs}.conflict-${stamp}`;
167
+ for (let n = 2; existsSync(candidate); n += 1) candidate = `${abs}.conflict-${stamp}-${n}`;
168
+ return candidate;
169
+ }
170
+
171
+ /**
172
+ * Where a path from the server lands, or an error. The path must pass the sync rules, stay inside the project root, and
173
+ * no part of it that exists may be a symbolic link (a link could send a write outside the project).
174
+ */
175
+ export function safeTarget(root: string, rel: string): { ok: true; abs: string } | { ok: false; reason: string } {
176
+ const checked = checkSyncPath(rel);
177
+ if (!checked.ok) return { ok: false, reason: checked.reason };
178
+ let cur = root;
179
+ for (const part of rel.split("/")) {
180
+ cur = path.join(cur, part);
181
+ try {
182
+ if (lstatSync(cur).isSymbolicLink()) return { ok: false, reason: "a part of the path is a symbolic link" };
183
+ } catch {
184
+ break;
185
+ }
186
+ }
187
+ const abs = path.join(root, ...rel.split("/"));
188
+ if (path.relative(root, abs).startsWith("..") || path.isAbsolute(path.relative(root, abs))) return { ok: false, reason: "outside the project" };
189
+ return { ok: true, abs };
190
+ }
191
+
192
+ export function formatSize(n: number): string {
193
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
194
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
195
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
196
+ return `${n} B`;
197
+ }
@@ -0,0 +1,75 @@
1
+ // Run metadata for `vfrey cloud push`: stages, checks and a publications summary from the project database.
2
+ // Only summaries leave the machine: no script text, no tokens, no account secrets.
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { storedVideoPath } from "../project/videos.js";
7
+ import type { CloudRunBody } from "./types.js";
8
+
9
+ const MAX_RUNS = 100;
10
+
11
+ type Row = { id: string; stage: string; status: string; payload: string; created_at: string; finished_at?: string | null };
12
+ type Payload = {
13
+ content?: { title?: unknown; outputPath?: unknown } | null;
14
+ rendered?: { durationSec?: unknown } | null;
15
+ checks?: unknown;
16
+ stages?: unknown;
17
+ };
18
+
19
+ const str = (v: unknown, max: number): string | null => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
20
+
21
+ /** The newest pipeline runs in `<dataDir>/pipeline.sqlite`. `videoPaths` are the project-relative videos that are (or just were) in the cloud: only those are named. */
22
+ export function collectRuns(dataDir: string, root: string, videoPaths: ReadonlySet<string>): CloudRunBody[] {
23
+ const file = path.join(dataDir, "pipeline.sqlite");
24
+ if (!existsSync(file)) return [];
25
+ const db = new DatabaseSync(file, { readOnly: true });
26
+ try {
27
+ const rows = db.prepare(`SELECT * FROM jobs WHERE kind = 'pipeline' ORDER BY created_at DESC, id DESC LIMIT ${MAX_RUNS}`).all() as unknown as Row[];
28
+ const pubs = db.prepare(`SELECT platform, external_id, published_at, title FROM publications WHERE job_id = ? ORDER BY published_at`);
29
+ return rows.map((row) => {
30
+ let payload: Payload = {};
31
+ try {
32
+ payload = JSON.parse(row.payload) as Payload;
33
+ } catch {
34
+ payload = {};
35
+ }
36
+ const stored = payload.content?.outputPath;
37
+ let videoPath: string | null = null;
38
+ if (typeof stored === "string" && stored.endsWith(".mp4")) {
39
+ const rel = path.relative(root, storedVideoPath(dataDir, stored)).split(path.sep).join("/");
40
+ if (videoPaths.has(rel)) videoPath = rel;
41
+ }
42
+ const checks = (Array.isArray(payload.checks) ? payload.checks : []) as Array<Record<string, unknown>>;
43
+ const stages = Array.isArray(payload.stages) ? (payload.stages as Array<Record<string, unknown>>) : [{ stage: row.stage, status: row.status }];
44
+ const length = payload.rendered?.durationSec;
45
+ return {
46
+ localRunId: row.id,
47
+ title: str(payload.content?.title, 200),
48
+ status: row.status,
49
+ stage: row.stage,
50
+ startedAt: row.created_at,
51
+ finishedAt: row.finished_at ?? null,
52
+ durationSec: typeof length === "number" && Number.isFinite(length) ? length : null,
53
+ videoPath,
54
+ stages: stages.slice(0, 20).map((s) => ({ stage: str(s.stage, 60) ?? "", status: str(s.status, 40) ?? "", ...(str(s.at, 40) ? { at: str(s.at, 40)! } : {}) })),
55
+ checks: checks.slice(0, 100).map((c) => ({
56
+ id: str(c.id, 80) ?? "",
57
+ ...(str(c.phase, 20) ? { phase: str(c.phase, 20)! } : {}),
58
+ ...(str(c.severity, 20) ? { severity: str(c.severity, 20)! } : {}),
59
+ status: str(c.status, 20) ?? "",
60
+ ...(str(c.message, 300) ? { message: str(c.message, 300)! } : {}),
61
+ fix: str(c.fix, 300),
62
+ ...(typeof c.attempt === "number" ? { attempt: c.attempt } : {}),
63
+ })),
64
+ publications: (pubs.all(row.id) as Array<{ platform: string; external_id: string | null; published_at: string | null; title: string | null }>).slice(0, 20).map((p) => ({
65
+ platform: p.platform,
66
+ externalId: p.external_id,
67
+ publishedAt: p.published_at,
68
+ title: str(p.title, 200),
69
+ })),
70
+ };
71
+ });
72
+ } finally {
73
+ db.close();
74
+ }
75
+ }