fix(studio): review round 2 - a conversion that fails after its commit recovers without a restart

- The convert job reloads the course on success and on failure, so learners
  get the intact import (rolled back) or the published studio course, never a
  course whose folder moved away.
- POST /convert on a course with an incomplete conversion (.converting-<id>,
  staging or retired folders) queues a job that recovers and finishes it
  instead of answering 409; the studio page shows "Conversion incomplete" with
  Retry for the owner.
- The 409 for pending import jobs names their kinds and ids; the dead withLock
  in git.ts is gone; the dot-folder discovery rule is documented in
  docs/operations.md and spec section 5.
- Route tests (SQLite and Postgres) inject a failed cross-filesystem copy, a
  checksum mismatch and a pre-commit failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 21:00:45 +01:00
1 parent 771d755da4
commit f02e30450c
11 files changed
+168 -34

No files matched your search

+3 -3
View File
@@ -3,7 +3,7 @@ import type { AppConfig, AuthUser, JobQueue, ServerContext } from "@stc/contract
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { studioFeature } from "../src/server/feature";
import { studioFeature, type StudioFeatureOptions } from "../src/server/feature";
import { studioCourseDirs } from "../src/server/paths";
import { tempDir } from "./helpers";
@@ -15,7 +15,7 @@ export const users: Record<string, AuthUser> = {
};
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}) {
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}, featureOpts: StudioFeatureOptions = {}) {
const dataDir = tempDir();
prepare?.(dataDir);
let dropSchema = async () => {};
@@ -37,7 +37,7 @@ export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite:/
config, db, courses, jobs: null as unknown as JobQueue,
auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null },
} satisfies ServerContext;
const feature = studioFeature(ctx, { watch: false });
const feature = studioFeature(ctx, { watch: false, ...featureOpts });
// the real queue, as composeServer builds it from the features' jobs
const queue = createJobQueue(db, feature.jobs ?? {}, { pollMs: 20 });
ctx.jobs = queue;
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { studioFeature } from "../src/server/feature";
import { SimulatedCrash } from "../src/server/convert";
@@ -84,9 +84,11 @@ describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
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 blocker = await env.ctx.jobs.list("import.code-repo");
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");
expect(r.json.error).toContain(`import.code-repo ${blocker[0]!.id}`);
// 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);
@@ -122,6 +124,61 @@ describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
});
});
describe.each(dbs)("a conversion that fails without a restart [%s]", (_name, dbUrl) => {
const media = (as: string) => env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": as, range: "bytes=10-19" } }));
const learnerView = async (where: "import" | "studio") => {
const all = env.ctx.courses.list().filter((c) => c.id === "imp");
expect(all).toHaveLength(1);
expect(all[0]!.status).toBe("ok");
expect(all[0]!.root).toBe(join(env.dataDir, where === "import" ? "courses/imp" : "worktrees/imp/published"));
const r = await media("lea");
expect(r.status).toBe(206);
expect(new Uint8Array(await r.arrayBuffer())).toEqual(videoBytes(4096, 7).slice(10, 20));
};
const cases: [string, { forceExdev?: boolean; failAt?: string; corruptStaging?: boolean }, "import" | "studio", RegExp][] = [
["the cross-filesystem copy fails (disk full)", { forceExdev: true, failAt: "staging-copied" }, "studio", /injected failure at staging-copied/],
["the copied media fail their checksum", { forceExdev: true, corruptStaging: true }, "studio", /do not match/],
["the checkout fails before the commit point", { failAt: "worktree" }, "import", /injected failure at worktree/],
];
test.each(cases)("%s: learners keep a working course and the owner's retry completes", async (_c, hooks, after, error) => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d), {}, { storeTestHooks: hooks });
await env.db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('imp', 'user', 'u-lea')`;
await learnerView("import");
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(r.status).toBe(202);
const j = await env.waitJob(r.json.jobId);
expect(j.status).toBe("failed");
expect(j.error).toMatch(error);
// no restart: learners already get the intact import or the finished studio course
await learnerView(after);
if (after === "studio") {
const st = (await env.call("owner", "GET", "/api/studio/courses/imp")).json;
expect(st).toMatchObject({ readOnly: false, conversionIncomplete: true, canRetryConversion: true });
expect((await env.call("ada", "GET", "/api/studio/courses/imp")).json).toMatchObject({ conversionIncomplete: true, canRetryConversion: false });
} else {
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json).toMatchObject({ readOnly: true, convertible: true });
}
// the cause goes away (space freed, disk fixed); the owner retries through the same route
delete hooks.failAt;
delete hooks.corruptStaging;
expect((await env.call("ada", "POST", "/api/studio/courses/imp/convert")).status).toBe(403);
const again = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(again.status).toBe(202);
const j2 = await env.waitJob(again.json.jobId);
expect(j2.error).toBeNull();
expect(j2.status).toBe("done");
await learnerView("studio");
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json.conversionIncomplete).toBe(false);
expect(readdirSync(join(env.dataDir, "courses"))).toEqual([]);
expect(readdirSync(join(env.dataDir, "media"))).toEqual(["imp"]);
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409);
});
});
describe("convert route refusals", () => {
test("only the owner; cross-site requests are refused; nothing changes", async () => {
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { ConvertPanelView, ReadOnlyView } from "../src/ui/ConvertPanel";
import { ConvertPanelView, IncompleteConversionView, ReadOnlyView } from "../src/ui/ConvertPanel";
const noop = () => {};
@@ -36,3 +36,16 @@ test("read-only view: imported for the owner offers the action; for others expla
expect(outside).toContain("/repos/o");
expect(outside).not.toContain("<button");
});
test("an incomplete conversion: the owner can retry, others are told who can", () => {
const owner = renderToString(<IncompleteConversionView phase={{ kind: "idle" }} canRetry onRetry={noop} />);
expect(owner).toContain("Conversion incomplete");
expect(owner).toContain(">Retry</button>");
const author = renderToString(<IncompleteConversionView phase={{ kind: "idle" }} canRetry={false} onRetry={noop} />);
expect(author).toContain("Conversion incomplete");
expect(author).not.toContain("<button");
expect(author).toContain("Ask the owner");
const failed = renderToString(<IncompleteConversionView phase={{ kind: "failed", error: "disk full" }} canRetry onRetry={noop} />);
expect(failed).toContain('role="alert"');
expect(failed).toContain("disk full");
});