package platform import ( "database/sql" "encoding/json" "io" "net/http" ) func (s *service) cancelCanvasAIRun(w http.ResponseWriter, r *http.Request, project, account string) error { if r.Method != http.MethodPost { reject(w, 405, "method_not_allowed") return nil } key, id := canvasIdempotencyKey(r), r.PathValue("run") if !uuidSlugPattern.MatchString(key) || !uuidSlugPattern.MatchString(id) { reject(w, 400, "invalid_ai_request") return nil } raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4096)) if err != nil { reject(w, 413, "context_too_large") return nil } input := map[string]any{} if len(raw) > 0 && (json.Unmarshal(raw, &input) != nil || input == nil || len(input) > 0) { reject(w, 400, "invalid_ai_request") return nil } hash := payloadDigest(input) workspace, board := r.PathValue("workspace"), r.PathValue("material") tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() _, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) if err != nil { return err } if role == "viewer" { reject(w, 403, "forbidden") return nil } var status string var rawPlan json.RawMessage err = tx.QueryRowContext(r.Context(), `SELECT status,plan FROM canvas_ai_runs WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 FOR UPDATE`, project, workspace, board, id).Scan(&status, &rawPlan) if err != nil { return err } var priorHash string var prior json.RawMessage err = tx.QueryRowContext(r.Context(), `SELECT payload_hash,response_body FROM canvas_ai_mutations WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND account_id=$4 AND action='cancel' AND idempotency_key=$5`, project, workspace, id, account, key).Scan(&priorHash, &prior) if err == nil { if priorHash != hash { reject(w, 409, "idempotency_conflict") return nil } reply(w, 200, prior) return nil } if err != sql.ErrNoRows { return err } var plan canvasAIPlan if err = json.Unmarshal(rawPlan, &plan); err != nil { return err } for _, step := range plan.Steps { _, role, err := lockMaterial(r.Context(), tx, project, workspace, step.BoardID, account) if err != nil { return err } if role == "viewer" { reject(w, 403, "forbidden") return nil } } if status == "queued" || status == "running" { _, err = tx.ExecContext(r.Context(), `UPDATE canvas_ai_runs SET status='cancelled',cancel_requested=true,completed_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id) if err != nil { return err } rows, err := tx.QueryContext(r.Context(), `UPDATE canvas_ai_steps SET status='cancelled',error_code='generation_cancelled',completed_at=now() WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND status IN('queued','running') RETURNING id,board_id::text,node_id`, project, workspace, id) if err != nil { return err } events := []response{} for rows.Next() { var step, board, node string if err = rows.Scan(&step, &board, &node); err != nil { break } events = append(events, response{"step_id": step, "board_id": board, "node_id": node, "status": "cancelled"}) } if err == nil { err = rows.Err() } rows.Close() if err != nil { return err } cancelled := map[string]bool{} for _, event := range events { cancelled[event["step_id"].(string)] = true if err = appendAIEvent(r.Context(), tx, project, workspace, id, "step.cancelled", event); err != nil { return err } } for _, edge := range plan.Edges { if cancelled[edge.Target] { if err = appendAIEvent(r.Context(), tx, project, workspace, id, "edge.skipped", response{"logical_edge_id": edge.ID, "status": "skipped"}); err != nil { return err } } } status = "cancelled" if err = appendAIEvent(r.Context(), tx, project, workspace, id, "run.completed", response{"status": status}); err != nil { return err } } rows, err := tx.QueryContext(r.Context(), `SELECT id,board_id::text,node_id,status,applied_version FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND run_id=$3 ORDER BY ordinal`, project, workspace, id) if err != nil { return err } applied, cancelledSteps := []any{}, []any{} for rows.Next() { var step, board, node, state string var version sql.NullInt64 if err = rows.Scan(&step, &board, &node, &state, &version); err != nil { break } if version.Valid { applied = append(applied, response{"step_id": step, "board_id": board, "node_id": node, "canvas_version": version.Int64}) } if state == "cancelled" { cancelledSteps = append(cancelledSteps, step) } } if err == nil { err = rows.Err() } rows.Close() if err != nil { return err } result := response{"run_id": id, "status": status, "applied": applied, "cancelled_step_ids": cancelledSteps, "worker_stop_grace_ms": 1000} encoded, _ := json.Marshal(result) _, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_mutations(project,workspace_id,run_id,account_id,action,idempotency_key,payload_hash,response_body) VALUES($1,$2,$3,$4,'cancel',$5,$6,$7)`, project, workspace, id, account, key, hash, encoded) if err != nil { return err } if err = tx.Commit(); err != nil { return err } reply(w, 200, result) return nil }