feat(studio): published-worktree watcher and DraftWriter API for the AI package

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:16:13 +01:00
1 parent c2385b472c
commit 06b6ea6168
5 files changed
+173

No files matched your search

@@ -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/<author>, 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 <u-x@users.stcourses.invalid>");
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/);
});
@@ -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]]);
});