From 3b15d87a5f0055584089b94791dc17598080f47d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 02:48:10 +0100 Subject: [PATCH] feat(contracts): shared server, exercise, ai, import and web interfaces Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/ai.ts | 3 ++ packages/contracts/src/exercise.ts | 7 ++++ packages/contracts/src/import.ts | 2 + packages/contracts/src/index.ts | 5 +++ packages/contracts/src/server.ts | 28 ++++++++++++++ packages/contracts/src/web.ts | 4 ++ packages/contracts/test/types.test.ts | 54 +++++++++++++++++++++++++++ 7 files changed, 103 insertions(+) create mode 100644 packages/contracts/src/ai.ts create mode 100644 packages/contracts/src/exercise.ts create mode 100644 packages/contracts/src/import.ts create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/src/server.ts create mode 100644 packages/contracts/src/web.ts create mode 100644 packages/contracts/test/types.test.ts diff --git a/packages/contracts/src/ai.ts b/packages/contracts/src/ai.ts new file mode 100644 index 0000000..e0815c1 --- /dev/null +++ b/packages/contracts/src/ai.ts @@ -0,0 +1,3 @@ +export interface ChatMessage { role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string; name?: string } +export interface ToolSpec { name: string; description: string; inputSchema: Record } +export interface AiProvider { id: string; chat(messages: ChatMessage[], opts: { tools?: ToolSpec[]; signal?: AbortSignal }): AsyncIterable<{ text?: string; toolCall?: { id: string; name: string; input: unknown } }> } diff --git a/packages/contracts/src/exercise.ts b/packages/contracts/src/exercise.ts new file mode 100644 index 0000000..54723c5 --- /dev/null +++ b/packages/contracts/src/exercise.ts @@ -0,0 +1,7 @@ +// Browser-runnable exercises (spec 4.3 type 6) +export type ExerciseLanguage = "js" | "ts"; +export interface Assert { equal(actual: unknown, expected: unknown, message?: string): void; deepEqual(actual: unknown, expected: unknown, message?: string): void; ok(value: unknown, message?: string): void } +export interface ExerciseTest { name: string; run(mod: Record, assert: Assert): void | Promise } +// A tests file is an ES module: `export const tests: ExerciseTest[]`. Learner code is an ES module. +export interface TestResult { name: string; passed: boolean; message?: string; expected?: string; actual?: string } +export interface ExerciseRunner { language: ExerciseLanguage; run(learnerCode: string, testsModule: string, timeoutMs: number): Promise } diff --git a/packages/contracts/src/import.ts b/packages/contracts/src/import.ts new file mode 100644 index 0000000..dc75989 --- /dev/null +++ b/packages/contracts/src/import.ts @@ -0,0 +1,2 @@ +export interface ImportSource { kind: "packt-video" | "pdf-deck" | "code-repo" | "video-folder"; path: string; options?: Record } +export interface ImportResult { courseId: string; manifestPath: string; warnings: string[] } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..859b47a --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,5 @@ +export * from "./server"; +export * from "./exercise"; +export * from "./ai"; +export * from "./import"; +export * from "./web"; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts new file mode 100644 index 0000000..dbfca4a --- /dev/null +++ b/packages/contracts/src/server.ts @@ -0,0 +1,28 @@ +export type Role = "owner" | "author" | "learner"; +export interface AuthUser { id: string; email: string; name: string; roles: Role[]; groups: string[] } +export interface RequestContext { user: AuthUser | null; params: Record; url: URL } +export type RouteHandler = (req: Request, rc: RequestContext) => Response | Promise; +export interface Feature { + name: string; // "learner" | "import" | "studio" | "ai" | "core" + routes: Record>>; // paths start with /api/ + jobs?: Record; // job kind -> handler +} +export interface JobContext { reportProgress(fraction: number, message?: string): Promise; signal: AbortSignal } +export type JobHandler = (payload: unknown, jc: JobContext) => Promise; +export type JobStatus = "queued" | "running" | "done" | "failed"; +export interface JobRecord { id: string; kind: string; status: JobStatus; progress: number; message: string | null; payload: unknown; result: unknown; error: string | null; createdAt: string; updatedAt: string } +export interface JobQueue { enqueue(kind: string, payload: unknown): Promise; get(id: string): Promise; list(kind?: string): Promise } +export interface AuthService { userFromRequest(req: Request): Promise } +// Placeholders until Task 4 (@stc/db) and Task 7 (@stc/course-format) exist; switch to the real imports in Task 7. +type Db = unknown; +type CourseRepository = unknown; +export interface ServerContext { + config: AppConfig; db: Db; courses: CourseRepository; + jobs: JobQueue; auth: AuthService; +} +export interface AppConfig { + port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, media/, worktrees/ + courseDirs: string[]; // folders scanned for stcourse.yaml (course repos and overlays/) + secret: string; // STC_SECRET, used to encrypt BYOK keys + publicUrl: string; +} diff --git a/packages/contracts/src/web.ts b/packages/contracts/src/web.ts new file mode 100644 index 0000000..2b7717c --- /dev/null +++ b/packages/contracts/src/web.ts @@ -0,0 +1,4 @@ +import type React from "react"; + +export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin" } } +export interface WebFeature { name: string; routes: WebRoute[] } diff --git a/packages/contracts/test/types.test.ts b/packages/contracts/test/types.test.ts new file mode 100644 index 0000000..ebf1370 --- /dev/null +++ b/packages/contracts/test/types.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import type { + AiProvider, AppConfig, AuthService, AuthUser, Assert, ChatMessage, ExerciseRunner, ExerciseTest, + Feature, ImportResult, ImportSource, JobContext, JobHandler, JobQueue, JobRecord, RequestContext, + RouteHandler, ServerContext, TestResult, ToolSpec, WebFeature, WebRoute, +} from "../src/index"; + +test("contract interfaces accept well-formed literals", async () => { + const user: AuthUser = { id: "u1", email: "a@b.c", name: "A", roles: ["owner", "learner"], groups: ["g"] }; + const rc: RequestContext = { user, params: { id: "1" }, url: new URL("http://localhost/api/x") }; + const handler: RouteHandler = async () => new Response("ok"); + const jobHandler: JobHandler = async (_p, jc) => { await jc.reportProgress(0.5, "half"); return null; }; + const feature: Feature = { + name: "core", + routes: { "/api/health": { GET: handler, POST: handler } }, + jobs: { noop: jobHandler }, + }; + const jc: JobContext = { reportProgress: async () => {}, signal: new AbortController().signal }; + const record: JobRecord = { + id: "j1", kind: "noop", status: "queued", progress: 0, message: null, payload: {}, result: null, + error: null, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", + }; + const queue: JobQueue = { enqueue: async () => "j1", get: async () => record, list: async () => [record] }; + const auth: AuthService = { userFromRequest: async () => user }; + const config: AppConfig = { + port: 3000, databaseUrl: "sqlite://./data/stcourses.db", dataDir: "./data", courseDirs: ["./overlays"], + secret: "s", publicUrl: "http://localhost:3000", + }; + const ctx: Pick = { config, jobs: queue, auth }; + + const assert: Assert = { equal() {}, deepEqual() {}, ok() {} }; + const t: ExerciseTest = { name: "t", run: (_mod, a) => a.ok(true) }; + const runner: ExerciseRunner = { + language: "ts", + run: async () => [{ name: "t", passed: true } satisfies TestResult], + }; + const msg: ChatMessage = { role: "user", content: "hi" }; + const tool: ToolSpec = { name: "x", description: "d", inputSchema: {} }; + const provider: AiProvider = { + id: "p", + async *chat() { yield { text: "hi" }; yield { toolCall: { id: "1", name: "x", input: {} } }; }, + }; + const source: ImportSource = { kind: "pdf-deck", path: "/tmp/a.pdf", options: {} }; + const result: ImportResult = { courseId: "c", manifestPath: "/tmp/stcourse.yaml", warnings: [] }; + const route: WebRoute = { + path: "/", element: async () => ({ default: () => null }), nav: { label: "Home", area: "header" }, + }; + const web: WebFeature = { name: "learner", routes: [route] }; + + expect(typeof feature.routes).toBe("object"); + expect(await queue.enqueue("noop", {})).toBe("j1"); + expect((await runner.run("", "", 1000))[0]!.passed).toBe(true); + void [rc, jc, ctx, assert, t, msg, tool, provider, source, result, web]; +});