210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
package platform
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
)
|
|
|
|
func publicAIPlan(plan canvasAIPlan) response {
|
|
steps := []any{}
|
|
for _, step := range plan.Steps {
|
|
static := []any{}
|
|
for _, source := range step.StaticInputs {
|
|
static = append(static, response{"logical_edge_id": source.EdgeID, "board_id": source.BoardID, "node_id": source.NodeID, "kind": source.Kind, "source": "frozen_snapshot"})
|
|
}
|
|
steps = append(steps, response{"step_id": step.ID, "board_id": step.BoardID, "node_id": step.NodeID, "target_incarnation": step.TargetIncarnation, "canonical_root": step.CanonicalRoot, "kind": step.Kind, "model_id": step.ModelID, "max_execution_ms": step.MaxExecutionMS, "status": step.Status, "dependencies": step.Dependencies, "static_inputs": static})
|
|
}
|
|
return response{"steps": steps, "edges": plan.Edges, "frozen_versions": plan.Versions}
|
|
}
|
|
func (s *service) getCanvasAIRun(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodGet {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
id := r.PathValue("run")
|
|
if !uuidSlugPattern.MatchString(id) {
|
|
reject(w, 404, "not_found")
|
|
return nil
|
|
}
|
|
after := int64(0)
|
|
limit := 100
|
|
var err error
|
|
if raw := r.URL.Query().Get("after_seq"); raw != "" {
|
|
after, err = strconv.ParseInt(raw, 10, 64)
|
|
}
|
|
if err != nil || after < 0 {
|
|
reject(w, 400, "invalid_pagination")
|
|
return nil
|
|
}
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
limit, err = strconv.Atoi(raw)
|
|
}
|
|
if err != nil || limit < 1 || limit > 100 {
|
|
reject(w, 400, "invalid_pagination")
|
|
return nil
|
|
}
|
|
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, _, err = lockCanvasAIRunRoute(r.Context(), tx, project, workspace, board, account); err != nil {
|
|
return err
|
|
}
|
|
var status string
|
|
var rawPlan json.RawMessage
|
|
var last int64
|
|
var errorCode sql.NullString
|
|
err = tx.QueryRowContext(r.Context(), `SELECT status,plan,last_seq,error_code FROM canvas_ai_runs WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4`, project, workspace, board, id).Scan(&status, &rawPlan, &last, &errorCode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var plan canvasAIPlan
|
|
if err = json.Unmarshal(rawPlan, &plan); err != nil {
|
|
return err
|
|
}
|
|
checked := map[string]bool{board: true}
|
|
for _, step := range plan.Steps {
|
|
target, ok, e := resolveCanvasAITarget(r.Context(), tx, project, workspace, step)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if !ok {
|
|
target = step.BoardID
|
|
}
|
|
if !checked[target] {
|
|
if _, _, e = lockCanvasAIRunRoute(r.Context(), tx, project, workspace, target, account); e != nil {
|
|
return e
|
|
}
|
|
checked[target] = true
|
|
}
|
|
}
|
|
rows, err := tx.QueryContext(r.Context(), `SELECT id,COALESCE(applied_board_id,board_id)::text,node_id,status,error_code,applied_version,CASE WHEN output IS NULL THEN NULL ELSE output-'text' END 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
|
|
}
|
|
steps := []any{}
|
|
outputs := []any{}
|
|
versions := map[string]int64{}
|
|
for rows.Next() {
|
|
var step, node, stepBoard, stepStatus string
|
|
var code sql.NullString
|
|
var version sql.NullInt64
|
|
var output []byte
|
|
if err = rows.Scan(&step, &stepBoard, &node, &stepStatus, &code, &version, &output); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
item := response{"step_id": step, "board_id": stepBoard, "node_id": node, "status": stepStatus}
|
|
if code.Valid {
|
|
item["error"] = code.String
|
|
}
|
|
if version.Valid {
|
|
item["applied_version"] = version.Int64
|
|
if version.Int64 > versions[stepBoard] {
|
|
versions[stepBoard] = version.Int64
|
|
}
|
|
}
|
|
if len(output) > 0 {
|
|
outputs = append(outputs, response{"step_id": step, "output": json.RawMessage(output)})
|
|
}
|
|
steps = append(steps, item)
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err = tx.QueryContext(r.Context(), `SELECT seq,type,data,created_at FROM canvas_ai_events WHERE project=$1 AND workspace_id=$2 AND run_id=$3 AND seq>$4 ORDER BY seq LIMIT $5`, project, workspace, id, after, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
events := []any{}
|
|
next := after
|
|
size := 0
|
|
for rows.Next() {
|
|
var seq int64
|
|
var kind string
|
|
var data json.RawMessage
|
|
var timestamp any
|
|
if err = rows.Scan(&seq, &kind, &data, ×tamp); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
if size+len(data) > 800<<10 && len(events) > 0 {
|
|
break
|
|
}
|
|
var event response
|
|
if err = json.Unmarshal(data, &event); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
if event == nil {
|
|
event = response{}
|
|
}
|
|
event["seq"] = seq
|
|
event["run_id"] = id
|
|
event["type"] = kind
|
|
event["timestamp"] = timestamp
|
|
events = append(events, event)
|
|
next = seq
|
|
size += len(data)
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := response{"run_id": id, "status": status, "plan": publicAIPlan(plan), "steps": steps, "events": events, "next_seq": next, "has_more": next < last, "outputs": outputs, "applied_versions": versions, "retention_days": 30}
|
|
if errorCode.Valid {
|
|
result["error"] = errorCode.String
|
|
}
|
|
reply(w, 200, result)
|
|
return nil
|
|
}
|
|
|
|
// Resolve admission by the original client key, before the caller knows a run ID.
|
|
// Absence is unknown, not a terminal proof that an earlier invocation had no effect.
|
|
func (s *service) getCanvasAIAdmission(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodGet {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
key := r.PathValue("request")
|
|
if !uuidSlugPattern.MatchString(key) {
|
|
reject(w, 400, "invalid_request_id")
|
|
return nil
|
|
}
|
|
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, _, err = lockCanvasAIRunRoute(r.Context(), tx, project, workspace, board, account); err != nil {
|
|
return err
|
|
}
|
|
var body json.RawMessage
|
|
err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_ai_requests WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND request_id=$5`, project, workspace, board, account, key).Scan(&body)
|
|
if err == nil {
|
|
reply(w, 200, body)
|
|
return nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
err = tx.QueryRowContext(r.Context(), `SELECT create_response FROM canvas_ai_runs WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND account_id=$4 AND idempotency_key=$5`, project, workspace, board, account, key).Scan(&body)
|
|
if err == sql.ErrNoRows {
|
|
reply(w, 404, response{"protocol_version": 2, "request_id": key, "status": "unknown", "error": "ai_admission_unknown"})
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reply(w, 200, body)
|
|
return nil
|
|
}
|