11 KiB
| phase | verified | status | score | gaps | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 40-job-infrastructure | 2026-04-04T12:48:00Z | gaps_found | 9/10 must-haves verified |
|
Phase 40: Job Infrastructure Verification Report
Phase Goal: Every content generation request returns a job ID immediately, progresses through a tracked lifecycle, and stores its output in namespaced storage — so nothing blocks and nothing is orphaned Verified: 2026-04-04T12:48:00Z Status: gaps_found Re-verification: No — initial verification
Goal Achievement
Observable Truths
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | content_jobs table exists in DB with queued/running/done/failed lifecycle columns | VERIFIED | packages/db/src/schema/content_jobs.ts has all lifecycle columns; migration 0046_tense_randall.sql applies CREATE TABLE |
| 2 | assets table has a source_task_id column for conversation linkage | VERIFIED | packages/db/src/schema/assets.ts line 18: sourceTaskId: text("source_task_id"); same migration adds column |
| 3 | LIVE_EVENT_TYPES includes content_job.queued, content_job.running, content_job.done, content_job.failed | VERIFIED | packages/shared/src/constants.ts lines 334-337 confirm all four entries |
| 4 | MAX_GENERATED_ASSET_BYTES constant exists and defaults to 500MB | VERIFIED | server/src/attachment-types.ts line 76-77: exports constant, defaults to 500 * 1024 * 1024 |
| 5 | contentJobStore service can create, get, list, and transition jobs | VERIFIED | server/src/services/content-job-store.ts exports all four methods backed by real Drizzle queries |
| 6 | contentJobRunner dispatches a job asynchronously without blocking, transitions through lifecycle, stores asset, publishes live events | VERIFIED | void runJob() pattern confirmed; transitions running→done/failed; putFile with namespace "generated"; assetService.create with sourceTaskId; publishLiveEvent called |
| 7 | POST /api/companies/:companyId/content-jobs returns 202 with jobId and status within 200ms | VERIFIED | Route handler returns res.status(202).json({ jobId, status, createdAt }); dispatch is fire-and-forget |
| 8 | GET /api/companies/:companyId/content-jobs/:jobId returns the job record with current status | VERIFIED | Route calls contentJobStore(db).getById, returns job or 404 |
| 9 | GET /api/companies/:companyId/content-jobs lists all jobs for a company ordered by createdAt desc | VERIFIED | Route calls listByCompany, Drizzle query uses orderBy(desc(contentJobs.createdAt)) |
| 10 | GET /api/companies/:companyId/content-jobs/:jobId/events streams SSE events until terminal state then closes | FAILED | Initial-status fast-path works for already-terminal jobs. For running jobs, SSE terminal detection checks event.payload.status but runner publishes { jobId, assetId } / { jobId, errorMessage } with no status field — stream will not auto-close on completion |
Score: 9/10 truths verified
Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
packages/db/src/schema/content_jobs.ts |
content_jobs table with lifecycle | VERIFIED | 33 lines, exports contentJobs pgTable + CONTENT_JOB_STATUSES, two indexes |
server/src/services/content-job-store.ts |
CRUD for content_jobs | VERIFIED | 37 lines, all four methods, real Drizzle queries |
server/src/services/content-job-runner.ts |
Async dispatcher with live events | VERIFIED | 88 lines, fire-and-forget dispatch, lifecycle transitions, asset storage, live events published |
server/src/routes/content-jobs.ts |
HTTP routes for job API | VERIFIED | 122 lines, all four endpoints implemented |
server/src/__tests__/content-jobs-routes.test.ts |
Route integration tests (min 50 lines) | VERIFIED | 190 lines, 8 tests covering 202/400/404/sourceTaskId |
server/src/__tests__/content-jobs-sse.test.ts |
SSE integration tests (min 30 lines) | VERIFIED | 139 lines, 5 tests covering content-type/initial event/terminal states |
Key Link Verification
| From | To | Via | Status | Details |
|---|---|---|---|---|
| content-job-runner.ts | content-job-store.ts | store.transition() calls | WIRED | Lines 26, 57, 70 call store.transition() after await |
| content-job-runner.ts | live-events.ts | publishLiveEvent for content_job.* events | WIRED | Lines 27-31, 62-65, 74-78 publish running/done/failed events |
| content-job-runner.ts | assets.ts | assetService.create with sourceTaskId | WIRED | Lines 50-55: assetService(db).create with sourceTaskId: job.sourceTaskId |
| content-jobs.ts (route) | content-job-store.ts | contentJobStore(db) calls | WIRED | Lines 28, 51, 60, 82 all call contentJobStore(db) |
| content-jobs.ts (route) | content-job-runner.ts | contentJobRunner.dispatch() in POST handler | WIRED | Line 37: void contentJobRunner.dispatch(db, storage, job!) |
| content-jobs.ts (route) | live-events.ts | subscribeCompanyLiveEvents in SSE endpoint | WIRED | Line 102: subscribeCompanyLiveEvents() with req.on("close") cleanup |
| app.ts | content-jobs.ts | api.use mount | WIRED | Line 190: api.use(contentJobRoutes(db, opts.storageService)) |
Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|---|---|---|---|---|
| content-job-store.ts → create | row returned | db.insert(contentJobs).values().returning() | Yes — Drizzle INSERT with RETURNING | FLOWING |
| content-job-store.ts → getById | row or null | db.select().from(contentJobs).where(eq(id)) | Yes — Drizzle SELECT | FLOWING |
| content-job-store.ts → listByCompany | rows array | db.select().from(contentJobs).where().orderBy() | Yes — Drizzle SELECT | FLOWING |
| content-job-runner.ts → renderContent | buffer | Stub returning fixed Buffer.from("placeholder output") | Intentional stub — phases 41-45 add real renderers | STUB (documented, intentional) |
| content-jobs.ts (POST route) | job response | contentJobStore(db).create() | Yes — store backed by real Drizzle | FLOWING |
Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
| All 13 integration tests pass | npx vitest run content-jobs-routes.test.ts content-jobs-sse.test.ts | 13/13 passed in 790ms | PASS |
| TypeScript compiles clean (db) | pnpm tsc --noEmit --project packages/db/tsconfig.json | Exit 0 | PASS |
| TypeScript compiles clean (shared) | pnpm tsc --noEmit --project packages/shared/tsconfig.json | Exit 0 | PASS |
| TypeScript compiles clean (server) | pnpm tsc --noEmit --project server/tsconfig.json | Exit 0 | PASS |
| Migration file contains content_jobs DDL | grep content_jobs migrations/*.sql | Found in 0046_tense_randall.sql | PASS |
| SSE stream auto-close for live jobs | (code inspection) | event.payload.status never set by runner | FAIL |
Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| INFRA-01 | 40-01, 40-02 | System processes content generation jobs asynchronously with queued → running → done/failed lifecycle | SATISFIED | contentJobRunner.dispatch fires void runJob; transitions queued→running→done/failed; POST returns 202; tests verify 202/queued |
| INFRA-02 | 40-02 | System pushes job progress updates via SSE to connected clients | PARTIAL | SSE endpoint exists and streams initial status + live events. However, SSE does not auto-close for live jobs because runner payloads lack status field — stream hangs open until client disconnect |
| INFRA-03 | 40-01, 40-02 | Generated content stored in namespaced storage without size restrictions blocking video/images | SATISFIED | storage.putFile called with namespace: "generated"; MAX_GENERATED_ASSET_BYTES = 500MB; no blocking — async dispatch |
| INFRA-04 | 40-01, 40-02 | All generated content tracked in database with source conversation linkage | SATISFIED | assets.sourceTaskId column added; runner passes sourceTaskId: job.sourceTaskId to assetService.create; test verifies sourceTaskId persistence via POST then GET |
Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|---|---|---|---|---|
| server/src/services/content-job-runner.ts | 11-21 | renderContent stub returning hardcoded buffer | Info | Intentional — documented in SUMMARY as placeholder for phases 41-45 |
| server/src/routes/content-jobs.ts | 108 | event.payload.status terminal check against payloads that never include status | Blocker | SSE stream for live jobs never auto-closes on completion — client sees no completion signal until disconnect |
Human Verification Required
None required — all behavioral checks were resolved programmatically.
Gaps Summary
One gap blocks complete goal achievement:
SSE stream does not auto-close when a running job reaches terminal state. The contentJobRunner publishes content_job.done with { jobId, assetId } and content_job.failed with { jobId, errorMessage }. Neither payload contains a status field. The SSE route's terminal detection at line 108 reads event.payload.status and checks for "done" or "failed" — this will always be undefined, so unsubscribe() and res.end() are never called by the subscriber. The stream remains open indefinitely until the client disconnects.
The initial-status fast-path works correctly: if a job is already terminal when the SSE connection opens, the stream closes immediately after sending the first event. Only the live-event-driven close path is broken.
Fix options (either resolves the gap):
- In
content-job-runner.ts, addstatus: "done"to thecontent_job.donepayload andstatus: "failed"to thecontent_job.failedpayload. - In
content-jobs.ts(SSE route), replace the payload status check withevent.type === "content_job.done" || event.type === "content_job.failed".
The renderContent stub is not a gap — it is intentionally deferred to phases 41-45 and documented as such.
Verified: 2026-04-04T12:48:00Z Verifier: Claude (gsd-verifier)