Add async job runner and GET /api/v1/jobs/{id}
CI / ci (pull_request) Successful in 1m29s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (2 high)
CI / ci (push) Successful in 1m27s

internal/jobs persists jobs in the jobs table, which is also the queue: a
bounded pool of workers claims queued jobs oldest first. Handlers are
registered per kind before Start with typed payloads and results, and can
publish partial results to the row while they run. Only typed results and
*jobs.Failure messages are stored; any other handler error is logged and
stored as "internal error", so dependency error text cannot surface a
secret. Start fails jobs a previous process left running with
"interrupted by restart" before any worker runs.

GET /api/v1/jobs/{id} returns {id, kind, status, result, error} inside the
protected /api/v1 group; an unknown id is a 404 not_found.

Migration 0003_jobs_status rebuilds jobs with status queued, running,
succeeded, failed, mapping legacy pending and done rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:05:23 -04:00
co-authored by Claude Opus 5.5
parent 3ba78d47bc
commit f37f240b26
16 changed files with 1290 additions and 22 deletions
+51 -4
View File
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
}
}
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -262,3 +262,50 @@ func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
}
}
// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in
// every 0001 status and checks 0003 carries each row over with its status
// mapped and every other column unchanged.
func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} {
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"}
for old := range legacy {
if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil {
t.Fatalf("insert %s job: %v", old, err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
for old, want := range legacy {
var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string
if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old).
Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil {
t.Fatalf("read %s job: %v", old, err)
}
if status != want {
t.Errorf("%s job status = %q, want %q", old, status, want)
}
got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt}
if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) {
t.Errorf("%s job columns = %v, want %v", old, got, wantCols)
}
}
if tableExists(t, s, "jobs_new") {
t.Error("jobs_new left behind")
}
}
+2 -2
View File
@@ -165,7 +165,7 @@ func testRow(table string, i int) map[string]any {
r["attempts"] = 0
case "jobs":
r["kind"] = "suggest"
r["status"] = "pending"
r["status"] = "queued"
r["payload"] = "{}"
case "schedule_runs":
r["schedule"] = "plan_draft"
@@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
{"notification_prefs", "chat", 2},
{"notification_outbox", "channel", "email"},
{"notification_outbox", "status", "queued"},
{"jobs", "status", "queued"},
{"jobs", "status", "bogus"},
{"schedule_runs", "schedule", "hourly"},
}
s := seededStore(t)