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 Winther
2026-10-30 03:16:13 +01:00
co-authored by Claude Opus 5.5
parent c2385b472c
commit 06b6ea6168
5 changed files with 173 additions and 0 deletions
@@ -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> <authorId@users.stcourses.invalid>`. */
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;
}