feat(studio): published-worktree watcher and DraftWriter API for the AI package
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c2385b472c
commit
06b6ea6168
@@ -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/<authorId>`, 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 `<authorId> <[email protected]>`. */
|
||||
identity?: (authorId: string) => Promise<Identity | null> | Identity | null;
|
||||
}
|
||||
|
||||
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { join, resolve } from "node:path";
|
||||
import type { AppConfig } from "@stc/contracts";
|
||||
|
||||
/** Where learners read studio courses from: `<dataDir>/worktrees/<id>/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<AppConfig, "dataDir" | "courseDirs">): string[] {
|
||||
const wt = resolve(studioWorktreesDir(config.dataDir));
|
||||
const dirs = config.courseDirs.map((d) => resolve(d));
|
||||
return dirs.includes(wt) ? dirs : [...dirs, wt];
|
||||
}
|
||||
@@ -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<string[]>;
|
||||
/** 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<string, string>();
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let running: Promise<string[]> | null = null;
|
||||
|
||||
const run = async (): Promise<string[]> => {
|
||||
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;
|
||||
}
|
||||
@@ -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 <[email protected]>");
|
||||
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]]);
|
||||
});
|
||||
Reference in New Issue
Block a user