diff --git a/packages/feature-studio/src/server/draft-writer.ts b/packages/feature-studio/src/server/draft-writer.ts new file mode 100644 index 0000000..a4100d3 --- /dev/null +++ b/packages/feature-studio/src/server/draft-writer.ts @@ -0,0 +1,31 @@ +import type { AppConfig } from "@stc/contracts"; +import { createStudioStore, StudioError, safeAuthor, type Identity } from "./store"; + +/** Programmatic draft API for other packages (WP-D's studio generator). Writes only to `draft/`, never publishes. */ +export interface DraftWriter { + writeDraftFiles( + courseId: string, + authorId: string, + files: { path: string; content: string }[], + message: string, + ): Promise<{ branch: string; commit: string }>; +} + +export interface DraftWriterOptions { + /** Commit author for `authorId`. Without it (or when it returns null) commits use ` `. */ + identity?: (authorId: string) => Promise | Identity | null; +} + +export function createDraftWriter(ctx: { config: Pick }, opts: DraftWriterOptions = {}): DraftWriter { + const store = createStudioStore({ dataDir: ctx.config.dataDir }); + return { + async writeDraftFiles(courseId, authorId, files, message) { + if (!store.isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404); + const id = (await opts.identity?.(authorId)) ?? { name: authorId, email: `${safeAuthor(authorId)}@users.stcourses.invalid` }; + const r = await store.saveFiles(courseId, { ...id, id: authorId }, files.map((f) => ({ path: f.path, content: f.content })), message); + if (!r.ok) throw new StudioError("draft write conflict", 409); // unreachable: no base hashes are given + const st = await store.status(courseId, authorId); + return { branch: st.branch, commit: r.commit ?? st.draftHead }; + }, + }; +} diff --git a/packages/feature-studio/src/server/paths.ts b/packages/feature-studio/src/server/paths.ts new file mode 100644 index 0000000..f95b9f0 --- /dev/null +++ b/packages/feature-studio/src/server/paths.ts @@ -0,0 +1,17 @@ +import { join, resolve } from "node:path"; +import type { AppConfig } from "@stc/contracts"; + +/** Where learners read studio courses from: `/worktrees//published` (depth 2; drafts sit at depth 3 and are never discovered). */ +export function studioWorktreesDir(dataDir: string): string { + return join(dataDir, "worktrees"); +} + +/** + * The course dirs the CourseRepository must scan so studio courses load from their published worktree. + * Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md. + */ +export function studioCourseDirs(config: Pick): string[] { + const wt = resolve(studioWorktreesDir(config.dataDir)); + const dirs = config.courseDirs.map((d) => resolve(d)); + return dirs.includes(wt) ? dirs : [...dirs, wt]; +} diff --git a/packages/feature-studio/src/server/watch.ts b/packages/feature-studio/src/server/watch.ts new file mode 100644 index 0000000..29d484d --- /dev/null +++ b/packages/feature-studio/src/server/watch.ts @@ -0,0 +1,52 @@ +import type { CourseRepository } from "@stc/course-format"; +import type { StudioStore } from "./store"; + +export interface PublishedWatcher { + /** Compares every managed course's published HEAD with the last one seen; reloads the repository for changed ones. */ + check(): Promise; + /** Records a head the studio itself just produced (publish, sync) after reloading. */ + note(courseId: string, head: string): void; + start(): void; + stop(): void; +} + +/** + * Spec 12: "The platform watches the published worktree and reloads when it changes, after an external commit or a pull." + * Polling `git rev-parse HEAD` is portable (fs.watch on .git internals is not) and costs one git spawn per course per tick. + */ +export function createPublishedWatcher(store: StudioStore, courses: CourseRepository, opts: { intervalMs?: number } = {}): PublishedWatcher { + const seen = new Map(); + let timer: ReturnType | null = null; + let running: Promise | null = null; + + const run = async (): Promise => { + const changed: string[] = []; + for (const id of await store.listManaged()) { + let head: string; + try { head = await store.publishedHead(id); } catch { continue; } + const prev = seen.get(id); + seen.set(id, head); + if (prev === undefined ? courses.get(id) === null : prev !== head) changed.push(id); + } + for (const id of changed) await courses.reload(id); + return changed; + }; + + const w: PublishedWatcher = { + check() { + running ??= run().finally(() => { running = null; }); + return running; + }, + note(courseId, head) { seen.set(courseId, head); }, + start() { + if (timer) return; + timer = setInterval(() => { w.check().catch((e) => console.error("[studio] watcher", e)); }, opts.intervalMs ?? 3000); + (timer as { unref?: () => void }).unref?.(); + }, + stop() { + if (timer) clearInterval(timer); + timer = null; + }, + }; + return w; +} diff --git a/packages/feature-studio/test/draft-writer.test.ts b/packages/feature-studio/test/draft-writer.test.ts new file mode 100644 index 0000000..3833051 --- /dev/null +++ b/packages/feature-studio/test/draft-writer.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createDraftWriter } from "../src/server/draft-writer"; +import { ada, extGit, newStore } from "./helpers"; + +test("writeDraftFiles commits on draft/, never on the published branch", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const writer = createDraftWriter({ config: { dataDir } }, { identity: async (id) => (id === ada.id ? ada : null) }); + const r = await writer.writeDraftFiles("c1", ada.id, [{ path: "lessons/generated.md", content: "# Generated\n" }], "AI draft: module 2"); + expect(r.branch).toBe("draft/u-ada"); + const draft = store.paths("c1").draft(ada.id); + expect(await extGit(draft, "rev-parse", "HEAD")).toBe(r.commit); + expect(await extGit(draft, "log", "-1", "--format=%an|%s")).toBe("Ada Lovelace|AI draft: module 2"); + expect(readFileSync(join(draft, "lessons/generated.md"), "utf8")).toBe("# Generated\n"); + expect(existsSync(join(store.paths("c1").published, "lessons/generated.md"))).toBe(false); +}); + +test("unknown identity falls back to the author id; unmanaged course and bad paths throw", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const writer = createDraftWriter({ config: { dataDir } }); + const r = await writer.writeDraftFiles("c1", "u-x", [{ path: "a.md", content: "a" }], "m"); + expect(await extGit(store.paths("c1").draft("u-x"), "log", "-1", "--format=%an <%ae>")).toBe("u-x "); + expect(r.branch).toBe("draft/u-x"); + await expect(writer.writeDraftFiles("pure-snake", "u-x", [{ path: "a.md", content: "a" }], "m")).rejects.toThrow(/not managed/); + await expect(writer.writeDraftFiles("c1", "u-x", [{ path: "../a.md", content: "a" }], "m")).rejects.toThrow(/path/); +}); diff --git a/packages/feature-studio/test/watch.test.ts b/packages/feature-studio/test/watch.test.ts new file mode 100644 index 0000000..05de072 --- /dev/null +++ b/packages/feature-studio/test/watch.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createCourseRepository } from "@stc/course-format"; +import { createPublishedWatcher } from "../src/server/watch"; +import { studioCourseDirs } from "../src/server/paths"; +import { ada, extGit, newStore } from "./helpers"; + +test("an external commit in the published worktree reloads the CourseRepository", async () => { + const { dataDir, store } = await newStore(); + const courses = await createCourseRepository(studioCourseDirs({ dataDir, courseDirs: [] })); + let reloads = 0; + const realReload = courses.reload.bind(courses); + courses.reload = async (id?: string) => { reloads++; await realReload(id); }; + const w = createPublishedWatcher(store, courses, { intervalMs: 50 }); + + await store.createCourse({ id: "c1", title: "C" }, ada); + expect(await w.check()).toEqual(["c1"]); // new course, not yet loaded + expect(courses.get("c1")?.status).toBe("ok"); + expect(await w.check()).toEqual([]); + + const pub = store.paths("c1").published; + writeFileSync(join(pub, "lessons/welcome.md"), "# External\n"); + await extGit(pub, "commit", "-qam", "external"); + expect(await w.check()).toEqual(["c1"]); + expect(await courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); + expect(reloads).toBe(2); + + // the timer path + writeFileSync(join(pub, "lessons/welcome.md"), "# External 2\n"); + await extGit(pub, "commit", "-qam", "external 2"); + w.start(); + for (let i = 0; i < 40 && (await courses.readLessonMarkdown("c1", "welcome")) !== "# External 2\n"; i++) await Bun.sleep(25); + w.stop(); + expect(await courses.readLessonMarkdown("c1", "welcome")).toBe("# External 2\n"); +}); + +test("drafts are not discovered as courses", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + await store.ensureDraft("c1", ada.id); + const courses = await createCourseRepository(studioCourseDirs({ dataDir, courseDirs: [] })); + expect(courses.list().map((c) => [c.id, c.root])).toEqual([["c1", store.paths("c1").published]]); +});