| |
| |
| |
| |
|
|
| import Database from "better-sqlite3"; |
| import { mkdirSync, existsSync } from "node:fs"; |
| import { dirname } from "node:path"; |
| import { randomUUID } from "node:crypto"; |
|
|
| export type Awaitable<T> = T | Promise<T>; |
|
|
| export type JobStatus = "pending" | "running" | "completed" | "cancelled"; |
| export type ItemStatus = |
| | "queued" |
| | "running" |
| | "judging" |
| | "completed" |
| | "failed" |
| | "stopped"; |
| export type DetectedMode = "api" | "mcp"; |
|
|
| export interface JobRow { |
| id: string; |
| status: JobStatus; |
| options_snapshot: string; |
| created_at: number; |
| updated_at: number; |
| } |
|
|
| export interface ItemRow { |
| id: string; |
| job_id: string; |
| ord: number; |
| filename: string; |
| upload_path: string; |
| detected_mode: DetectedMode; |
| status: ItemStatus; |
| evidence_path: string | null; |
| task_package_path: string | null; |
| judge_result_path: string | null; |
| export_zip_path: string | null; |
| error_preview: string | null; |
| error_details_path: string | null; |
| attempt_count: number; |
| last_stage: string | null; |
| created_at: number; |
| updated_at: number; |
| } |
|
|
| export interface SetItemStatusMeta { |
| lastStage?: string | null; |
| errorDetailsPath?: string | null; |
| } |
|
|
| export interface Store { |
| close(): Awaitable<void>; |
| createJob(optionsSnapshot: object): Awaitable<JobRow>; |
| getJob(id: string): Awaitable<JobRow | null>; |
| setJobStatus(id: string, status: JobStatus): Awaitable<void>; |
| updateJobOptions(id: string, optionsSnapshot: object): Awaitable<void>; |
| listResumableJobs(): Awaitable<JobRow[]>; |
| recoverInterruptedJob(id: string): Awaitable<void>; |
| createItem(input: { |
| jobId: string; |
| ord: number; |
| filename: string; |
| uploadPath: string; |
| detectedMode: DetectedMode; |
| }): Awaitable<ItemRow>; |
| getItem(id: string): Awaitable<ItemRow | null>; |
| listItemsByJob(jobId: string): Awaitable<ItemRow[]>; |
| setItemStatus( |
| id: string, |
| status: ItemStatus, |
| errorPreview?: string, |
| meta?: SetItemStatusMeta, |
| ): Awaitable<void>; |
| setItemPaths( |
| id: string, |
| paths: Partial<{ |
| evidencePath: string; |
| taskPackagePath: string; |
| judgeResultPath: string; |
| exportZipPath: string; |
| }>, |
| ): Awaitable<void>; |
| takeNextQueuedItem(jobId: string): Awaitable<ItemRow | null>; |
| resetItemForRetry(id: string): Awaitable<void>; |
| } |
|
|
| const SCHEMA_SQL = ` |
| PRAGMA journal_mode = WAL; |
| PRAGMA foreign_keys = ON; |
| PRAGMA busy_timeout = 5000; |
| |
| CREATE TABLE IF NOT EXISTS jobs ( |
| id TEXT PRIMARY KEY, |
| status TEXT NOT NULL, |
| options_snapshot TEXT NOT NULL, |
| created_at INTEGER NOT NULL, |
| updated_at INTEGER NOT NULL |
| ); |
| |
| CREATE TABLE IF NOT EXISTS items ( |
| id TEXT PRIMARY KEY, |
| job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, |
| ord INTEGER NOT NULL, |
| filename TEXT NOT NULL, |
| upload_path TEXT NOT NULL, |
| detected_mode TEXT NOT NULL, |
| status TEXT NOT NULL, |
| evidence_path TEXT, |
| task_package_path TEXT, |
| judge_result_path TEXT, |
| export_zip_path TEXT, |
| error_preview TEXT, |
| error_details_path TEXT, |
| attempt_count INTEGER NOT NULL DEFAULT 0, |
| last_stage TEXT, |
| created_at INTEGER NOT NULL, |
| updated_at INTEGER NOT NULL |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_items_job_ord ON items(job_id, ord); |
| CREATE INDEX IF NOT EXISTS idx_items_status ON items(status); |
| CREATE INDEX IF NOT EXISTS idx_items_job_status_ord ON items(job_id, status, ord); |
| `; |
|
|
| const ERROR_PREVIEW_MAX = 2 * 1024; |
|
|
| export function nowMs() { |
| return Date.now(); |
| } |
|
|
| export function truncateError(s: string | null | undefined): string | null { |
| if (!s) return null; |
| return s.length > ERROR_PREVIEW_MAX |
| ? s.slice(0, ERROR_PREVIEW_MAX) + "...(truncated)" |
| : s; |
| } |
|
|
| function hasColumn(db: Database.Database, table: string, column: string): boolean { |
| const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: string }>; |
| return rows.some((row) => row.name === column); |
| } |
|
|
| function ensureSqliteColumns(db: Database.Database): void { |
| const additions: Array<[string, string]> = [ |
| ["error_details_path", "ALTER TABLE items ADD COLUMN error_details_path TEXT"], |
| ["attempt_count", "ALTER TABLE items ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0"], |
| ["last_stage", "ALTER TABLE items ADD COLUMN last_stage TEXT"], |
| ]; |
| additions.forEach(([column, sql]) => { |
| if (!hasColumn(db, "items", column)) db.exec(sql); |
| }); |
| } |
|
|
| function normalizeItemRow(row: ItemRow): ItemRow { |
| return { |
| ...row, |
| attempt_count: Number(row.attempt_count || 0), |
| error_details_path: row.error_details_path ?? null, |
| last_stage: row.last_stage ?? null, |
| }; |
| } |
|
|
| export class SqliteStore implements Store { |
| private db: Database.Database; |
|
|
| constructor(sqlitePath: string) { |
| if (!existsSync(dirname(sqlitePath))) { |
| mkdirSync(dirname(sqlitePath), { recursive: true }); |
| } |
| this.db = new Database(sqlitePath); |
| this.db.exec(SCHEMA_SQL); |
| ensureSqliteColumns(this.db); |
| } |
|
|
| close(): void { |
| this.db.close(); |
| } |
|
|
| |
| createJob(optionsSnapshot: object): JobRow { |
| const id = randomUUID(); |
| const t = nowMs(); |
| const job: JobRow = { |
| id, |
| status: "pending", |
| options_snapshot: JSON.stringify(optionsSnapshot), |
| created_at: t, |
| updated_at: t, |
| }; |
| this.db |
| .prepare( |
| "INSERT INTO jobs (id, status, options_snapshot, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", |
| ) |
| .run(job.id, job.status, job.options_snapshot, job.created_at, job.updated_at); |
| return job; |
| } |
|
|
| getJob(id: string): JobRow | null { |
| const row = this.db.prepare("SELECT * FROM jobs WHERE id = ?").get(id) as JobRow | undefined; |
| return row ?? null; |
| } |
|
|
| setJobStatus(id: string, status: JobStatus): void { |
| this.db |
| .prepare("UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?") |
| .run(status, nowMs(), id); |
| } |
|
|
| updateJobOptions(id: string, optionsSnapshot: object): void { |
| this.db |
| .prepare("UPDATE jobs SET options_snapshot = ?, updated_at = ? WHERE id = ?") |
| .run(JSON.stringify(optionsSnapshot), nowMs(), id); |
| } |
|
|
| listResumableJobs(): JobRow[] { |
| return this.db |
| .prepare("SELECT * FROM jobs WHERE status = 'running' ORDER BY created_at") |
| .all() as JobRow[]; |
| } |
|
|
| recoverInterruptedJob(id: string): void { |
| const t = nowMs(); |
| const tx = this.db.transaction((jobId: string) => { |
| this.db |
| .prepare( |
| `UPDATE items |
| SET status = 'queued', |
| error_preview = NULL, |
| last_stage = 'recovered', |
| updated_at = ? |
| WHERE job_id = ? AND status IN ('running', 'judging')`, |
| ) |
| .run(t, jobId); |
| this.db |
| .prepare("UPDATE jobs SET status = 'running', updated_at = ? WHERE id = ?") |
| .run(t, jobId); |
| }); |
| tx(id); |
| } |
|
|
| |
| createItem(input: { |
| jobId: string; |
| ord: number; |
| filename: string; |
| uploadPath: string; |
| detectedMode: DetectedMode; |
| }): ItemRow { |
| const id = randomUUID(); |
| const t = nowMs(); |
| const row: ItemRow = { |
| id, |
| job_id: input.jobId, |
| ord: input.ord, |
| filename: input.filename, |
| upload_path: input.uploadPath, |
| detected_mode: input.detectedMode, |
| status: "queued", |
| evidence_path: null, |
| task_package_path: null, |
| judge_result_path: null, |
| export_zip_path: null, |
| error_preview: null, |
| error_details_path: null, |
| attempt_count: 0, |
| last_stage: null, |
| created_at: t, |
| updated_at: t, |
| }; |
| this.db |
| .prepare( |
| `INSERT INTO items |
| (id, job_id, ord, filename, upload_path, detected_mode, status, |
| evidence_path, task_package_path, judge_result_path, export_zip_path, |
| error_preview, error_details_path, attempt_count, last_stage, created_at, updated_at) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| ) |
| .run( |
| row.id, |
| row.job_id, |
| row.ord, |
| row.filename, |
| row.upload_path, |
| row.detected_mode, |
| row.status, |
| row.evidence_path, |
| row.task_package_path, |
| row.judge_result_path, |
| row.export_zip_path, |
| row.error_preview, |
| row.error_details_path, |
| row.attempt_count, |
| row.last_stage, |
| row.created_at, |
| row.updated_at, |
| ); |
| return row; |
| } |
|
|
| getItem(id: string): ItemRow | null { |
| const row = this.db.prepare("SELECT * FROM items WHERE id = ?").get(id) as ItemRow | undefined; |
| return row ? normalizeItemRow(row) : null; |
| } |
|
|
| listItemsByJob(jobId: string): ItemRow[] { |
| return ( |
| this.db |
| .prepare("SELECT * FROM items WHERE job_id = ? ORDER BY ord ASC") |
| .all(jobId) as ItemRow[] |
| ).map(normalizeItemRow); |
| } |
|
|
| setItemStatus( |
| id: string, |
| status: ItemStatus, |
| errorPreview?: string, |
| meta: SetItemStatusMeta = {}, |
| ): void { |
| this.db |
| .prepare( |
| `UPDATE items |
| SET status = ?, |
| error_preview = ?, |
| last_stage = COALESCE(?, last_stage), |
| error_details_path = COALESCE(?, error_details_path), |
| updated_at = ? |
| WHERE id = ?`, |
| ) |
| .run( |
| status, |
| truncateError(errorPreview), |
| meta.lastStage ?? null, |
| meta.errorDetailsPath ?? null, |
| nowMs(), |
| id, |
| ); |
| } |
|
|
| setItemPaths( |
| id: string, |
| paths: Partial<{ |
| evidencePath: string; |
| taskPackagePath: string; |
| judgeResultPath: string; |
| exportZipPath: string; |
| }>, |
| ): void { |
| const fields: string[] = []; |
| const values: unknown[] = []; |
| if (paths.evidencePath !== undefined) { |
| fields.push("evidence_path = ?"); |
| values.push(paths.evidencePath); |
| } |
| if (paths.taskPackagePath !== undefined) { |
| fields.push("task_package_path = ?"); |
| values.push(paths.taskPackagePath); |
| } |
| if (paths.judgeResultPath !== undefined) { |
| fields.push("judge_result_path = ?"); |
| values.push(paths.judgeResultPath); |
| } |
| if (paths.exportZipPath !== undefined) { |
| fields.push("export_zip_path = ?"); |
| values.push(paths.exportZipPath); |
| } |
| if (!fields.length) return; |
| fields.push("updated_at = ?"); |
| values.push(nowMs()); |
| values.push(id); |
| this.db.prepare(`UPDATE items SET ${fields.join(", ")} WHERE id = ?`).run(...values); |
| } |
|
|
| takeNextQueuedItem(jobId: string): ItemRow | null { |
| const tx = this.db.transaction((id: string) => { |
| const row = this.db |
| .prepare( |
| "SELECT * FROM items WHERE job_id = ? AND status = 'queued' ORDER BY ord ASC LIMIT 1", |
| ) |
| .get(id) as ItemRow | undefined; |
| if (!row) return null; |
| const t = nowMs(); |
| this.db |
| .prepare( |
| `UPDATE items |
| SET status = 'running', |
| attempt_count = attempt_count + 1, |
| error_preview = NULL, |
| last_stage = 'running', |
| updated_at = ? |
| WHERE id = ?`, |
| ) |
| .run(t, row.id); |
| return normalizeItemRow({ |
| ...row, |
| status: "running" as const, |
| attempt_count: Number(row.attempt_count || 0) + 1, |
| last_stage: "running", |
| updated_at: t, |
| }); |
| }); |
| return tx(jobId); |
| } |
|
|
| resetItemForRetry(id: string): void { |
| this.db |
| .prepare( |
| `UPDATE items |
| SET status = 'queued', |
| error_preview = NULL, |
| error_details_path = NULL, |
| attempt_count = 0, |
| last_stage = NULL, |
| evidence_path = NULL, |
| task_package_path = NULL, |
| judge_result_path = NULL, |
| export_zip_path = NULL, |
| updated_at = ? |
| WHERE id = ?`, |
| ) |
| .run(nowMs(), id); |
| } |
| } |
|
|