diff --git a/eslint.config.mjs b/eslint.config.mjs index 55b2459c..4042d38b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -76,5 +76,42 @@ export default tseslint.config( ...globals.vitest, }, }, + }, + { + // B2 Phase 1 step 5/5 — block new raw status-writes on tasks. + // + // During dual-write every `UPDATE tasks SET status = ...` must go + // through `advanceTaskPhase` (or the four grandfathered sites in + // database-base.ts / database/tasks-*.ts / database/runs.ts that + // already dual-write and carry `eslint-disable-next-line + // no-restricted-syntax` with a comment explaining the exemption). + // The no-restricted-syntax rule below intercepts string literals + // and template literals whose text starts with the offending SQL + // prefix. New code that needs to mutate `tasks.status` must either + // route through `advanceTaskPhase` (preferred) or add an + // `eslint-disable-next-line` with a rationale comment. + // + // Migrations are deliberately excluded — schema changes operate on + // the legacy column by design. + files: ["src/main/**/*.ts"], + ignores: ["src/main/database/migrations/**/*.ts"], + rules: { + "no-restricted-syntax": [ + "error", + { + // Plain string literals and normal template heads. + selector: + "Literal[value=/\\b[Uu][Pp][Dd][Aa][Tt][Ee]\\s+tasks\\s+[Ss][Ee][Tt]\\s+status\\b/]", + message: + "B2 Phase 1: write task.status through advanceTaskPhase (or tag with `// eslint-disable-next-line no-restricted-syntax` + dual-write rationale for grandfathered sites).", + }, + { + selector: + "TemplateElement[value.raw=/\\b[Uu][Pp][Dd][Aa][Tt][Ee]\\s+tasks\\s+[Ss][Ee][Tt]\\s+status\\b/]", + message: + "B2 Phase 1: write task.status through advanceTaskPhase (or tag with `// eslint-disable-next-line no-restricted-syntax` + dual-write rationale for grandfathered sites).", + }, + ], + }, } ); diff --git a/src/main/database-base.ts b/src/main/database-base.ts index 54b7750e..77e625a9 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -422,6 +422,7 @@ export class AppDatabaseBase { interruptedCount = Number(result.changes); if (interruptedCount > 0) { this.db.prepare( + // eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: crash-recovery sweep; trigger mirrors status→phase, step 3/5 already drops shadow leases below `update tasks set status = 'blocked', updated_at = ? where id in (select task_id from runs where status = 'interrupted' and finished_at = ?) and status in ('in_progress', 'todo')`, ).run(ts, ts); } diff --git a/src/main/database/runs.ts b/src/main/database/runs.ts index 5cebcc1d..c8a9c60b 100644 --- a/src/main/database/runs.ts +++ b/src/main/database/runs.ts @@ -161,6 +161,7 @@ export function buildRunMethods( // NOT EXISTS clause is the canonical "no live owner" check. getDb() .prepare( + // eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: finishRun's "runNeverStarted" rollback; trigger mirrors status→phase, shadow lease already dropped above `UPDATE tasks SET status = 'todo', checkout_run_id = NULL, diff --git a/src/main/database/tasks-status.ts b/src/main/database/tasks-status.ts index 7a896836..7ba9e7c3 100644 --- a/src/main/database/tasks-status.ts +++ b/src/main/database/tasks-status.ts @@ -377,6 +377,7 @@ export function buildTaskStatusMethods( ): void { const ts = nowIso(); getDb() + // eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: crash-recovery retry bounces task back to todo; trigger mirrors status→phase, no lease held at this point .prepare("update tasks set status = 'todo', updated_at = ? where id = ?") .run(ts, taskId); getDb() @@ -402,6 +403,7 @@ export function buildTaskStatusMethods( markInterruptedTaskFailed(taskId: string): void { const ts = nowIso(); getDb() + // eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: crash-recovery terminal transition; trigger mirrors status→phase, lease was already cleared by interruptStaleRuns .prepare("update tasks set status = 'cancelled', updated_at = ? where id = ?") .run(ts, taskId); },