318 lines
10 KiB
Go
318 lines
10 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex"
|
|
)
|
|
|
|
func appendAIEvent(rctx context.Context, tx *sql.Tx, project, workspace, run, kind string, data any) error {
|
|
raw, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(rctx, `WITH next AS (UPDATE canvas_ai_runs SET last_seq=last_seq+1 WHERE project=$1 AND workspace_id=$2 AND id=$3 RETURNING last_seq) INSERT INTO canvas_ai_events(project,workspace_id,run_id,seq,type,data) SELECT $1,$2,$3,last_seq,$4,$5 FROM next`, project, workspace, run, kind, raw)
|
|
return err
|
|
}
|
|
func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodPost {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
if !acceptsCanvasV2(r) {
|
|
reject(w, 409, "canvas_schema_upgrade_required")
|
|
return nil
|
|
}
|
|
key := canvasIdempotencyKey(r)
|
|
if !uuidSlugPattern.MatchString(key) {
|
|
reject(w, 400, "invalid_idempotency_key")
|
|
return nil
|
|
}
|
|
expected, ok := expectedVersion(w, r)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<10))
|
|
if err != nil {
|
|
reject(w, 413, "context_too_large")
|
|
return nil
|
|
}
|
|
var input struct {
|
|
NodeID string `json:"node_id"`
|
|
ModelID string `json:"model_id"`
|
|
Prompt string `json:"prompt"`
|
|
Parameters map[string]any `json:"parameters"`
|
|
Propagate bool `json:"propagate"`
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
if !utf8.Valid(raw) || decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF || !canvasID.MatchString(input.NodeID) || strings.TrimSpace(input.Prompt) == "" || !canvasString(input.Prompt, 16000) {
|
|
reject(w, 400, "invalid_ai_request")
|
|
return nil
|
|
}
|
|
if input.Parameters == nil {
|
|
input.Parameters = map[string]any{}
|
|
}
|
|
params, _ := json.Marshal(input.Parameters)
|
|
if len(params) > 16<<10 {
|
|
reject(w, 413, "context_too_large")
|
|
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()
|
|
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if kind != "board" {
|
|
reject(w, 400, "invalid_material")
|
|
return nil
|
|
}
|
|
if role == "viewer" {
|
|
reject(w, 403, "forbidden")
|
|
return nil
|
|
}
|
|
var previousHash string
|
|
var previous json.RawMessage
|
|
err = tx.QueryRowContext(r.Context(), `SELECT payload_hash,create_response FROM canvas_ai_runs WHERE project=$1 AND account_id=$2 AND board_id=$3 AND idempotency_key=$4 AND workspace_id=$5`, project, account, board, key, workspace).Scan(&previousHash, &previous)
|
|
if err == nil {
|
|
if previousHash != hash {
|
|
reject(w, 409, "idempotency_conflict")
|
|
return nil
|
|
}
|
|
var result struct {
|
|
Version int64 `json:"canvas_version"`
|
|
}
|
|
_ = json.Unmarshal(previous, &result)
|
|
setVersion(w, result.Version)
|
|
reply(w, 202, previous)
|
|
return nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
parent, err := loadCanvas(r, tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if parent.Version != expected {
|
|
reject(w, 412, "version_conflict")
|
|
return nil
|
|
}
|
|
var doc map[string]any
|
|
_ = json.Unmarshal(parent.Document, &doc)
|
|
var target map[string]any
|
|
for _, v := range doc["nodes"].([]any) {
|
|
n := v.(map[string]any)
|
|
if n["id"] == input.NodeID {
|
|
target = n["data"].(map[string]any)
|
|
}
|
|
}
|
|
if target == nil {
|
|
reject(w, 404, "not_found")
|
|
return nil
|
|
}
|
|
if !canvasChoice(target["kind"], "text", "script", "plain-text", "image", "plain-image") {
|
|
reject(w, 422, "model_incompatible")
|
|
return nil
|
|
}
|
|
target["ai"] = map[string]any{"enabled": true, "modelId": input.ModelID, "prompt": input.Prompt, "parameters": input.Parameters, "propagate": input.Propagate}
|
|
doc["schema_version"] = 2
|
|
encoded, _ := json.Marshal(doc)
|
|
normalized, ok := normalizeCanvas(encoded)
|
|
if !ok {
|
|
reject(w, 400, "invalid_ai_request")
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
models, err := s.canvasAI.Models(ctx)
|
|
if err != nil {
|
|
reject(w, 503, "codex_unavailable")
|
|
return nil
|
|
}
|
|
catalogue := map[string]codex.CanvasModel{}
|
|
for _, model := range models {
|
|
catalogue[model.ID] = model
|
|
}
|
|
parent.Document = normalized
|
|
parent.Version++
|
|
plan, err := loadCanvasAIPlan(r, tx, project, workspace, account, parent, input.NodeID, input.Propagate)
|
|
if err != nil {
|
|
if err.Error() == "execution_cycle" {
|
|
reject(w, 409, "execution_cycle")
|
|
} else {
|
|
reject(w, 413, "graph_limit_exceeded")
|
|
}
|
|
return nil
|
|
}
|
|
stepKinds := map[string]string{}
|
|
for _, step := range plan.Steps {
|
|
stepKinds[step.ID] = step.Kind
|
|
}
|
|
freezeCtx, stopFreeze := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer stopFreeze()
|
|
frozenImages := map[string]map[string]any{}
|
|
for index, step := range plan.Steps {
|
|
if step.Status == "skipped" {
|
|
continue
|
|
}
|
|
model, exists := catalogue[step.ModelID]
|
|
if !exists || !model.Available {
|
|
reject(w, 422, "model_unavailable")
|
|
return nil
|
|
}
|
|
modality := "text"
|
|
if canvasChoice(step.Kind, "image", "plain-image") {
|
|
modality = "image"
|
|
}
|
|
if !canvasModelSupports(model, modality) {
|
|
reject(w, 422, "model_incompatible")
|
|
return nil
|
|
}
|
|
imageSources := map[string]bool{}
|
|
if canvasChoice(step.Kind, "image", "plain-image") {
|
|
imageSources[step.ID] = true
|
|
}
|
|
for _, edge := range plan.Edges {
|
|
if edge.Target == step.ID && canvasChoice(stepKinds[edge.Source], "image", "plain-image") {
|
|
imageSources[edge.Source] = true
|
|
}
|
|
}
|
|
for _, source := range step.StaticInputs {
|
|
if canvasChoice(source.Kind, "image", "plain-image") {
|
|
imageSources[canvasNodeKey(source.BoardID, source.NodeID)] = true
|
|
}
|
|
}
|
|
if len(imageSources) > 0 && !canvasModelAccepts(model, "image") {
|
|
reject(w, 422, "model_incompatible")
|
|
return nil
|
|
}
|
|
if len(imageSources) > 4 {
|
|
reject(w, 413, "context_too_large")
|
|
return nil
|
|
}
|
|
plan.Steps[index].MaxExecutionMS = model.MaxExecutionMS
|
|
if canvasChoice(step.Kind, "image", "plain-image") {
|
|
content := frozenImages[step.ID]
|
|
var e error
|
|
if content == nil {
|
|
content, e = s.freezeCanvasImage(freezeCtx, tx, project, workspace, step.BoardID, account, step.FrozenContent)
|
|
frozenImages[step.ID] = content
|
|
}
|
|
if e != nil {
|
|
reject(w, 422, "model_incompatible")
|
|
return nil
|
|
}
|
|
plan.Steps[index].FrozenContent = content
|
|
}
|
|
|
|
for staticIndex, source := range step.StaticInputs {
|
|
if !canvasChoice(source.Kind, "image", "plain-image") {
|
|
continue
|
|
}
|
|
key := canvasNodeKey(source.BoardID, source.NodeID)
|
|
content := frozenImages[key]
|
|
var e error
|
|
if content == nil {
|
|
content, e = s.freezeCanvasImage(freezeCtx, tx, project, workspace, source.BoardID, account, source.Content)
|
|
frozenImages[key] = content
|
|
}
|
|
if e != nil {
|
|
reject(w, 422, "model_incompatible")
|
|
return nil
|
|
}
|
|
plan.Steps[index].StaticInputs[staticIndex].Content = content
|
|
}
|
|
if _, e := assembleCanvasTask(plan, plan.Steps[index], nil); e != nil {
|
|
reject(w, 413, "context_too_large")
|
|
return nil
|
|
}
|
|
|
|
parameters, _ := step.Config["parameters"].(map[string]any)
|
|
if len(parameters) > 0 {
|
|
reject(w, 400, "invalid_ai_request")
|
|
return nil
|
|
}
|
|
prompt, _ := step.Config["prompt"].(string)
|
|
text, _ := step.FrozenContent["text"].(string)
|
|
if len(prompt)+len(text) > model.MaxInputBytes {
|
|
reject(w, 413, "context_too_large")
|
|
return nil
|
|
}
|
|
var busy bool
|
|
err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND node_id=$4 AND status IN('queued','running'))`, project, workspace, step.BoardID, step.NodeID).Scan(&busy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if busy {
|
|
reject(w, 409, "node_busy")
|
|
return nil
|
|
}
|
|
}
|
|
// A project-scoped advisory lock makes the queue limit atomic across workspaces.
|
|
_, err = tx.ExecContext(r.Context(), `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, project+"/canvas-ai-queue")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var queued int
|
|
err = tx.QueryRowContext(r.Context(), `SELECT count(*) FROM canvas_ai_runs WHERE project=$1 AND status IN('queued','running')`, project).Scan(&queued)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if queued >= 128 {
|
|
w.Header().Set("Retry-After", "5")
|
|
reject(w, 429, "queue_full")
|
|
return nil
|
|
}
|
|
var id string
|
|
err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text`).Scan(&id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
planJSON, _ := json.Marshal(plan)
|
|
poll := "/projects/" + url.PathEscape(project) + "/workspaces/" + workspace + "/materials/" + board + "/canvas/ai/runs/" + id
|
|
result := response{"run_id": id, "status": "queued", "poll_url": poll, "plan": publicAIPlan(plan), "canvas_version": parent.Version}
|
|
resultJSON, _ := json.Marshal(result)
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_runs(project,workspace_id,board_id,id,account_id,idempotency_key,payload_hash,status,plan,create_response) VALUES($1,$2,$3,$4,$5,$6,$7,'queued',$8,$9)`, project, workspace, board, id, account, key, hash, planJSON, resultJSON)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = s.saveCanvas(r, tx, project, workspace, parent); err != nil {
|
|
return err
|
|
}
|
|
if err = appendAIEvent(r.Context(), tx, project, workspace, id, "run.queued", response{"status": "queued"}); err != nil {
|
|
return err
|
|
}
|
|
for ordinal, step := range plan.Steps {
|
|
frozen, _ := json.Marshal(step)
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_ai_steps(project,workspace_id,run_id,id,board_id,node_id,ordinal,status,model_id,frozen_input,target_fingerprint) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, project, workspace, id, step.ID, step.BoardID, step.NodeID, ordinal, step.Status, step.ModelID, frozen, step.Fingerprint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = appendAIEvent(r.Context(), tx, project, workspace, id, "step."+step.Status, response{"step_id": step.ID, "board_id": step.BoardID, "node_id": step.NodeID, "status": step.Status}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
setVersion(w, parent.Version)
|
|
reply(w, 202, result)
|
|
return nil
|
|
}
|