Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions hub/src/db/scheduled-tasks-dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,10 @@ export async function insertRunV2(input: {
started_at?: Date | null
finished_at?: Date | null
}): Promise<ScheduledTaskRun> {
const startedAt =
input.started_at !== undefined
? input.started_at
: input.status === 'pending' ? null : new Date()
// started_at is NOT NULL in the schema. Always default to now() when the
// caller doesn't pass one (or passes null/undefined explicitly). The legacy
// pending=>null branch caused cron fires to fail the insert (#PR49 regression).
const startedAt = input.started_at ?? new Date()
const finishedAt = input.finished_at ?? null
const rows = await sql<ScheduledTaskRun[]>`
INSERT INTO scheduled_task_runs (
Expand Down
5 changes: 5 additions & 0 deletions hub/src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS output_snippet TEXT;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS triggered_by_run_id TEXT REFERENCES scheduled_task_runs(id) ON DELETE SET NULL;
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now();

-- Belt-and-suspenders for the cron-fire regression: ensure started_at always
-- has a DB-side default so an accidentally-omitted JS value never trips the
-- NOT NULL constraint. Idempotent.
ALTER TABLE scheduled_task_runs ALTER COLUMN started_at SET DEFAULT now();

CREATE INDEX IF NOT EXISTS idx_scheduled_runs_task_scheduled
ON scheduled_task_runs(task_id, scheduled_for DESC);
CREATE INDEX IF NOT EXISTS idx_scheduled_runs_user_scheduled
Expand Down
82 changes: 82 additions & 0 deletions hub/test/insert-run-started-at.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Regression: PR #49 made started_at default to null when status='pending',
* which violates the NOT NULL column constraint. Every cron fire was failing
* in prod. insertRunV2 must ALWAYS pass a non-null started_at to SQL.
*/
import { describe, it, expect, mock, beforeEach } from 'bun:test'

// Capture the values passed to the tagged-template `sql` call.
let captured: any[] = []

mock.module('../src/db/postgres.ts', () => {
const sql: any = (strings: TemplateStringsArray, ...values: any[]) => {
captured.push({ strings: [...strings], values })
// INSERT ... RETURNING * — return a fake row so insertRunV2 resolves.
return Promise.resolve([
{ id: 'run_test', status: 'pending', started_at: new Date() },
])
}
return { sql, default: sql }
})

const { insertRunV2 } = await import('../src/db/scheduled-tasks-dal.ts')

describe('insertRunV2 started_at safety', () => {
beforeEach(() => {
captured = []
})

it('passes a non-null Date for started_at when status=pending and started_at omitted', async () => {
await insertRunV2({
task_id: 't1',
user_id: 'u1',
status: 'pending',
scheduled_for: new Date(),
target_kind: 'agent',
})
expect(captured.length).toBeGreaterThan(0)
const vals = captured[0].values
// started_at sits between triggered_by_run_id and finished_at in the
// INSERT VALUES clause. Find any Date value that isn't scheduled_for.
const dates = vals.filter((v: any) => v instanceof Date)
expect(dates.length).toBeGreaterThanOrEqual(2) // scheduled_for + started_at
// None of the date values should be null/undefined.
for (const v of vals) {
if (v === null || v === undefined) continue
// ok
}
// Specifically: no Date slot in values is null.
const allDates = vals.filter(
(v: any) => v instanceof Date || v === null || v === undefined,
)
// started_at slot must not be null
expect(dates.some((d: Date) => d instanceof Date)).toBe(true)
})

it('passes a non-null Date for status=success path', async () => {
await insertRunV2({
task_id: 't1',
user_id: 'u1',
status: 'success',
scheduled_for: new Date(),
target_kind: 'agent',
})
const vals = captured[0].values
const dates = vals.filter((v: any) => v instanceof Date)
expect(dates.length).toBeGreaterThanOrEqual(2)
})

it('honors caller-provided started_at when given', async () => {
const explicit = new Date('2026-01-01T00:00:00Z')
await insertRunV2({
task_id: 't1',
user_id: 'u1',
status: 'pending',
scheduled_for: new Date(),
target_kind: 'agent',
started_at: explicit,
})
const vals = captured[0].values
expect(vals.some((v: any) => v instanceof Date && v.getTime() === explicit.getTime())).toBe(true)
})
})
Loading