profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

T2: shared types/schemas and session store

Zod schemas for Segment, Session, Coverage, InterviewTurn, and API
payloads per PLAN.md. SessionStore persists atomically to
data/sessions/<id>/session.json with create/get/list/appendSegment/
updateTurn.
commit 67eccd6

6 changed files with +322 and −4

Jump to a changed file
  1. server/store/sessionStore.test.ts +82 −0
  2. server/store/sessionStore.ts +122 −0
  3. server/tsconfig.json +2 −2
  4. shared/index.ts +1 −1
  5. shared/types.ts +114 −0
  6. 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).