merge: studio

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 14:42:51 +01:00
commit 2854c26a7d
35 files changed
+3566 -2

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
# Contract requests from WP-C (studio)
Each item says what is missing, what the studio does locally for now, and what Phase 2 should change.
## 1. Features that need the ServerContext (apps/server)
`startServer(config, features: Feature[])` builds the `ServerContext` internally, so a feature factory such as `studioFeature(ctx)` cannot be put into the feature list. The studio therefore does **not** touch `apps/server/src/main.ts`. Locally it runs through `packages/feature-studio/dev/serve.ts`, which mirrors `startServer`.
Proposal:
- `startServer(config, features: (Feature | ((ctx: ServerContext) => Feature))[])`, and main.ts adds `studioFeature`.
- An optional `stop?(): void | Promise<void>` on `Feature`, called from `stop()`. The studio uses it to stop its published-worktree watcher (an unref'd interval, so it never blocks exit).
## 2. Learners must read studio courses from the published worktree (config / main.ts)
Layout after the review (controller ruling on I4):
- `<dataDir>/studio/repos/<id>.git`: studio bare repos. They contain no `stcourse.yaml`, so discovery never loads them.
- `<dataDir>/worktrees/<id>/published`: the published branch, which is what learners read.
- `<dataDir>/worktrees/<id>/drafts/<author>`: drafts. They sit at depth 3, so the depth-2 discovery never loads them.
- `<dataDir>/courses/<id>`: the importer's plain folders (WP-B), untouched by the studio.
`createCourseRepository(config.courseDirs)` scans neither `<dataDir>/worktrees` nor `<dataDir>/courses`. The package exports `studioCourseDirs(config)`, which returns the configured dirs plus `<dataDir>/courses` and `<dataDir>/worktrees`. Tests and the dev entry use it.
Proposal: main.ts calls `createCourseRepository(studioCourseDirs(config))`, or `loadConfig` appends both dirs.
Imported courses (`source.kind` other than `authored`, with their root under `<dataDir>/courses`) are read-only in the studio for v1, with the message "Imported course: read-only in the studio for now". There is a `TODO(studio)` in `feature.ts` (`readOnlyMessage`) for converting one into a studio-managed repo. The studio refuses to create a course whose id already exists as `<dataDir>/courses/<id>`. WP-B should likewise refuse an import id that `<dataDir>/studio/repos/<id>.git` already uses.
## 3. Live preview: swap in the learner `LessonView`
The studio preview sits behind an adapter in `@stc/feature-studio/web`:
```ts
export interface LessonPreviewProps {
courseId: string;
markdown: string;
lesson?: { id: string; title: string; type: string; anchor?: string } | null;
resolveUrl: (rel: string) => string; // course-relative path -> URL of the draft file
}
export function setLessonPreview(c: React.ComponentType<LessonPreviewProps>): void;
```
v1 uses a small, safe built-in Markdown renderer. Proposal: `@stc/feature-learner` exports a `LessonView` that can render from a Markdown string plus a URL resolver, without fetching the published course. Phase 2 then calls `setLessonPreview(adapter)` in `packages/feature-studio/src/web.ts`, so drafts are previewed with the real renderer.
## 4. Web entry points (tsconfig paths)
Phase 1 packages have server code (`Bun.$`, `node:fs`) that must not enter the browser bundle, so the studio has a separate browser entry, `src/web.ts`. The `@stc/*` → `packages/*/src` path maps `@stc/feature-studio/web` to a directory that does not exist, and the workspace has no `node_modules/@stc` links. For now, `apps/web/src/features.ts` imports it with a relative path.
Proposal: add `"@stc/*/web": ["packages/*/src/web.ts"]` to the root tsconfig `paths`, and switch the line to `import { studioWeb } from "@stc/feature-studio/web"`.
## 5. Role-aware navigation (contracts/web.ts)
`WebRoute.nav` cannot be limited by role, so the header "Studio" link shows for everyone. The page itself tells learners and anonymous users that the studio is for authors, and the API returns 401/403.
Proposal: add `nav.roles?: Role[]`, and have the shell show the link only to matching users. This needs the learner package's session in the web shell.
## 6. Draft API for WP-D (AI)
Exported from `@stc/feature-studio`:
```ts
export interface DraftWriter {
writeDraftFiles(courseId: string, authorId: string, files: { path: string; content: string }[], message: string): Promise<{ branch: string; commit: string }>;
}
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, opts?: {
identity?: (authorId: string) => Promise<{ name: string; email: string } | null> | { name: string; email: string } | null;
}): DraftWriter;
```
- It writes and commits on `draft/<authorId>` and never publishes.
- Without `identity`, commits are authored as `<authorId> <authorId@users.stcourses.invalid>`. WP-D should pass the signed-in user's name and email.
- It throws for courses not managed by the studio, and for paths that are absolute, contain `..` or touch `.git`.
- `studioFeature(ctx).draftWriter` is the same API, already bound to `ctx`.
## 7. Authors and roles (decision to confirm)
Studio access goes to:
- `owner` and `author` roles: all studio courses, and they can create courses;
- users with a `course_authors` row: that course only.
Creating a course inserts the creator into `course_authors`. No new tables are added, so `migrationsDirs` is `[]`.
## 8. External pushes straight into the bare repo (no action needed)
The platform reloads when the published worktree's HEAD changes: an external commit in that worktree, a studio publish, or a Sync. A direct `git push` into `<dataDir>/courses/<id>` is refused by git itself ("branch is currently checked out", checked with git 2.47), so the published files cannot silently fall behind `main`. Outside tools should commit in the published worktree, or push to a configured remote that the author then Syncs.
## 9. Core media route serves SVG/HTML inline (core, Phase 2)
The studio's draft `raw` route now sends `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`. The learner-facing `/api/media/:courseId/*` in `packages/core` serves author-supplied SVG inline from the app's origin and sets neither header.
Proposal: add the same two headers in `serveMedia`.
@@ -0,0 +1,77 @@
# STCourses WP-C Studio Implementation Plan
> **For agentic workers:** executed inline by the WP-C agent (superpowers:executing-plans). Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Browser studio for studio-managed courses: create a course, edit the manifest, lessons (Markdown with live preview), deck links, attachments and exercises in a per-author draft worktree; every save is a commit by the author; publish merges the draft into the published branch that learners read; Sync with a remote only on request; conflicts are shown and resolved by the author, never automatically.
**Architecture:**
- `packages/feature-studio/src/server/git.ts`: a thin `git` CLI wrapper over `Bun.$` (explicit identity, no hooks, no signing) plus a per-course async mutex.
- `packages/feature-studio/src/server/store.ts`: `StudioStore`, the git model of spec section 12:
- `<dataDir>/courses/<id>`: a **bare** repo (it holds no `stcourse.yaml`, so course discovery never loads it);
- `<dataDir>/worktrees/<id>/published`: worktree on the published branch (`main`); the only tree learners read;
- `<dataDir>/worktrees/<id>/drafts/<author>`: worktree on `draft/<author>` (depth 3 under `worktrees/`, so discovery, which stops at depth 2, never loads drafts).
- Publish = `git merge --no-ff draft/<author>` inside the published worktree. A conflicting merge is aborted at once, so the published tree is never left half-merged, and the conflicting paths are returned. The author then runs "Update draft" (merge of `main`, or `origin/main` for Sync, into the draft worktree), resolves each file in the studio, and publishes again.
- Save uses optimistic concurrency: each file carries the hash of the content the editor loaded; a mismatch with the file on disk (an external edit) gives 409 with both versions. Nothing is overwritten.
- `watch.ts`: polls the published HEAD of each managed course (default every 3 s, unref'd timer) and calls `ctx.courses.reload(id)` when it changed (external commit, pull, publish).
- `feature.ts`: `studioFeature(ctx)` with `/api/studio/*` routes. Access: owner, `author` role, or a `course_authors` row for that course. Courses not managed by the studio (overlays, fixture/imported folders) are listed read-only: "This course is managed outside STCourses".
- `draft-writer.ts`: `createDraftWriter(ctx)` for WP-D.
- Web (`src/web/*`): `/studio` (course list, New course) and `/studio/:courseId` (outline sidebar, editors, status bar with Publish / Update draft / Sync and the conflict view). Work look from the mockup. The lesson preview sits behind a `LessonPreview` adapter; v1 is a small safe Markdown renderer; Phase 2 swaps in the learner `LessonView` (contract request).
**Tech Stack:** Bun 1.3, TypeScript strict, git CLI 2.4x via `Bun.$`, React 19 + React Router 6, `@stc/course-format` for validation, `@stc/ui` primitives.
**Spec:** sections 5, 9.1, 12 (git model), 13. Brief: `.superpowers/sdd/2026-10-30-stcourses/phase-1-brief.md` (WP-C).
## Global Constraints
Inherits every item of the Global Constraints in `docs/superpowers/plans/2026-10-30-stcourses.md`. Additionally:
- Never write into a course whose root is outside `<dataDir>` (overlays) — the studio refuses writes for non-managed courses.
- Paths written through the studio must be relative, stay inside the worktree, and never touch `.git`.
- No new DB tables (authors use the existing `course_authors`); `migrationsDirs` is exported empty.
## Review Focus
1. A conflicting external commit to `main` is detected at publish and shown; the published tree keeps the external content (test: acceptance).
2. Drafts never reach learners: before publish the `CourseRepository` still serves the published content (test: acceptance).
3. An external edit to a draft file between load and save gives 409, not an overwrite (test: store).
4. Paths with spaces and non-ASCII characters save, commit and publish (test: store).
5. Path traversal (`..`, absolute, `.git/…`) is rejected (test: store).
## Tasks
### Task 1: git wrapper and StudioStore (create, drafts, save)
**Files:** Create `packages/feature-studio/src/server/{git.ts,store.ts,paths.ts}`; Test `packages/feature-studio/test/store.test.ts`.
**Interfaces (Produces):** `createStudioStore(dataDir): StudioStore` with `createCourse`, `isManaged`, `listManaged`, `ensureDraft`, `readFile`, `listFiles`, `saveFiles`, `status`.
- [ ] Step 1: failing tests: create gives bare repo + published worktree with a valid manifest; draft worktree on `draft/<author>`; save commits with the author's name/email; stale base hash gives a conflict; traversal rejected; non-ASCII path works.
- [ ] Step 2: run, FAIL. Step 3: implement. Step 4: PASS. Step 5: commit.
### Task 2: publish, update draft, conflict resolution, sync
**Files:** extend `store.ts`; Test `packages/feature-studio/test/publish.test.ts`.
- [ ] Step 1: failing tests: publish merges; external conflicting commit gives `{ok:false, conflicts}` and leaves `main` untouched; dirty published tree refuses publish; update draft shows ours/theirs; resolve + publish succeeds; abort; Sync with a local bare remote (pull fast-forward, push, conflict reported); no remote gives an error.
- [ ] Steps 2–5 as above.
### Task 3: watcher and draft writer
**Files:** `src/server/{watch.ts,draft-writer.ts}`; Test `test/watch.test.ts`, `test/draft-writer.test.ts`.
- [ ] Failing tests: external commit triggers `courses.reload`; `writeDraftFiles` commits on `draft/<author>` and the published tree is unchanged. Implement, pass, commit.
### Task 4: HTTP feature and acceptance
**Files:** `src/server/feature.ts`, `src/index.ts`; Test `test/feature.test.ts`, `test/acceptance.test.ts`.
- [ ] Failing tests: 401/403 for anonymous and learners; overlay course read-only; create → edit lesson → publish → learner sees it via `CourseRepository` and `/api/media`; conflicting external commit shown, not overwritten; manifest validation errors returned. Implement, pass, commit.
### Task 5: Studio web UI
**Files:** `src/web/{api.ts,markdown.ts,preview.tsx,StudioHome.tsx,CourseStudio.tsx,editors/*.tsx,studio.css}`, `src/web.ts`; Test `test/markdown.test.ts`.
- [ ] Failing test for the Markdown renderer (escaping, headings, fences, lists, links without `javascript:`). Implement UI. Commit.
### Task 6: wiring, dev server, contract requests, verification
- [ ] Append to `apps/web/src/features.ts`; dev entry `packages/feature-studio/dev/serve.ts`; `docs/contract-requests/studio.md`; run `bun test`, typecheck, Postgres run; headless Chrome screenshots of `/studio` and `/studio/:id`; report.