fix(studio): review round 1 for imported-course editing

- Media exclusions move from the editable root .gitignore to the repo's
  info/exclude, and dirtyFiles never counts files listed in media.json, so an
  author's or remote's .gitignore cannot block publish or Sync. Publish and
  Sync refuse commits that track a file over referenced media.
- Importer jobs (code-repo, pdf-deck, transcribe) and the cover route run
  under the course lock now shared with the studio (core withCourseLock) and
  re-check the course inside it; after conversion they fail with "this course
  is now edited in the studio". Convert answers 409 while an import job for
  the course is queued or running, and the job re-checks.
- Crash-safe conversion: the import folder is hidden (.converting-<id>) before
  the published worktree exists; cross-filesystem moves copy into a staging
  dir, fsync, verify size and sha256, then rename; the source is retired as
  .converted-<id>.<n>. Recovery runs at server start and before every convert;
  a re-run of a finished conversion reports success. The loader skips
  dot-folders.
- Minor: no raw NUL bytes in source, link fallback only on EXDEV/EPERM/EMLINK,
  referenced media are not read whole by the editor, missing media are logged,
  mediaDir JSDoc, docs on per-author copies across filesystems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 20:53:50 +01:00
1 parent 08e62ff683
commit 771d755da4
17 files changed
+618 -88

No files matched your search

+13
View File
@@ -1,6 +1,7 @@
import { mkdir, readdir, rename, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import type { ServerContext } from "@stc/contracts";
import { withCourseLock } from "@stc/core";
import { readRawManifest, writeManifest, type RawManifest } from "./util/manifest";
import { slugify, uniqueId } from "./util/text";
import { extractFrame, type Tools } from "./media";
@@ -65,9 +66,21 @@ export async function publishCourse(d: Deps, stage: string, id: string): Promise
export interface ManagedCourse { id: string; dir: string; manifest: RawManifest }
export const STUDIO_OWNED = "this course is now edited in the studio; add files there instead";
/**
* Runs importer work that changes an existing course under the course lock shared with the studio (core
* `withCourseLock`), after checking, inside the lock, that the course is still an importer folder. A conversion to a
* studio course therefore either waits for the job, or the job fails cleanly with STUDIO_OWNED and writes nothing.
*/
export function withManagedCourse<T>(d: Deps, courseId: string | undefined, fn: (c: ManagedCourse) => Promise<T>): Promise<T> {
return withCourseLock(courseId ?? "", async () => fn(await managedCourse(d, courseId)));
}
/** An existing course the importer may add to. Only folders directly under <dataDir>/courses qualify: overlays and authored repos are never edited. */
export async function managedCourse(d: Deps, courseId: string | undefined): Promise<ManagedCourse> {
if (!courseId) throw new Error("options.courseId is required: choose the course to add to");
if (isPlainId(courseId) && (await exists(studioRepo(d, courseId)))) throw new Error(STUDIO_OWNED);
const dir = join(coursesDir(d), courseId);
const rel = relative(coursesDir(d), dir);
if (!isPlainId(courseId) || rel.startsWith("..") || !(await exists(join(dir, "stcourse.yaml")))) {
@@ -1,7 +1,7 @@
import { cp, lstat, mkdir, readdir } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, managedCourse, saveManifest, stageDir, type Deps } from "../context";
import { finish, saveManifest, stageDir, withManagedCourse, type Deps, type ManagedCourse } from "../context";
import { attachDeck } from "../deck";
import { putFile } from "../media";
import { run } from "../util/exec";
@@ -48,9 +48,13 @@ async function findPdfs(dir: string, depth = 0, out: string[] = []): Promise<str
}
export async function importCodeRepo(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path, options } = parsePayload(payload, "code-repo");
const { options } = parsePayload(payload, "code-repo");
return withManagedCourse(d, optStr(options, "courseId"), (course) => importCodeRepoInto(d, course, payload, jc));
}
async function importCodeRepoInto(d: Deps, course: ManagedCourse, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path } = parsePayload(payload, "code-repo");
const warnings: string[] = [];
const course = await managedCourse(d, optStr(options, "courseId"));
if (!GIT_URL.test(path)) await assertAllowedPath(d, path);
const stage = await stageDir(d, "repo");
+3 -2
View File
@@ -1,7 +1,7 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, managedCourse, saveManifest, type Deps } from "../context";
import { finish, saveManifest, withManagedCourse, type Deps } from "../context";
import { assertAllowedPath } from "../roots";
import { attachDeck } from "../deck";
import { optStr, parsePayload } from "./common";
@@ -11,7 +11,7 @@ export async function importPdfDeck(d: Deps, payload: unknown, jc: JobContext):
const warnings: string[] = [];
await assertAllowedPath(d, path);
if (!(await stat(path).catch(() => null))?.isFile() || !/\.pdf$/i.test(path)) throw new Error(`not a PDF file: ${path}`);
const course = await managedCourse(d, optStr(options, "courseId"));
return withManagedCourse(d, optStr(options, "courseId"), async (course) => {
const attachTo = optStr(options, "attachTo");
if (!attachTo) throw new Error("options.attachTo is required: the lesson or module id the deck belongs to");
await jc.reportProgress(0.05, "Copying the deck");
@@ -25,4 +25,5 @@ export async function importPdfDeck(d: Deps, payload: unknown, jc: JobContext):
await finish(d, course.id, warnings);
await jc.reportProgress(1, r.pages ? `Deck attached (${r.pages} slides)` : "Deck attached");
return { courseId: course.id, manifestPath: join(course.dir, "stcourse.yaml"), warnings };
});
}
@@ -1,7 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { JobContext } from "@stc/contracts";
import { finish, managedCourse, saveManifest, stageDir, transcriptCacheDir, type Deps } from "../context";
import { finish, saveManifest, stageDir, transcriptCacheDir, withManagedCourse, type Deps, type ManagedCourse } from "../context";
import { run } from "../util/exec";
import { optStr, withStage } from "./common";
@@ -36,7 +36,10 @@ export async function transcribeCourse(d: Deps, payload: unknown, jc: JobContext
const url = d.transcribeUrl;
const courseId = optStr(p, "courseId") ?? "";
if (!url) return { courseId, skipped: true, reason: "STC_TRANSCRIBE_URL is not set", transcribed: 0, warnings: [] };
const course = await managedCourse(d, courseId);
return withManagedCourse(d, courseId, (course) => transcribeInto(d, course, url, p, jc));
}
async function transcribeInto(d: Deps, course: ManagedCourse, url: string, p: Record<string, unknown>, jc: JobContext): Promise<TranscribeResult> {
const only = Array.isArray(p.lessonIds) ? new Set(p.lessonIds.map(String)) : null;
const lessons = (course.manifest.modules as any[]).flatMap((m) => m.lessons as any[]).filter((l) => l.type === "video" && !l.transcript && (!only || only.has(l.id)));
const warnings: string[] = [];
+13 -8
View File
@@ -3,7 +3,7 @@ import { join, relative, resolve } from "node:path";
import type { Feature, RouteHandler } from "@stc/contracts";
import { serveMedia } from "@stc/core";
import { assertAllowedPath, importRoots, OUTSIDE } from "./roots";
import { coverCacheDir, coursesDir, exists, isPlainId, managedCourse, saveManifest, type Deps } from "./context";
import { coverCacheDir, coursesDir, exists, isPlainId, saveManifest, STUDIO_OWNED, withManagedCourse, type Deps } from "./context";
export const KINDS = ["packt-video", "code-repo", "pdf-deck", "video-folder"] as const;
const OPTION_KEYS = ["title", "courseId", "attachTo", "media", "transcribe"] as const;
@@ -90,13 +90,18 @@ export function importRoutes(deps: () => Deps): Feature["routes"] {
if (!isPlainId(rc.params.courseId!)) return json({ error: "Invalid course id" }, 400);
const src = join(coverCacheDir(d, rc.params.courseId!), name);
if (!(await exists(src))) return json({ error: "No such candidate" }, 404);
let course;
try { course = await managedCourse(d, rc.params.courseId); } catch (e) { return json({ error: (e as Error).message }, 404); }
await mkdir(join(course.dir, "media"), { recursive: true });
await copyFile(src, join(course.dir, "media", "cover.jpg"));
course.manifest.cover = "media/cover.jpg";
await saveManifest(course.dir, course.manifest);
await d.ctx.courses.reload(course.id);
try {
await withManagedCourse(d, rc.params.courseId, async (course) => {
await mkdir(join(course.dir, "media"), { recursive: true });
await copyFile(src, join(course.dir, "media", "cover.jpg"));
course.manifest.cover = "media/cover.jpg";
await saveManifest(course.dir, course.manifest);
await d.ctx.courses.reload(course.id);
});
} catch (e) {
const msg = (e as Error).message;
return json({ error: msg }, msg === STUDIO_OWNED ? 409 : 404);
}
return json({ cover: "media/cover.jpg" });
}),
},
@@ -0,0 +1,56 @@
// v1.1 S review fix: importer jobs and "Make editable in the studio" share the course lock. Either order is safe:
// a job that started first finishes and its files are converted with the course; a job that comes second fails
// cleanly and never recreates <dataDir>/courses/<id>.
import { expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { $ } from "bun";
import { loadAll } from "@stc/course-format";
import { createStudioStore } from "@stc/feature-studio";
import { STUDIO_OWNED } from "../src/context";
import { importCodeRepo } from "../src/jobs/code-repo";
import { importPacktVideo } from "../src/jobs/packt-video";
import { transcribeCourse } from "../src/jobs/transcribe";
import { makeEnv } from "./harness";
import { makePacktZip, makeRepoZip, tmp } from "./fixtures";
const owner = { name: "Olive Owner", email: "own@example.org" };
async function imported(env: Record<string, string> = {}) {
const e = await makeEnv(env);
const r = await importPacktVideo(e.deps, { path: await makePacktZip(await tmp()), options: { title: "Race Test" } }, e.jc);
const store = createStudioStore({ dataDir: e.dataDir });
const count = async () => (await loadAll([join(e.dataDir, "courses"), join(e.dataDir, "worktrees")])).filter((c) => c.id === r.courseId).length;
return { e, id: r.courseId, store, count };
}
test("an importer job that started first finishes; the conversion waits and commits its files", async () => {
const { e, id, store, count } = await imported();
const zip = await makeRepoZip(await tmp());
const job = importCodeRepo(e.deps, { kind: "code-repo", path: zip, options: { courseId: id } }, e.jc);
const conv = store.convertImported(id, owner);
const [res] = await Promise.all([job, conv]);
expect(res.courseId).toBe(id);
const pub = store.paths(id).published;
const files = (await $`git -c core.quotepath=false ls-files`.cwd(pub).quiet()).stdout.toString();
expect(files).toContain("code/S02 - Images (Sample)/app/main.py");
expect(await Bun.file(join(pub, "stcourse.yaml")).text()).toContain("code/S02 - Images (Sample)");
expect(existsSync(join(e.dataDir, "courses", id))).toBe(false);
expect(await count()).toBe(1);
});
test("an importer job that comes after the conversion fails cleanly and writes nothing", async () => {
const { e, id, store, count } = await imported({ STC_TRANSCRIBE_URL: "http://127.0.0.1:9/never" });
const zip = await makeRepoZip(await tmp());
const conv = store.convertImported(id, owner);
const job = importCodeRepo(e.deps, { kind: "code-repo", path: zip, options: { courseId: id } }, e.jc);
const tr = transcribeCourse(e.deps, { courseId: id }, e.jc);
const [c, j, t] = await Promise.allSettled([conv, job, tr]);
expect(c.status).toBe("fulfilled");
expect(j.status === "rejected" && (j.reason as Error).message).toBe(STUDIO_OWNED);
expect(t.status === "rejected" && (t.reason as Error).message).toBe(STUDIO_OWNED);
expect(existsSync(join(e.dataDir, "courses", id))).toBe(false);
expect(await count()).toBe(1);
// and later ones too
await expect(importCodeRepo(e.deps, { kind: "code-repo", path: zip, options: { courseId: id } }, e.jc)).rejects.toThrow(STUDIO_OWNED);
});