Bind canonical AI admission to the project version vector
This commit is contained in:
@@ -20,6 +20,25 @@ type canvasAIChange struct {
|
||||
Incarnation int64
|
||||
}
|
||||
|
||||
// Until a narrower read-set protocol exists, admission binds every active
|
||||
// canvas in this canonical project. Archived versions are not graph inputs.
|
||||
// Workspace locking keeps validation, plan freezing and admission atomic.
|
||||
func validateCanvasAIVersions(model canvasModel, board string, header int64, expected map[string]int64) string {
|
||||
if len(expected) != len(model.Canvases) || expected[board] != header {
|
||||
return "invalid_ai_versions"
|
||||
}
|
||||
for id := range model.Canvases {
|
||||
version, ok := expected[id]
|
||||
if !ok || version < 1 || version > 9007199254740991 {
|
||||
return "invalid_ai_versions"
|
||||
}
|
||||
if version != model.Versions[id] {
|
||||
return "version_conflict"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func prepareModelAIPatch(before canvasModel, board, node string, patch response) (canvasModel, error) {
|
||||
element := before.Elements[node]
|
||||
if element == nil || element["structure"] != "node" || element["ownerCanvasId"] != board || modelInt(element["dataVersion"]) != 1 || !canvasChoice(element["componentId"], "text", "script", "plain-text", "image", "plain-image") {
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestCanonicalAIAdmissionAndLifecycle(t *testing.T) {
|
||||
command := func(board, target, model string, incarnation int64) (response, string) {
|
||||
next += 2
|
||||
key := modelTestID(next)
|
||||
return response{"protocol_version": 2, "request_id": key, "operation_id": modelTestID(next + 1), "target_incarnation": incarnation, "node_id": target, "model_id": model, "prompt": "Generate", "parameters": response{}, "propagate": false}, key
|
||||
return response{"protocol_version": 2, "request_id": key, "operation_id": modelTestID(next + 1), "target_incarnation": incarnation, "expected_versions": read().Versions, "node_id": target, "model_id": model, "prompt": "Generate", "parameters": response{}, "propagate": false}, key
|
||||
}
|
||||
start := func(board, target, model string, incarnation int64) response {
|
||||
input, key := command(board, target, model, incarnation)
|
||||
@@ -235,6 +235,7 @@ func TestCanonicalAIAdmissionAndLifecycle(t *testing.T) {
|
||||
if !modelEqual(admitted, retry) {
|
||||
t.Fatal("admission retry changed result")
|
||||
}
|
||||
call("POST", base(root)+"/ai/runs", input, 409, sourceVersion+1, key)
|
||||
queried := call("GET", base(root)+"/ai/operations/"+key, nil, 200, 0, "")
|
||||
if !modelEqual(admitted, queried) {
|
||||
t.Fatal("lost admission response not recoverable")
|
||||
@@ -364,6 +365,13 @@ func TestCanonicalAIAdmissionAndLifecycle(t *testing.T) {
|
||||
if e = s.completeCanvasRun(ctx, *moving, nil); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
staleInput, staleKey := command(child, imageID, "image", 1)
|
||||
staleVersions := staleInput["expected_versions"].(map[string]int64)
|
||||
staleVersions[root]-- // The target version is current; its ancestor context is not.
|
||||
call("POST", base(child)+"/ai/runs", staleInput, 412, read().Versions[child], staleKey)
|
||||
missingInput, missingKey := command(child, imageID, "image", 1)
|
||||
delete(missingInput, "expected_versions")
|
||||
call("POST", base(child)+"/ai/runs", missingInput, 400, read().Versions[child], missingKey)
|
||||
childRun := start(child, imageID, "image", 1)
|
||||
execute(childRun["run_id"].(string))
|
||||
if read().Elements[imageID]["ownerCanvasId"] != child {
|
||||
|
||||
@@ -56,3 +56,42 @@ func TestCanonicalAIProjectionDoesNotExecuteFutureConnections(t *testing.T) {
|
||||
t.Fatal("future connection was executed or removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalAIAdmissionVersions(t *testing.T) {
|
||||
model := modelTestDocument()
|
||||
model.Canvases[modelChildID] = modelCanvas{ID: modelChildID}
|
||||
model.Versions[modelChildID] = 3
|
||||
// Include an archived version: it must not become an executable read dependency.
|
||||
model.Versions["archived"] = 8
|
||||
valid := map[string]int64{}
|
||||
for id := range model.Canvases {
|
||||
valid[id] = model.Versions[id]
|
||||
}
|
||||
if code := validateCanvasAIVersions(model, modelRootID, valid[modelRootID], valid); code != "" {
|
||||
t.Fatal(code)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
versions map[string]int64
|
||||
header int64
|
||||
want string
|
||||
}{
|
||||
{"missing", nil, 1, "invalid_ai_versions"},
|
||||
{"changed-header", valid, 99, "invalid_ai_versions"},
|
||||
{"incomplete", map[string]int64{modelRootID: 1}, 1, "invalid_ai_versions"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := validateCanvasAIVersions(model, modelRootID, test.header, test.versions); got != test.want {
|
||||
t.Fatalf("got %q want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
stale := map[string]int64{}
|
||||
for id, version := range valid {
|
||||
stale[id] = version
|
||||
}
|
||||
stale[modelChildID]++
|
||||
if code := validateCanvasAIVersions(model, modelRootID, valid[modelRootID], stale); code != "version_conflict" {
|
||||
t.Fatal(code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,15 +47,16 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj
|
||||
return nil
|
||||
}
|
||||
var input struct {
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
OperationID string `json:"operation_id,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
TargetIncarnation int64 `json:"target_incarnation,omitempty"`
|
||||
NodeID string `json:"node_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
Propagate bool `json:"propagate"`
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"`
|
||||
OperationID string `json:"operation_id,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
TargetIncarnation int64 `json:"target_incarnation,omitempty"`
|
||||
ExpectedVersions map[string]int64 `json:"expected_versions,omitempty"`
|
||||
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()
|
||||
@@ -76,6 +77,10 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj
|
||||
return nil
|
||||
}
|
||||
hash := payloadDigest(input)
|
||||
if input.ProtocolVersion != 0 {
|
||||
// The version header is part of the immutable canonical admission request.
|
||||
hash = payloadDigest(response{"input": input, "if_match": expected})
|
||||
}
|
||||
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
@@ -161,7 +166,7 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj
|
||||
reject(w, 409, "target_changed")
|
||||
return nil
|
||||
}
|
||||
} else if input.ProtocolVersion != 0 || input.RequestID != "" || input.TargetIncarnation != 0 || input.OperationID != "" {
|
||||
} else if input.ProtocolVersion != 0 || input.RequestID != "" || input.TargetIncarnation != 0 || input.OperationID != "" || input.ExpectedVersions != nil {
|
||||
reject(w, 409, "canonical_migration_required")
|
||||
return nil
|
||||
}
|
||||
@@ -170,6 +175,16 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj
|
||||
reject(w, 400, "invalid_ai_request")
|
||||
return nil
|
||||
}
|
||||
if change.Before != nil {
|
||||
if code := validateCanvasAIVersions(*change.Before, board, expected, input.ExpectedVersions); code != "" {
|
||||
status := http.StatusBadRequest
|
||||
if code == "version_conflict" {
|
||||
status = http.StatusPreconditionFailed
|
||||
}
|
||||
reject(w, status, code)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
models, err := s.canvasAI.Models(ctx)
|
||||
|
||||
Reference in New Issue
Block a user