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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
08e62ff683
commit
771d755da4
@@ -49,6 +49,8 @@ export interface StudioFeature extends Feature {
|
||||
store: StudioStore;
|
||||
watcher: PublishedWatcher;
|
||||
draftWriter: DraftWriter;
|
||||
/** Resolves once interrupted conversions are recovered (every studio route waits for it). */
|
||||
ready: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
@@ -108,10 +110,21 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
|
||||
/** v1.1 S: only the owner may turn an imported course into a studio course ("Make editable in the studio"). */
|
||||
const convertible = (u: AuthUser, courseId: string) => isOwner(u) && store.isConvertibleImport(courseId);
|
||||
|
||||
/** Importer jobs (queued or running) that would write into this course's import folder. */
|
||||
const pendingImports = async (courseId: string) =>
|
||||
(await ctx.jobs.list()).filter((j) => {
|
||||
if (!j.kind.startsWith("import.") || (j.status !== "queued" && j.status !== "running")) return false;
|
||||
const p = (j.payload ?? {}) as { courseId?: unknown; options?: { courseId?: unknown } };
|
||||
return p.courseId === courseId || p.options?.courseId === courseId;
|
||||
});
|
||||
const IMPORT_RUNNING = "an import job for this course is still running; make it editable once it has finished";
|
||||
|
||||
/** The conversion job: payload `{ courseId, actor }`, checked again by the store under the course lock. */
|
||||
const convertJob: JobHandler = async (payload, jc) => {
|
||||
await ready;
|
||||
const p = (payload ?? {}) as { courseId?: unknown; actor?: { id?: unknown; name?: unknown; email?: unknown } };
|
||||
const courseId = typeof p.courseId === "string" ? p.courseId : "";
|
||||
if ((await pendingImports(courseId)).length) throw new StudioError(IMPORT_RUNNING, 409);
|
||||
const actor = { name: String(p.actor?.name ?? "STCourses"), email: String(p.actor?.email ?? "[email protected]") };
|
||||
const r = await store.convertImported(courseId, actor, { onProgress: (f, m) => jc.reportProgress(f, m) });
|
||||
await afterPublishedChange(courseId);
|
||||
@@ -123,11 +136,17 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
|
||||
watcher.note(courseId, await store.publishedHead(courseId));
|
||||
};
|
||||
|
||||
// Crash recovery for conversions interrupted by a stop (v1.1 S); learners never see a course twice meanwhile.
|
||||
const ready: Promise<void> = store.recoverConversions()
|
||||
.then(async (ids) => { if (ids.length) await ctx.courses.reload(); })
|
||||
.catch((e) => console.error("[studio] recovery", e));
|
||||
|
||||
/** Wraps a handler: signed-in author/owner (or per-course author), error mapping. */
|
||||
const guard = (h: (req: Request, rc: RequestContext, user: AuthUser) => Promise<Response>): RouteHandler => async (req, rc) => {
|
||||
const user = rc.user;
|
||||
if (!user) return fail(401, "sign in to use the studio");
|
||||
try {
|
||||
await ready;
|
||||
if (!hasRole(user) && (await authoredCourses(user)).length === 0) return fail(403, "the studio is for authors");
|
||||
return await h(req, rc, user);
|
||||
} catch (e) {
|
||||
@@ -245,6 +264,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
|
||||
}
|
||||
const pending = (await ctx.jobs.list(CONVERT_JOB)).find((j) => (j.payload as { courseId?: string } | null)?.courseId === courseId && (j.status === "queued" || j.status === "running"));
|
||||
if (pending) return fail(409, "this course is already being made editable");
|
||||
if ((await pendingImports(courseId)).length) return fail(409, IMPORT_RUNNING);
|
||||
const jobId = await ctx.jobs.enqueue(CONVERT_JOB, { courseId, actor: { id: user.id, name: who(user).name, email: who(user).email } });
|
||||
return json({ jobId }, 202);
|
||||
}),
|
||||
@@ -405,5 +425,5 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
|
||||
watcher.start();
|
||||
}
|
||||
|
||||
return { name: "studio", routes, jobs: { [CONVERT_JOB]: convertJob }, multipartRoutes: ["/api/studio/courses/:courseId/upload"], store, watcher, draftWriter, stop: () => watcher.stop() };
|
||||
return { name: "studio", routes, jobs: { [CONVERT_JOB]: convertJob }, multipartRoutes: ["/api/studio/courses/:courseId/upload"], store, watcher, draftWriter, ready, stop: () => watcher.stop() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user