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

@@ -1,6 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { studioFeature } from "../src/server/feature";
import { SimulatedCrash } from "../src/server/convert";
import { createStudioStore } from "../src/server/store";
import { REPO, setupApp } from "./app-helpers";
import { makeImportedCourse, VIDEO, videoBytes } from "./import-fixture";
@@ -76,6 +79,49 @@ describe.each(dbs)("convert routes [%s]", (_name, dbUrl) => {
});
});
describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
test("409 while an import job for the course is queued; the job itself re-checks", async () => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
// no importer feature in this app: its jobs stay queued
await env.ctx.jobs.enqueue("import.code-repo", { kind: "code-repo", path: "/x", options: { courseId: "imp" } });
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(r.status).toBe(409);
expect(r.json.error).toContain("an import job for this course is still running");
// a convert job that was queued anyway (before the import was) fails cleanly and changes nothing
const id = await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } });
const j = await env.waitJob(id);
expect(j.status).toBe("failed");
expect(j.error).toContain("an import job for this course is still running");
expect(existsSync(join(env.dataDir, "courses/imp/stcourse.yaml"))).toBe(true);
expect(existsSync(join(env.dataDir, "studio/repos/imp.git"))).toBe(false);
// an import job for another course does not block
await env.db.sql`DELETE FROM jobs WHERE kind = 'import.code-repo'`;
await env.ctx.jobs.enqueue("import.transcribe", { courseId: "other" });
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(202);
});
test("server start recovers a conversion that crashed mid-move; learners see the course once", async () => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
const crashing = createStudioStore({ dataDir: env.dataDir, testHooks: { crashAt: "staging-copied", forceExdev: true } });
await expect(crashing.convertImported("imp", { name: "O", email: "o@x" })).rejects.toBeInstanceOf(SimulatedCrash);
await env.ctx.courses.reload();
expect(env.ctx.courses.list().filter((c) => c.id === "imp").length).toBeLessThanOrEqual(1);
// a restart: a new studio feature recovers before serving
const restarted = studioFeature(env.ctx, { watch: false });
await restarted.ready;
restarted.stop();
const loaded = env.ctx.courses.list().filter((c) => c.id === "imp");
expect(loaded).toHaveLength(1);
expect(loaded[0]!.root).toBe(join(env.dataDir, "worktrees/imp/published"));
expect(existsSync(join(env.dataDir, "courses/imp"))).toBe(false);
const r = await env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": "owner", range: "bytes=0-9" } }));
expect(r.status).toBe(206);
// the requeued job (or the owner) retrying reports success
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409);
expect(await env.feature.store.convertImported("imp", { name: "O", email: "o@x" })).toMatchObject({ resumed: true });
});
});
describe("convert route refusals", () => {
test("only the owner; cross-site requests are refused; nothing changes", async () => {
env = await setupApp([], undefined, (d) => makeImportedCourse(d));