/** * TaskAttemptRepository - CRUD + next_attempt_index, list_by_task for task_attempts table (§9) * * Implements Repository * per contracts §6. * * @module packages/runtime/src/storage/repositories/TaskAttemptRepository */ import type { Repository, TransactionHandle, SessionID, TaskID, AgentID, UUID, ISOTimeString, } from '@aircoding/contracts' import { DatabaseHandle } from '../MigrationRunner.js' // ============================================================================= // Types - per db-schema §9 // ============================================================================= export type TaskAttemptStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' export interface TaskAttemptRecord { id: UUID session_id: SessionID task_id: TaskID attempt_index: number agent_id?: AgentID status: TaskAttemptStatus failure_signature?: string failure_summary?: string started_at: ISOTimeString completed_at?: ISOTimeString worker_result_json?: string metadata_json?: string } export type TaskAttemptInsert = Omit & { id?: UUID } export type TaskAttemptUpdate = Partial> // ============================================================================= // TaskAttemptRepository // ============================================================================= export class TaskAttemptRepository implements Repository { private db: DatabaseHandle constructor(db: DatabaseHandle) { this.db = db } /** * Get a task attempt by ID. */ async get(id: UUID, tx?: TransactionHandle): Promise { const stmt = (tx?.db ?? this.db).prepare('SELECT * FROM task_attempts WHERE id = ?') const row = stmt.get(id) as TaskAttemptRecord | undefined return row } /** * Insert a new task attempt. Status is set by EventStore projection (INV-1). */ async insert(record: TaskAttemptInsert, tx?: TransactionHandle): Promise { // Status is set by EventStore.project(), not by caller const status: TaskAttemptStatus = 'pending' const stmt = (tx?.db ?? this.db).prepare(` INSERT INTO task_attempts ( id, session_id, task_id, attempt_index, agent_id, status, failure_signature, failure_summary, started_at, completed_at, worker_result_json, metadata_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) stmt.run( record.id, record.session_id, record.task_id, record.attempt_index, record.agent_id ?? null, status, record.failure_signature ?? null, record.failure_summary ?? null, record.started_at, record.completed_at ?? null, record.worker_result_json ?? null, record.metadata_json ?? null, ) } /** * Update an existing task attempt. Status changes only via EventStore projection (INV-1). */ async update(id: UUID, patch: TaskAttemptUpdate, tx?: TransactionHandle): Promise { const fields: string[] = [] const values: unknown[] = [] // status reaches here only via EventStore.project() (INV-1's authorized writer). if (patch.status !== undefined) { fields.push('status = ?') values.push(patch.status) } if (patch.agent_id !== undefined) { fields.push('agent_id = ?') values.push(patch.agent_id) } if (patch.failure_signature !== undefined) { fields.push('failure_signature = ?') values.push(patch.failure_signature) } if (patch.failure_summary !== undefined) { fields.push('failure_summary = ?') values.push(patch.failure_summary) } if (patch.completed_at !== undefined) { fields.push('completed_at = ?') values.push(patch.completed_at) } if (patch.worker_result_json !== undefined) { fields.push('worker_result_json = ?') values.push(patch.worker_result_json) } if (patch.metadata_json !== undefined) { fields.push('metadata_json = ?') values.push(patch.metadata_json) } if (fields.length === 0) { return // Nothing to update } values.push(id) const stmt = (tx?.db ?? this.db).prepare(`UPDATE task_attempts SET ${fields.join(', ')} WHERE id = ?`) stmt.run(...values) } // ============================================================================= // Extra methods // ============================================================================= /** * Get the next attempt index for a task (0-based). */ async next_attempt_index(task_id: TaskID): Promise { const stmt = this.db.prepare( 'SELECT MAX(attempt_index) as max_index FROM task_attempts WHERE task_id = ?', ) const row = stmt.get(task_id) as { max_index: number | null } | undefined return (row?.max_index ?? -1) + 1 } /** * List all attempts for a task, ordered by attempt_index. */ async list_by_task(task_id: TaskID): Promise { const stmt = this.db.prepare( 'SELECT * FROM task_attempts WHERE task_id = ? ORDER BY attempt_index ASC', ) return stmt.all(task_id) as TaskAttemptRecord[] } }