kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 17:26:31 +02:00
parent 17d7806838
commit eab9e951ab
12 changed files with 1696 additions and 5 deletions

View file

@ -226,12 +226,19 @@ class ProjectCreate(BaseModel):
return self
VALID_DEPLOY_RUNTIMES = {"docker", "node", "python", "static"}
class ProjectPatch(BaseModel):
execution_mode: str | None = None
autocommit_enabled: bool | None = None
auto_test_enabled: bool | None = None
obsidian_vault_path: str | None = None
deploy_command: str | None = None
deploy_host: str | None = None
deploy_path: str | None = None
deploy_runtime: str | None = None
deploy_restart_cmd: str | None = None
test_command: str | None = None
project_type: str | None = None
ssh_host: str | None = None
@ -246,6 +253,8 @@ def patch_project(project_id: str, body: ProjectPatch):
body.execution_mode, body.autocommit_enabled is not None,
body.auto_test_enabled is not None,
body.obsidian_vault_path, body.deploy_command is not None,
body.deploy_host is not None, body.deploy_path is not None,
body.deploy_runtime is not None, body.deploy_restart_cmd is not None,
body.test_command is not None,
body.project_type, body.ssh_host is not None,
body.ssh_user is not None, body.ssh_key_path is not None,
@ -257,6 +266,8 @@ def patch_project(project_id: str, body: ProjectPatch):
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
if body.project_type is not None and body.project_type not in VALID_PROJECT_TYPES:
raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}")
if body.deploy_runtime is not None and body.deploy_runtime != "" and body.deploy_runtime not in VALID_DEPLOY_RUNTIMES:
raise HTTPException(400, f"Invalid deploy_runtime '{body.deploy_runtime}'. Must be one of: {', '.join(sorted(VALID_DEPLOY_RUNTIMES))}")
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
@ -274,6 +285,14 @@ def patch_project(project_id: str, body: ProjectPatch):
if body.deploy_command is not None:
# Empty string = sentinel for clearing (decision #68)
fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command
if body.deploy_host is not None:
fields["deploy_host"] = None if body.deploy_host == "" else body.deploy_host
if body.deploy_path is not None:
fields["deploy_path"] = None if body.deploy_path == "" else body.deploy_path
if body.deploy_runtime is not None:
fields["deploy_runtime"] = None if body.deploy_runtime == "" else body.deploy_runtime
if body.deploy_restart_cmd is not None:
fields["deploy_restart_cmd"] = None if body.deploy_restart_cmd == "" else body.deploy_restart_cmd
if body.test_command is not None:
fields["test_command"] = body.test_command
if body.project_type is not None:
@ -325,19 +344,42 @@ def sync_obsidian_endpoint(project_id: str):
@app.post("/api/projects/{project_id}/deploy")
def deploy_project(project_id: str):
"""Execute deploy_command for a project. Returns stdout/stderr/exit_code.
"""Deploy a project using structured runtime steps or legacy deploy_command.
# WARNING: shell=True — deploy_command is admin-only, set in Settings by the project owner.
New-style (deploy_runtime set): uses core/deploy.py structured steps,
cascades to dependent projects via project_links.
Legacy fallback (only deploy_command set): runs deploy_command via shell.
WARNING: shell=True deploy commands are admin-only, set in Settings by the project owner.
"""
import time
from core.deploy import deploy_with_dependents
conn = get_conn()
p = models.get_project(conn, project_id)
conn.close()
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
deploy_runtime = p.get("deploy_runtime")
deploy_command = p.get("deploy_command")
if not deploy_command:
raise HTTPException(400, "deploy_command not set for this project")
if not deploy_runtime and not deploy_command:
conn.close()
raise HTTPException(400, "Neither deploy_runtime nor deploy_command is set for this project")
if deploy_runtime:
# New structured deploy with dependency cascade
try:
result = deploy_with_dependents(conn, project_id)
except Exception as e:
conn.close()
raise HTTPException(500, f"Deploy failed: {e}")
conn.close()
return result
# Legacy fallback: run deploy_command via shell
conn.close()
cwd = p.get("path") or None
start = time.monotonic()
try:
@ -386,6 +428,57 @@ def create_project(body: ProjectCreate):
return p
# ---------------------------------------------------------------------------
# Project Links (KIN-079)
# ---------------------------------------------------------------------------
class ProjectLinkCreate(BaseModel):
from_project: str
to_project: str
type: str
description: str | None = None
@app.post("/api/project-links")
def create_project_link(body: ProjectLinkCreate):
"""Create a project dependency link."""
conn = get_conn()
if not models.get_project(conn, body.from_project):
conn.close()
raise HTTPException(404, f"Project '{body.from_project}' not found")
if not models.get_project(conn, body.to_project):
conn.close()
raise HTTPException(404, f"Project '{body.to_project}' not found")
link = models.create_project_link(
conn, body.from_project, body.to_project, body.type, body.description
)
conn.close()
return link
@app.get("/api/projects/{project_id}/links")
def get_project_links(project_id: str):
"""Get all project links where project is from or to."""
conn = get_conn()
if not models.get_project(conn, project_id):
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
links = models.get_project_links(conn, project_id)
conn.close()
return links
@app.delete("/api/project-links/{link_id}", status_code=204)
def delete_project_link(link_id: int):
"""Delete a project link by id."""
conn = get_conn()
deleted = models.delete_project_link(conn, link_id)
conn.close()
if not deleted:
raise HTTPException(404, f"Link {link_id} not found")
return Response(status_code=204)
# ---------------------------------------------------------------------------
# Phases (KIN-059)
# ---------------------------------------------------------------------------
@ -670,6 +763,19 @@ def patch_task(task_id: str, body: TaskPatch):
return t
@app.get("/api/pipelines/{pipeline_id}/logs")
def get_pipeline_logs(pipeline_id: int, since_id: int = 0):
"""Get pipeline log entries after since_id (for live console polling)."""
conn = get_conn()
row = conn.execute("SELECT id FROM pipelines WHERE id = ?", (pipeline_id,)).fetchone()
if not row:
conn.close()
raise HTTPException(404, f"Pipeline {pipeline_id} not found")
logs = models.get_pipeline_logs(conn, pipeline_id, since_id=since_id)
conn.close()
return logs
@app.get("/api/tasks/{task_id}/pipeline")
def get_task_pipeline(task_id: str):
"""Get agent_logs for a task (pipeline steps)."""