From 0950c09097c85c90ce12dd5f08e0f7bf7d4e6629 Mon Sep 17 00:00:00 2001 From: Jeewoo Lee Date: Mon, 27 Apr 2026 16:46:25 -0700 Subject: [PATCH] fix(tasks): write slug+owner on task create, accept either id or slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production tasks table has NOT NULL slug and owner columns (added externally from the unmerged staging refactor 472d4b7), but main's POST /tasks insert omits them — every public task create has been failing with NotNullViolation. The published 0.2.6.dev1 CLI also sends slug instead of id, so requests are 422'd by FastAPI before reaching the DB. - POST /tasks: accept id and/or slug as form fields, normalize to one task_id, insert (id, slug=task_id, owner='hive', ...). - _sync_tasks_from_github: insert slug+owner. - POST /tasks/private: insert slug+owner (owner=str(user_id) for per-user namespace). - db.py: add idempotent migration that ADD COLUMN slug/owner if missing (gated on information_schema). No-op on prod where they already exist. CLI is intentionally untouched — server now accepts both shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hive/server/db.py | 18 ++++++++++++++++++ src/hive/server/main.py | 30 ++++++++++++++++++------------ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/hive/server/db.py b/src/hive/server/db.py index 881c2aa..0a98e1b 100644 --- a/src/hive/server/db.py +++ b/src/hive/server/db.py @@ -412,6 +412,24 @@ def _ensure_postgres_migrations(conn) -> None: ).fetchone() if not row: conn.execute("ALTER TABLE forks ADD COLUMN branch_prefix TEXT") + # slug + owner on tasks — compat with prod schema applied externally from + # staging refactor 472d4b7. Additive + idempotent: each ALTER is gated on + # an information_schema check, slug is backfilled before SET NOT NULL, + # owner gets a default so existing rows satisfy NOT NULL immediately. + row = conn.execute( + "SELECT 1 FROM information_schema.columns" + " WHERE table_name = 'tasks' AND column_name = 'slug'" + ).fetchone() + if not row: + conn.execute("ALTER TABLE tasks ADD COLUMN slug TEXT") + conn.execute("UPDATE tasks SET slug = id WHERE slug IS NULL") + conn.execute("ALTER TABLE tasks ALTER COLUMN slug SET NOT NULL") + row = conn.execute( + "SELECT 1 FROM information_schema.columns" + " WHERE table_name = 'tasks' AND column_name = 'owner'" + ).fetchone() + if not row: + conn.execute("ALTER TABLE tasks ADD COLUMN owner TEXT NOT NULL DEFAULT 'hive'") # --- Async connection pool (one per worker process) --- diff --git a/src/hive/server/main.py b/src/hive/server/main.py index fde511e..6fee4fb 100644 --- a/src/hive/server/main.py +++ b/src/hive/server/main.py @@ -341,8 +341,9 @@ def _sync_tasks_from_github(): continue desc = repo.get("description") or "" conn.execute( - "INSERT INTO tasks (id, name, description, repo_url, created_at) VALUES (%s, %s, %s, %s, %s)", - (task_id, task_id, desc, repo["html_url"], now()), + "INSERT INTO tasks (id, slug, owner, name, description, repo_url, created_at)" + " VALUES (%s, %s, %s, %s, %s, %s, %s)", + (task_id, task_id, "hive", task_id, desc, repo["html_url"], now()), ) except Exception: pass # best-effort; server starts even if GitHub is unreachable @@ -886,32 +887,37 @@ def _validate_task_description(description: str): @router.post("/tasks", status_code=201) async def create_task( archive: UploadFile = File(...), - id: str = Form(...), + id: str | None = Form(None), + slug: str | None = Form(None), name: str = Form(...), description: str = Form(...), config: str | None = Form(None), x_admin_key: str = Header(""), authorization: str = Header(""), ): await require_admin(x_admin_key, authorization) - _validate_task_id(id) + task_id = (id or slug or "").strip() + if not task_id: + raise HTTPException(400, "id (or slug) is required") + _validate_task_id(task_id) _validate_task_description(description) async with get_db() as conn: - if await (await conn.execute("SELECT id FROM tasks WHERE id = %s", (id,))).fetchone(): + if await (await conn.execute("SELECT id FROM tasks WHERE id = %s", (task_id,))).fetchone(): raise HTTPException(409, "A public or private task with this ID already exists. Try a different ID.") try: gh = get_github_app() except Exception as e: raise HTTPException(503, f"GitHub App not configured: {e}") try: - repo_url = await asyncio.to_thread(gh.create_task_repo, id, archive.file.read(), description) + repo_url = await asyncio.to_thread(gh.create_task_repo, task_id, archive.file.read(), description) except Exception as e: raise HTTPException(502, f"Failed to create GitHub repo: {e}") async with get_db() as conn: await conn.execute( - "INSERT INTO tasks (id, name, description, repo_url, config, created_at) VALUES (%s, %s, %s, %s, %s, %s)", - (id, name, description, repo_url, config, now()), + "INSERT INTO tasks (id, slug, owner, name, description, repo_url, config, created_at)" + " VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (task_id, task_id, "hive", name, description, repo_url, config, now()), ) - return JSONResponse({"id": id, "name": name, "repo_url": repo_url, "status": "active"}, status_code=201) + return JSONResponse({"id": task_id, "name": name, "repo_url": repo_url, "status": "active"}, status_code=201) @router.get("/tasks/mine") @@ -992,9 +998,9 @@ def _validate_repo(): except Exception: pass # best-effort await conn.execute( - "INSERT INTO tasks (id, name, description, repo_url, task_type, owner_id, visibility, source_repo, installation_id, created_at)" - " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", - (task_id, task_name, description, repo_url, "private", user_id, "private", repo_full_name, installation_id, now()), + "INSERT INTO tasks (id, slug, owner, name, description, repo_url, task_type, owner_id, visibility, source_repo, installation_id, created_at)" + " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + (task_id, task_id, str(user_id), task_name, description, repo_url, "private", user_id, "private", repo_full_name, installation_id, now()), ) resp_body: dict[str, Any] = { "id": task_id, "name": task_name, "repo_url": repo_url,