Commit
T2: shared types/schemas and session store
commit
67eccd6
6 changed files with +322 and −4
Jump to a changed file
- server/store/sessionStore.test.ts +82 −0
- server/store/sessionStore.ts +122 −0
- server/tsconfig.json +2 −2
- shared/index.ts +1 −1
- shared/types.ts +114 −0
- spec/TASKS.md +1 −1
added server/store/sessionStore.test.ts +82 −0
| @@ -0,0 +1,82 @@ | ||
| 1 | +import { mkdtemp, rm } from 'node:fs/promises' | |
| 2 | +import { tmpdir } from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 5 | +import { initialCoverage } from '../../shared/types' | |
| 6 | +import { SessionStore } from './sessionStore' | |
| 7 | + | |
| 8 | +describe('SessionStore', () => { | |
| 9 | + let baseDir: string | |
| 10 | + let store: SessionStore | |
| 11 | + | |
| 12 | + beforeEach(async () => { | |
| 13 | + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-')) | |
| 14 | + store = new SessionStore(baseDir) | |
| 15 | + }) | |
| 16 | + | |
| 17 | + afterEach(async () => { | |
| 18 | + await rm(baseDir, { recursive: true, force: true }) | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('creates a session and reloads it with matching data', async () => { | |
| 22 | + const created = await store.createSession('my project', '/tmp/target') | |
| 23 | + expect(created.name).toBe('my project') | |
| 24 | + expect(created.targetDir).toBe('/tmp/target') | |
| 25 | + expect(created.status).toBe('interviewing') | |
| 26 | + expect(created.coverage).toEqual(initialCoverage()) | |
| 27 | + | |
| 28 | + const reloaded = await store.getSession(created.id) | |
| 29 | + expect(reloaded).toEqual(created) | |
| 30 | + }) | |
| 31 | + | |
| 32 | + it('returns null for an unknown session id', async () => { | |
| 33 | + expect(await store.getSession('does-not-exist')).toBeNull() | |
| 34 | + }) | |
| 35 | + | |
| 36 | + it('assigns sequential segment ids S1..Sn', async () => { | |
| 37 | + const session = await store.createSession('proj', '/tmp/target') | |
| 38 | + const first = await store.appendSegment(session.id, 'user', 'hello') | |
| 39 | + expect(first.segment.id).toBe('S1') | |
| 40 | + const second = await store.appendSegment(session.id, 'interviewer', 'what next?') | |
| 41 | + expect(second.segment.id).toBe('S2') | |
| 42 | + const third = await store.appendSegment(session.id, 'user', 'more') | |
| 43 | + expect(third.segment.id).toBe('S3') | |
| 44 | + | |
| 45 | + const reloaded = await store.getSession(session.id) | |
| 46 | + expect(reloaded?.segments.map((s) => s.id)).toEqual(['S1', 'S2', 'S3']) | |
| 47 | + expect(reloaded?.segments.map((s) => s.text)).toEqual(['hello', 'what next?', 'more']) | |
| 48 | + }) | |
| 49 | + | |
| 50 | + it('updates coverage, summary, and status', async () => { | |
| 51 | + const session = await store.createSession('proj', '/tmp/target') | |
| 52 | + const coverage = { ...initialCoverage(), goal: 'clear' as const } | |
| 53 | + const updated = await store.updateTurn(session.id, { | |
| 54 | + coverage, | |
| 55 | + summary: 'a running summary', | |
| 56 | + status: 'done', | |
| 57 | + }) | |
| 58 | + expect(updated.coverage).toEqual(coverage) | |
| 59 | + expect(updated.summary).toBe('a running summary') | |
| 60 | + expect(updated.status).toBe('done') | |
| 61 | + }) | |
| 62 | + | |
| 63 | + it('lists sessions with id, name, status only', async () => { | |
| 64 | + const a = await store.createSession('alpha', '/tmp/a') | |
| 65 | + const b = await store.createSession('beta', '/tmp/b') | |
| 66 | + await store.updateTurn(b.id, { status: 'done' }) | |
| 67 | + | |
| 68 | + const list = await store.listSessions() | |
| 69 | + expect(list).toHaveLength(2) | |
| 70 | + expect(list).toEqual( | |
| 71 | + expect.arrayContaining([ | |
| 72 | + { id: a.id, name: 'alpha', status: 'interviewing' }, | |
| 73 | + { id: b.id, name: 'beta', status: 'done' }, | |
| 74 | + ]), | |
| 75 | + ) | |
| 76 | + }) | |
| 77 | + | |
| 78 | + it('lists no sessions when the base directory does not exist yet', async () => { | |
| 79 | + const emptyStore = new SessionStore(path.join(baseDir, 'nonexistent')) | |
| 80 | + expect(await emptyStore.listSessions()).toEqual([]) | |
| 81 | + }) | |
| 82 | +}) |
added server/store/sessionStore.ts +122 −0
| @@ -0,0 +1,122 @@ | ||
| 1 | +import { randomUUID } from 'node:crypto' | |
| 2 | +import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { | |
| 5 | + SessionSchema, | |
| 6 | + initialCoverage, | |
| 7 | + type Coverage, | |
| 8 | + type Segment, | |
| 9 | + type Session, | |
| 10 | + type SessionStatus, | |
| 11 | + type SessionSummary, | |
| 12 | + type Speaker, | |
| 13 | +} from '../../shared/types' | |
| 14 | + | |
| 15 | +export class SessionStore { | |
| 16 | + constructor(private readonly baseDir: string) {} | |
| 17 | + | |
| 18 | + private sessionDir(id: string): string { | |
| 19 | + return path.join(this.baseDir, id) | |
| 20 | + } | |
| 21 | + | |
| 22 | + private sessionFile(id: string): string { | |
| 23 | + return path.join(this.sessionDir(id), 'session.json') | |
| 24 | + } | |
| 25 | + | |
| 26 | + private async writeSession(session: Session): Promise<void> { | |
| 27 | + const dir = this.sessionDir(session.id) | |
| 28 | + await mkdir(dir, { recursive: true }) | |
| 29 | + const file = this.sessionFile(session.id) | |
| 30 | + const tmp = path.join(dir, `.session-${randomUUID()}.tmp`) | |
| 31 | + await writeFile(tmp, JSON.stringify(session, null, 2), 'utf8') | |
| 32 | + await rename(tmp, file) | |
| 33 | + } | |
| 34 | + | |
| 35 | + async createSession(name: string, targetDir: string): Promise<Session> { | |
| 36 | + const session: Session = { | |
| 37 | + id: randomUUID(), | |
| 38 | + name, | |
| 39 | + targetDir, | |
| 40 | + createdAt: new Date().toISOString(), | |
| 41 | + segments: [], | |
| 42 | + coverage: initialCoverage(), | |
| 43 | + summary: '', | |
| 44 | + status: 'interviewing', | |
| 45 | + openBlockers: [], | |
| 46 | + } | |
| 47 | + await this.writeSession(session) | |
| 48 | + return session | |
| 49 | + } | |
| 50 | + | |
| 51 | + async getSession(id: string): Promise<Session | null> { | |
| 52 | + try { | |
| 53 | + const raw = await readFile(this.sessionFile(id), 'utf8') | |
| 54 | + return SessionSchema.parse(JSON.parse(raw)) | |
| 55 | + } catch (err) { | |
| 56 | + if (isNotFound(err)) return null | |
| 57 | + throw err | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + async listSessions(): Promise<SessionSummary[]> { | |
| 62 | + let entries: string[] | |
| 63 | + try { | |
| 64 | + entries = await readdir(this.baseDir) | |
| 65 | + } catch (err) { | |
| 66 | + if (isNotFound(err)) return [] | |
| 67 | + throw err | |
| 68 | + } | |
| 69 | + const summaries: SessionSummary[] = [] | |
| 70 | + for (const id of entries) { | |
| 71 | + const session = await this.getSession(id) | |
| 72 | + if (session) summaries.push({ id: session.id, name: session.name, status: session.status }) | |
| 73 | + } | |
| 74 | + return summaries | |
| 75 | + } | |
| 76 | + | |
| 77 | + async appendSegment( | |
| 78 | + id: string, | |
| 79 | + speaker: Speaker, | |
| 80 | + text: string, | |
| 81 | + ): Promise<{ session: Session; segment: Segment }> { | |
| 82 | + const session = await this.getSession(id) | |
| 83 | + if (!session) throw new Error(`session not found: ${id}`) | |
| 84 | + const segment: Segment = { | |
| 85 | + id: `S${session.segments.length + 1}`, | |
| 86 | + ts: new Date().toISOString(), | |
| 87 | + speaker, | |
| 88 | + text, | |
| 89 | + } | |
| 90 | + session.segments.push(segment) | |
| 91 | + await this.writeSession(session) | |
| 92 | + return { session, segment } | |
| 93 | + } | |
| 94 | + | |
| 95 | + async updateTurn( | |
| 96 | + id: string, | |
| 97 | + update: { | |
| 98 | + coverage?: Coverage | |
| 99 | + summary?: string | |
| 100 | + status?: SessionStatus | |
| 101 | + openBlockers?: string[] | |
| 102 | + }, | |
| 103 | + ): Promise<Session> { | |
| 104 | + const session = await this.getSession(id) | |
| 105 | + if (!session) throw new Error(`session not found: ${id}`) | |
| 106 | + if (update.coverage) session.coverage = update.coverage | |
| 107 | + if (update.summary !== undefined) session.summary = update.summary | |
| 108 | + if (update.status) session.status = update.status | |
| 109 | + if (update.openBlockers) session.openBlockers = update.openBlockers | |
| 110 | + await this.writeSession(session) | |
| 111 | + return session | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +function isNotFound(err: unknown): boolean { | |
| 116 | + return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: string }).code === 'ENOENT' | |
| 117 | +} | |
| 118 | + | |
| 119 | +export function createDefaultSessionStore(): SessionStore { | |
| 120 | + const baseDir = process.env.DATA_DIR ?? path.join(process.cwd(), 'data', 'sessions') | |
| 121 | + return new SessionStore(baseDir) | |
| 122 | +} |
modified server/tsconfig.json +2 −2
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "extends": "../tsconfig.base.json", |
| 3 | 3 | "compilerOptions": { |
| 4 | - "module": "NodeNext", | |
| 5 | - "moduleResolution": "NodeNext", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "bundler", | |
| 6 | 6 | "lib": ["ES2022"], |
| 7 | 7 | "types": ["node"], |
| 8 | 8 | "outDir": "../dist/server", |
modified shared/index.ts +1 −1
| @@ -1 +1 @@ | ||
| 1 | -export {} | |
| 1 | +export * from './types' |
added shared/types.ts +114 −0
| @@ -0,0 +1,114 @@ | ||
| 1 | +import { z } from 'zod' | |
| 2 | + | |
| 3 | +export const CATEGORY_IDS = [ | |
| 4 | + 'goal', | |
| 5 | + 'users', | |
| 6 | + 'core-flow', | |
| 7 | + 'data', | |
| 8 | + 'integrations', | |
| 9 | + 'edge-cases', | |
| 10 | + 'constraints', | |
| 11 | + 'non-goals', | |
| 12 | + 'verification', | |
| 13 | +] as const | |
| 14 | + | |
| 15 | +export type CategoryId = (typeof CATEGORY_IDS)[number] | |
| 16 | + | |
| 17 | +export const CoverageLevelSchema = z.enum(['missing', 'partial', 'clear']) | |
| 18 | +export type CoverageLevel = z.infer<typeof CoverageLevelSchema> | |
| 19 | + | |
| 20 | +const coverageShape = Object.fromEntries( | |
| 21 | + CATEGORY_IDS.map((category) => [category, CoverageLevelSchema]), | |
| 22 | +) as Record<CategoryId, typeof CoverageLevelSchema> | |
| 23 | + | |
| 24 | +export const CoverageSchema = z.object(coverageShape) | |
| 25 | +export type Coverage = z.infer<typeof CoverageSchema> | |
| 26 | + | |
| 27 | +export function initialCoverage(): Coverage { | |
| 28 | + return Object.fromEntries(CATEGORY_IDS.map((category) => [category, 'missing'])) as Coverage | |
| 29 | +} | |
| 30 | + | |
| 31 | +export const SpeakerSchema = z.enum(['user', 'interviewer']) | |
| 32 | +export type Speaker = z.infer<typeof SpeakerSchema> | |
| 33 | + | |
| 34 | +export const SegmentSchema = z.object({ | |
| 35 | + id: z.string(), | |
| 36 | + ts: z.string(), | |
| 37 | + speaker: SpeakerSchema, | |
| 38 | + text: z.string(), | |
| 39 | +}) | |
| 40 | +export type Segment = z.infer<typeof SegmentSchema> | |
| 41 | + | |
| 42 | +export const SessionStatusSchema = z.enum(['interviewing', 'done']) | |
| 43 | +export type SessionStatus = z.infer<typeof SessionStatusSchema> | |
| 44 | + | |
| 45 | +export const SessionSchema = z.object({ | |
| 46 | + id: z.string(), | |
| 47 | + name: z.string(), | |
| 48 | + targetDir: z.string(), | |
| 49 | + createdAt: z.string(), | |
| 50 | + segments: z.array(SegmentSchema), | |
| 51 | + coverage: CoverageSchema, | |
| 52 | + summary: z.string(), | |
| 53 | + status: SessionStatusSchema, | |
| 54 | + openBlockers: z.array(z.string()), | |
| 55 | +}) | |
| 56 | +export type Session = z.infer<typeof SessionSchema> | |
| 57 | + | |
| 58 | +export const SessionSummarySchema = SessionSchema.pick({ id: true, name: true, status: true }) | |
| 59 | +export type SessionSummary = z.infer<typeof SessionSummarySchema> | |
| 60 | + | |
| 61 | +export const ContradictionSchema = z.object({ | |
| 62 | + segmentIds: z.array(z.string()), | |
| 63 | + description: z.string(), | |
| 64 | +}) | |
| 65 | +export type Contradiction = z.infer<typeof ContradictionSchema> | |
| 66 | + | |
| 67 | +export const InterviewTurnSchema = z.object({ | |
| 68 | + coverage: CoverageSchema, | |
| 69 | + nextQuestion: z.string(), | |
| 70 | + contradiction: ContradictionSchema.nullable(), | |
| 71 | + done: z.boolean(), | |
| 72 | + summaryUpdate: z.string().nullable(), | |
| 73 | +}) | |
| 74 | +export type InterviewTurn = z.infer<typeof InterviewTurnSchema> | |
| 75 | + | |
| 76 | +// API payloads | |
| 77 | + | |
| 78 | +export const CreateSessionRequestSchema = z.object({ | |
| 79 | + name: z.string().min(1), | |
| 80 | + targetDir: z.string().min(1), | |
| 81 | +}) | |
| 82 | +export type CreateSessionRequest = z.infer<typeof CreateSessionRequestSchema> | |
| 83 | + | |
| 84 | +export const AnswerRequestSchema = z.object({ | |
| 85 | + text: z.string().min(1), | |
| 86 | +}) | |
| 87 | +export type AnswerRequest = z.infer<typeof AnswerRequestSchema> | |
| 88 | + | |
| 89 | +export const AnswerResponseSchema = z.object({ | |
| 90 | + segment: SegmentSchema, | |
| 91 | + turn: InterviewTurnSchema, | |
| 92 | +}) | |
| 93 | +export type AnswerResponse = z.infer<typeof AnswerResponseSchema> | |
| 94 | + | |
| 95 | +export const GenerateRequestSchema = z.object({ | |
| 96 | + overwrite: z.boolean().optional(), | |
| 97 | +}) | |
| 98 | +export type GenerateRequest = z.infer<typeof GenerateRequestSchema> | |
| 99 | + | |
| 100 | +export const GenerateResponseSchema = z.object({ | |
| 101 | + files: z.array(z.string()), | |
| 102 | + warnings: z.array(z.string()), | |
| 103 | +}) | |
| 104 | +export type GenerateResponse = z.infer<typeof GenerateResponseSchema> | |
| 105 | + | |
| 106 | +export const BlockersResponseSchema = z.object({ | |
| 107 | + questions: z.array(z.string()), | |
| 108 | +}) | |
| 109 | +export type BlockersResponse = z.infer<typeof BlockersResponseSchema> | |
| 110 | + | |
| 111 | +export const ErrorResponseSchema = z.object({ | |
| 112 | + error: z.string(), | |
| 113 | +}) | |
| 114 | +export type ErrorResponse = z.infer<typeof ErrorResponseSchema> |
modified spec/TASKS.md +1 −1
| @@ -7,7 +7,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 7 | 7 | - Depends: nothing |
| 8 | 8 | - Verify: `npm run typecheck` and `npm test` and `npm run build` all exit 0. |
| 9 | 9 | |
| 10 | -- [ ] T2 Shared types and session store | |
| 10 | +- [x] T2 Shared types and session store | |
| 11 | 11 | - Implement `shared/` types + zod schemas from PLAN.md. Implement `server/store/sessionStore.ts`: create, get, list, append segment, update coverage/summary/status, persisted to `data/sessions/<id>/session.json` atomically (write temp file, rename). |
| 12 | 12 | - Depends: T1 |
| 13 | 13 | - Verify: `npm test` (store unit tests: create/reload roundtrip, sequential segment ids S1..Sn, list). |