From 156974471da0a6e7d4ca1240dc309fda3e9721ab Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Fri, 16 Oct 2026 11:25:12 +0800 Subject: [PATCH 01/26] feat(core): bind WorkflowRun identity and workspace lifecycle to the control plane specs#99 D3/D8 step 4 (run identity): the WorkflowRun becomes an execution aggregate the control plane accepts, while session execution stays unwired - the scheduler still fails runs on unwired agent nodes until the executor-slot resolution bridge lands, per the slots decision's landing order (cloud behavior is unchanged). - migration 0035: workflow_runs freezes created_by/project_id; workspaces bind workflow_run_id exclusively with issue_run_id; execution_work carries the same optional ownership under an exactly-one constraint with one partial unregistered index per owner; runtime_controls.maintenance_run_id drops its IssueRun FK, since the live run of either kind is validated where it is granted on. - createWorkflowRun freezes created_by from the authenticated Identity. - createWorkflowRunWorkspace/deleteWorkflowRunWorkspace: the WorkflowRun counterpart of the IssueRun workspace lifecycle, keyed on the frozen identity (project and actor required; idempotent; busy semantics shared). - the six public-visibility filters and the document key strip exclude workflow-run workspaces exactly as they exclude issue-run ones. - enqueue, dispatch and runtime permit accept either run kind; the three settlement hooks (workspace settled/deleted, thread events) accept a WorkflowRun and write nothing - the evidence-driven scheduler reads terminal facts directly. Co-Authored-By: Claude Code --- integration/revision_upgrade_test.go | 5 +- internal/core/agent_run_release.go | 8 + internal/core/agent_run_settle.go | 9 + internal/core/agent_run_thread.go | 11 +- internal/core/execution_work.go | 27 +- .../migrations/0035_workflow_run_identity.sql | 43 +++ internal/core/node_execution.go | 6 +- internal/core/plugins.go | 4 +- internal/core/public.go | 2 +- internal/core/run_workspace.go | 61 +++++ internal/core/runtime_control.go | 11 +- internal/core/runtime_control_delivery.go | 5 + internal/core/runtime_permissions.go | 2 +- internal/core/store.go | 5 +- internal/core/workflow_run.go | 8 +- .../core/workflow_run_identity_db_test.go | 255 ++++++++++++++++++ 16 files changed, 447 insertions(+), 15 deletions(-) create mode 100644 internal/core/migrations/0035_workflow_run_identity.sql create mode 100644 internal/core/workflow_run_identity_db_test.go diff --git a/integration/revision_upgrade_test.go b/integration/revision_upgrade_test.go index 1d69401c9..e666eea28 100644 --- a/integration/revision_upgrade_test.go +++ b/integration/revision_upgrade_test.go @@ -93,7 +93,10 @@ func TestRevisionUpgradePreservesPublishedWorkflowSchema(t *testing.T) { must(t, err) } must(t, tx.Commit()) - const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r))::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` + // The run document strips the identity columns a later migration (0035) added: they land as + // NULL on published rows, which is an intended shape change, not a data change — the + // comparison keeps guarding the row data the published schema carried. + const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r) - ARRAY['created_by','project_id'])::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` const ledger = `SELECT jsonb_object_agg(version,checksum)::text FROM schema_migrations WHERE version <> '0027_verified_revisions.sql' AND version < '0030'` var beforeDocuments, beforeLedger string must(t, pool.QueryRow(documents, run).Scan(&beforeDocuments)) diff --git a/internal/core/agent_run_release.go b/internal/core/agent_run_release.go index a8ad20ddf..6da3ea814 100644 --- a/internal/core/agent_run_release.go +++ b/internal/core/agent_run_release.go @@ -284,6 +284,14 @@ func (s *Store) settleRunWorkspaceDeleted(t *transaction, runID string) error { } o := t.one("SELECT * FROM issue_runs WHERE id=$1 AND deleted_at IS NULL", runID) if o == nil { + // D8: a run Workspace may belong to a WorkflowRun. A delete_workspace terminal firing + // under a WorkflowRun reaches this hook with the WorkflowRun's id; the scheduler owns the + // run-level facts, so settlement writes nothing — but it must ACCEPT the fact, or the + // terminal transaction aborts and a workflow run's delete can never settle. An id that is + // neither a live issue run nor a live workflow run is still an invariant violation. + if t.one("SELECT id FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) != nil { + return nil + } return fmt.Errorf("runWorkspaceDeleted: issue_run %s not found", runID) } if o.S("executorType") != "agent" { diff --git a/internal/core/agent_run_settle.go b/internal/core/agent_run_settle.go index d3492e46b..2ff57e752 100644 --- a/internal/core/agent_run_settle.go +++ b/internal/core/agent_run_settle.go @@ -35,6 +35,15 @@ func (s *Store) settleRunWorkspace(t *transaction, runID, status string) error { } o := t.one("SELECT * FROM issue_runs WHERE id=$1 AND deleted_at IS NULL", runID) if o == nil { + // D8: a run Workspace may belong to a WorkflowRun. A create_workspace terminal firing + // under a WorkflowRun reaches this hook with the WorkflowRun's id; the evidence-driven + // scheduler reads terminal operation facts directly, so settlement writes nothing — but + // it must ACCEPT the fact, or the terminal transaction aborts and a workflow run's + // Workspace can never settle. An id that is neither a live issue run nor a live + // workflow run is still an invariant violation, not a replay. + if t.one("SELECT id FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) != nil { + return nil + } // A create_workspace terminal firing for a run that does not exist is an invariant // violation, not a replay: returning an error aborts the whole transaction. return fmt.Errorf("runWorkspaceSettled: issue_run %s not found", runID) diff --git a/internal/core/agent_run_thread.go b/internal/core/agent_run_thread.go index 2316ea7ec..f3c16fa04 100644 --- a/internal/core/agent_run_thread.go +++ b/internal/core/agent_run_thread.go @@ -90,8 +90,15 @@ func (s *Store) settleThreadEvents(t *transaction, runID, executionID string, ev o := t.one("SELECT * FROM issue_runs WHERE id=$1 AND deleted_at IS NULL", runID) if o == nil { - // No authoritative run to accept the events: an invariant violation, not a replay, so the - // batch must not be acked (unknown run). + // D8: a session may execute under a WorkflowRun. Phase-1 (D7) defers workflow thread + // history — thread_entries is keyed on issue_runs — so the receipts fence (A-side + // node_event_receipts, already recorded in the same transaction) is the durable record + // and nothing is appended here. Accepting the batch is what lets the terminal result + // commit; an id that is neither a live issue run nor a live workflow run is still an + // invariant violation, not a replay, so the batch must not be acked (unknown run). + if t.one("SELECT id FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) != nil { + return nil + } return fmt.Errorf("settleThreadEvents: issue_run %s not found", runID) } if o.S("executorType") != "agent" { diff --git a/internal/core/execution_work.go b/internal/core/execution_work.go index e5fa44f2b..4420317ac 100644 --- a/internal/core/execution_work.go +++ b/internal/core/execution_work.go @@ -16,7 +16,9 @@ func (s *Store) EnqueueExecutionWork(ctx context.Context, runID, kind string, in func enqueueExecutionWork(t *transaction, runID, kind string, input, target Object, availableAt time.Time) Object { require(validID(runID), 400, "invalid_dispatch") run := t.one("SELECT * FROM issue_runs WHERE id=$1 AND deleted_at IS NULL", runID) - require(run != nil, 404, "not_found") + if run == nil { + return enqueueWorkflowExecutionWork(t, runID, kind, input, target, availableAt) + } require(t.one("SELECT run_id FROM agent_run_residuals WHERE run_id=$1", runID) == nil, 409, "dispatch_conflict") if kind == "deliver_revision" { if t.one("SELECT run_id FROM revision_delivery_skips WHERE run_id=$1", runID) != nil { @@ -54,6 +56,29 @@ func enqueueExecutionWork(t *transaction, runID, kind string, input, target Obje return t.one("SELECT * FROM execution_work WHERE id=$1", id) } +// enqueueWorkflowExecutionWork releases one workflow run's session work for Controllers to claim. +// D8: the item registers against workflow_runs — exactly one unregistered item per run (the +// workflow partial index), kind fixed to agent_session, and no delivery variant (D7 defers +// 成果交付). Repeating the same unregistered input returns the original item; a different input conflicts. +func enqueueWorkflowExecutionWork(t *transaction, runID, kind string, input, target Object, availableAt time.Time) Object { + require(t.one("SELECT id FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) != nil, 404, "not_found") + require(kind == "agent_session", 400, "invalid_dispatch") + require(input.S("kind") == kind, 400, "invalid_dispatch") + require(target.S("workspaceId") != "" && target.S("sandboxInstanceId") != "" && target.S("nodeId") != "", 400, "invalid_dispatch") + if existing := t.one("SELECT * FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL", runID); existing != nil { + require(existing.S("kind") == kind && jsonText(existing.O("input")) == jsonText(input) && jsonText(existing.O("target")) == jsonText(target), 409, "dispatch_conflict") + return existing + } + id := newID() + if availableAt.IsZero() { + t.exec("INSERT INTO execution_work(id,workflow_run_id,kind,input,target) VALUES($1,$2,$3,$4,$5)", id, runID, kind, jsonText(input), jsonText(target)) + } else { + t.exec("INSERT INTO execution_work(id,workflow_run_id,kind,input,target,available_at) VALUES($1,$2,$3,$4,$5,$6)", id, runID, kind, jsonText(input), jsonText(target), availableAt) + } + t.workSignals = append(t.workSignals, runID) + return t.one("SELECT * FROM execution_work WHERE id=$1", id) +} + // deliveryWorkInput fixes server-owned locations before an execution identity exists. // Copying the caller's map keeps retries from accidentally retaining an earlier attempt's keys. func deliveryWorkInput(t *transaction, run Object, workID string, input Object) Object { diff --git a/internal/core/migrations/0035_workflow_run_identity.sql b/internal/core/migrations/0035_workflow_run_identity.sql new file mode 100644 index 000000000..56ab30374 --- /dev/null +++ b/internal/core/migrations/0035_workflow_run_identity.sql @@ -0,0 +1,43 @@ +-- WorkflowRun identity and bindings (specs decisions/cloud/workflow +-- 0-cloud-side-scheduler-owns-workflow-execution.md D3/D8, landing order step 4 "运行身份"; +-- semantics inherited from decisions/cloud/workflow/20261015-executor-slots-replace-agent-model-ids.md +-- and decisions/cloud/model-access/20261015-executor-slot-mappings-and-per-slot-run-bindings.md). +-- +-- The WorkflowRun is the execution aggregate for workflow invocations, so it needs the frozen identity +-- the run Workspace lifecycle reads (who invoked it, which project its Workspace provisions in), and +-- the control plane's work registry must accept a WorkflowRun as an alternative work owner without a +-- new Controller contract (D8: 判别列 + 恰好一个约束). Session execution itself stays unwired until +-- the executor-slot resolution bridge lands per the slots decision's 落地顺序 — until then Cloud does +-- not execute workflows, and behavior is unchanged. + +-- 1. Frozen run identity. Both nullable: rows written before this migration carry no recoverable +-- actor, and project_id is filled by the invocation path that knows a project (the @-path; the +-- editor's run button does not). New runs freeze both at creation. +ALTER TABLE workflow_runs ADD COLUMN created_by uuid REFERENCES users(id); +ALTER TABLE workflow_runs ADD COLUMN project_id uuid REFERENCES projects(id); + +-- 2. A run Workspace can belong to a WorkflowRun. The binding is exclusive with the IssueRun one; +-- workspaces without either stay the public kind. +ALTER TABLE workspaces ADD COLUMN workflow_run_id uuid UNIQUE REFERENCES workflow_runs(id); +ALTER TABLE workspaces ADD CONSTRAINT workspace_run_binding_exclusive + CHECK (issue_run_id IS NULL OR workflow_run_id IS NULL); + +-- 3. Execution work carries the same optional ownership: a work item names exactly one run, of either +-- kind. The single-column uniqueness splits into one partial index per owner, so a run of each kind +-- keeps at most one unregistered item and the two ownerships never collide. +ALTER TABLE execution_work ALTER COLUMN run_id DROP NOT NULL; +ALTER TABLE execution_work ADD COLUMN workflow_run_id uuid REFERENCES workflow_runs(id); +ALTER TABLE execution_work ADD CONSTRAINT execution_work_owner_exclusive + CHECK ((run_id IS NULL) <> (workflow_run_id IS NULL)); +DROP INDEX one_unregistered_execution_work; +CREATE UNIQUE INDEX one_unregistered_execution_work ON execution_work(run_id) + WHERE execution_id IS NULL AND workflow_run_id IS NULL; +CREATE UNIQUE INDEX one_unregistered_workflow_execution_work ON execution_work(workflow_run_id) + WHERE execution_id IS NULL AND run_id IS NULL; + +-- 4. Runtime control's run reservation names the run of either kind that owns the Workspace's next +-- control epoch. The IssueRun FK is dropped, not reworked: a polymorphic run reference cannot be a +-- foreign key, so the liveness check moves to the code that grants on it (runtime_permit re-reads +-- the live run before a Node binds, reconciliation clears the reservation), and 0026's own CHECKs — +-- exactly one of operation/run id while maintenance is reserved — are unchanged. +ALTER TABLE runtime_controls DROP CONSTRAINT runtime_controls_maintenance_run_id_fkey; diff --git a/internal/core/node_execution.go b/internal/core/node_execution.go index ae13c8a1b..47cac23cb 100644 --- a/internal/core/node_execution.go +++ b/internal/core/node_execution.go @@ -43,7 +43,9 @@ func dispatchPluginExecution(t *transaction, r *ControlRequest, operation, execu func dispatchRunExecution(t *transaction, r *ControlRequest, runID, execution, node string, input Object) Object { require(t.one("SELECT run_id FROM agent_run_residuals WHERE run_id=$1", runID) == nil, 409, "dispatch_conflict") - work := t.one("SELECT * FROM execution_work WHERE run_id=$1 AND execution_id IS NULL AND kind=$2", runID, input.S("kind")) + // The work item names its owner — IssueRun or WorkflowRun (D8); operation_id is that run's id + // either way, so the ownership check re-reads whichever column the row carries. + work := t.one("SELECT * FROM execution_work WHERE (run_id=$1 OR workflow_run_id=$1) AND execution_id IS NULL AND kind=$2", runID, input.S("kind")) require(work != nil, 409, "dispatch_conflict") require(jsonText(work.O("input")) == jsonText(input), 409, "dispatch_conflict") target := work.O("target") @@ -51,7 +53,7 @@ func dispatchRunExecution(t *transaction, r *ControlRequest, runID, execution, n wid := target.S("workspaceId") requireNoForceStop(t, wid) w := t.one("SELECT * FROM workspaces WHERE id=$1", wid) - require(w.S("issueRunId") == runID && w.S("observedState") == "ready" && w.B("admissionOpen"), 409, "dispatch_conflict") + require((w.S("issueRunId") == runID || w.S("workflowRunId") == runID) && w.S("observedState") == "ready" && w.B("admissionOpen"), 409, "dispatch_conflict") c := runtimeControl(t, wid) require(c.S("state") == "maintenance" && c.S("maintenanceRunId") == runID, 409, "runtime_control_required") current := currentNode(t, wid) diff --git a/internal/core/plugins.go b/internal/core/plugins.go index ea41cc9d1..1ee93e221 100644 --- a/internal/core/plugins.go +++ b/internal/core/plugins.go @@ -141,7 +141,7 @@ func pluginIdentity(id string) (namespace, identifier string, ok bool) { func livePluginWorkspaces(t *transaction, spaceID string) []Object { // Run Workspaces install plugins through their own create_workspace step. They are not fan-out // targets: a user install must not queue an operation against a Workspace the user cannot see. - return t.list(`SELECT w.* FROM workspaces w JOIN projects p ON p.id=w.project_id WHERE p.space_id=$1 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL ORDER BY w.id`, spaceID) + return t.list(`SELECT w.* FROM workspaces w JOIN projects p ON p.id=w.project_id WHERE p.space_id=$1 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL AND w.workflow_run_id IS NULL ORDER BY w.id`, spaceID) } // installSpacePlugin records an administrator's pinned selection. Execution waits durably; @@ -271,7 +271,7 @@ func pluginInstanceWriteback(t *transaction, op Object, pluginID, state, version } // Run Workspace instances are execution facts for that run. They do not change the Space aggregate, // so one disposable install failure cannot retire an Agent the Space still has selected. - states := t.list("SELECT observed_state FROM workspace_plugin_instances wi WHERE source_namespace=$1 AND identifier=$2 AND workspace_id IN (SELECT w.id FROM workspaces w JOIN projects p ON p.id=w.project_id WHERE p.space_id=$3 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL)", namespace, identifier, row.S("spaceId")) + states := t.list("SELECT observed_state FROM workspace_plugin_instances wi WHERE source_namespace=$1 AND identifier=$2 AND workspace_id IN (SELECT w.id FROM workspaces w JOIN projects p ON p.id=w.project_id WHERE p.space_id=$3 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL AND w.workflow_run_id IS NULL)", namespace, identifier, row.S("spaceId")) aggregate := "installed" if desired := t.one("SELECT desired_state,tenant_id FROM space_plugins WHERE space_id=$1 AND source_namespace=$2 AND identifier=$3", row.S("spaceId"), namespace, identifier); desired != nil { desiredState := desired.S("desiredState") diff --git a/internal/core/public.go b/internal/core/public.go index 688abad2e..01a4c51d8 100644 --- a/internal/core/public.go +++ b/internal/core/public.go @@ -492,7 +492,7 @@ func readPublic(t *transaction, r *PublicRequest, uid string) Object { case strings.HasSuffix(r.Path, "/issues"): return issueList(t, r) case strings.HasSuffix(r.Path, "/resource-status"): - return page(t, "SELECT w.id,w.project_id,w.owner_user_id,w.kind,w.desired_state,w.observed_state,w.runtime_generation,w.version FROM workspaces w WHERE w.tenant_id=$1 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL", []any{r.TenantID}, "w.id", r) + return page(t, "SELECT w.id,w.project_id,w.owner_user_id,w.kind,w.desired_state,w.observed_state,w.runtime_generation,w.version FROM workspaces w WHERE w.tenant_id=$1 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL AND w.workflow_run_id IS NULL", []any{r.TenantID}, "w.id", r) case strings.Contains(r.Path, "/workflows"): // A snapshot-scoped form read is checked before the bare snapshot read, because both carry a // snapshot id and only the path distinguishes projecting a version from describing it. diff --git a/internal/core/run_workspace.go b/internal/core/run_workspace.go index a28f33d72..e3e7ae874 100644 --- a/internal/core/run_workspace.go +++ b/internal/core/run_workspace.go @@ -46,6 +46,67 @@ func runTriggerActor(t *transaction, run Object) string { return actors[0].S("actorId") } +// createWorkflowRunWorkspace is the WorkflowRun counterpart of createRunWorkspace: it idempotently +// creates the isolated Workspace and its create_workspace operation for one workflow run. The run's +// frozen identity is the whole basis (specs#99 D3): project_id names the Project the Workspace +// provisions in, and created_by is the actor the Workspace and its operation record. A run without a +// frozen project has no place to provision in — the caller fails it honestly rather than guessing a +// Project; an unbound run is the editor path today, and the invocation path (#71) freezes one. +func createWorkflowRunWorkspace(t *transaction, runID string) Object { + run := t.one("SELECT * FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) + require(run != nil, 404, "not_found") + require(run.S("projectId") != "", 409, "workflow_run_project_required") + require(run.S("createdBy") != "", 409, "workflow_run_actor_required") + if existing := t.one("SELECT * FROM workspaces WHERE workflow_run_id=$1", runID); existing != nil { + op := t.one("SELECT * FROM operations WHERE workspace_id=$1 AND kind='create_workspace' ORDER BY created_at,id LIMIT 1", existing.S("id")) + return Object{"busy": false, "workspace": existing, "operation": op} + } + p := t.one("SELECT * FROM projects WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL", run.S("projectId"), run.S("tenantId")) + require(p != nil, 404, "not_found") + if p.S("lifecycle") != "active" || t.one("SELECT id FROM operations WHERE project_id=$1 AND state IN ('queued','running','retry_wait','blocked')", p.S("id")) != nil { + return Object{"busy": true} + } + wid := newID() + ref := p.S("defaultBranch") + require(ref != "" && ref != "HEAD", 400, "default_branch_required") + insertWorkspace(t, run.S("tenantId"), p.S("ownerUserId"), run.S("createdBy"), p.S("id"), wid, "isolated", ref, "Workflow run") + t.exec("UPDATE workspaces SET workflow_run_id=$2 WHERE id=$1", wid, runID) + op := newOperation(t, &PublicRequest{TenantID: run.S("tenantId"), Key: "workflow-run-" + runID + "-create"}, run.S("createdBy"), p.S("id"), wid, "create_workspace", "sandbox", requestHash("workflow-run-workspace", runID, Object{"kind": "create"}), Object{}) + return Object{"busy": false, "workspace": t.one("SELECT * FROM workspaces WHERE id=$1", wid), "operation": op} +} + +// deleteWorkflowRunWorkspace is the WorkflowRun counterpart of deleteRunWorkspace: it idempotently +// queues deletion of the run Workspace bound to a workflow run, sharing the busy/quiesce semantics and +// the pre-declaration snapshot. The snapshot omits workflow_run_id for the same reason its IssueRun +// counterpart omits issue_run_id: the operation claim response deliberately carries no run binding, +// and the snapshot is what a refused quiesce restores from. +func deleteWorkflowRunWorkspace(t *transaction, runID string) Object { + w := t.one("SELECT * FROM workspaces WHERE workflow_run_id=$1", runID) + require(w != nil, 404, "not_found") + if w["deletedAt"] != nil { + return Object{"busy": false, "workspace": w} + } + if existing := t.one("SELECT * FROM operations WHERE workspace_id=$1 AND kind='delete_workspace' AND state IN ('queued','running','retry_wait','blocked','succeeded') ORDER BY created_at DESC,id DESC LIMIT 1", w.S("id")); existing != nil { + return Object{"busy": false, "workspace": w, "operation": existing} + } + if t.one("SELECT id FROM operations WHERE project_id=$1 AND state IN ('queued','running','retry_wait','blocked')", w.S("projectId")) != nil { + return Object{"busy": true} + } + require(t.one("SELECT run_id FROM agent_run_residuals WHERE workspace_id=$1 AND cleared_at IS NULL", w.S("id")) == nil, 409, "resource_in_use") + closeAdmission(t, w, "deleted") + actor := w.S("creatorUserId") + snapshot := Object{} + for k, v := range w { + if k != "workflowRunId" { + snapshot[k] = v + } + } + req := Object{"previous": Object{w.S("id"): snapshot}} + op := newOperation(t, &PublicRequest{TenantID: w.S("tenantId"), Key: "workflow-run-" + runID + "-delete"}, actor, w.S("projectId"), w.S("id"), "delete_workspace", "quiesce", requestHash("workflow-run-workspace-delete", runID, Object{}), req) + reserveRuntimeMaintenance(t, w.S("id"), op.S("id")) + return Object{"busy": false, "workspace": t.one("SELECT * FROM workspaces WHERE id=$1", w.S("id")), "operation": op} +} + func deleteRunWorkspace(t *transaction, runID string) Object { w := t.one("SELECT * FROM workspaces WHERE issue_run_id=$1", runID) require(w != nil, 404, "not_found") diff --git a/internal/core/runtime_control.go b/internal/core/runtime_control.go index 42d7b3693..213070231 100644 --- a/internal/core/runtime_control.go +++ b/internal/core/runtime_control.go @@ -126,10 +126,15 @@ func reserveRuntimeMaintenance(t *transaction, wid, oid string) { // finishRuntimeMaintenance requests closure after the fixed intent finishes; it cannot grant // another session on a still-running Node before that Node confirms its durable responsibilities. func finishRuntimeMaintenance(t *transaction, oid string) { - if run := t.one("SELECT w.id,w.issue_run_id FROM workspaces w JOIN operations o ON o.workspace_id=w.id WHERE o.id=$1 AND o.kind='create_workspace' AND o.state='succeeded' AND w.issue_run_id IS NOT NULL", oid); run != nil { + if run := t.one("SELECT w.id,w.issue_run_id,w.workflow_run_id FROM workspaces w JOIN operations o ON o.workspace_id=w.id WHERE o.id=$1 AND o.kind='create_workspace' AND o.state='succeeded' AND (w.issue_run_id IS NOT NULL OR w.workflow_run_id IS NOT NULL)", oid); run != nil { // Initialization handed back terminal evidence for clone/plugins. The next control epoch - // belongs to this run; Node must acknowledge it before any session or delivery is permitted. - t.exec("UPDATE runtime_controls SET state='maintenance',maintenance_operation_id=NULL,maintenance_run_id=$2,control_epoch=control_epoch+1,binding_confirmed=false,input_closed=false,version=version+1,updated_at=clock_timestamp() WHERE workspace_id=$1", run.S("id"), run.S("issueRunId")) + // belongs to this run — IssueRun or WorkflowRun, whichever owns the Workspace (D8); Node + // must acknowledge it before any session or delivery is permitted. + owner := run.S("issueRunId") + if owner == "" { + owner = run.S("workflowRunId") + } + t.exec("UPDATE runtime_controls SET state='maintenance',maintenance_operation_id=NULL,maintenance_run_id=$2,control_epoch=control_epoch+1,binding_confirmed=false,input_closed=false,version=version+1,updated_at=clock_timestamp() WHERE workspace_id=$1", run.S("id"), owner) auditRuntimeControl(t, runtimeControl(t, run.S("id")), "run_reserved", "") return } diff --git a/internal/core/runtime_control_delivery.go b/internal/core/runtime_control_delivery.go index 76d38c45a..d9e0ff6c9 100644 --- a/internal/core/runtime_control_delivery.go +++ b/internal/core/runtime_control_delivery.go @@ -47,7 +47,12 @@ func runtimeControlCommand(t *transaction, r *ControlRequest) Object { require(e != nil && (e.S("kind") == "agent_session" || e.S("kind") == "deliver_revision") && e.S("operationId") == c.S("maintenanceRunId") && e.S("nodeId") == n.S("nodeId") && e["result"] == nil && e["terminatedByForceStopId"] == nil, 409, "stale_execution") work := t.one("SELECT target FROM execution_work WHERE id=$1", e.S("workId")) require(work.O("target").S("sandboxInstanceId") == n.S("sandboxInstanceId"), 409, "stale_node") + // The maintenance holder is a live run of either kind (D8): the IssueRun join first, the + // WorkflowRun row when the Workspace belongs to a workflow invocation. run := t.one("SELECT r.id FROM issue_runs r JOIN issues i ON i.id=r.issue_id WHERE r.id=$1 AND r.deleted_at IS NULL AND i.deleted_at IS NULL", c.S("maintenanceRunId")) + if run == nil { + run = t.one("SELECT id FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", c.S("maintenanceRunId")) + } w := t.one("SELECT * FROM workspaces WHERE id=$1", c.S("workspaceId")) require(run != nil && runtimeUsable(t, w, w.S("creatorUserId")), 403, "runtime_use_forbidden") requireRepositoryAccess(t, w.S("projectId")) diff --git a/internal/core/runtime_permissions.go b/internal/core/runtime_permissions.go index fb4cd4e82..cb1b10efa 100644 --- a/internal/core/runtime_permissions.go +++ b/internal/core/runtime_permissions.go @@ -20,7 +20,7 @@ func runtimeOverview(w Object, usable bool) Object { // runtimeListing keeps shared safe summaries while exposing clone/task content only to its users. func runtimeListing(t *transaction, r *PublicRequest, uid, pid string) Object { - out := page(t, "SELECT w.*,wt.branch_name,task.title FROM workspaces w LEFT JOIN workspace_worktrees wt ON wt.workspace_id=w.id LEFT JOIN tasks task ON task.workspace_id=w.id WHERE w.project_id=$1 AND w.tenant_id=$2 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL", []any{pid, r.TenantID}, "w.id", r) + out := page(t, "SELECT w.*,wt.branch_name,task.title FROM workspaces w LEFT JOIN workspace_worktrees wt ON wt.workspace_id=w.id LEFT JOIN tasks task ON task.workspace_id=w.id WHERE w.project_id=$1 AND w.tenant_id=$2 AND w.deleted_at IS NULL AND w.issue_run_id IS NULL AND w.workflow_run_id IS NULL", []any{pid, r.TenantID}, "w.id", r) items, _ := out["items"].([]Object) for i, w := range items { usable := runtimeUsable(t, w, uid) diff --git a/internal/core/store.go b/internal/core/store.go index f6dc87d3a..1452d77eb 100644 --- a/internal/core/store.go +++ b/internal/core/store.go @@ -278,6 +278,9 @@ func (t *transaction) list(q string, args ...any) []Object { if o["issueRunId"] == nil { delete(o, "issueRunId") } + if o["workflowRunId"] == nil { + delete(o, "workflowRunId") + } out = append(out, o) } if e = rows.Err(); e != nil { @@ -602,7 +605,7 @@ func project(t *transaction, tid, uid, pid string) Object { // workspace loads authorized runtime content. Safe summaries use runtimeOverview instead. func workspace(t *transaction, tid, uid, wid string, admin bool) Object { require(validID(wid), 404, "not_found") - w := t.one("SELECT * FROM workspaces WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL AND issue_run_id IS NULL", wid, tid) + w := t.one("SELECT * FROM workspaces WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL AND issue_run_id IS NULL AND workflow_run_id IS NULL", wid, tid) require(w != nil, 404, "not_found") require(runtimeUsable(t, w, uid), 403, "runtime_use_forbidden") if admin { diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index bb9937d41..dc6acddb2 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -91,7 +91,13 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { input := r.Body.O("input") requireWorkflowRunInput(s.O("graph"), input) id := newID() - t.exec("INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input) VALUES($1,$2,$3,$4,$5,$6,$7)", id, r.TenantID, r.WorkflowID, s.S("id"), name, "pending", jsonText(input)) + // The invocation identity freezes here (D3): created_by is the aggregate's actor for the whole + // run — the run Workspace lifecycle reads it instead of re-resolving who pressed Run. The + // authenticated Identity, not a /users path parameter, is who that is. project_id stays unset on + // this path; the invocation path that knows a project (@, #71) freezes it too. + require(r.Identity != nil, 401, "user_credential_required") + createdBy := identityWithAlias(t, r.Identity).S("id") + t.exec("INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", id, r.TenantID, r.WorkflowID, s.S("id"), name, "pending", jsonText(input), createdBy) if t.simulator != nil { nodeStates, rounds, status := t.simulator.SimulateWorkflowRun(s.O("graph"), input) t.exec("UPDATE workflow_runs SET node_states=$1,rounds=$2,status=$3,started_at=now(),finished_at=now(),version=version+1,updated_at=now() WHERE id=$4", jsonText(nodeStates), jsonText(rounds), status, id) diff --git a/internal/core/workflow_run_identity_db_test.go b/internal/core/workflow_run_identity_db_test.go new file mode 100644 index 000000000..ab86c3b87 --- /dev/null +++ b/internal/core/workflow_run_identity_db_test.go @@ -0,0 +1,255 @@ +package core + +// DB tests for the WorkflowRun execution identity (migration 0035, specs#99 D3/D8): a run's frozen +// identity drives the workspace lifecycle, the workspace binds to workflow_runs, and the control +// plane's seams — enqueue, dispatch, runtime permit and the settlement hooks — accept a WorkflowRun +// as the run of either kind. Session execution stays unwired (the scheduler still fails runs on +// unwired agent nodes); these tests exercise the seams the execution wiring lands on. + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +// seedWorkflowIdentityScene loads one tenant with an active project, a workflow, a frozen snapshot and +// a pending run carrying the frozen identity columns (created_by, project_id). withProject=false +// stages the editor-path run as it exists today — identity unbound — for the refusal test. +func seedWorkflowIdentityScene(t *testing.T, store *Store, withProject bool) (runID, project, actor string) { + t.Helper() + uid, tid := uuid.NewString(), uuid.NewString() + wid, sid, runID := uuid.NewString(), uuid.NewString(), uuid.NewString() + project, space := uuid.NewString(), uuid.NewString() + var projectID any + if withProject { + projectID = project + } + tx, err := store.Pool.Begin() + if err != nil { + t.Fatalf("seed begin: %v", err) + } + exec := func(name, q string, args ...any) { + if _, err := tx.Exec(q, args...); err != nil { + t.Fatalf("seed %s: %v", name, err) + } + } + exec("user", `INSERT INTO users(id,display_name,status) VALUES($1,'wfi','active')`, uid) + exec("tenant", `INSERT INTO tenants(id,name,status) VALUES($1,'wfi','active')`, tid) + exec("membership", `INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')`, tid, uid) + exec("space", `INSERT INTO collab_workspaces(id,tenant_id,name,slug,created_by) VALUES($1,$2,'wfi','wfi-'||left($4::text,8),$3)`, space, tid, uid, project) + exec("project", `INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,$4,'wfi','https://example.invalid/wfi.git','main','active')`, project, tid, uid, space) + // A live project must carry exactly one main workspace (deferred project_main trigger). + exec("main-workspace", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref) VALUES($1,$2,$3,$3,'verified_request',$4,'main','running','ready',1,'HEAD')`, uuid.NewString(), tid, uid, project) + exec("workflow", `INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'wfi','{"nodes":[],"edges":[]}'::jsonb)`, wid, tid) + exec("snapshot", `INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1','{"nodes":[],"edges":[]}'::jsonb)`, sid, tid, wid) + exec("run", `INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) VALUES($1,$2,$3,$4,'disp','pending','{}'::jsonb,$5,$6)`, runID, tid, wid, sid, uid, projectID) + if err := tx.Commit(); err != nil { + t.Fatalf("seed commit: %v", err) + } + return runID, project, uid +} + +// The lifecycle a wired run will drive: the frozen identity provisions the Workspace (created_by is +// the creator), the create_workspace terminal hands runtime control to the run, and the declared +// delete settles — every step accepting the WorkflowRun as the Workspace's owner. +func TestWorkflowRunWorkspaceLifecycle(t *testing.T) { + store := dispatcherDB(t) + runID, _, actor := seedWorkflowIdentityScene(t, store, true) + + var wsID, createOp string + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + created := createWorkflowRunWorkspace(tx, runID) + if created.B("busy") { + t.Fatal("the project must be idle") + } + wsID, createOp = created.O("workspace").S("id"), created.O("operation").S("id") + return Object{} + }); err != nil { + t.Fatalf("create workflow run workspace: %v", err) + } + var creator, boundRun, kind string + if err := store.Pool.QueryRow(`SELECT creator_user_id,COALESCE(workflow_run_id::text,''),kind FROM workspaces WHERE id=$1`, wsID). + Scan(&creator, &boundRun, &kind); err != nil { + t.Fatalf("read workspace: %v", err) + } + if creator != actor || boundRun != runID || kind != "isolated" { + t.Fatalf("workspace creator=%q run=%q kind=%q; want the frozen actor, the owning run, isolated", creator, boundRun, kind) + } + + // The create_workspace terminal step the control plane itself drives: the operation succeeds, + // runtime control is handed back reserved for this run, and the settlement hook accepts a + // workflow-owned workspace — writing no issue-run facts. + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + settleCreateWorkspaceOperation(t, tx, createOp) + runWorkspaceSettled(tx, runID, "ready") + return Object{} + }); err != nil { + t.Fatalf("settle create_workspace terminal: %v", err) + } + var state, maintenanceRun string + if err := store.Pool.QueryRow(`SELECT state,COALESCE(maintenance_run_id::text,'') FROM runtime_controls WHERE workspace_id=$1`, wsID). + Scan(&state, &maintenanceRun); err != nil { + t.Fatalf("read runtime control: %v", err) + } + if state != "maintenance" || maintenanceRun != runID { + t.Fatalf("runtime control state=%q run=%q; want maintenance reserved for the workflow run", state, maintenanceRun) + } + + // Deletion: declared like the scheduler declares it, settled through the terminal step, and + // accepted by the delete hook — the terminal transaction must not abort on a workflow run id. + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + declared := deleteWorkflowRunWorkspace(tx, runID) + if declared.B("busy") { + t.Fatal("declare delete: the project must be idle") + } + deleteOp := declared.O("operation").S("id") + tx.exec(`UPDATE operations SET state='succeeded', step='done', version=version+1, updated_at=now() WHERE id=$1`, deleteOp) + tx.exec(`UPDATE workspaces SET deleted_at=now(), observed_state='deleted', version=version+1 WHERE id=$1`, wsID) + runWorkspaceDeleted(tx, runID) + finishRuntimeMaintenance(tx, deleteOp) + return Object{} + }); err != nil { + t.Fatalf("settle delete_workspace terminal: %v", err) + } + var deleted sql.NullString + if err := store.Pool.QueryRow(`SELECT observed_state FROM workspaces WHERE id=$1`, wsID).Scan(&deleted); err != nil { + t.Fatalf("read deleted workspace: %v", err) + } + if deleted.String != "deleted" { + t.Fatalf("workspace observed_state=%q; want deleted", deleted.String) + } + if err := store.Pool.QueryRow(`SELECT state FROM runtime_controls WHERE workspace_id=$1`, wsID).Scan(&state); err != nil { + t.Fatalf("read runtime control after delete: %v", err) + } + if state != "idle" { + t.Fatalf("runtime control state=%q after the delete settled; want idle", state) + } +} + +// A run whose identity never learned a project — the editor path today — has no place to provision a +// Workspace in, and says so instead of guessing one. +func TestWorkflowRunWorkspaceRequiresProject(t *testing.T) { + store := dispatcherDB(t) + runID, _, _ := seedWorkflowIdentityScene(t, store, false) + _, err := store.transact(context.Background(), func(tx *transaction) Object { + createWorkflowRunWorkspace(tx, runID) + return Object{} + }) + if ErrorCode(err).Code != "workflow_run_project_required" { + t.Fatalf("error = %v; want workflow_run_project_required", err) + } +} + +// Execution work names exactly one run of either kind: the discriminator constraint rejects a row +// carrying both owners and a row carrying neither, while the IssueRun-only shape keeps inserting. +func TestExecutionWorkOwnerExclusivity(t *testing.T) { + store := dispatcherDB(t) + seed := seedDispatchScene(t, store.Pool, false) + wfRun, _, _ := seedWorkflowIdentityScene(t, store, true) + doc := `'{"kind":"agent_session"}'::jsonb` + if _, err := store.Pool.Exec(`INSERT INTO execution_work(id,run_id,workflow_run_id,kind,input,target) VALUES($1,$2,$3,'agent_session',`+doc+`,`+doc+`)`, uuid.NewString(), seed.run, wfRun); err == nil || !strings.Contains(err.Error(), "execution_work_owner_exclusive") { + t.Fatalf("both owners: err = %v; want execution_work_owner_exclusive", err) + } + if _, err := store.Pool.Exec(`INSERT INTO execution_work(id,kind,input,target) VALUES($1,'agent_session',`+doc+`,`+doc+`)`, uuid.NewString()); err == nil || !strings.Contains(err.Error(), "execution_work_owner_exclusive") { + t.Fatalf("neither owner: err = %v; want execution_work_owner_exclusive", err) + } + if _, err := store.Pool.Exec(`INSERT INTO execution_work(id,run_id,kind,input,target) VALUES($1,$2,'agent_session',`+doc+`,`+doc+`)`, uuid.NewString(), seed.run); err != nil { + t.Fatalf("issue-run-only work must still insert: %v", err) + } +} + +// The seam a wired run drives end to end: work registers against the workflow run, repeats of the +// same input are idempotent, the Node's dispatch is accepted under workflow ownership, and the run +// reserved on runtime control is permitted to bind the Node. +func TestWorkflowExecutionWorkDispatchSeam(t *testing.T) { + store := dispatcherDB(t) + runID, _, actor := seedWorkflowIdentityScene(t, store, true) + + var wsID string + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + created := createWorkflowRunWorkspace(tx, runID) + if created.B("busy") { + t.Fatal("the project must be idle") + } + wsID = created.O("workspace").S("id") + settleCreateWorkspaceOperation(t, tx, created.O("operation").S("id")) + runWorkspaceSettled(tx, runID, "ready") + tx.exec(`UPDATE workspaces SET observed_state='ready', admission_open=true, version=version+1 WHERE id=$1`, wsID) + return Object{} + }); err != nil { + t.Fatalf("stage ready workspace: %v", err) + } + sandbox, nodeService := bindConnectedNode(t, store, wsID) + var nodeRow string + if err := store.Pool.QueryRow(`SELECT id FROM node_instances WHERE workspace_id=$1 AND ended_at IS NULL`, wsID).Scan(&nodeRow); err != nil { + t.Fatalf("read node row: %v", err) + } + + input := Object{"kind": "agent_session"} + target := Object{"workspaceId": wsID, "sandboxInstanceId": sandbox, "nodeId": nodeService} + work, err := store.EnqueueExecutionWork(context.Background(), runID, "agent_session", input, target, time.Time{}) + if err != nil { + t.Fatalf("enqueue workflow work: %v", err) + } + if work.S("workflowRunId") != runID || work.S("runId") != "" { + t.Fatalf("work %v must register against the workflow run alone", work) + } + again, err := store.EnqueueExecutionWork(context.Background(), runID, "agent_session", input, target, time.Time{}) + if err != nil || again.S("id") != work.S("id") { + t.Fatalf("same unregistered input returned %v (err %v); want the original item", again, err) + } + _, err = store.EnqueueExecutionWork(context.Background(), runID, "agent_session", input, Object{"workspaceId": wsID, "sandboxInstanceId": sandbox, "nodeId": "node-elsewhere"}, time.Time{}) + if ErrorCode(err).Code != "dispatch_conflict" { + t.Fatalf("conflicting target: err = %v; want dispatch_conflict", err) + } + + execution := "exec-" + runID[:8] + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + nodeDispatch(tx, &ControlRequest{Body: Object{ + "operationId": runID, "executionId": execution, "nodeId": nodeService, + "input": Object{"kind": "agent_session"}, "epoch": int64(1), + }}) + return Object{} + }); err != nil { + t.Fatalf("dispatch under workflow ownership: %v", err) + } + var boundExecution string + if err := store.Pool.QueryRow(`SELECT COALESCE(execution_id::text,'') FROM execution_work WHERE id=$1`, work.S("id")).Scan(&boundExecution); err != nil { + t.Fatalf("read work: %v", err) + } + if boundExecution != execution { + t.Fatalf("work registered execution %q; want %q", boundExecution, execution) + } + + // The Node acks its binding and takes the permit: the maintenance holder is the workflow run, and + // the binding's actor is the run's frozen creator. + var epoch, controlVersion int64 + if err := store.Pool.QueryRow(`SELECT control_epoch,version FROM runtime_controls WHERE workspace_id=$1`, wsID).Scan(&epoch, &controlVersion); err != nil { + t.Fatalf("read control epoch: %v", err) + } + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + runtimeControlCommand(tx, &ControlRequest{Action: "runtime_ack", Body: Object{ + "workspaceId": wsID, "controlEpoch": epoch, "controlVersion": controlVersion, "nodeInstanceId": nodeRow, + "inputClosed": false, "unfinishedExecutionIds": []any{}, + }}) + return Object{} + }); err != nil { + t.Fatalf("runtime ack: %v", err) + } + var permit Object + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + permit = runtimeControlCommand(tx, &ControlRequest{Action: "runtime_permit", Body: Object{ + "workspaceId": wsID, "controlEpoch": epoch, "nodeInstanceId": nodeRow, "executionId": execution, + }}) + return Object{} + }); err != nil { + t.Fatalf("runtime permit: %v", err) + } + if permit.S("actorUserId") != actor || permit.S("operationId") != runID { + t.Fatalf("permit binding actor=%q operation=%q; want the frozen creator and the workflow run", permit.S("actorUserId"), permit.S("operationId")) + } +} From 88b56f66b7e5442910432c0bf77020b06e2866c2 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 10:38:06 +0800 Subject: [PATCH 02/26] fix(core): freeze the run Project a direct run states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs#99 D3, run identity: workflow_runs.project_id had no production writer at all. Migration 0035 added the column and createWorkflowRunWorkspace already refuses a run without one, but the only creation path that exists — the editor's Run button — never wrote one, so every direct run was identity-incomplete and could only ever fail at its first agent node with workflow_run_project_required. Nothing ever named the Project a run provisions in. - createWorkflowRun accepts an optional projectId from the request body, freezes it in the same INSERT as created_by, and authorizes it through the same `project` helper every project-scoped route uses: an unknown, malformed, other tenant's or archived Project is one 404, and the frozen value is the row's own id rather than the caller's string. No hardcoded or default Project — a workflow document belongs to a tenant, not to a Project, so there is nothing to default to. - A Project already `deleting` is refused (409 resource_unavailable, the rule the rest of the Project API applies). Freezing one would commit a run whose Workspace can never be created — createWorkflowRunWorkspace reads a deleting Project as busy and a deleted one as gone — leaving the run `running` for good, one failed advance per tick and no terminal state. A `provisioning` Project is still accepted: that state is transient, and the scheduler already waits for it the way it waits for any other admission. - Omitting projectId stays legal: a graph with no agent node needs no Workspace, and the editor's Run button has no Project to offer. Such a run stays identity-incomplete and fails honestly at its first agent node rather than guessing. - The route's field list, api/openapi.json and the generated client carry the new optional field. Tests (integration/workflow_run_project_test.go) drive the real HTTP path rather than seeding rows. The freeze test proves the creation transaction wrote the value — read off the row, not off the response, since the run resource carries no project field — and that deleting the Project afterwards cannot change what an already-created run was created against. The refusal test proves no run row survives a rejected Project, and the `deleting` case proves the hang guard. Both were confirmed failing before this change: the old router rejects the field outright (400 unknown_field field:projectId), and the `deleting` case answered 400 instead of 409. Co-Authored-By: Claude Code --- api/openapi.json | 5 + frontend/src/api/generated.schemas.ts | 2 + integration/workflow_run_project_test.go | 163 +++++++++++++++++++++++ internal/api/router/router.go | 2 +- internal/contract/openapi.go | 6 + internal/core/workflow_run.go | 41 +++++- 6 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 integration/workflow_run_project_test.go diff --git a/api/openapi.json b/api/openapi.json index 362505ded..0e5b77b3e 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -22305,6 +22305,11 @@ "name": { "type": "string" }, + "projectId": { + "description": "The Project the run's Workspace provisions in. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. Omit it for a graph with no agent node; a run without one fails honestly at its first agent node.", + "format": "uuid", + "type": "string" + }, "snapshotId": { "type": "string" } diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index d98bc99cb..d02b4fd06 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -3247,6 +3247,8 @@ export type PostApiV1TenantsTidWorkflowsWfidRunsBodyInput = { [key: string]: unk export type PostApiV1TenantsTidWorkflowsWfidRunsBody = { input?: PostApiV1TenantsTidWorkflowsWfidRunsBodyInput; name?: string; + /** The Project the run's Workspace provisions in. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. Omit it for a graph with no agent node; a run without one fails honestly at its first agent node. */ + projectId?: string; snapshotId?: string; }; diff --git a/integration/workflow_run_project_test.go b/integration/workflow_run_project_test.go new file mode 100644 index 000000000..98d7faf10 --- /dev/null +++ b/integration/workflow_run_project_test.go @@ -0,0 +1,163 @@ +package integration + +import ( + "database/sql" + "testing" + + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/core" +) + +// frozenRunProject reads the Project a run froze, straight off the row. The run resource carries no +// project field, so the only way to tell "the API accepted the value" from "the creation transaction +// wrote it" is to look at what the transaction left behind. +func frozenRunProject(t *testing.T, f *fixture, runID string) string { + t.Helper() + var frozen sql.NullString + if err := f.store.Pool.QueryRow(`SELECT project_id FROM workflow_runs WHERE id=$1`, runID).Scan(&frozen); err != nil { + t.Fatalf("read run project: %v", err) + } + return frozen.String +} + +func runCount(t *testing.T, f *fixture, wfid string) int { + t.Helper() + return len(issueItems(f.call("GET", f.path("/workflows/"+wfid+"/runs"), nil, "", 200))) +} + +// archiveProject takes a fully provisioned Project into `deleting` and stops there — the caller +// drains when it wants the delete to settle. The runtime control the clone left on the Project's main +// workspace has to be handed back first, or the Project API refuses the delete as held. +func archiveProject(t *testing.T, f *fixture, pid, key string) { + t.Helper() + active := f.call("GET", f.path("/projects/"+pid), nil, "", 200) + if active.S("lifecycle") != "active" { + t.Fatalf("project lifecycle = %q; want active before the delete", active.S("lifecycle")) + } + f.call("DELETE", f.path("/projects/"+pid), core.Object{"version": active.N("version")}, key, 202) +} + +// foreignProject provisions a second tenant and returns a live Project id inside it. The row is +// seeded directly because the fixture actor is not a member of that tenant and so cannot create one +// there through the API — which is exactly the property under test: the id names a Project that +// really exists and is unreachable from the calling tenant, so the refusal cannot be explained by +// the Project merely being absent. The Project and its main workspace go in together because the +// deferred project_main trigger requires a live Project to carry exactly one. +func foreignProject(t *testing.T, f *fixture) string { + t.Helper() + other, err := f.store.Bootstrap(t.Context(), "Other", "corp", "other", "Other") + must(t, err) + pid := uuid.NewString() + tx, err := f.store.Pool.Begin() + if err != nil { + t.Fatalf("foreign project begin: %v", err) + } + defer tx.Rollback() + if _, err := tx.Exec(`INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,$4,'Foreign','https://example.invalid/foreign.git','main','active')`, + pid, other.S("tenantId"), other.S("userId"), other.S("spaceId")); err != nil { + t.Fatalf("seed foreign project: %v", err) + } + if _, err := tx.Exec(`INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref) VALUES($1,$2,$3,$3,'verified_request',$4,'main','running','ready',1,'HEAD')`, + uuid.NewString(), other.S("tenantId"), other.S("userId"), pid); err != nil { + t.Fatalf("seed foreign project workspace: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("seed foreign project commit: %v", err) + } + return pid +} + +// The direct run's Project is stated by the caller and frozen in the creation transaction (D3). +// Before this, no production path wrote workflow_runs.project_id at all, so every editor-created run +// was identity-incomplete and could only ever fail at its first agent node +// (workflow_run_project_required); the run's Workspace had nowhere to provision and the project +// access a run implies was never checked against anyone. +func TestWorkflowRunFreezesCallerProject(t *testing.T) { + f := setup(t) + seedPersonalModel(t, f) + pid := f.call("POST", f.path("/projects"), core.Object{ + "name": "Run project", "repositoryUrl": "https://example.invalid/repo.git", "defaultBranch": "main", + }, "run-project", 202).O("resource").S("id") + wfid := f.call("POST", f.path("/workflows"), core.Object{ + "name": "Project run", "graph": runGraph(), + }, "run-project-wf", 200).O("resource").S("id") + f.call("POST", f.path("/workflows/"+wfid+"/publish"), core.Object{}, "run-project-pub", 200) + + run := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{"projectId": pid}, "run-project-1", 200).O("resource") + if got := frozenRunProject(t, f, run.S("id")); got != pid { + t.Fatalf("run froze project %q; want %q", got, pid) + } + // The same run replayed under its idempotency key keeps the one frozen value. + replay := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{"projectId": pid}, "run-project-1", 200).O("resource") + if replay.S("id") != run.S("id") || frozenRunProject(t, f, replay.S("id")) != pid { + t.Fatalf("replayed create diverged: %v", replay) + } + + // The identity is frozen, not re-derived: deleting the Project afterwards cannot change what an + // already-created run was created against. + f.drain() + f.acknowledgeSimulatorBindings() + archiveProject(t, f, pid, "run-project-del") + f.drain() + if got := frozenRunProject(t, f, run.S("id")); got != pid { + t.Fatalf("project after delete = %q; want the frozen %q", got, pid) + } +} + +// A Project the caller cannot reach is refused, and refused inside the creation transaction: the run +// does not exist afterwards. The check is the same one every project-scoped route makes, so an +// unknown, a malformed and another tenant's Project are one indistinguishable 404 — a run is never +// created against a Project its creator could not open, and never against a default nobody named. +func TestWorkflowRunRefusesUnreachableProject(t *testing.T) { + f := setup(t) + seedPersonalModel(t, f) + wfid := f.call("POST", f.path("/workflows"), core.Object{ + "name": "Guarded run", "graph": runGraph(), + }, "guard-run-wf", 200).O("resource").S("id") + f.call("POST", f.path("/workflows/"+wfid+"/publish"), core.Object{}, "guard-run-pub", 200) + baseline := runCount(t, f, wfid) + + // A Project the caller owns but is deleting. Not drained, so it sits in `deleting` — the state a + // run must never be frozen against, because its Workspace could never be created and the run would + // stay `running` for good instead of reaching a terminal state. + deleting := f.call("POST", f.path("/projects"), core.Object{ + "name": "Deleting", "repositoryUrl": "https://example.invalid/repo.git", "defaultBranch": "main", + }, "guard-run-deleting-project", 202).O("resource").S("id") + f.drain() + f.acknowledgeSimulatorBindings() + archiveProject(t, f, deleting, "guard-run-deleting-del") + + for _, c := range []struct { + name, projectID string + status int + code string + }{ + {"unknown", uuid.NewString(), 404, "not_found"}, + {"malformed", "not-a-uuid", 404, "not_found"}, + {"other tenant", foreignProject(t, f), 404, "not_found"}, + {"deleting", deleting, 409, "resource_unavailable"}, + } { + refused := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{"projectId": c.projectID}, "guard-run-"+c.name, c.status) + if refused.S("code") != c.code { + t.Fatalf("%s project: code = %q; want %q", c.name, refused.S("code"), c.code) + } + if got := runCount(t, f, wfid); got != baseline { + t.Fatalf("%s project created a run anyway: %d rows, want %d", c.name, got, baseline) + } + } + + // Once the delete settles the same Project is gone, and reads as the same 404 an unknown id does. + f.drain() + if refused := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{"projectId": deleting}, "guard-run-deleted", 404); refused.S("code") != "not_found" { + t.Fatalf("deleted project: code = %q; want not_found", refused.S("code")) + } + + // Omitting the Project stays accepted: a graph with no agent node needs no Workspace, and the + // editor's Run button has no Project to offer. Such a run is identity-incomplete and fails + // honestly at its first agent node rather than guessing a Project. + unbound := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{}, "guard-run-unbound", 200).O("resource") + if got := frozenRunProject(t, f, unbound.S("id")); got != "" { + t.Fatalf("unbound run froze project %q; want none", got) + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 0fe946d8b..e2be6b25c 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -116,7 +116,7 @@ func Routes() []Route { {"GET", "/api/v1/tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form", "", nil}, {"PUT", "/api/v1/tenants/:tid/workflows/:wfid/snapshots/:snapshotId/restore", "", []string{"version"}}, {"GET", "/api/v1/tenants/:tid/workflows/:wfid/runs", "", nil}, - {"POST", "/api/v1/tenants/:tid/workflows/:wfid/runs", "", []string{"name", "snapshotId", "input"}}, + {"POST", "/api/v1/tenants/:tid/workflows/:wfid/runs", "", []string{"name", "snapshotId", "input", "projectId"}}, {"GET", "/api/v1/tenants/:tid/workflows/:wfid/runs/:rid", "", nil}, {"GET", "/api/v1/tenants/:tid/issue-views", "", nil}, {"POST", "/api/v1/tenants/:tid/issue-views", "", []string{"name", "filter"}}, diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index f53a1be98..6b117d590 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -885,6 +885,10 @@ func optionalField(name string, r router.Route) bool { case "snapshotId": // A run may omit the snapshot to pin the latest published one. return strings.HasSuffix(r.Path, "/runs") + case "projectId": + // A run states the Project its Workspace provisions in; a graph with no agent node + // needs no Workspace, so a run may omit it and fail honestly at its first agent node. + return strings.HasSuffix(r.Path, "/runs") case "pluginVersion": // Omitted install pins the catalog's current version. return true @@ -958,6 +962,8 @@ func inputSchema(name string, r router.Route) obj { return enumeration("unstarted", "started", "done", "closed") case "labelId", "userId", "assigneeId", "executorId", "refId", "parentId", "projectRef", "targetId": return uuid() + case "projectId": + return obj{"type": "string", "format": "uuid", "description": "The Project the run's Workspace provisions in. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. Omit it for a graph with no agent node; a run without one fails honestly at its first agent node."} case "ids": return array(uuid()) case "filter", "properties", "input", "values", "graph": diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index dc6acddb2..3796ce2e4 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -81,6 +81,9 @@ func composeWorkflowRun(t *transaction, run Object) Object { // entry accepts exactly what a manual workflow IssueRun and a confirmed @ interaction accept. The // initiator matters here: the executor slots are resolved against *their* personal configuration, // which is why the uid is part of the request rather than inferred later. +// +// The run's Project comes from the caller (D3): see resolveWorkflowRunProject for why it is stated +// rather than inferred, and why omitting it is still accepted. func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { w := workflow(t, r.TenantID, r.WorkflowID) name := w.S("name") @@ -93,14 +96,46 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { id := newID() // The invocation identity freezes here (D3): created_by is the aggregate's actor for the whole // run — the run Workspace lifecycle reads it instead of re-resolving who pressed Run. The - // authenticated Identity, not a /users path parameter, is who that is. project_id stays unset on - // this path; the invocation path that knows a project (@, #71) freezes it too. + // authenticated Identity, not a /users path parameter, is who that is. The project freezes in the + // same statement, so the two halves of the identity can never disagree about who ran what where. require(r.Identity != nil, 401, "user_credential_required") createdBy := identityWithAlias(t, r.Identity).S("id") - t.exec("INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", id, r.TenantID, r.WorkflowID, s.S("id"), name, "pending", jsonText(input), createdBy) + projectID := resolveWorkflowRunProject(t, r, uid) + t.exec("INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)", id, r.TenantID, r.WorkflowID, s.S("id"), name, "pending", jsonText(input), createdBy, projectID) if t.simulator != nil { nodeStates, rounds, status := t.simulator.SimulateWorkflowRun(s.O("graph"), input) t.exec("UPDATE workflow_runs SET node_states=$1,rounds=$2,status=$3,started_at=now(),finished_at=now(),version=version+1,updated_at=now() WHERE id=$4", jsonText(nodeStates), jsonText(rounds), status, id) } return composeWorkflowRun(t, t.one("SELECT * FROM workflow_runs WHERE id=$1 AND tenant_id=$2", id, r.TenantID)) } + +// resolveWorkflowRunProject authorizes and returns the optional Project a direct run freezes (D3). +// The Project is the one thing that says where the run's Workspace may provision — +// createWorkflowRunWorkspace refuses a run without one — and it is stated by the caller rather than +// inferred, because nothing else knows it: a workflow document belongs to a tenant, not to a Project, +// so there is no project to default to and defaulting to one would place a run's Workspace, its clone +// and its whole checkout in a Project nobody named. +// +// The value is authorized by the same helper every project-scoped route uses, so an unknown, foreign, +// archived or foreign-space project is the same 404 and a run can never be created against a Project +// its creator could not open. The frozen value is the row's own id, never the string the caller sent. +// +// A Project already being deleted is refused outright (the `resource_unavailable` rule the rest of the +// Project API applies). Freezing one would be worse than refusing it: the run would commit, and its +// Workspace — which can never be created, because createWorkflowRunWorkspace reads a deleting Project +// as busy and a deleted one as gone — would leave the run `running` for good, one failed advance per +// tick and no terminal state. A Project still provisioning is accepted: that state is transient, and +// the scheduler waits for it the same way it waits for any other admission. +// +// Omitting it stays legal. A graph whose nodes are all start/condition/output needs no Workspace, and +// the editor's Run button has no Project to offer; such a run stays identity-incomplete and fails +// honestly at its first agent node (workflow_run_project_required) rather than guessing. +func resolveWorkflowRunProject(t *transaction, r *PublicRequest, uid string) any { + pid := r.Body.S("projectId") + if pid == "" { + return nil + } + p := project(t, r.TenantID, uid, pid) + require(p.S("lifecycle") != "deleting", 409, "resource_unavailable") + return p.S("id") +} From 8d5b875df1269aef0c2f29f352f80cfecd9af647 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 16:24:49 +0800 Subject: [PATCH 03/26] fix(core): refuse a direct run that names no Project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling on #141 (2026-10-17): `project_id` is half of a run's frozen identity (specs#99 D3), so a direct run that names no Project is refused at creation rather than persisted. The refusal reuses the code createWorkflowRunWorkspace already raises for the same condition (`workflow_run_project_required`, 409) instead of inventing a second one. - `resolveWorkflowRunProject` requires a non-empty `projectId`. The gate lives on the resolver rather than on its one caller so that every entry which creates a run — the editor today, and the @ confirm / manual run once they are linked to the same creation point — makes one shared decision inside the creation transaction instead of each writing its own "no Project" rule. This is the single creation point the owner asked for; today the INSERT exists only here. - This reverses the previous "omitting it stays legal" contract. That reading treated a Project-less run as merely incomplete and deferred the failure to the run's first agent node. It is not recoverable there: the run can never provision the Workspace those nodes need, so the honest place to report the gap is before the row exists. - A graph that genuinely needs no Workspace still names one. The rule is unconditional, which is the part a "only graphs that provision" reading would get wrong, so a start -> input graph is the case the new test pins. - `createWorkflowRunWorkspace`'s guard is kept: it now guards rows written before this gate, or by a future entry that bypasses the shared creation point. Tests (integration/workflow_run_project_test.go) drive the real HTTP path: - `TestWorkflowRunWithoutProjectIsRefused` — a start -> input graph is refused with `workflow_run_project_required`, leaves no run row, and runs once the caller names a Project, so the refusal is about the missing identity and not about the graph shape. - `TestWorkflowRunRefusesUnreachableProject` — its trailing "omitting stays accepted" case is now the missing-Project refusal, asserted against the same run-count baseline the unreachable-Project table uses. - The tests that start runs legitimately (`TestWorkflowRunLifecycle`, `TestWorkflowRunTenantIsolation`, the admission and snapshot-scope cases) now name a Project through the new `runProject` helper. A missing Project is checked after admission and input validation, so the cases asserting a snapshot, input or slot refusal are unchanged. Co-Authored-By: Claude Code --- integration/workflow_admission_test.go | 10 +++-- integration/workflow_run_project_test.go | 57 +++++++++++++++++++++--- integration/workflow_run_test.go | 12 ++--- internal/core/run_workspace.go | 4 +- internal/core/workflow_run.go | 23 +++++++--- 5 files changed, 83 insertions(+), 23 deletions(-) diff --git a/integration/workflow_admission_test.go b/integration/workflow_admission_test.go index 8ed03b6d8..b2a24db7c 100644 --- a/integration/workflow_admission_test.go +++ b/integration/workflow_admission_test.go @@ -227,7 +227,7 @@ func TestWorkflowRunSnapshotIsScopedToItsWorkflow(t *testing.T) { publishWorkflow(t, f, second.S("id"), "adm-scope-pub-b") // The workflow's own snapshot runs. - run := f.call("POST", f.path("/workflows/"+first.S("id")+"/runs"), core.Object{"snapshotId": mine.S("id")}, "adm-scope-1", 200).O("resource") + run := f.call("POST", f.path("/workflows/"+first.S("id")+"/runs"), core.Object{"snapshotId": mine.S("id"), "projectId": runProject(t, f, "adm-scope-project")}, "adm-scope-1", 200).O("resource") if run.S("snapshotId") != mine.S("id") { t.Fatalf("the run did not execute the version it named: %v", run) } @@ -246,6 +246,7 @@ func TestWorkflowRunSnapshotIsScopedToItsWorkflow(t *testing.T) { // and the newest published version is what an unqualified run gets. func TestWorkflowRunValidatesAgainstTheVersionItRuns(t *testing.T) { f := setup(t) + pid := runProject(t, f, "adm-hist-project") graph := func(names ...string) core.Object { variables := []any{} for _, name := range names { @@ -268,7 +269,7 @@ func TestWorkflowRunValidatesAgainstTheVersionItRuns(t *testing.T) { // The older version does not demand a variable that did not exist when it was frozen... f.call("POST", f.path("/workflows/"+wf.S("id")+"/runs"), core.Object{ - "snapshotId": older.S("id"), "input": core.Object{"topic": "auth"}, + "snapshotId": older.S("id"), "input": core.Object{"topic": "auth"}, "projectId": pid, }, "adm-hist-1", 200) // ...and still enforces its own required variable. f.call("POST", f.path("/workflows/"+wf.S("id")+"/runs"), core.Object{ @@ -282,7 +283,7 @@ func TestWorkflowRunValidatesAgainstTheVersionItRuns(t *testing.T) { t.Fatalf("the newest version was not the one applied: %v", missing) } newest := f.call("POST", f.path("/workflows/"+wf.S("id")+"/runs"), core.Object{ - "snapshotId": newer.S("id"), "input": core.Object{"topic": "auth", "added-later": "x"}, + "snapshotId": newer.S("id"), "input": core.Object{"topic": "auth", "added-later": "x"}, "projectId": pid, }, "adm-hist-4", 200).O("resource") // The version a run executed is recorded on the run row, not left to be re-derived later. @@ -388,6 +389,7 @@ func TestManualWorkflowRunCannotNameAForeignWorkflow(t *testing.T) { // as itself, and no run is created while it is unresolved. func TestWorkflowRunDefaultSlotGate(t *testing.T) { f := setup(t) + pid := runProject(t, f, "adm-gate-project") wf := f.call("POST", f.path("/workflows"), core.Object{ "name": "Gated flow", "graph": agentRunGraph("", false, nil), }, "adm-gate-wf", 200).O("resource") @@ -422,7 +424,7 @@ func TestWorkflowRunDefaultSlotGate(t *testing.T) { _, err := f.store.PutModelCredential(context.Background(), &f.user, connection.S("id"), connection.N("version"), "adm-gate-credential", uuid.NewString(), make([]byte, 32), "fixture-key") must(t, err) - run := f.call("POST", runPath, core.Object{}, "adm-gate-3", 200).O("resource") + run := f.call("POST", runPath, core.Object{"projectId": pid}, "adm-gate-3", 200).O("resource") if run.S("status") != "succeeded" { t.Fatalf("the admitted run did not execute: %v", run) } diff --git a/integration/workflow_run_project_test.go b/integration/workflow_run_project_test.go index 98d7faf10..d1ebbd3ca 100644 --- a/integration/workflow_run_project_test.go +++ b/integration/workflow_run_project_test.go @@ -26,6 +26,18 @@ func runCount(t *testing.T, f *fixture, wfid string) int { return len(issueItems(f.call("GET", f.path("/workflows/"+wfid+"/runs"), nil, "", 200))) } +// runProject creates a Project the fixture actor may start runs in and returns its id. Every direct +// run names one, so a test that creates a run needs a reachable Project even when its graph never +// provisions a Workspace. The Project is deliberately left `provisioning`: a Project still converging +// is an accepted place to freeze a run against, and draining it would make each of these tests wait +// on an operation it does not care about. +func runProject(t *testing.T, f *fixture, key string) string { + t.Helper() + return f.call("POST", f.path("/projects"), core.Object{ + "name": "Run project", "repositoryUrl": "https://example.invalid/repo.git", "defaultBranch": "main", + }, key, 202).O("resource").S("id") +} + // archiveProject takes a fully provisioned Project into `deleting` and stops there — the caller // drains when it wants the delete to settle. The runtime control the clone left on the Project's main // workspace has to be handed back first, or the Project API refuses the delete as held. @@ -153,11 +165,44 @@ func TestWorkflowRunRefusesUnreachableProject(t *testing.T) { t.Fatalf("deleted project: code = %q; want not_found", refused.S("code")) } - // Omitting the Project stays accepted: a graph with no agent node needs no Workspace, and the - // editor's Run button has no Project to offer. Such a run is identity-incomplete and fails - // honestly at its first agent node rather than guessing a Project. - unbound := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{}, "guard-run-unbound", 200).O("resource") - if got := frozenRunProject(t, f, unbound.S("id")); got != "" { - t.Fatalf("unbound run froze project %q; want none", got) + // Omitting the Project is refused at creation, and refused as the same condition + // createWorkflowRunWorkspace raises it as: a run with no frozen Project is an aggregate that can + // never provision the Workspace its agent nodes need, so it must not be persisted at all. A + // missing Project is a distinct case from an unreachable one — nothing was named, so nothing was + // authorized — which is why it is asserted here rather than as another row of the table above. + missing := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{}, "guard-run-unbound", 409) + if missing.S("code") != "workflow_run_project_required" { + t.Fatalf("missing project: code = %q; want workflow_run_project_required", missing.S("code")) } + if got := runCount(t, f, wfid); got != baseline { + t.Fatalf("a run naming no project was created anyway: %d rows, want %d", got, baseline) + } +} + +// Naming a Project is mandatory for every direct run, including one whose graph never provisions a +// Workspace. project_id is half of the run's frozen identity (D3), so a run created without one would +// be born unable to finish rather than merely incomplete, and the place to say so is the creation +// transaction rather than an execution that can never proceed. A start -> input graph is refused +// exactly like an agent graph: that is the part a "only graphs that need a Workspace" reading of the +// rule would get wrong, so it is the case this test pins. +func TestWorkflowRunWithoutProjectIsRefused(t *testing.T) { + f := setup(t) + wf := f.call("POST", f.path("/workflows"), core.Object{ + "name": "No project", "graph": startInputGraph(nil), + }, "no-project-wf", 200).O("resource") + wfid := wf.S("id") + publishWorkflow(t, f, wfid, "no-project-pub") + baseline := runCount(t, f, wfid) + + refused := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{}, "no-project-run", 409) + if refused.S("code") != "workflow_run_project_required" { + t.Fatalf("code = %q; want workflow_run_project_required", refused.S("code")) + } + if got := runCount(t, f, wfid); got != baseline { + t.Fatalf("a refused start left %d runs, want %d", got, baseline) + } + + // The same graph runs once the caller names a Project, so the refusal is about the missing + // identity and not about the graph shape. + f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{"projectId": runProject(t, f, "no-project-project")}, "no-project-run-2", 200) } diff --git a/integration/workflow_run_test.go b/integration/workflow_run_test.go index 007d2a3a9..3fd64dac1 100644 --- a/integration/workflow_run_test.go +++ b/integration/workflow_run_test.go @@ -66,6 +66,8 @@ func TestWorkflowRunLifecycle(t *testing.T) { // The document's agent node runs on the initiator's personal default model, so the actor needs one // before any start can be admitted. seedPersonalModel(t, f) + // Every direct run names the Project it freezes, whether or not its graph provisions a Workspace. + pid := runProject(t, f, "run-project") wf := f.call("POST", f.path("/workflows"), core.Object{ "name": "Run target", "graph": runGraph(), }, "run-wf", 200).O("resource") @@ -83,7 +85,7 @@ func TestWorkflowRunLifecycle(t *testing.T) { snap := f.call("POST", f.path("/workflows/"+wfid+"/publish"), core.Object{}, "run-pub", 200).O("resource") snapID := snap.S("id") run := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{ - "name": "Trial run", "input": core.Object{"branch": "main"}, + "name": "Trial run", "input": core.Object{"branch": "main"}, "projectId": pid, }, "run-1", 200).O("resource") if run.S("workflowId") != wfid || run.S("name") != "Trial run" || run.S("status") != "succeeded" { t.Fatalf("create run wrong: %v", run) @@ -111,12 +113,12 @@ func TestWorkflowRunLifecycle(t *testing.T) { // Same idempotency key replays the same run; a fresh key with no name defaults to the workflow name. replay := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{ - "name": "Trial run", "input": core.Object{"branch": "main"}, + "name": "Trial run", "input": core.Object{"branch": "main"}, "projectId": pid, }, "run-1", 200).O("resource") if replay.S("id") != run.S("id") { t.Fatalf("idempotent create made another run: %v", replay) } - f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{}, "run-2", 200).O("resource") + f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{"projectId": pid}, "run-2", 200).O("resource") // Read one run back and list them (newest of the run-2 page is visible by reading ids). one := f.call("GET", f.path("/workflows/"+wfid+"/runs/"+run.S("id")), nil, "", 200) @@ -135,7 +137,7 @@ func TestWorkflowRunLifecycle(t *testing.T) { // A run pinned to a specific, still-owned snapshot works; a foreign snapshot id is read 404. explicit := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{ - "snapshotId": snapID, + "snapshotId": snapID, "projectId": pid, }, "run-3", 200).O("resource") if explicit.S("snapshotId") != snapID { t.Fatalf("explicit snapshot create wrong: %v", explicit) @@ -164,7 +166,7 @@ func TestWorkflowRunTenantIsolation(t *testing.T) { }, "iso-run-wf", 200).O("resource") wfid := wf.S("id") snap := f.call("POST", f.path("/workflows/"+wfid+"/publish"), core.Object{}, "iso-run-pub", 200).O("resource") - run := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{}, "iso-run-1", 200).O("resource") + run := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{"projectId": runProject(t, f, "iso-run-project")}, "iso-run-1", 200).O("resource") other, e := f.store.Bootstrap(context.Background(), "Other", "corp", "other", "Other") must(t, e) diff --git a/internal/core/run_workspace.go b/internal/core/run_workspace.go index e3e7ae874..638f915e1 100644 --- a/internal/core/run_workspace.go +++ b/internal/core/run_workspace.go @@ -51,7 +51,9 @@ func runTriggerActor(t *transaction, run Object) string { // frozen identity is the whole basis (specs#99 D3): project_id names the Project the Workspace // provisions in, and created_by is the actor the Workspace and its operation record. A run without a // frozen project has no place to provision in — the caller fails it honestly rather than guessing a -// Project; an unbound run is the editor path today, and the invocation path (#71) freezes one. +// Project. Creation now refuses to persist such a run at all (resolveWorkflowRunProject), so the two +// checks agree by construction: this one still guards runs written before that gate, or by a future +// entry that inserts a run row without going through the shared creation point. func createWorkflowRunWorkspace(t *transaction, runID string) Object { run := t.one("SELECT * FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) require(run != nil, 404, "not_found") diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index 3796ce2e4..8b116ab66 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -83,7 +83,7 @@ func composeWorkflowRun(t *transaction, run Object) Object { // which is why the uid is part of the request rather than inferred later. // // The run's Project comes from the caller (D3): see resolveWorkflowRunProject for why it is stated -// rather than inferred, and why omitting it is still accepted. +// rather than inferred, and why a request that names none is refused rather than accepted. func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { w := workflow(t, r.TenantID, r.WorkflowID) name := w.S("name") @@ -127,14 +127,23 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { // tick and no terminal state. A Project still provisioning is accepted: that state is transient, and // the scheduler waits for it the same way it waits for any other admission. // -// Omitting it stays legal. A graph whose nodes are all start/condition/output needs no Workspace, and -// the editor's Run button has no Project to offer; such a run stays identity-incomplete and fails -// honestly at its first agent node (workflow_run_project_required) rather than guessing. +// Naming one is mandatory, not optional. A run with no frozen Project is not a run that has not +// needed one yet — it is an identity-incomplete aggregate that can never provision the Workspace its +// agent nodes require, so accepting it would persist a row that is born unable to finish and would +// only ever report the gap later, from inside an execution that cannot proceed. This is the same +// reason created_by is required one line up: project_id is the "where" half of the frozen identity, +// and freezing half an identity is not freezing one. A graph that genuinely needs no Workspace still +// names the Project it would provision in, which costs its caller one choice and keeps every run's +// identity whole. +// +// The gate lives on this resolver rather than on the one caller so that every entry which creates a +// run — the editor today, the @ confirm and the manual run once they are linked to the same creation +// point — makes one shared decision in the creation transaction instead of each writing its own +// "no Project" rule. Refusal reuses the code createWorkflowRunWorkspace already raises for the same +// condition, so a missing Project reads the same whether it is caught here or there. func resolveWorkflowRunProject(t *transaction, r *PublicRequest, uid string) any { pid := r.Body.S("projectId") - if pid == "" { - return nil - } + require(pid != "", 409, "workflow_run_project_required") p := project(t, r.TenantID, uid, pid) require(p.S("lifecycle") != "deleting", 409, "resource_unavailable") return p.S("id") From b249aa06637566d0ece1ce6df528e142dd5b40fa Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 16:55:16 +0800 Subject: [PATCH 04/26] fix(core): hand the Controller the run a claimed work item belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ClaimWork` projected `OperationId` from `run_id` alone. A workflow work item carries `workflow_run_id` and leaves `run_id` NULL by the mutual-exclusion CHECK 0035 added, so the Controller was handed the empty string; the dispatch that echoes it back then matched no row in `dispatchRunExecution` and was refused as `dispatch_conflict` forever. The whole session dispatch chain was dead for workflow runs, and the existing seam test could not see it because it calls `nodeDispatch` directly instead of claiming first. The claim now projects `coalesce(run_id, workflow_run_id)` under its own name. The alias is deliberate: rows are serialized with `row_to_json`, which keeps both copies of a repeated column name, and which one the decoder keeps is not something this query should depend on. New DB test drives the whole chain through the real control actions — `clone_claim` -> `clone_dispatch` -> `node_executions` — and asserts the claim projects the workflow run id and that the dispatch it authorizes binds both the execution and the work item. Against the old projection it fails with the empty string, which is the bug. Co-Authored-By: Claude Code --- internal/controlgrpc/executions.go | 5 +- internal/core/execution_work.go | 16 ++++- .../core/workflow_run_identity_db_test.go | 65 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/internal/controlgrpc/executions.go b/internal/controlgrpc/executions.go index 3442cb58f..b32710345 100644 --- a/internal/controlgrpc/executions.go +++ b/internal/controlgrpc/executions.go @@ -28,7 +28,10 @@ func (s *executionService) ClaimWork(ctx context.Context, req *controlpb.ClaimWo if work := out.O("work"); work.S("id") != "" { target := work.O("target") return &controlpb.ClaimWorkResponse{Item: &controlpb.WorkItem{ - OperationId: work.S("runId"), + // claimedRunId is the claim's own projection of "the run this work belongs to" — run_id + // for agent work, workflow_run_id for workflow work (claimWorkItem explains why one + // column cannot answer for both). + OperationId: work.S("claimedRunId"), Input: input(work.O("input")), Target: &controlpb.WorkTarget{WorkspaceId: target.S("workspaceId"), SandboxInstanceId: target.S("sandboxInstanceId"), NodeId: target.S("nodeId")}, }}, nil diff --git a/internal/core/execution_work.go b/internal/core/execution_work.go index 4420317ac..783a880e2 100644 --- a/internal/core/execution_work.go +++ b/internal/core/execution_work.go @@ -105,7 +105,21 @@ func claimWorkItem(t *transaction) Object { } else if t.one("SELECT id FROM clone_requests WHERE state='queued'") != nil { reject(410, "runtime_scope_required") } - work := t.one("SELECT * FROM execution_work ew WHERE execution_id IS NULL AND available_at<=clock_timestamp() AND NOT EXISTS(SELECT 1 FROM agent_run_residuals ar WHERE ar.run_id=ew.run_id) ORDER BY created_at,id LIMIT 1") + // A work item names the run it belongs to in one of two columns: agent work carries run_id (an + // issue_runs id), workflow work carries workflow_run_id (a workflow_runs id) and leaves run_id + // NULL by the mutual-exclusion CHECK 0035 added. The Controller is handed the run id as the + // operation id and echoes it back on dispatch, which resolves either column — so the claim has to + // project one value. Reading the agent-only column instead hands back the empty string a workflow + // row produces, and every dispatch of that item is then refused as a conflict it cannot resolve. + // + // The alias is deliberate rather than coalescing over run_id: the row is serialized with + // row_to_json, which keeps both copies of a repeated column name, and which one the decoder ends + // up with is not something this query should depend on. + work := t.one(`SELECT ew.*, coalesce(ew.run_id, ew.workflow_run_id) AS claimed_run_id + FROM execution_work ew + WHERE execution_id IS NULL AND available_at<=clock_timestamp() + AND NOT EXISTS(SELECT 1 FROM agent_run_residuals ar WHERE ar.run_id=ew.run_id) + ORDER BY created_at,id LIMIT 1`) if work != nil && (request == nil || work.S("createdAt") < request.S("createdAt")) { return Object{"work": work} } diff --git a/internal/core/workflow_run_identity_db_test.go b/internal/core/workflow_run_identity_db_test.go index ab86c3b87..66952d2c5 100644 --- a/internal/core/workflow_run_identity_db_test.go +++ b/internal/core/workflow_run_identity_db_test.go @@ -253,3 +253,68 @@ func TestWorkflowExecutionWorkDispatchSeam(t *testing.T) { t.Fatalf("permit binding actor=%q operation=%q; want the frozen creator and the workflow run", permit.S("actorUserId"), permit.S("operationId")) } } + +// The claim hands the Controller the run its work belongs to, and the dispatch that echoes that value +// back is accepted. This is the whole chain — clone_claim -> clone_dispatch -> node_executions — +// driven through the real control actions rather than by calling nodeDispatch directly. +// +// The seam test above dispatches directly and so cannot see this: a workflow work item carries +// workflow_run_id and leaves run_id NULL by 0035's mutual-exclusion CHECK, so a claim that projects +// the agent-only column hands the Controller the empty string, and every dispatch of that item then +// matches no row and is refused as a conflict it can never resolve. +func TestWorkflowExecutionWorkClaimCarriesItsRunID(t *testing.T) { + store := dispatcherDB(t) + runID, _, _ := seedWorkflowIdentityScene(t, store, true) + seedControllerLease(t, store) + + var wsID string + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + created := createWorkflowRunWorkspace(tx, runID) + if created.B("busy") { + t.Fatal("the project must be idle") + } + wsID = created.O("workspace").S("id") + settleCreateWorkspaceOperation(t, tx, created.O("operation").S("id")) + runWorkspaceSettled(tx, runID, "ready") + tx.exec(`UPDATE workspaces SET observed_state='ready', admission_open=true, version=version+1 WHERE id=$1`, wsID) + return Object{} + }); err != nil { + t.Fatalf("stage ready workspace: %v", err) + } + sandbox, nodeService := bindConnectedNode(t, store, wsID) + + input := Object{"kind": "agent_session"} + target := Object{"workspaceId": wsID, "sandboxInstanceId": sandbox, "nodeId": nodeService} + work, err := store.EnqueueExecutionWork(context.Background(), runID, "agent_session", input, target, time.Time{}) + if err != nil { + t.Fatalf("enqueue workflow work: %v", err) + } + + claims := controllerClaims() + pick, err := store.Control(context.Background(), &ControlRequest{Action: "clone_claim", Body: Object{"epoch": 1}, Service: claims}) + if err != nil { + t.Fatalf("claim: %v", err) + } + claimed := pick.O("work") + if claimed.S("id") != work.S("id") { + t.Fatalf("claim returned work %v; want the declared workflow item", claimed) + } + if claimed.S("claimedRunId") != runID { + t.Fatalf("claim projected run %q; want the workflow run %q — an empty value is refused at every dispatch", claimed.S("claimedRunId"), runID) + } + + execution := "exec-claim-" + runID[:8] + if _, err := store.Control(context.Background(), &ControlRequest{ + Action: "clone_dispatch", SubmissionID: "claim-dispatch", Service: claims, + Body: Object{"epoch": int64(1), "operationId": claimed.S("claimedRunId"), "executionId": execution, "nodeId": nodeService, "input": input}, + }); err != nil { + t.Fatalf("dispatch the claimed run id: %v", err) + } + var operation, workID string + if err := store.Pool.QueryRow(`SELECT coalesce(operation_id::text,''),coalesce(work_id::text,'') FROM node_executions WHERE execution_id=$1`, execution).Scan(&operation, &workID); err != nil { + t.Fatalf("read node execution: %v", err) + } + if operation != runID || workID != work.S("id") { + t.Fatalf("node execution operation=%q work=%q; want the workflow run and the claimed item", operation, workID) + } +} From 65ed9d36ffb998e18a07a8fadec743201150e96e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 17:18:24 +0800 Subject: [PATCH 05/26] test(core): register migration 0035 and cover it fresh, upgraded and rerun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #68. The migration shipped without the two things every other migration in this directory has: an entry in the READMEs and a fresh/upgrade/ rerun test. `TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades` asserts the four columns, both exactly-one-owner CHECKs, both partial unique indexes, and — the one this migration does by deletion rather than addition — that runtime_controls' IssueRun foreign key is gone while the column it constrained stays. A polymorphic run reference cannot be a foreign key, so the liveness check moved into the code that grants on it, and that is worth a test rather than a comment. The migration is pinned to LF at the same time. It is introduced by this PR and no released database has applied it, so this is the last moment the pin is free: after merge the bytes are in a ledger this change could not reach. 0034, which is on main, keeps its platform-dependent newline for exactly that reason, and 0035_model_attempt_statistics.sql is left to the PR that owns it. Co-Authored-By: Claude Code --- .gitattributes | 7 +++ integration/migration_upgrade_path_test.go | 60 +++++++++++++++++++++- internal/core/migrations/README.en.md | 15 ++++++ internal/core/migrations/README.md | 12 +++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 571382da5..bd6ef6b67 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,10 @@ internal/core/migrations/0028_workflow_snapshots.sql text eol=lf internal/core/migrations/0029_workflow_runs.sql text eol=lf internal/core/migrations/0033_personal_model_connections.sql text eol=lf internal/core/migrations/0033_revision_resume.sql text eol=lf +# 0034 is the one migration since the allowlist lapsed that is already on main, so its checksum is +# recorded in databases this branch cannot reach; pinning it now would fail every one of them with +# migration_checksum_mismatch. It stays as it is. (0035_model_attempt_statistics.sql is in the same +# position for a different reason — it belongs to another PR, which has to make this call for it.) +# Everything introduced since main is pinned here instead, while no released database has recorded +# it yet; after merge that would no longer be true. +internal/core/migrations/0035_workflow_run_identity.sql text eol=lf diff --git a/integration/migration_upgrade_path_test.go b/integration/migration_upgrade_path_test.go index 2a3d54778..9ffa9d39c 100644 --- a/integration/migration_upgrade_path_test.go +++ b/integration/migration_upgrade_path_test.go @@ -1018,7 +1018,7 @@ func TestMigration0033RevisionResumeAppliesFreshAndUpgrades(t *testing.T) { constraints := func(pool *sql.DB) string { t.Helper() var names string - must(t, pool.QueryRow(`SELECT string_agg(conname, ',' ORDER BY conname) FROM pg_constraint + must(t, pool.QueryRow(`SELECT coalesce(string_agg(conname, ',' ORDER BY conname),'') FROM pg_constraint WHERE conrelid='revisions'::regclass AND contype='c' AND conname IN ('revisions_check','revisions_bundle_shape','revisions_resume_refused')`).Scan(&names)) return names } @@ -1047,3 +1047,61 @@ func TestMigration0033RevisionResumeAppliesFreshAndUpgrades(t *testing.T) { t.Fatalf("after 0033: columns %d constraints %q", got, names) } } + +// 0035 turns the WorkflowRun into an execution aggregate the control plane accepts as a work owner: +// the frozen run identity, the workspace binding, the exactly-one-owner discriminator on work and +// the polymorphic runtime reservation. Fresh databases get all of it, an upgrade from 0034 gets it +// without a backfill, and a rerun changes nothing. +func TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades(t *testing.T) { + columns := func(pool *sql.DB) int { + t.Helper() + var n int + must(t, pool.QueryRow(`SELECT count(*) FROM information_schema.columns WHERE table_schema=current_schema() AND + ((table_name='workflow_runs' AND column_name IN ('created_by','project_id')) + OR (table_name='workspaces' AND column_name='workflow_run_id') + OR (table_name='execution_work' AND column_name='workflow_run_id'))`).Scan(&n)) + return n + } + constraints := func(pool *sql.DB) string { + t.Helper() + var names string + must(t, pool.QueryRow(`SELECT coalesce(string_agg(conname, ',' ORDER BY conname),'') FROM pg_constraint + WHERE conrelid IN ('workspaces'::regclass,'execution_work'::regclass) AND contype='c' + AND conname IN ('workspace_run_binding_exclusive','execution_work_owner_exclusive')`).Scan(&names)) + return names + } + indexes := func(pool *sql.DB) string { + t.Helper() + var names string + must(t, pool.QueryRow(`SELECT coalesce(string_agg(indexname, ',' ORDER BY indexname),'') FROM pg_indexes + WHERE schemaname=current_schema() AND tablename='execution_work' + AND indexname IN ('one_unregistered_execution_work','one_unregistered_workflow_execution_work')`).Scan(&names)) + return names + } + // A polymorphic run reference cannot be a foreign key, so 0035 drops the IssueRun FK instead of + // reworking it. The column stays; only the constraint goes. Resolved through the search path + // rather than by name alone, which would count another schema's constraint of the same name. + reservationFK := func(pool *sql.DB) int { + t.Helper() + var n int + must(t, pool.QueryRow(`SELECT count(*) FROM pg_constraint + WHERE conrelid='runtime_controls'::regclass AND conname='runtime_controls_maintenance_run_id_fkey'`).Scan(&n)) + return n + } + + freshPool, _ := testSchema(t, "test_wfi35f_") + migratedTwice(t, newStoreOnSchema(t, freshPool)) + if got, names, idx, fk := columns(freshPool), constraints(freshPool), indexes(freshPool), reservationFK(freshPool); got != 4 || names != "execution_work_owner_exclusive,workspace_run_binding_exclusive" || idx != "one_unregistered_execution_work,one_unregistered_workflow_execution_work" || fk != 0 { + t.Fatalf("fresh: columns %d constraints %q indexes %q reservation fk %d", got, names, idx, fk) + } + + pool, _ := testSchema(t, "test_wfi35_") + applyMigrationsUpTo(t, pool, migrationsBefore(t, "0035_workflow_run_identity.sql")) + if got, names, fk := columns(pool), constraints(pool), reservationFK(pool); got != 0 || names != "" || fk != 1 { + t.Fatalf("before 0035: columns %d constraints %q reservation fk %d", got, names, fk) + } + migratedTwice(t, newStoreOnSchema(t, pool)) + if got, names, idx, fk := columns(pool), constraints(pool), indexes(pool), reservationFK(pool); got != 4 || names != "execution_work_owner_exclusive,workspace_run_binding_exclusive" || idx != "one_unregistered_execution_work,one_unregistered_workflow_execution_work" || fk != 0 { + t.Fatalf("after 0035: columns %d constraints %q indexes %q reservation fk %d", got, names, idx, fk) + } +} diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index 42c3fe688..ce78619bc 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -114,3 +114,18 @@ only validate schema. `TestMigration0035ModelAttemptsFreshAndUpgradeWithoutBackf ## 0038: Bounded abandonment of a blocked run Workspace creation Adds issue_runs.workspace_blocked_at, workspace_deadline_at, workspace_blocked_source (blocked_write / first_observed) with a provisioning-phase partial index, and widens agent_run_residuals' reason constraint to agent_start_failed / workspace_unavailable. The first transaction persisting `blocked` anchors the absolute deadline; legacy blocked creations have no immutable first-blocked evidence, are not backfilled, and receive one default window from the first policy scan as first_observed. Defer, manual retry, re-claiming, restarts and configuration changes never renew; only the creation's real terminal ends the window. Purely forward and retry-safe, without rewriting historical outcomes. Fresh/upgrade/repeat coverage: TestMigration0038WorkspaceBlockedDeadlineAppliesFreshAndUpgrades. + +## 0035: WorkflowRun identity + +`0035_workflow_run_identity.sql` hands run identity and work-item ownership to the control plane. +`workflow_runs` gains `created_by` and `project_id` (both nullable: rows written before this migration +carry no recoverable actor, and `project_id` is filled by the invocation path that knows a Project), +which new runs freeze at creation. `workspaces` gains a mutually exclusive `workflow_run_id`, so a run +Workspace can belong to a WorkflowRun. `execution_work` takes the same exactly-one-owner discriminator +shape, its single-column uniqueness splitting into one partial index per owner, so a run of each kind +keeps at most one unregistered item and the two ownerships never collide. Runtime control's reservation +becomes a polymorphic run reference: the `maintenance_run_id` foreign key is dropped rather than +reworked, because a polymorphic reference cannot be a foreign key, and the liveness check moves to the +code that grants on it. Additive: columns, constraints and indexes, no backfill. + +`TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades` covers fresh, 0034 upgrade and rerun. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index d23dfd558..863861109 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -113,3 +113,15 @@ Cloud 领域方法。冻结与撤销规则见 [个人模型连接](../../../docs ## 0038:运行 Workspace 创建 blocked 的有界放弃 新增 issue_runs.workspace_blocked_at、workspace_deadline_at、workspace_blocked_source(blocked_write / first_observed)与 provisioning 期部分索引,并把 agent_run_residuals 的 reason 约束扩为 agent_start_failed / workspace_unavailable。首次持久写入 blocked 的同事务锚定绝对截止;存量 blocked 无不可变首次证据,不回填,由首次策略扫描以 first_observed 给一次默认窗口。defer、人工 retry、重新领取、重启与配置调整不续期;只有原创建真实终态结束窗口。纯前向、可重复执行,不改历史执行结果。新库/升级/重复执行见 TestMigration0038WorkspaceBlockedDeadlineAppliesFreshAndUpgrades。 + +## 0035:WorkflowRun 运行身份 + +`0035_workflow_run_identity.sql` 把运行身份与工作项所有权交给控制面。`workflow_runs` 新增 +`created_by` 与 `project_id` 两列(均可空:本迁移之前的行没有可恢复的发起人,而 `project_id` 由知道 +Project 的调用路径写入),新运行在创建时冻结二者;`workspaces` 新增互斥的 `workflow_run_id`,使运行 +Workspace 可以属于 WorkflowRun;`execution_work` 同样变为「恰有一个所有者」的判别形状,原单列唯一索引 +拆为每种所有者各一条部分索引,因此两类所有者各自最多保留一个未注册工作项且互不冲突; +`runtime_controls` 的预留改为多态运行引用,`maintenance_run_id` 的外键被删除而非改写——多态引用无法做 +外键,存活校验移到授予它的代码里。只新增列、约束与索引,不回填。 + +`TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades` 验证空库、从 0034 升级及重复迁移。 From 0fbdafad79d889d2f6d8a09ead5c5d6e0485754f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 19:16:41 +0800 Subject: [PATCH 06/26] fix(core): read the Project's repository through its Binding Rebasing onto main surfaced three conflicts the textual merge could not see: 0037 (cloud#75, already on main) moved a Project's repository out of the projects table into repository_bindings, and this branch still read it where it used to live. - createWorkflowRunWorkspace selected the projects row whole and read defaultBranch from it. That column is gone, so every run Workspace was refused with default_branch_required. It now reads through projectView, exactly as its IssueRun counterpart in the same file does. - TestWorkflowRunRefusesUnreachableProject and the dispatcherDB seed inserted projects(repository_url, default_branch). Both now insert the Project and its Binding together, the idiom every other direct seed in this repository uses. Behavior is unchanged: the same branch name, read from the row that now holds it. Co-Authored-By: Claude Code --- integration/workflow_run_project_test.go | 4 +++- internal/core/run_workspace.go | 5 ++++- internal/core/workflow_run_identity_db_test.go | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/integration/workflow_run_project_test.go b/integration/workflow_run_project_test.go index d1ebbd3ca..077af0134 100644 --- a/integration/workflow_run_project_test.go +++ b/integration/workflow_run_project_test.go @@ -66,7 +66,9 @@ func foreignProject(t *testing.T, f *fixture) string { t.Fatalf("foreign project begin: %v", err) } defer tx.Rollback() - if _, err := tx.Exec(`INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,$4,'Foreign','https://example.invalid/foreign.git','main','active')`, + // The repository lives in the Project's Binding, not in Project columns (0037): the two go in + // together, the way every other direct seed in this repository now does it. + if _, err := tx.Exec(`WITH p AS (INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,lifecycle) VALUES($1,$2,$3,$4,'Foreign','active') RETURNING id,tenant_id) INSERT INTO repository_bindings(id,tenant_id,project_id,repository_url,branch) SELECT gen_random_uuid(),tenant_id,id,'https://example.invalid/foreign.git','main' FROM p`, pid, other.S("tenantId"), other.S("userId"), other.S("spaceId")); err != nil { t.Fatalf("seed foreign project: %v", err) } diff --git a/internal/core/run_workspace.go b/internal/core/run_workspace.go index 638f915e1..40e193489 100644 --- a/internal/core/run_workspace.go +++ b/internal/core/run_workspace.go @@ -63,7 +63,10 @@ func createWorkflowRunWorkspace(t *transaction, runID string) Object { op := t.one("SELECT * FROM operations WHERE workspace_id=$1 AND kind='create_workspace' ORDER BY created_at,id LIMIT 1", existing.S("id")) return Object{"busy": false, "workspace": existing, "operation": op} } - p := t.one("SELECT * FROM projects WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL", run.S("projectId"), run.S("tenantId")) + // projectView, not the projects table: 0037 moved the repository to the Project's Binding, so + // `default_branch` is read back under the Project's published name from there. It is the same + // read its IssueRun counterpart above makes, and the reason both now require a Binding. + p := t.one("SELECT * FROM "+projectView+" p WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL", run.S("projectId"), run.S("tenantId")) require(p != nil, 404, "not_found") if p.S("lifecycle") != "active" || t.one("SELECT id FROM operations WHERE project_id=$1 AND state IN ('queued','running','retry_wait','blocked')", p.S("id")) != nil { return Object{"busy": true} diff --git a/internal/core/workflow_run_identity_db_test.go b/internal/core/workflow_run_identity_db_test.go index 66952d2c5..e010bd0a8 100644 --- a/internal/core/workflow_run_identity_db_test.go +++ b/internal/core/workflow_run_identity_db_test.go @@ -41,7 +41,7 @@ func seedWorkflowIdentityScene(t *testing.T, store *Store, withProject bool) (ru exec("tenant", `INSERT INTO tenants(id,name,status) VALUES($1,'wfi','active')`, tid) exec("membership", `INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')`, tid, uid) exec("space", `INSERT INTO collab_workspaces(id,tenant_id,name,slug,created_by) VALUES($1,$2,'wfi','wfi-'||left($4::text,8),$3)`, space, tid, uid, project) - exec("project", `INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,$4,'wfi','https://example.invalid/wfi.git','main','active')`, project, tid, uid, space) + exec("project", `WITH p AS (INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,lifecycle) VALUES($1,$2,$3,$4,'wfi','active') RETURNING id,tenant_id) INSERT INTO repository_bindings(id,tenant_id,project_id,repository_url,branch) SELECT gen_random_uuid(),tenant_id,id,'https://example.invalid/wfi.git','main' FROM p`, project, tid, uid, space) // A live project must carry exactly one main workspace (deferred project_main trigger). exec("main-workspace", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref) VALUES($1,$2,$3,$3,'verified_request',$4,'main','running','ready',1,'HEAD')`, uuid.NewString(), tid, uid, project) exec("workflow", `INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'wfi','{"nodes":[],"edges":[]}'::jsonb)`, wid, tid) From 83a121c71850e6bc49a6b92ed3a1d55a1cbed431 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 20:43:58 +0800 Subject: [PATCH 07/26] fix(contract): require the Project a direct run freezes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime refuses a run that names no Project with 409 `workflow_run_project_required`, but the contract still described the opposite: `projectId` sat in `optionalField`'s `/runs` branch, so the published schema left it out of `required`, and its description promised that a run without one "fails honestly at its first agent node". Neither holds — a run that names none is never created, so it never reaches a node. `resolveWorkflowRunProject`'s own doc comment contradicted itself the same way, calling the Project optional two paragraphs above "Naming one is mandatory, not optional." `projectId` is now required on the run-creation route and nowhere else, the description states the refusal, and the doc comment says what the body does. The same change is carried through `api/openapi.json` and `frontend/src/api`, so the generated client stops offering a request the service always rejects. That makes the editor's create mutation unable to build its body at all: it has no Project to state, and the run form that picks one is not built yet. The mutation refuses such a run before sending it rather than inventing a placeholder Project to fill the field — the service answers the same code, and no run is frozen against a Project nobody chose. `useCreateWorkflowRun` is where that gate lives, so the form that lands later states a Project and the gate opens; the toolbar tests pin the refusal on both sides of the app's real routes. Co-Authored-By: Claude Code --- api/openapi.json | 5 +- frontend/src/api/generated.schemas.ts | 4 +- frontend/src/features/workflows/api.ts | 19 +++++ .../workflows/editor/workflow-editor.test.tsx | 7 +- .../editor/workflow-run-tools.test.tsx | 73 ++++++++++++++++--- internal/contract/openapi.go | 6 +- internal/contract/openapi_test.go | 15 ++++ internal/core/workflow_run.go | 2 +- 8 files changed, 109 insertions(+), 22 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 0e5b77b3e..4bf4a78ff 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -22306,7 +22306,7 @@ "type": "string" }, "projectId": { - "description": "The Project the run's Workspace provisions in. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. Omit it for a graph with no agent node; a run without one fails honestly at its first agent node.", + "description": "The Project the run's Workspace provisions in. Required: a run that names none is refused with 409 workflow_run_project_required, never persisted without one. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404.", "format": "uuid", "type": "string" }, @@ -22314,6 +22314,9 @@ "type": "string" } }, + "required": [ + "projectId" + ], "type": "object" } } diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index d02b4fd06..289a80b51 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -3247,8 +3247,8 @@ export type PostApiV1TenantsTidWorkflowsWfidRunsBodyInput = { [key: string]: unk export type PostApiV1TenantsTidWorkflowsWfidRunsBody = { input?: PostApiV1TenantsTidWorkflowsWfidRunsBodyInput; name?: string; - /** The Project the run's Workspace provisions in. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. Omit it for a graph with no agent node; a run without one fails honestly at its first agent node. */ - projectId?: string; + /** The Project the run's Workspace provisions in. Required: a run that names none is refused with 409 workflow_run_project_required, never persisted without one. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. */ + projectId: string; snapshotId?: string; }; diff --git a/frontend/src/features/workflows/api.ts b/frontend/src/features/workflows/api.ts index 1ac6ffbc4..485d114c7 100644 --- a/frontend/src/features/workflows/api.ts +++ b/frontend/src/features/workflows/api.ts @@ -364,6 +364,13 @@ export function useWorkflowRun( /** Input for running a workflow once against a published snapshot. */ export interface CreateWorkflowRunInput { workflowId: string + /** + * The Project the run's Workspace provisions in. The contract requires it — a run that names + * none is refused with 409 `workflow_run_project_required` — so the mutation refuses a caller + * that has none instead of sending a body the contract does not describe. Optional here only + * because the run form that picks a Project is not built yet. + */ + projectId?: string /** Optional display name; defaults to the workflow's name. */ name?: string /** The exact snapshot to execute, or omit to pin the latest published one. */ @@ -382,6 +389,10 @@ export interface CreateWorkflowRunInput { * idempotency key, so a retried run replays the stored response instead of * minting a second execution. * + * The run's Project is required by the contract — a run that names none is refused with 409 + * `workflow_run_project_required` — and this editor states none yet: the run form that picks a + * Project is not built, so a run started here is refused until that lands. + * * @param tenantId - Tenant the workflow belongs to. * @returns The create mutation; `data` is the stored run. */ @@ -391,10 +402,18 @@ export function useCreateWorkflowRun(tenantId: string | undefined) { return useMutation, CreateWorkflowRunInput>({ mutationFn: (input: CreateWorkflowRunInput) => { if (!tenantId) throw new Error('cloud tenant not resolved') + // Refused here rather than sent: a run with no frozen Project is an identity-incomplete + // aggregate that can never provision the Workspace its agent nodes need, so the service + // answers this same code (409) and there is no value the client could put in the field + // instead — the caller either names a Project or names none. + if (input.projectId === undefined || input.projectId === '') { + throw new Error('workflow_run_project_required') + } return postApiV1TenantsTidWorkflowsWfidRuns( tenantId, input.workflowId, { + projectId: input.projectId, ...(input.name === undefined || input.name === '' ? {} : { name: input.name }), ...(input.snapshotId === undefined || input.snapshotId === '' ? {} diff --git a/frontend/src/features/workflows/editor/workflow-editor.test.tsx b/frontend/src/features/workflows/editor/workflow-editor.test.tsx index 5a7464b8b..af9194271 100644 --- a/frontend/src/features/workflows/editor/workflow-editor.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-editor.test.tsx @@ -1021,7 +1021,7 @@ describe('WorkflowEditor run tools', () => { expect(screen.getByText('还没有运行记录。')).toBeInTheDocument() }) - it('creates a run from the toolbar once a snapshot is published', async () => { + it('starts no run from the toolbar, which cannot state the Project a run freezes', async () => { const published: WorkflowSnapshot = { id: 'snap-1', tenantId: TEST_TENANT_ID, @@ -1047,6 +1047,9 @@ describe('WorkflowEditor run tools', () => { ) fireEvent.click(runButton) - await waitFor(() => expect(bodies).toEqual([{}])) + // The run form that picks the Project is not built yet, so the mutation refuses the run before + // the request is sent and this capture stays empty. + await waitFor(() => expect(runButton).toBeEnabled()) + expect(bodies).toEqual([]) }) }) diff --git a/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx b/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx index d2e4185f5..3fe8ec6ae 100644 --- a/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx @@ -1,7 +1,9 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { beforeEach, describe, expect, it } from 'vitest' import type { WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas' +import { useCreateWorkflowRun } from '@/features/workflows/api' import { WorkflowRunTools } from '@/features/workflows/editor/workflow-run-tools' import { routes } from '@/routes' import { @@ -15,6 +17,8 @@ import { renderRoutes } from '@/test/render' import { stubViewportGeometry } from '@/test/viewport' const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444' +/** The Project a run freezes; the editor has none to state yet, so only the hook tests name one. */ +const PROJECT_ID = '55555555-5555-5555-5555-555555555555' /** * The readable workspace slug the browser route carries. It is deliberately not * `TEST_TENANT_ID`: the tenant UUID scopes API calls, and using it as the slug is @@ -117,6 +121,49 @@ beforeEach(() => { stubViewportGeometry() }) +/** A client whose mutations never retry, so one refusal settles once. */ +function wrapper() { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +/** + * The create mutation's own gate, pinned at the hook because the editor cannot reach it: a run must + * name the Project its Workspace provisions in, and the editor has none to state until the run form + * that picks one lands. A caller that states a Project still gets a run, and the Project it stated + * is what the request carries. + */ +describe('useCreateWorkflowRun', () => { + it('refuses a run that names no Project without sending it', async () => { + const bodies: unknown[] = [] + installRunHandlers({ onCreate: (body) => bodies.push(body) }) + const { result } = renderHook(() => useCreateWorkflowRun(TEST_TENANT_ID), { + wrapper: wrapper(), + }) + + result.current.mutate({ workflowId: WORKFLOW_ID }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(result.current.error?.message).toBe('workflow_run_project_required') + expect(bodies).toEqual([]) + }) + + it('sends the stated Project with the run', async () => { + const bodies: unknown[] = [] + installRunHandlers({ onCreate: (body) => bodies.push(body) }) + const { result } = renderHook(() => useCreateWorkflowRun(TEST_TENANT_ID), { + wrapper: wrapper(), + }) + + result.current.mutate({ workflowId: WORKFLOW_ID, projectId: PROJECT_ID }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(bodies).toEqual([{ projectId: PROJECT_ID }]) + }) +}) + describe('WorkflowRunTools', () => { it('disables the run button while no snapshot exists to execute', async () => { renderRunTools({ snapshots: [] }) @@ -128,7 +175,7 @@ describe('WorkflowRunTools', () => { expect(runButton).toHaveAttribute('title', '请先发布一个版本,才能运行这个工作流') }) - it('arms the run button once a snapshot exists, creating the run and navigating to it', async () => { + it('arms the run button once a snapshot exists, but starts no run it cannot state a Project for', async () => { const bodies: unknown[] = [] const router = renderRunTools({ runs: [], onCreate: (body) => bodies.push(body) }) const runButton = await screen.findByRole('button', { name: '运行' }) @@ -136,9 +183,12 @@ describe('WorkflowRunTools', () => { fireEvent.click(runButton) - // The browser path carries the workspace slug, never the tenant UUID. - await waitFor(() => expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-new`)) - expect(bodies).toEqual([{}]) + // A run must name the Project its Workspace provisions in, and the editor has none to state + // yet: the refusal happens before the request, so nothing is created and nothing navigates. + // The button goes pending and comes back armed, which is where the refusal settles. + await waitFor(() => expect(runButton).toBeEnabled()) + expect(bodies).toEqual([]) + expect(router.state.location.pathname).toBe(EDITOR_PATH) }) it('lists run history and opens a run from a row', async () => { @@ -182,18 +232,19 @@ describe('workflow run navigation through the app routes', () => { return { ...view, bodies } } - it('creates one run and stays on the same workspace route instead of the UUID one', async () => { + it('starts no run, and stays on the same workspace route instead of the UUID one', async () => { const { router, bodies } = renderAppAt(EDITOR_PATH) const runButton = await screen.findByRole('button', { name: '运行' }) await waitFor(() => expect(runButton).toBeEnabled()) fireEvent.click(runButton) - // The destination is the real run detail page: a UUID workspace path would have - // been redirected to Issues by the Layout before this page ever mounted. - expect(await screen.findByRole('heading', { name: '运行详情' })).toBeInTheDocument() - expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-new`) - expect(bodies).toHaveLength(1) + // A UUID workspace path would have been redirected to Issues by the Layout before + // this page ever mounted, so the route is what proves the click stayed put. + await waitFor(() => expect(runButton).toBeEnabled()) + expect(bodies).toHaveLength(0) + expect(router.state.location.pathname).toBe(EDITOR_PATH) + expect(screen.queryByRole('heading', { name: '运行详情' })).not.toBeInTheDocument() }) it('opens an existing run from the history with the same slug and creates nothing', async () => { diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index 6b117d590..f47554b42 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -885,10 +885,6 @@ func optionalField(name string, r router.Route) bool { case "snapshotId": // A run may omit the snapshot to pin the latest published one. return strings.HasSuffix(r.Path, "/runs") - case "projectId": - // A run states the Project its Workspace provisions in; a graph with no agent node - // needs no Workspace, so a run may omit it and fail honestly at its first agent node. - return strings.HasSuffix(r.Path, "/runs") case "pluginVersion": // Omitted install pins the catalog's current version. return true @@ -963,7 +959,7 @@ func inputSchema(name string, r router.Route) obj { case "labelId", "userId", "assigneeId", "executorId", "refId", "parentId", "projectRef", "targetId": return uuid() case "projectId": - return obj{"type": "string", "format": "uuid", "description": "The Project the run's Workspace provisions in. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404. Omit it for a graph with no agent node; a run without one fails honestly at its first agent node."} + return obj{"type": "string", "format": "uuid", "description": "The Project the run's Workspace provisions in. Required: a run that names none is refused with 409 workflow_run_project_required, never persisted without one. Frozen at creation for the run's whole lifetime. Authorized against the caller's own collaboration space: an unknown, archived or foreign Project is 404."} case "ids": return array(uuid()) case "filter", "properties", "input", "values", "graph": diff --git a/internal/contract/openapi_test.go b/internal/contract/openapi_test.go index 3c23159b7..9539e59c3 100644 --- a/internal/contract/openapi_test.go +++ b/internal/contract/openapi_test.go @@ -114,3 +114,18 @@ func TestPluginRoutesInDocument(t *testing.T) { t.Fatalf("DELETE plugins required fields = %v", deleteRequired) } } + +// A direct run states the Project it freezes, and the document has to say so: the runtime refuses a +// run that names none with 409 workflow_run_project_required, so a schema that let a client omit +// projectId would describe a request the service always rejects. The other three fields stay +// optional — a run defaults its name to the workflow's, pins the latest snapshot when none is given, +// and may carry no input — which is why the assertion is the exact set rather than membership. +func TestWorkflowRunRouteRequiresProject(t *testing.T) { + paths := Document()["paths"].(map[string]any) + post := paths["/api/v1/tenants/{tid}/workflows/{wfid}/runs"].(map[string]any)["post"].(map[string]any) + body := post["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any) + required := body["required"].([]string) + if len(required) != 1 || required[0] != "projectId" { + t.Fatalf("POST workflow runs required fields = %v; want [projectId]", required) + } +} diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index 8b116ab66..4dbc26bd5 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -109,7 +109,7 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { return composeWorkflowRun(t, t.one("SELECT * FROM workflow_runs WHERE id=$1 AND tenant_id=$2", id, r.TenantID)) } -// resolveWorkflowRunProject authorizes and returns the optional Project a direct run freezes (D3). +// resolveWorkflowRunProject authorizes and returns the Project a direct run freezes (D3). // The Project is the one thing that says where the run's Workspace may provision — // createWorkflowRunWorkspace refuses a run without one — and it is stated by the caller rather than // inferred, because nothing else knows it: a workflow document belongs to a tenant, not to a Project, From 0198ee0bf9996dc78b844a7fdf1041ed9fd127f4 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 21:44:54 +0800 Subject: [PATCH 08/26] chore(core): renumber the run-identity migration to 0039 Main now carries 0038_agent_workspace_blocked_deadline.sql (cloud#62), so the run-identity migration moves one further along: 0038_workflow_run_identity.sql becomes 0039, with the test name, upgrade baseline (from 0038), migrations README entries (zh and en) and the eol pin following it. Main keeps its own 0038 section and file untouched. Co-Authored-By: Claude Code --- .gitattributes | 2 +- integration/migration_upgrade_path_test.go | 18 +++++++++--------- integration/revision_upgrade_test.go | 2 +- internal/core/execution_work.go | 2 +- ...tity.sql => 0039_workflow_run_identity.sql} | 0 internal/core/migrations/README.en.md | 6 +++--- internal/core/migrations/README.md | 6 +++--- internal/core/workflow_run_identity_db_test.go | 4 ++-- 8 files changed, 20 insertions(+), 20 deletions(-) rename internal/core/migrations/{0035_workflow_run_identity.sql => 0039_workflow_run_identity.sql} (100%) diff --git a/.gitattributes b/.gitattributes index bd6ef6b67..9ef377e92 100644 --- a/.gitattributes +++ b/.gitattributes @@ -17,4 +17,4 @@ internal/core/migrations/0033_revision_resume.sql text eol=lf # position for a different reason — it belongs to another PR, which has to make this call for it.) # Everything introduced since main is pinned here instead, while no released database has recorded # it yet; after merge that would no longer be true. -internal/core/migrations/0035_workflow_run_identity.sql text eol=lf +internal/core/migrations/0039_workflow_run_identity.sql text eol=lf diff --git a/integration/migration_upgrade_path_test.go b/integration/migration_upgrade_path_test.go index 9ffa9d39c..81c1893fc 100644 --- a/integration/migration_upgrade_path_test.go +++ b/integration/migration_upgrade_path_test.go @@ -1048,11 +1048,11 @@ func TestMigration0033RevisionResumeAppliesFreshAndUpgrades(t *testing.T) { } } -// 0035 turns the WorkflowRun into an execution aggregate the control plane accepts as a work owner: +// 0039 turns the WorkflowRun into an execution aggregate the control plane accepts as a work owner: // the frozen run identity, the workspace binding, the exactly-one-owner discriminator on work and -// the polymorphic runtime reservation. Fresh databases get all of it, an upgrade from 0034 gets it +// the polymorphic runtime reservation. Fresh databases get all of it, an upgrade from 0037 gets it // without a backfill, and a rerun changes nothing. -func TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades(t *testing.T) { +func TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades(t *testing.T) { columns := func(pool *sql.DB) int { t.Helper() var n int @@ -1078,7 +1078,7 @@ func TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades(t *testing.T) { AND indexname IN ('one_unregistered_execution_work','one_unregistered_workflow_execution_work')`).Scan(&names)) return names } - // A polymorphic run reference cannot be a foreign key, so 0035 drops the IssueRun FK instead of + // A polymorphic run reference cannot be a foreign key, so 0039 drops the IssueRun FK instead of // reworking it. The column stays; only the constraint goes. Resolved through the search path // rather than by name alone, which would count another schema's constraint of the same name. reservationFK := func(pool *sql.DB) int { @@ -1089,19 +1089,19 @@ func TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades(t *testing.T) { return n } - freshPool, _ := testSchema(t, "test_wfi35f_") + freshPool, _ := testSchema(t, "test_wfi39f_") migratedTwice(t, newStoreOnSchema(t, freshPool)) if got, names, idx, fk := columns(freshPool), constraints(freshPool), indexes(freshPool), reservationFK(freshPool); got != 4 || names != "execution_work_owner_exclusive,workspace_run_binding_exclusive" || idx != "one_unregistered_execution_work,one_unregistered_workflow_execution_work" || fk != 0 { t.Fatalf("fresh: columns %d constraints %q indexes %q reservation fk %d", got, names, idx, fk) } - pool, _ := testSchema(t, "test_wfi35_") - applyMigrationsUpTo(t, pool, migrationsBefore(t, "0035_workflow_run_identity.sql")) + pool, _ := testSchema(t, "test_wfi39_") + applyMigrationsUpTo(t, pool, migrationsBefore(t, "0039_workflow_run_identity.sql")) if got, names, fk := columns(pool), constraints(pool), reservationFK(pool); got != 0 || names != "" || fk != 1 { - t.Fatalf("before 0035: columns %d constraints %q reservation fk %d", got, names, fk) + t.Fatalf("before 0039: columns %d constraints %q reservation fk %d", got, names, fk) } migratedTwice(t, newStoreOnSchema(t, pool)) if got, names, idx, fk := columns(pool), constraints(pool), indexes(pool), reservationFK(pool); got != 4 || names != "execution_work_owner_exclusive,workspace_run_binding_exclusive" || idx != "one_unregistered_execution_work,one_unregistered_workflow_execution_work" || fk != 0 { - t.Fatalf("after 0035: columns %d constraints %q indexes %q reservation fk %d", got, names, idx, fk) + t.Fatalf("after 0039: columns %d constraints %q indexes %q reservation fk %d", got, names, idx, fk) } } diff --git a/integration/revision_upgrade_test.go b/integration/revision_upgrade_test.go index e666eea28..860523b1f 100644 --- a/integration/revision_upgrade_test.go +++ b/integration/revision_upgrade_test.go @@ -93,7 +93,7 @@ func TestRevisionUpgradePreservesPublishedWorkflowSchema(t *testing.T) { must(t, err) } must(t, tx.Commit()) - // The run document strips the identity columns a later migration (0035) added: they land as + // The run document strips the identity columns a later migration (0039) added: they land as // NULL on published rows, which is an intended shape change, not a data change — the // comparison keeps guarding the row data the published schema carried. const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r) - ARRAY['created_by','project_id'])::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` diff --git a/internal/core/execution_work.go b/internal/core/execution_work.go index 783a880e2..ad8d46a21 100644 --- a/internal/core/execution_work.go +++ b/internal/core/execution_work.go @@ -107,7 +107,7 @@ func claimWorkItem(t *transaction) Object { } // A work item names the run it belongs to in one of two columns: agent work carries run_id (an // issue_runs id), workflow work carries workflow_run_id (a workflow_runs id) and leaves run_id - // NULL by the mutual-exclusion CHECK 0035 added. The Controller is handed the run id as the + // NULL by the mutual-exclusion CHECK 0039 added. The Controller is handed the run id as the // operation id and echoes it back on dispatch, which resolves either column — so the claim has to // project one value. Reading the agent-only column instead hands back the empty string a workflow // row produces, and every dispatch of that item is then refused as a conflict it cannot resolve. diff --git a/internal/core/migrations/0035_workflow_run_identity.sql b/internal/core/migrations/0039_workflow_run_identity.sql similarity index 100% rename from internal/core/migrations/0035_workflow_run_identity.sql rename to internal/core/migrations/0039_workflow_run_identity.sql diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index ce78619bc..3a48209e0 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -115,9 +115,9 @@ only validate schema. `TestMigration0035ModelAttemptsFreshAndUpgradeWithoutBackf Adds issue_runs.workspace_blocked_at, workspace_deadline_at, workspace_blocked_source (blocked_write / first_observed) with a provisioning-phase partial index, and widens agent_run_residuals' reason constraint to agent_start_failed / workspace_unavailable. The first transaction persisting `blocked` anchors the absolute deadline; legacy blocked creations have no immutable first-blocked evidence, are not backfilled, and receive one default window from the first policy scan as first_observed. Defer, manual retry, re-claiming, restarts and configuration changes never renew; only the creation's real terminal ends the window. Purely forward and retry-safe, without rewriting historical outcomes. Fresh/upgrade/repeat coverage: TestMigration0038WorkspaceBlockedDeadlineAppliesFreshAndUpgrades. -## 0035: WorkflowRun identity +## 0039: WorkflowRun identity -`0035_workflow_run_identity.sql` hands run identity and work-item ownership to the control plane. +`0039_workflow_run_identity.sql` hands run identity and work-item ownership to the control plane. `workflow_runs` gains `created_by` and `project_id` (both nullable: rows written before this migration carry no recoverable actor, and `project_id` is filled by the invocation path that knows a Project), which new runs freeze at creation. `workspaces` gains a mutually exclusive `workflow_run_id`, so a run @@ -128,4 +128,4 @@ becomes a polymorphic run reference: the `maintenance_run_id` foreign key is dro reworked, because a polymorphic reference cannot be a foreign key, and the liveness check moves to the code that grants on it. Additive: columns, constraints and indexes, no backfill. -`TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades` covers fresh, 0034 upgrade and rerun. +`TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` covers fresh, 0038 upgrade and rerun. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 863861109..c30eefdb0 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -114,9 +114,9 @@ Cloud 领域方法。冻结与撤销规则见 [个人模型连接](../../../docs 新增 issue_runs.workspace_blocked_at、workspace_deadline_at、workspace_blocked_source(blocked_write / first_observed)与 provisioning 期部分索引,并把 agent_run_residuals 的 reason 约束扩为 agent_start_failed / workspace_unavailable。首次持久写入 blocked 的同事务锚定绝对截止;存量 blocked 无不可变首次证据,不回填,由首次策略扫描以 first_observed 给一次默认窗口。defer、人工 retry、重新领取、重启与配置调整不续期;只有原创建真实终态结束窗口。纯前向、可重复执行,不改历史执行结果。新库/升级/重复执行见 TestMigration0038WorkspaceBlockedDeadlineAppliesFreshAndUpgrades。 -## 0035:WorkflowRun 运行身份 +## 0039:WorkflowRun 运行身份 -`0035_workflow_run_identity.sql` 把运行身份与工作项所有权交给控制面。`workflow_runs` 新增 +`0039_workflow_run_identity.sql` 把运行身份与工作项所有权交给控制面。`workflow_runs` 新增 `created_by` 与 `project_id` 两列(均可空:本迁移之前的行没有可恢复的发起人,而 `project_id` 由知道 Project 的调用路径写入),新运行在创建时冻结二者;`workspaces` 新增互斥的 `workflow_run_id`,使运行 Workspace 可以属于 WorkflowRun;`execution_work` 同样变为「恰有一个所有者」的判别形状,原单列唯一索引 @@ -124,4 +124,4 @@ Workspace 可以属于 WorkflowRun;`execution_work` 同样变为「恰有一 `runtime_controls` 的预留改为多态运行引用,`maintenance_run_id` 的外键被删除而非改写——多态引用无法做 外键,存活校验移到授予它的代码里。只新增列、约束与索引,不回填。 -`TestMigration0035WorkflowRunIdentityAppliesFreshAndUpgrades` 验证空库、从 0034 升级及重复迁移。 +`TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` 验证空库、从 0038 升级及重复迁移。 diff --git a/internal/core/workflow_run_identity_db_test.go b/internal/core/workflow_run_identity_db_test.go index e010bd0a8..8a9423259 100644 --- a/internal/core/workflow_run_identity_db_test.go +++ b/internal/core/workflow_run_identity_db_test.go @@ -1,6 +1,6 @@ package core -// DB tests for the WorkflowRun execution identity (migration 0035, specs#99 D3/D8): a run's frozen +// DB tests for the WorkflowRun execution identity (migration 0039, specs#99 D3/D8): a run's frozen // identity drives the workspace lifecycle, the workspace binds to workflow_runs, and the control // plane's seams — enqueue, dispatch, runtime permit and the settlement hooks — accept a WorkflowRun // as the run of either kind. Session execution stays unwired (the scheduler still fails runs on @@ -259,7 +259,7 @@ func TestWorkflowExecutionWorkDispatchSeam(t *testing.T) { // driven through the real control actions rather than by calling nodeDispatch directly. // // The seam test above dispatches directly and so cannot see this: a workflow work item carries -// workflow_run_id and leaves run_id NULL by 0035's mutual-exclusion CHECK, so a claim that projects +// workflow_run_id and leaves run_id NULL by 0039's mutual-exclusion CHECK, so a claim that projects // the agent-only column hands the Controller the empty string, and every dispatch of that item then // matches no row and is refused as a conflict it can never resolve. func TestWorkflowExecutionWorkClaimCarriesItsRunID(t *testing.T) { From 73a81893f7eb1d77a7aa71a4492b32c9d4c983ca Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Mon, 19 Oct 2026 09:47:59 +0800 Subject: [PATCH 09/26] docs(workflows): record the frozen run identity and run Workspace ownership main rebuilt docs/ into bilingual per-domain documents (cloud#85), so the behavior this PR adds is documented there: the workflow run section gains the mandatory projectId and the created_by/project_id frozen at creation, and the run Workspace ownership (workspaces.workflow_run_id, mutually exclusive with issue_run_id) is stated where the Workspace aggregates and the runtime control reservation are described. Both languages change together, per the Documentation rules. Co-Authored-By: Claude Code --- docs/agent-runs/issue-runs.en.md | 3 ++- docs/agent-runs/issue-runs.md | 3 ++- docs/runtime/runtime-control.en.md | 5 +++-- docs/runtime/runtime-control.md | 4 ++-- docs/runtime/workspaces.en.md | 2 ++ docs/runtime/workspaces.md | 2 ++ docs/workflows/workflows.en.md | 9 +++++++++ docs/workflows/workflows.md | 6 ++++++ 8 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/agent-runs/issue-runs.en.md b/docs/agent-runs/issue-runs.en.md index 8c8681137..5cac12f19 100644 --- a/docs/agent-runs/issue-runs.en.md +++ b/docs/agent-runs/issue-runs.en.md @@ -54,7 +54,8 @@ Cloud in the transaction that takes over the previous phase's evidence. [the delete-side limit](#limits)). A run Workspace belongs to exactly this run (`workspaces.issue_run_id` and `issue_runs.workspace_id` -reference each other uniquely). Its creator and operation actor are the user who triggered the run, it +reference each other uniquely; a WorkflowRun's run Workspace likewise belongs only to it, see +[Workflows](../workflows/workflows.en.md#runs)). Its creator and operation actor are the user who triggered the run, it is cloned from the Project's default branch, it never appears in any public Workspace list or endpoint, and it accepts no other run or interactive session. diff --git a/docs/agent-runs/issue-runs.md b/docs/agent-runs/issue-runs.md index 55cd175df..a26c7327b 100644 --- a/docs/agent-runs/issue-runs.md +++ b/docs/agent-runs/issue-runs.md @@ -40,7 +40,8 @@ Cloud 在接管上一阶段证据的事务里写入。 - 创建操作停在 `blocked` 超过上限时,运行按 `failed`、`done` 收尾并保留残留责任,原创建操作失败、释放 Project 的操作槽(见 [创建 blocked 的有界放弃](startup-settlement.md#创建-blocked-的有界放弃))。 - **`done` 不代表运行 Workspace 已经删除**,只有删除操作成功才是(见 [删除侧的上限](#上限))。 -运行 Workspace 只属于这一次运行(`workspaces.issue_run_id` 与 `issue_runs.workspace_id` 互相唯一引用),以触发运行的用户为创建者 +运行 Workspace 只属于这一次运行(`workspaces.issue_run_id` 与 `issue_runs.workspace_id` 互相唯一引用;一次 WorkflowRun 的运行 Workspace 同样只属于它, +见 [工作流](../workflows/workflows.md#运行)),以触发运行的用户为创建者 和操作发起者,从 Project 默认分支克隆,不出现在任何公开的 Workspace 列表或接口里,也不接受其他运行或交互式会话。 ## 后台循环 diff --git a/docs/runtime/runtime-control.en.md b/docs/runtime/runtime-control.en.md index e08fcb43a..a4ce62752 100644 --- a/docs/runtime/runtime-control.en.md +++ b/docs/runtime/runtime-control.en.md @@ -75,8 +75,9 @@ idle ───────────────▶ acquiring ───── **Lifecycle operations share the same write boundary.** As soon as a lifecycle operation is registered it puts control into `maintenance` (recording the operation ID and incrementing the control epoch), so page sessions cannot operate meanwhile. When the operation ends, control becomes `draining` -and settles by the rules above. After an Agent run's run Workspace is created, control becomes a -`maintenance` owned by that run, which bounds its sessions and deliveries from then on. +and settles by the rules above. After a run Workspace's creation operation succeeds, control becomes a +`maintenance` owned by the run that created it — the reservation records the run id, and both IssueRuns +and WorkflowRuns count — which bounds its sessions and deliveries from then on. An administrator's ordinary operations cannot take over control someone else holds; force-stop is the way to interrupt immediately. diff --git a/docs/runtime/runtime-control.md b/docs/runtime/runtime-control.md index eca9ffbfc..57203a0cf 100644 --- a/docs/runtime/runtime-control.md +++ b/docs/runtime/runtime-control.md @@ -60,8 +60,8 @@ idle ───────────────▶ acquiring ───── - 幂等重放返回历史响应,但不会让已撤回的资格复活。每次状态变化都写入 `runtime_control_events`,保留持有者和原会话。 **生命周期操作与资格共用同一个写边界。** 生命周期操作一旦登记,就把资格置为 `maintenance`(记录操作 ID,控制代次加一), -页面会话在此期间不能操作;操作结束后资格进入 `draining`,按上面的规则结清。Agent 运行的运行 Workspace 创建成功后,资格转为 -属于该运行的 `maintenance`,之后的会话和交付都以它为边界。 +页面会话在此期间不能操作;操作结束后资格进入 `draining`,按上面的规则结清。运行 Workspace 的创建操作成功后,资格转为属于创建它的那次运行的 +`maintenance`(预留记录运行 ID,IssueRun 与 WorkflowRun 都认),之后的会话和交付都以它为边界。 管理员的普通操作不能抢占别人持有的资格;需要立即打断时使用强停。 diff --git a/docs/runtime/workspaces.en.md b/docs/runtime/workspaces.en.md index 1a034b844..0449d608f 100644 --- a/docs/runtime/workspaces.en.md +++ b/docs/runtime/workspaces.en.md @@ -76,6 +76,8 @@ by the Controller on its behalf. **Run Workspaces.** Agent IssueRuns also use isolated Workspaces (`issue_run_id` set). Cloud creates and deletes them under the identity of the user who triggered the run, through the same operations, but they never appear in any public list or endpoint (see [Agent IssueRuns](../agent-runs/issue-runs.en.md)). +A run Workspace belongs to exactly one run: `issue_run_id` and `workflow_run_id` are mutually exclusive +columns, and the latter belongs to one [WorkflowRun](../workflows/workflows.en.md#runs). ## Durable operations diff --git a/docs/runtime/workspaces.md b/docs/runtime/workspaces.md index de6c67782..9e4b43984 100644 --- a/docs/runtime/workspaces.md +++ b/docs/runtime/workspaces.md @@ -64,6 +64,8 @@ Cloud 不直接调用 Substrate 或 Node。它只保存意图和证据,并按 **运行 Workspace。** Agent IssueRun 也使用 isolated Workspace(`issue_run_id` 非空),由 Cloud 以触发运行的用户 身份建删,走同一套操作,但不出现在任何公开列表或接口里(见 [Agent IssueRun](../agent-runs/issue-runs.md))。 +运行 Workspace 恰好属于一次运行:`issue_run_id` 与 `workflow_run_id` 两列互斥,后者属于一次 +[WorkflowRun](../workflows/workflows.md#运行)。 ## 持久操作 diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index 27b7b0f52..79746d019 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -76,6 +76,15 @@ Workflows have three start entries, all sharing one admission gate: If any check fails, no run, claimed interaction or activity is left behind. The executed version is frozen onto the run, so the run still knows which version it executed after the workflow is edited or published again. +**A run's identity is frozen at creation.** A direct run must name its `projectId`: a missing one is `409 +workflow_run_project_required`, an unusable, archived or foreign-space Project is refused as `404`, and one being +deleted is `409 resource_unavailable`. The run writes the initiating user (`created_by`) and that Project +(`project_id`) into its row and never changes them — a workflow document belongs to a tenant, not to a Project, so +there is nothing to default to, and the run's Workspace, its clone and its whole checkout land in the Project that +was named. A run Workspace belongs to the run that created it: `workspaces.workflow_run_id` is mutually exclusive +with an IssueRun's `issue_run_id`, and the `maintenance` reserved for it in runtime control is recorded against the +same run identity (see [Runtime control](../runtime/runtime-control.en.md)). + ## Scheduling WorkflowRuns are carried forward by a scheduling loop in the Cloud server (`AdvanceWorkflowRunsOnce`, every 10 diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index ff2796894..2f647692d 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -57,6 +57,12 @@ Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执 任何一项不通过都不会留下运行、已认领的交互或活动。被执行的版本冻结在运行上,运行之后工作流再被编辑或发布,它仍然知道自己执行的是哪个版本。 +**运行的身份在创建时冻结。** 直接创建运行时 `projectId` 必填:缺失返回 `409 workflow_run_project_required`,不可用、已归档或属于其他空间的 Project +按 `404` 拒绝,正在删除的返回 `409 resource_unavailable`。运行把发起人(`created_by`)和这个 Project(`project_id`)写进运行行,之后不再变化——工作流文档 +属于租户而不是某个 Project,没有可以默认的对象,而运行的 Workspace、它的 clone 和整份检出都落在被指定的 Project 里。运行 Workspace 归属于创建它的那次运行: +`workspaces.workflow_run_id` 与 IssueRun 的 `issue_run_id` 互斥,运行时控制里为它预留的 `maintenance` 同样按运行身份记录(见 +[运行时控制](../runtime/runtime-control.md))。 + ## 调度 WorkflowRun 由 Cloud server 里的调度循环推进(`AdvanceWorkflowRunsOnce`,每 10 秒一轮): From 9d62274f4116135c8bd1ec1cb0ed77c66763fb64 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Fri, 16 Oct 2026 16:02:19 +0800 Subject: [PATCH 10/26] feat(core): resolve executor slots and run workflow agent sessions specs#99 D5 + the executor-slot decisions (20261015-executor-slots, 20261015-executor-slot-mappings), landing order steps 1/2/4 and the session wiring: a WorkflowRun's agent nodes now execute as real Node sessions against the invoker's own frozen per-slot bindings. Circus#70, run issue circus#141. The graph schema and the admission gate are upstream's (#65). This branch replays onto it and drops the duplicate it had carried, so the publish gate, validateWorkflowGraph and requireWorkflowExecutorSlots stay one implementation. What is added here is the half that was missing: the runner's own slot mappings, and a run that freezes what admission admitted. Mappings API (model_slot_mappings.go): the verified owner's slot name -> executor resolution table under /api/v1/me/executor-slot-mappings, with CAS writes (PUT upsert on version, DELETE existence-then-CAS behind an Idempotency-Key) and a view that computes today's availability so a mapping survives a broken connection and shows the reason instead of vanishing. `default` is not a mapping name - it IS the personal default model. Resolution and freezing (workflow_run_gate.go): one resolver (resolveWorkflowSlotExecutor) answers "can this user supply this slot" for both halves of the gate, in the same transaction, so a slot cannot be admitted by one policy and frozen by another. `default` still resolves through defaultExecutorGap - now itself the same policy a mapped pair is judged by (personalExecutorGap) - and a named slot resolves through the user's own mapping, never through `default`, the author's hint or another user's configuration. A mapping that has since broken names which part broke, so requireWorkflowExecutorSlots keeps its 409 workflow_executor_unconfigured and its params.slots shape and now fills in a fixed slot's description and hint. Freezing (migration 0036): each referenced slot - including the implicit `default` - writes one run_model_bindings row inside the run's creation transaction, carrying the connection's facts as copied values, so editing the connection afterwards cannot rewrite what an already-created run was authorized against. Any gap rolls the whole creation back. The table's uniqueness becomes one (run, slot) key per owner kind and the owner a discriminator pair, so a WorkflowRun carries several bindings while a plain OpenCode IssueRun keeps its single one under `default`. Session execution (workflow_run_session.go): a ready agent node provisions the run Workspace, waits for a connected Node, builds the session spec from the node's frozen slot binding and the run project's plugin version, commits `running` under the version CAS and releases the work item. Sessions serialize per run; the pass holds a dispatched session until terminal evidence commits, and a session that never dispatches fails its node honestly once the 15-minute startup deadline passes. The end reason is the evidence: agent_failed and interrupted fail, cancelled cancels, idle_timeout and user_ended succeed. A WorkflowRun has no Thread, so settleSessionEnded proves the event is this run's own session work and acks it, leaving the terminal commit to the scheduler. Also: liveModelBinding resolves a WorkflowRun binding through its own branch (no Thread vocabulary) for the model gateway; the run detail reports the frozen per-slot bindings; one pass that settles a session then continues its downstream wave now CASes the second commit against the version the first one left behind - a stale version made the final settle a silent no-op and left the run in `running`; and every document-level assertion in workflow_graph.go goes through asWorkflowObject, because Object is a named type and a jsonb-decoded row is a plain map[string]any, so the direct assertion silently skipped it. Co-Authored-By: Claude Code --- api/openapi.json | 557 ++++++++++++++++++ frontend/src/api/generated.schemas.ts | 69 +++ frontend/src/api/me/me.ts | 238 ++++++++ internal/api/router/router.go | 5 +- internal/contract/openapi.go | 22 +- internal/core/agent_run_session_settle.go | 7 + .../0036_executor_slot_bindings.sql | 30 + internal/core/model_grants.go | 71 ++- internal/core/model_run_binding.go | 30 +- internal/core/model_slot_mappings.go | 103 ++++ internal/core/model_slot_mappings_db_test.go | 141 +++++ internal/core/public.go | 15 +- internal/core/workflow_admission.go | 19 +- internal/core/workflow_graph.go | 10 +- internal/core/workflow_run.go | 28 + internal/core/workflow_run_gate.go | 87 +++ internal/core/workflow_run_gate_db_test.go | 173 ++++++ internal/core/workflow_run_session.go | 265 +++++++++ internal/core/workflow_run_session_db_test.go | 313 ++++++++++ internal/core/workflow_scheduler.go | 89 ++- internal/core/workflow_scheduler_db_test.go | 29 +- internal/core/workflow_scheduler_test.go | 12 + 22 files changed, 2224 insertions(+), 89 deletions(-) create mode 100644 internal/core/migrations/0036_executor_slot_bindings.sql create mode 100644 internal/core/model_slot_mappings.go create mode 100644 internal/core/model_slot_mappings_db_test.go create mode 100644 internal/core/workflow_run_gate.go create mode 100644 internal/core/workflow_run_gate_db_test.go create mode 100644 internal/core/workflow_run_session.go create mode 100644 internal/core/workflow_run_session_db_test.go diff --git a/api/openapi.json b/api/openapi.json index 4bf4a78ff..ad48863d4 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3529,6 +3529,62 @@ ], "type": "object" }, + "SlotMapping": { + "additionalProperties": false, + "properties": { + "agentCli": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "connectionId": { + "format": "uuid", + "type": "string" + }, + "connectionName": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "modelId": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "slotName": { + "type": "string" + }, + "unavailableReason": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "version": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "slotName", + "agentCli", + "connectionId", + "modelId", + "modelName", + "connectionName", + "available", + "unavailableReason", + "version", + "createdAt", + "updatedAt" + ], + "type": "object" + }, "Snapshot": { "additionalProperties": false, "properties": { @@ -4401,6 +4457,14 @@ "description": "The kickoff input passed to the run's start node.", "type": "object" }, + "modelBindings": { + "description": "The frozen per-slot model bindings this run executes against, ordered by slot name. Present on a detail read. Each binding is frozen at run creation and used for the run's whole lifetime; only `revoked` can turn true (the owning connection's grant was revoked).", + "items": { + "$ref": "#/components/schemas/WorkflowRunModelBinding" + }, + "nullable": true, + "type": "array" + }, "name": { "type": "string" }, @@ -4471,6 +4535,34 @@ ], "type": "object" }, + "WorkflowRunModelBinding": { + "additionalProperties": false, + "properties": { + "connectionName": { + "type": "string" + }, + "modelId": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "revoked": { + "type": "boolean" + }, + "slot": { + "type": "string" + } + }, + "required": [ + "slot", + "connectionName", + "modelId", + "modelName", + "revoked" + ], + "type": "object" + }, "WorkflowSnapshot": { "additionalProperties": false, "properties": { @@ -5194,6 +5286,471 @@ ] } }, + "/api/v1/me/executor-slot-mappings": { + "get": { + "description": "Personal executor-slot mappings of the verified user: slot name -\u003e executor (agent CLI fixed to official/ora-space.opencode for now). Only the owner reads and writes; `default` is deliberately absent because it is the personal default model (PUT /me/model-default). The connection must be owned, enabled and contain the selected model at write time. A mapping survives its connection being deleted, disabled or losing the model — `available` computes what the run-creation gate would conclude today and `unavailableReason` names it (model_connection_unavailable, model_credential_required, model_not_found). DELETE requires a user-scoped Idempotency-Key; writes require the current resource version (a slot with no mapping is version 0).", + "operationId": "get_api_v1_me_executor-slot-mappings", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Exclusive UUID cursor; Agent sessions use descending (createdAt, id), other lists use ascending stable ordering.", + "in": "query", + "name": "after", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SlotMapping" + }, + "type": "array" + }, + "nextCursor": { + "type": "string" + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, last_admin, space_last_owner, space_slug_conflict, or default_space_protected" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "External capability not wired in this deployment (a port is Unavailable), e.g. form_descriptor_unavailable or assist_unavailable" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "GET /api/v1/me/executor-slot-mappings", + "tags": [ + "me" + ] + } + }, + "/api/v1/me/executor-slot-mappings/{slot}": { + "delete": { + "description": "Personal executor-slot mappings of the verified user: slot name -\u003e executor (agent CLI fixed to official/ora-space.opencode for now). Only the owner reads and writes; `default` is deliberately absent because it is the personal default model (PUT /me/model-default). The connection must be owned, enabled and contain the selected model at write time. A mapping survives its connection being deleted, disabled or losing the model — `available` computes what the run-creation gate would conclude today and `unavailableReason` names it (model_connection_unavailable, model_credential_required, model_not_found). DELETE requires a user-scoped Idempotency-Key; writes require the current resource version (a slot with no mapping is version 0).", + "operationId": "delete_api_v1_me_executor-slot-mappings_slot", + "parameters": [ + { + "in": "path", + "name": "slot", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "resource": { + "$ref": "#/components/schemas/SlotMapping" + } + }, + "required": [ + "resource" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, last_admin, space_last_owner, space_slug_conflict, or default_space_protected" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "External capability not wired in this deployment (a port is Unavailable), e.g. form_descriptor_unavailable or assist_unavailable" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "DELETE /api/v1/me/executor-slot-mappings/:slot", + "tags": [ + "me" + ] + }, + "put": { + "description": "Personal executor-slot mappings of the verified user: slot name -\u003e executor (agent CLI fixed to official/ora-space.opencode for now). Only the owner reads and writes; `default` is deliberately absent because it is the personal default model (PUT /me/model-default). The connection must be owned, enabled and contain the selected model at write time. A mapping survives its connection being deleted, disabled or losing the model — `available` computes what the run-creation gate would conclude today and `unavailableReason` names it (model_connection_unavailable, model_credential_required, model_not_found). DELETE requires a user-scoped Idempotency-Key; writes require the current resource version (a slot with no mapping is version 0).", + "operationId": "put_api_v1_me_executor-slot-mappings_slot", + "parameters": [ + { + "in": "path", + "name": "slot", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "agentCli": { + "type": "string" + }, + "connectionId": { + "type": "string" + }, + "modelId": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "version": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "agentCli", + "connectionId", + "modelId" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "resource": { + "$ref": "#/components/schemas/SlotMapping" + } + }, + "required": [ + "resource" + ], + "type": "object" + } + } + }, + "description": "Successful command or resource response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid JSON/field/input, missing idempotency key, invalid pagination or evidence" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Invalid, forged, expired, wrong-audience, untrusted, or caller-mismatched credential" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Disabled user, inactive/missing membership, wrong service role, or admin required" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Resource absent or outside authorized tenant/owner scope" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version/idempotency conflict, resource_in_use, closed admission, stale epoch/Node/sandbox, incomplete effect, invalid transition, unconfirmed termination/idle, last_admin, space_last_owner, space_slug_conflict, or default_space_protected" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Version precondition required" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Internal error; no SQL or secret details are exposed" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "External capability not wired in this deployment (a port is Unavailable), e.g. form_descriptor_unavailable or assist_unavailable" + } + }, + "security": [ + { + "serviceCredential": [], + "userCredential": [] + } + ], + "summary": "PUT /api/v1/me/executor-slot-mappings/:slot", + "tags": [ + "me" + ] + } + }, "/api/v1/me/git-identity": { "delete": { "description": "Restores the default git commit identity (display name and a per-user noreply address). A stated identity is removed only at its current version, otherwise 409 version_conflict; an identity that is already the default restores as a no-op whatever version is sent, which makes a retry safe without an Idempotency-Key.", diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index 289a80b51..78811aaa5 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -1509,6 +1509,20 @@ export interface SessionModelStatistics { usage: SessionModelStatisticsUsage; } +export interface SlotMapping { + agentCli: string; + available: boolean; + connectionId: string; + connectionName: string; + createdAt: string; + modelId: string; + modelName: string; + slotName: string; + unavailableReason: string; + updatedAt: string; + version: number; +} + export type SnapshotPluginInput = { [key: string]: unknown }; export interface Snapshot { @@ -1870,6 +1884,14 @@ export const WorkflowRunStatus = { cancelled: 'cancelled', } as const; +export interface WorkflowRunModelBinding { + connectionName: string; + modelId: string; + modelName: string; + revoked: boolean; + slot: string; +} + export interface WorkflowRun { createdAt: string; /** @@ -1883,6 +1905,11 @@ export interface WorkflowRun { id: string; /** The kickoff input passed to the run's start node. */ input: WorkflowRunInput; + /** + * The frozen per-slot model bindings this run executes against, ordered by slot name. Present on a detail read. Each binding is frozen at run creation and used for the run's whole lifetime; only `revoked` can turn true (the owning connection's grant was revoked). + * @nullable + */ + modelBindings?: WorkflowRunModelBinding[] | null; name: string; /** Per-node execution state keyed by node id: status, output, error, timestamps. */ nodeStates: WorkflowRunNodeStates; @@ -1995,6 +2022,48 @@ export type PostApiV1JoinRequestsBody = { token: string; }; +export type GetApiV1MeExecutorSlotMappingsParams = { +/** + * @minimum 1 + * @maximum 100 + */ +limit?: number; +/** + * Exclusive UUID cursor; Agent sessions use descending (createdAt, id), other lists use ascending stable ordering. + */ +after?: string; +}; + +export type GetApiV1MeExecutorSlotMappings200 = { + items: SlotMapping[]; + nextCursor: string; +}; + +export type DeleteApiV1MeExecutorSlotMappingsSlotBody = { + /** @minimum 0 */ + version: number; +}; + +export type DeleteApiV1MeExecutorSlotMappingsSlot200 = { + resource: SlotMapping; +}; + +export type PutApiV1MeExecutorSlotMappingsSlotBody = { + agentCli: string; + connectionId: string; + /** + * @minLength 1 + * @maxLength 500 + */ + modelId: string; + /** @minimum 0 */ + version?: number; +}; + +export type PutApiV1MeExecutorSlotMappingsSlot200 = { + resource: SlotMapping; +}; + export type DeleteApiV1MeGitIdentityBody = { /** @minimum 0 */ version: number; diff --git a/frontend/src/api/me/me.ts b/frontend/src/api/me/me.ts index d5addcfa3..cae4bc17d 100644 --- a/frontend/src/api/me/me.ts +++ b/frontend/src/api/me/me.ts @@ -25,12 +25,16 @@ import type { } from '@tanstack/react-query'; import type { + DeleteApiV1MeExecutorSlotMappingsSlot200, + DeleteApiV1MeExecutorSlotMappingsSlotBody, DeleteApiV1MeGitIdentityBody, DeleteApiV1MeModelConnectionsMcid200, DeleteApiV1MeModelConnectionsMcidBody, DeleteApiV1MeModelConnectionsMcidCredential200, DeleteApiV1MeModelConnectionsMcidCredentialBody, Error, + GetApiV1MeExecutorSlotMappings200, + GetApiV1MeExecutorSlotMappingsParams, GetApiV1MeJoinRequests200, GetApiV1MeJoinRequestsParams, GetApiV1MeModelConnections200, @@ -44,6 +48,8 @@ import type { ModelDefault, PostApiV1MeModelConnections201, PostApiV1MeModelConnectionsBody, + PutApiV1MeExecutorSlotMappingsSlot200, + PutApiV1MeExecutorSlotMappingsSlotBody, PutApiV1MeGitIdentityBody, PutApiV1MeModelConnectionsMcid200, PutApiV1MeModelConnectionsMcidBody, @@ -170,6 +176,238 @@ export function useGetApiV1Me>, TE /** + * Personal executor-slot mappings of the verified user: slot name -> executor (agent CLI fixed to official/ora-space.opencode for now). Only the owner reads and writes; `default` is deliberately absent because it is the personal default model (PUT /me/model-default). The connection must be owned, enabled and contain the selected model at write time. A mapping survives its connection being deleted, disabled or losing the model — `available` computes what the run-creation gate would conclude today and `unavailableReason` names it (model_connection_unavailable, model_credential_required, model_not_found). DELETE requires a user-scoped Idempotency-Key; writes require the current resource version (a slot with no mapping is version 0). + * @summary GET /api/v1/me/executor-slot-mappings + */ +export const getApiV1MeExecutorSlotMappings = ( + params?: GetApiV1MeExecutorSlotMappingsParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v1/me/executor-slot-mappings`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getGetApiV1MeExecutorSlotMappingsQueryKey = (params?: GetApiV1MeExecutorSlotMappingsParams,) => { + return [ + `/api/v1/me/executor-slot-mappings`, ...(params ? [params] : []) + ] as const; + } + + +export const getGetApiV1MeExecutorSlotMappingsQueryOptions = >, TError = ErrorType>(params?: GetApiV1MeExecutorSlotMappingsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetApiV1MeExecutorSlotMappingsQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => getApiV1MeExecutorSlotMappings(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetApiV1MeExecutorSlotMappingsQueryResult = NonNullable>> +export type GetApiV1MeExecutorSlotMappingsQueryError = ErrorType + + +export function useGetApiV1MeExecutorSlotMappings>, TError = ErrorType>( + params: undefined | GetApiV1MeExecutorSlotMappingsParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetApiV1MeExecutorSlotMappings>, TError = ErrorType>( + params?: GetApiV1MeExecutorSlotMappingsParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetApiV1MeExecutorSlotMappings>, TError = ErrorType>( + params?: GetApiV1MeExecutorSlotMappingsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary GET /api/v1/me/executor-slot-mappings + */ + +export function useGetApiV1MeExecutorSlotMappings>, TError = ErrorType>( + params?: GetApiV1MeExecutorSlotMappingsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetApiV1MeExecutorSlotMappingsQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + + + + + + +/** + * Personal executor-slot mappings of the verified user: slot name -> executor (agent CLI fixed to official/ora-space.opencode for now). Only the owner reads and writes; `default` is deliberately absent because it is the personal default model (PUT /me/model-default). The connection must be owned, enabled and contain the selected model at write time. A mapping survives its connection being deleted, disabled or losing the model — `available` computes what the run-creation gate would conclude today and `unavailableReason` names it (model_connection_unavailable, model_credential_required, model_not_found). DELETE requires a user-scoped Idempotency-Key; writes require the current resource version (a slot with no mapping is version 0). + * @summary DELETE /api/v1/me/executor-slot-mappings/:slot + */ +export const deleteApiV1MeExecutorSlotMappingsSlot = ( + slot: string, + deleteApiV1MeExecutorSlotMappingsSlotBody: DeleteApiV1MeExecutorSlotMappingsSlotBody, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v1/me/executor-slot-mappings/${slot}`, method: 'DELETE', + headers: {'Content-Type': 'application/json', }, + data: deleteApiV1MeExecutorSlotMappingsSlotBody, signal + }, + options); + } + + + + +export const getDeleteApiV1MeExecutorSlotMappingsSlotMutationKey = () => ['deleteApiV1MeExecutorSlotMappingsSlot'] as const; + +export const getDeleteApiV1MeExecutorSlotMappingsSlotMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,DeleteApiV1MeExecutorSlotMappingsSlotMutationVariables, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,DeleteApiV1MeExecutorSlotMappingsSlotMutationVariables, TContext> => { + +const mutationKey = getDeleteApiV1MeExecutorSlotMappingsSlotMutationKey(); +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, DeleteApiV1MeExecutorSlotMappingsSlotMutationVariables> = (props) => { + const {slot,data} = props ?? {}; + + return deleteApiV1MeExecutorSlotMappingsSlot(slot,data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteApiV1MeExecutorSlotMappingsSlotMutationResult = NonNullable>> + export type DeleteApiV1MeExecutorSlotMappingsSlotMutationBody = DeleteApiV1MeExecutorSlotMappingsSlotBody + export type DeleteApiV1MeExecutorSlotMappingsSlotMutationError = ErrorType + export type DeleteApiV1MeExecutorSlotMappingsSlotMutationVariables = {slot: string;data: DeleteApiV1MeExecutorSlotMappingsSlotBody} + + /** + * @summary DELETE /api/v1/me/executor-slot-mappings/:slot + */ +export const useDeleteApiV1MeExecutorSlotMappingsSlot = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,DeleteApiV1MeExecutorSlotMappingsSlotMutationVariables, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + DeleteApiV1MeExecutorSlotMappingsSlotMutationVariables, + TContext + > => { + return useMutation(getDeleteApiV1MeExecutorSlotMappingsSlotMutationOptions(options), queryClient); + } + /** + * Personal executor-slot mappings of the verified user: slot name -> executor (agent CLI fixed to official/ora-space.opencode for now). Only the owner reads and writes; `default` is deliberately absent because it is the personal default model (PUT /me/model-default). The connection must be owned, enabled and contain the selected model at write time. A mapping survives its connection being deleted, disabled or losing the model — `available` computes what the run-creation gate would conclude today and `unavailableReason` names it (model_connection_unavailable, model_credential_required, model_not_found). DELETE requires a user-scoped Idempotency-Key; writes require the current resource version (a slot with no mapping is version 0). + * @summary PUT /api/v1/me/executor-slot-mappings/:slot + */ +export const putApiV1MeExecutorSlotMappingsSlot = ( + slot: string, + putApiV1MeExecutorSlotMappingsSlotBody: PutApiV1MeExecutorSlotMappingsSlotBody, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v1/me/executor-slot-mappings/${slot}`, method: 'PUT', + headers: {'Content-Type': 'application/json', }, + data: putApiV1MeExecutorSlotMappingsSlotBody, signal + }, + options); + } + + + + +export const getPutApiV1MeExecutorSlotMappingsSlotMutationKey = () => ['putApiV1MeExecutorSlotMappingsSlot'] as const; + +export const getPutApiV1MeExecutorSlotMappingsSlotMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,PutApiV1MeExecutorSlotMappingsSlotMutationVariables, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,PutApiV1MeExecutorSlotMappingsSlotMutationVariables, TContext> => { + +const mutationKey = getPutApiV1MeExecutorSlotMappingsSlotMutationKey(); +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, PutApiV1MeExecutorSlotMappingsSlotMutationVariables> = (props) => { + const {slot,data} = props ?? {}; + + return putApiV1MeExecutorSlotMappingsSlot(slot,data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type PutApiV1MeExecutorSlotMappingsSlotMutationResult = NonNullable>> + export type PutApiV1MeExecutorSlotMappingsSlotMutationBody = PutApiV1MeExecutorSlotMappingsSlotBody + export type PutApiV1MeExecutorSlotMappingsSlotMutationError = ErrorType + export type PutApiV1MeExecutorSlotMappingsSlotMutationVariables = {slot: string;data: PutApiV1MeExecutorSlotMappingsSlotBody} + + /** + * @summary PUT /api/v1/me/executor-slot-mappings/:slot + */ +export const usePutApiV1MeExecutorSlotMappingsSlot = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,PutApiV1MeExecutorSlotMappingsSlotMutationVariables, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + PutApiV1MeExecutorSlotMappingsSlotMutationVariables, + TContext + > => { + return useMutation(getPutApiV1MeExecutorSlotMappingsSlotMutationOptions(options), queryClient); + } + /** * Restores the default git commit identity (display name and a per-user noreply address). A stated identity is removed only at its current version, otherwise 409 version_conflict; an identity that is already the default restores as a no-op whatever version is sent, which makes a retry safe without an Idempotency-Key. * @summary DELETE /api/v1/me/git-identity */ diff --git a/internal/api/router/router.go b/internal/api/router/router.go index e2be6b25c..9d4a13201 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -77,6 +77,9 @@ func Routes() []Route { {"DELETE", "/api/v1/me/model-connections/:mcid/credential", "", []string{"version"}}, {"GET", "/api/v1/me/model-default", "", nil}, {"PUT", "/api/v1/me/model-default", "", []string{"connectionId", "modelId", "version"}}, + {"GET", "/api/v1/me/executor-slot-mappings", "", nil}, + {"PUT", "/api/v1/me/executor-slot-mappings/:slot", "", []string{"agentCli", "connectionId", "modelId", "version"}}, + {"DELETE", "/api/v1/me/executor-slot-mappings/:slot", "", []string{"version"}}, {"GET", "/api/v1/me/tenants", "", nil}, {"GET", "/api/v1/me/spaces", "", nil}, {"GET", "/api/v1/me/join-requests", "", nil}, @@ -398,7 +401,7 @@ func newRouter(store *core.Store, auth *core.Authenticator, log *zap.Logger, leg return } } - out, status, e = store.Public(c.Request.Context(), &core.PublicRequest{Method: c.Request.Method, Path: c.Request.URL.Path, TenantID: c.Param("tid"), ProjectID: c.Param("pid"), WorkspaceID: c.Param("wid"), SpaceID: c.Param("spaceId"), OperationID: c.Param("oid"), CloneID: c.Param("cloneId"), UserID: c.Param("uid"), IssueID: c.Param("iid"), CommentID: c.Param("cid"), LabelID: c.Param("lid"), StatusID: c.Param("sid"), ViewID: c.Param("vid"), RunID: c.Param("rid"), ContextRefID: c.Param("crid"), InteractionID: c.Param("ixid"), WorkflowID: c.Param("wfid"), SnapshotID: c.Param("snapshotId"), FormRef: c.Param("formRef"), FormIssueID: c.Query("issueId"), ModelConnectionID: c.Param("mcid"), InvitationID: c.Param("iid"), JoinLinkID: c.Param("lid"), JoinRequestID: c.Param("rid"), Key: c.GetHeader("Idempotency-Key"), SessionProjectID: c.Query("projectId"), SessionStatus: c.Query("status"), SessionAgent: c.Query("agent"), SessionFrom: c.Query("from"), SessionTo: c.Query("to"), Limit: limit, After: c.Query("after"), Before: c.Query("before"), Query: c.Query("q"), GroupBy: c.Query("by"), Body: body, Identity: user, Person: person}) + out, status, e = store.Public(c.Request.Context(), &core.PublicRequest{Method: c.Request.Method, Path: c.Request.URL.Path, TenantID: c.Param("tid"), ProjectID: c.Param("pid"), WorkspaceID: c.Param("wid"), SpaceID: c.Param("spaceId"), OperationID: c.Param("oid"), CloneID: c.Param("cloneId"), UserID: c.Param("uid"), IssueID: c.Param("iid"), CommentID: c.Param("cid"), LabelID: c.Param("lid"), StatusID: c.Param("sid"), ViewID: c.Param("vid"), RunID: c.Param("rid"), ContextRefID: c.Param("crid"), InteractionID: c.Param("ixid"), WorkflowID: c.Param("wfid"), SnapshotID: c.Param("snapshotId"), FormRef: c.Param("formRef"), FormIssueID: c.Query("issueId"), ModelConnectionID: c.Param("mcid"), SlotName: c.Param("slot"), InvitationID: c.Param("iid"), JoinLinkID: c.Param("lid"), JoinRequestID: c.Param("rid"), Key: c.GetHeader("Idempotency-Key"), SessionProjectID: c.Query("projectId"), SessionStatus: c.Query("status"), SessionAgent: c.Query("agent"), SessionFrom: c.Query("from"), SessionTo: c.Query("to"), Limit: limit, After: c.Query("after"), Before: c.Query("before"), Query: c.Query("q"), GroupBy: c.Query("by"), Body: body, Identity: user, Person: person}) } else { out, e = store.Control(c.Request.Context(), &core.ControlRequest{Action: route.Action, OperationID: c.Param("oid"), EffectID: c.Param("eid"), TicketID: c.Param("ticket"), Body: body, Service: service, Identity: user}) // The retired development Node JSON contract keeps its historical resource shape. diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index f47554b42..3d3cbb297 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -108,6 +108,7 @@ func Document() map[string]any { "historyAvailable": boolean(), "model": optional(object(obj{"connectionName": str(), "modelId": str(), "modelName": str()}, "connectionName", "modelId", "modelName")), }, "id", "issueId", "issueNumber", "issueTitle", "projectId", "projectName", "agentPluginId", "status", "threadState", "createdAt", "startedAt", "completedAt", "historyAvailable", "model") s["SessionHistory"] = object(obj{"source": enumeration("archive"), "records": array(obj{"type": "object", "additionalProperties": true}), "sizeBytes": number(), "sha256": str()}, "source", "records", "sizeBytes", "sha256") + s["SlotMapping"] = object(obj{"slotName": str(), "agentCli": str(), "connectionId": uuid(), "modelId": str(), "modelName": str(), "connectionName": str(), "available": boolean(), "unavailableReason": str(), "version": number(), "createdAt": timestamp(), "updatedAt": timestamp()}, "slotName", "agentCli", "connectionId", "modelId", "modelName", "connectionName", "available", "unavailableReason", "version", "createdAt", "updatedAt") s["ThreadModel"] = object(obj{"connectionName": str(), "modelId": str(), "modelName": str()}, "connectionName", "modelId", "modelName") s["Project"] = resource("id tenantId ownerUserId spaceId name repositoryUrl defaultBranch credentialRefId lifecycle version createdAt deletedAt", "credentialRefId deletedAt") properties(s, "Project")["repositoryCredentialRefId"] = optional(uuid()) @@ -137,6 +138,16 @@ func Document() map[string]any { // definitionSnapshot lives only on a detail read (the history list trips without it); // it is optional so one schema serves both list rows and the composed run page. workflowRunProps["definitionSnapshot"] = optional(obj{"type": "object", "additionalProperties": true, "description": "The frozen graph document this run executed against, from the snapshot it captured."}) + s["WorkflowRunModelBinding"] = object(obj{ + "slot": str(), + "connectionName": str(), + "modelId": str(), + "modelName": str(), + "revoked": boolean(), + }, "slot", "connectionName", "modelId", "modelName", "revoked") + modelBindings := optional(array(ref("WorkflowRunModelBinding"))) + modelBindings["description"] = "The frozen per-slot model bindings this run executes against, ordered by slot name. Present on a detail read. Each binding is frozen at run creation and used for the run's whole lifetime; only `revoked` can turn true (the owning connection's grant was revoked)." + workflowRunProps["modelBindings"] = modelBindings s["IssueStatus"] = resource("id tenantId key name description category color icon isSystem position version createdAt updatedAt deletedAt", "deletedAt") statusProps := properties(s, "IssueStatus") statusProps["isSystem"] = boolean() @@ -524,7 +535,7 @@ func snapshotFormPath(path string) bool { } func isList(r router.Route) bool { - if r.Path == "/api/v1/me/model-connections" || r.Path == router.AgentSessionsPath || r.Path == router.SessionModelCallsPath { + if r.Path == "/api/v1/me/model-connections" || r.Path == router.AgentSessionsPath || r.Path == router.SessionModelCallsPath || r.Path == "/api/v1/me/executor-slot-mappings" { return r.Method == "GET" } return r.Method == "GET" && (strings.HasSuffix(r.Path, "/tenants") || strings.HasSuffix(r.Path, "/members") || strings.HasSuffix(r.Path, "/projects") || strings.HasSuffix(r.Path, "/workspaces") || strings.HasSuffix(r.Path, "/spaces") || strings.HasSuffix(r.Path, "/resource-status") || strings.HasSuffix(r.Path, "/issue-statuses") || strings.HasSuffix(r.Path, "/labels") || strings.HasSuffix(r.Path, "/issue-views") || strings.HasSuffix(r.Path, "/workflows") || strings.HasSuffix(r.Path, "/snapshots") || strings.HasSuffix(r.Path, "/runs") || strings.HasSuffix(r.Path, "/comments") || strings.HasSuffix(r.Path, "/subscribers") || strings.HasSuffix(r.Path, "/invitations") || strings.HasSuffix(r.Path, "/join-links") || strings.HasSuffix(r.Path, "/join-requests") || strings.HasSuffix(r.Path, "/clones")) @@ -559,6 +570,12 @@ func responseSchema(r router.Route) (schema obj, status string) { } return object(obj{"resource": ref("ModelConnection")}, "resource"), status } + if strings.HasPrefix(r.Path, "/api/v1/me/executor-slot-mappings") { + if isList(r) { + return object(obj{"items": array(ref("SlotMapping")), "nextCursor": str()}, "items", "nextCursor"), "200" + } + return object(obj{"resource": ref("SlotMapping")}, "resource"), "200" + } if r.Path == "/api/v1/me/git-identity" { return ref("GitIdentity"), "200" } @@ -1015,6 +1032,9 @@ func description(r router.Route) string { if r.Path == "/api/v1/me/model-default" { return "Reads or selects the verified user's default connection and model. An unset default has empty connectionId/modelId and version 0; replacing a selection requires its current version. The connection must be owned, enabled and contain the selected model. OpenCode run creation requires a usable default and credential and rejects atomically with model_default_required, model_credential_required or model_connection_unavailable before the comment/run persists." } + if strings.HasPrefix(r.Path, "/api/v1/me/executor-slot-mappings") { + return "Personal executor-slot mappings of the verified user: slot name -> executor (agent CLI fixed to official/ora-space.opencode for now). Only the owner reads and writes; `default` is deliberately absent because it is the personal default model (PUT /me/model-default). The connection must be owned, enabled and contain the selected model at write time. A mapping survives its connection being deleted, disabled or losing the model — `available` computes what the run-creation gate would conclude today and `unavailableReason` names it (model_connection_unavailable, model_credential_required, model_not_found). DELETE requires a user-scoped Idempotency-Key; writes require the current resource version (a slot with no mapping is version 0)." + } switch r.Path { case "/api/v1/me/spaces": return "Lists every active collaboration space whose tenant has an active membership for the verified user. A space corresponds to exactly one tenant; clients follow all pages before presenting the switcher." diff --git a/internal/core/agent_run_session_settle.go b/internal/core/agent_run_session_settle.go index 732c9b5ac..008ea6015 100644 --- a/internal/core/agent_run_session_settle.go +++ b/internal/core/agent_run_session_settle.go @@ -52,6 +52,13 @@ func (s *Store) settleSessionEnded(t *transaction, runID, executionID string, en o := t.one("SELECT * FROM issue_runs WHERE id=$1 AND deleted_at IS NULL", runID) if o == nil { + // A WorkflowRun session ends through this same hook and has its own, smaller settlement + // (settleWorkflowSessionEnded): the scheduler commits the node's terminal state from this + // evidence on its next pass, so the hook's job is to prove the event is this run's own and + // ack it. + if t.one("SELECT id FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) != nil { + return settleWorkflowSessionEnded(t, runID, executionID, ended) + } // No authoritative run to accept the terminal event: an invariant violation, not a replay, so // the event must not be acked. return fmt.Errorf("sessionEnded: issue_run %s not found", runID) diff --git a/internal/core/migrations/0036_executor_slot_bindings.sql b/internal/core/migrations/0036_executor_slot_bindings.sql new file mode 100644 index 000000000..0ec0a1ddd --- /dev/null +++ b/internal/core/migrations/0036_executor_slot_bindings.sql @@ -0,0 +1,30 @@ +-- Executor slots reach the runtime (specs decisions 20261015-executor-slots and +-- 20261015-executor-slot-mappings). A WorkflowRun freezes one run_model_bindings row per executor +-- slot its graph references, while plain OpenCode IssueRuns keep their single binding under the +-- reserved 'default' slot. The binding owner becomes a discriminator pair — exactly one of +-- run_id / workflow_run_id — and the previous whole-table UNIQUE(run_id) splits into one +-- (run, slot) uniqueness per owner, so a run may carry several bindings at once. Personal +-- executor-slot mappings (slot name -> executor) are the invoker-owned resolution table the +-- creation gate reads; 'default' is deliberately excluded there because it IS the personal +-- default model (personal_model_defaults), never a stored mapping. + +ALTER TABLE run_model_bindings ALTER COLUMN run_id DROP NOT NULL; +ALTER TABLE run_model_bindings ADD COLUMN workflow_run_id uuid REFERENCES workflow_runs(id); +ALTER TABLE run_model_bindings ADD COLUMN slot_name text NOT NULL DEFAULT 'default'; +ALTER TABLE run_model_bindings DROP CONSTRAINT run_model_bindings_run_id_key; +ALTER TABLE run_model_bindings ADD CONSTRAINT run_model_binding_owner_exclusive CHECK ((run_id IS NULL) <> (workflow_run_id IS NULL)); +CREATE UNIQUE INDEX run_model_bindings_run_slot_key ON run_model_bindings(run_id, slot_name) WHERE run_id IS NOT NULL; +CREATE UNIQUE INDEX run_model_bindings_workflow_run_slot_key ON run_model_bindings(workflow_run_id, slot_name) WHERE workflow_run_id IS NOT NULL; + +CREATE TABLE executor_slot_mappings ( + user_id uuid NOT NULL REFERENCES users(id), + slot_name text NOT NULL CHECK(slot_name <> 'default' AND slot_name ~ '^[a-z][a-z0-9_-]{0,63}$'), + agent_cli text NOT NULL CHECK(agent_cli = 'official/ora-space.opencode'), + connection_id uuid NOT NULL, + model_id text NOT NULL, + version bigint NOT NULL CHECK(version >= 1), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, slot_name), + FOREIGN KEY (connection_id, user_id) REFERENCES personal_model_connections(id, user_id) +); diff --git a/internal/core/model_grants.go b/internal/core/model_grants.go index b224a2936..30dc8ca3f 100644 --- a/internal/core/model_grants.go +++ b/internal/core/model_grants.go @@ -28,7 +28,38 @@ type ModelGrant struct { func liveModelBinding(t *transaction, scope ModelRuntimeScope, bindingID, executionID string) Object { require(validID(bindingID) && validID(scope.TenantID) && validID(scope.WorkspaceID) && scope.Generation > 0, 403, "model_scope_required") - b := t.one(`SELECT b.*, e.execution_id,e.node_id,e.work_id,w.id AS workspace_id,w.runtime_generation, + // The binding's owner discriminates the branch: an IssueRun binding checks the run's Thread + // lifecycle, a WorkflowRun binding checks the run's execution status — both keep every + // connection, user, tenant, membership, workspace and runtime gate (mappings decision D4: + // the grant's binding must belong to the session execution's run). + b := liveIssueRunModelBinding(t, scope, bindingID, executionID) + if b == nil { + b = liveWorkflowModelBinding(t, scope, bindingID, executionID) + } + require(b != nil, 403, "model_grant_revoked") + if b.S("runId") == "" { + b["runId"] = b.S("workflowRunId") + } + requireNoForceStop(t, scope.WorkspaceID) + n := currentNode(t, scope.WorkspaceID) + require(n.S("nodeId") == b.S("nodeId") && n.B("modelProxy"), 403, "model_grant_revoked") + c := runtimeControl(t, scope.WorkspaceID) + require(c.S("state") == "maintenance" && c.S("maintenanceRunId") == b.S("runId") && c.B("bindingConfirmed") && c.S("boundSandboxId") == n.S("sandboxInstanceId"), 403, "model_grant_revoked") + work := t.one("SELECT target FROM execution_work WHERE id=$1", b.S("workId")) + require(work != nil && work.O("target").S("sandboxInstanceId") == n.S("sandboxInstanceId"), 403, "model_grant_revoked") + // This denial is distinguishable only after the certificate and every current authority gate + // above passed. It lets Node reconcile the durable EndSession command without reclassifying + // a wrong scope, disabled account or cleared credential as a legitimate shutdown request. + if b.S("threadState") == "ending" || b.S("cancelRequestedAt") != "" { + reject(403, "model_session_ending") + } + return b +} + +// liveIssueRunModelBinding resolves a binding against a live Agent IssueRun session execution, +// with the full Thread lifecycle gates (issue-run-only vocabulary). +func liveIssueRunModelBinding(t *transaction, scope ModelRuntimeScope, bindingID, executionID string) Object { + return t.one(`SELECT b.*, e.execution_id,e.node_id,e.work_id,w.id AS workspace_id,w.runtime_generation, r.thread_state,r.cancel_requested_at FROM run_model_bindings b JOIN issue_runs r ON r.id=b.run_id JOIN issues issue ON issue.id=r.issue_id AND issue.deleted_at IS NULL @@ -45,21 +76,29 @@ func liveModelBinding(t *transaction, scope ModelRuntimeScope, bindingID, execut AND w.deleted_at IS NULL AND w.desired_state='running' AND w.observed_state='ready' AND w.admission_open AND c.deleted_at IS NULL AND c.enabled AND c.credential_id IS NOT NULL AND u.deleted_at IS NULL AND u.status='active' AND tenant.deleted_at IS NULL AND tenant.status='active' AND member.status='active'`, bindingID, executionID, scope.TenantID, scope.WorkspaceID, scope.Generation) - require(b != nil, 403, "model_grant_revoked") - requireNoForceStop(t, scope.WorkspaceID) - n := currentNode(t, scope.WorkspaceID) - require(n.S("nodeId") == b.S("nodeId") && n.B("modelProxy"), 403, "model_grant_revoked") - c := runtimeControl(t, scope.WorkspaceID) - require(c.S("state") == "maintenance" && c.S("maintenanceRunId") == b.S("runId") && c.B("bindingConfirmed") && c.S("boundSandboxId") == n.S("sandboxInstanceId"), 403, "model_grant_revoked") - work := t.one("SELECT target FROM execution_work WHERE id=$1", b.S("workId")) - require(work != nil && work.O("target").S("sandboxInstanceId") == n.S("sandboxInstanceId"), 403, "model_grant_revoked") - // This denial is distinguishable only after the certificate and every current authority gate - // above passed. It lets Node reconcile the durable EndSession command without reclassifying - // a wrong scope, disabled account or cleared credential as a legitimate shutdown request. - if b.S("threadState") == "ending" || b.S("cancelRequestedAt") != "" { - reject(403, "model_session_ending") - } - return b +} + +// liveWorkflowModelBinding resolves a binding against a live WorkflowRun session execution. A +// workflow run has no Thread: the run's `running` status and the session execution's open result +// are the liveness evidence, and the binding must belong to the run (workflow_run_id) the session +// execution registered against — correspondence registered by Cloud at dispatch, never trusted +// from a body. +func liveWorkflowModelBinding(t *transaction, scope ModelRuntimeScope, bindingID, executionID string) Object { + return t.one(`SELECT b.*, e.execution_id,e.node_id,e.work_id,w.id AS workspace_id,w.runtime_generation + FROM run_model_bindings b JOIN workflow_runs r ON r.id=b.workflow_run_id AND r.deleted_at IS NULL + JOIN node_executions e ON e.operation_id=r.id AND e.kind='agent_session' + JOIN workspaces w ON w.id=e.workspace_id AND w.workflow_run_id=r.id + JOIN personal_model_connections c ON c.id=b.connection_id AND c.user_id=b.user_id + JOIN users u ON u.id=b.user_id + JOIN tenants tenant ON tenant.id=r.tenant_id + JOIN tenant_memberships member ON member.tenant_id=r.tenant_id AND member.user_id=b.user_id + WHERE b.id=$1 AND e.execution_id=$2 AND r.tenant_id=$3 AND w.id=$4 AND w.runtime_generation=$5 + AND b.revoked_at IS NULL + AND r.status='running' + AND e.result IS NULL AND e.terminated_by_force_stop_id IS NULL + AND w.deleted_at IS NULL AND w.desired_state='running' AND w.observed_state='ready' AND w.admission_open + AND c.deleted_at IS NULL AND c.enabled AND c.credential_id IS NOT NULL + AND u.deleted_at IS NULL AND u.status='active' AND tenant.deleted_at IS NULL AND tenant.status='active' AND member.status='active'`, bindingID, executionID, scope.TenantID, scope.WorkspaceID, scope.Generation) } func modelGrantResult(t *transaction, g, b Object) ModelGrant { diff --git a/internal/core/model_run_binding.go b/internal/core/model_run_binding.go index 67239c17a..a1b045672 100644 --- a/internal/core/model_run_binding.go +++ b/internal/core/model_run_binding.go @@ -3,11 +3,11 @@ package core // defaultExecutorGap reports why the user's personal default executor cannot be used for a run, or // returns an empty reason when it can. It is the one policy behind both the OpenCode run binding and // the workflow `default` slot, so "the default model is usable" cannot come to mean two different -// things: the default selection must exist, its connection must still be the user's, enabled and -// undeleted, the connection must still carry a credential, and the chosen model must still be one the -// connection offers. The connection and model are returned for the caller that goes on to freeze them. +// things: the default selection must exist, and then the pair it names is judged by the same rules a +// mapped slot's pair is (personalExecutorGap). The connection and model are returned for the caller +// that goes on to freeze them. // -// It reports a reason rather than panicking because the two callers answer differently: the OpenCode +// It reports a reason rather than panicking because the callers answer differently: the OpenCode // binding rejects one run with one fault, while the workflow gate collects every unusable slot before // refusing so the user can fix them in one pass. func defaultExecutorGap(t *transaction, uid string) (reason string, connection, model Object) { @@ -15,25 +15,14 @@ func defaultExecutorGap(t *transaction, uid string) (reason string, connection, if d.S("connectionId") == "" { return slotReasonDefaultMissing, nil, nil } - c := t.one("SELECT * FROM personal_model_connections WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL AND enabled", d.S("connectionId"), uid) - if c == nil { - return slotReasonConnectionDown, nil, nil - } - if c.S("credentialId") == "" { - return slotReasonCredentialMissing, c, nil - } - m := findModel(c, d.S("modelId")) - if m == nil { - return slotReasonModelUnavailable, c, nil - } - return "", c, m + return personalExecutorGap(t, uid, d.S("connectionId"), d.S("modelId")) } // bindRunModel freezes the initiating user's personal configuration only for the production // OpenCode plugin. It runs in enqueueRun's transaction so missing configuration rolls back the // comment, interaction, activity and run together. Unrelated agents keep their existing contract. func bindRunModel(t *transaction, runID, uid string, input Object) { - if input.S("agentPluginId") != "official/ora-space.opencode" { + if input.S("agentPluginId") != opencodeAgentCLI { return } // The faults are unchanged from before the policy was shared: this path has always named the one @@ -59,8 +48,11 @@ func bindRunModel(t *transaction, runID, uid string, input Object) { } } id := newID() - t.exec(`INSERT INTO run_model_bindings(id,run_id,user_id,connection_id,connection_version,credential_id,connection_name,protocol,base_url,auth_mode,model) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, id, runID, uid, c.S("id"), c.N("version"), c.S("credentialId"), c.S("name"), c.S("protocol"), c.S("baseUrl"), c.S("authMode"), jsonText(m)) + // An IssueRun binds the implicit `default` slot: the run_model_bindings migration (0036) + // carries slot_name for both run kinds, so a session's binding lookup is uniform + // (workflow_run_session.go resolves the same way). + t.exec(`INSERT INTO run_model_bindings(id,run_id,slot_name,user_id,connection_id,connection_version,credential_id,connection_name,protocol,base_url,auth_mode,model) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, id, runID, "default", uid, c.S("id"), c.N("version"), c.S("credentialId"), c.S("name"), c.S("protocol"), c.S("baseUrl"), c.S("authMode"), jsonText(m)) input["gitIdentity"] = git // modelBindingId is a server-owned reference; no key or model-gateway grant enters the // durable execution input or Controller stream. diff --git a/internal/core/model_slot_mappings.go b/internal/core/model_slot_mappings.go new file mode 100644 index 000000000..6e0234afa --- /dev/null +++ b/internal/core/model_slot_mappings.go @@ -0,0 +1,103 @@ +package core + +// Personal executor-slot mappings (specs decision 20261015-executor-slot-mappings D1): the +// verified user's slot name -> executor resolution table. Only the owner reads and writes it — +// administrator roles grant nothing here. `default` is deliberately excluded: it IS the personal +// default model (personal_model_defaults), never a stored mapping. A mapping survives its +// connection being deleted, disabled or losing the model — the run-creation gate reports the +// reason instead of the row vanishing, so the user can see what broke. + +const opencodeAgentCLI = "official/ora-space.opencode" + +func personalSlotMappingsPublic(t *transaction, r *PublicRequest, uid string) (out Object, status int) { + if r.SlotName == "" { + require(r.Method == "GET", 405, "method_not_allowed") + pageResult := page(t, slotMappingQuery+"WHERE m.user_id=$1", []any{uid}, "m.slot_name", r) + items, ok := pageResult["items"].([]Object) + require(ok, 500, "internal_error") + for i, entry := range items { + items[i] = slotMappingView(entry) + } + return pageResult, 200 + } + require(workflowSlotNamePattern.MatchString(r.SlotName) && r.SlotName != workflowDefaultSlot, 400, "invalid_slot_name") + hash := requestHash(r.Method, r.Path, r.Body) + if r.Method == "DELETE" { + require(r.Key != "", 400, "idempotency_key_required") + if old := personalModelReplay(t, uid, r.Key, hash); old != nil { + return old.O("response"), int(old.N("status")) + } + } + _, hasVersion := r.Body["version"] + require(hasVersion, 428, "version_required") + switch r.Method { + case "PUT": + old := slotMapping(t, uid, r.SlotName) + oldVersion := int64(0) + if old != nil { + oldVersion = old.N("version") + } + require(oldVersion == r.Body.N("version"), 409, "version_conflict") + require(r.Body.S("agentCli") == opencodeAgentCLI, 400, "unsupported_agent_cli") + c := modelConnection(t, uid, r.Body.S("connectionId")) + require(c.B("enabled"), 409, "model_connection_disabled") + require(findModel(c, r.Body.S("modelId")) != nil, 400, "model_not_found") + if old == nil { + t.exec("INSERT INTO executor_slot_mappings(user_id,slot_name,agent_cli,connection_id,model_id,version) VALUES($1,$2,$3,$4,$5,1)", uid, r.SlotName, opencodeAgentCLI, c.S("id"), r.Body.S("modelId")) + } else { + t.exec("UPDATE executor_slot_mappings SET agent_cli=$3,connection_id=$4,model_id=$5,version=version+1,updated_at=now() WHERE user_id=$1 AND slot_name=$2", uid, r.SlotName, opencodeAgentCLI, c.S("id"), r.Body.S("modelId")) + } + out = Object{"resource": slotMappingView(slotMapping(t, uid, r.SlotName))} + case "DELETE": + // Existence is checked before the CAS: deleting a missing row is 404 whatever the body + // claims its version was. + old := slotMapping(t, uid, r.SlotName) + require(old != nil, 404, "not_found") + require(old.N("version") == r.Body.N("version"), 409, "version_conflict") + // The row is hard-deleted, so the response carries the view captured before the delete. + deleted := slotMappingView(old) + t.exec("DELETE FROM executor_slot_mappings WHERE user_id=$1 AND slot_name=$2", uid, r.SlotName) + out = Object{"resource": deleted} + recordPersonalModelWrite(t, uid, r.Key, hash, out, status) + default: + reject(405, "method_not_allowed") + } + return out, status +} + +const slotMappingQuery = `SELECT m.slot_name, m.agent_cli, m.connection_id, m.model_id, m.version, m.created_at, m.updated_at, + c.name AS connection_name, c.enabled, c.credential_id, c.models, c.deleted_at AS connection_deleted_at + FROM executor_slot_mappings m JOIN personal_model_connections c ON c.id=m.connection_id AND c.user_id=m.user_id ` + +// slotMapping loads one owned mapping with its connection context; a foreign or unknown slot is nil. +func slotMapping(t *transaction, uid, slot string) Object { + return t.one(slotMappingQuery+"WHERE m.user_id=$1 AND m.slot_name=$2", uid, slot) +} + +// slotMappingView allowlists fields: the connection name and model label travel, but no service +// address, credential reference or key — the same surface rule as run bindings. `available` +// computes what the run-creation gate would conclude today, so a kept-but-broken mapping shows +// its reason instead of silently failing at run time. +func slotMappingView(m Object) Object { + o := Object{} + for _, k := range []string{"slotName", "agentCli", "connectionId", "modelId", "connectionName", "version", "createdAt", "updatedAt"} { + o[k] = m[k] + } + modelName := "" + available := true + reason := "" + c := Object{"models": m["models"]} + if m.S("connectionName") == "" || m["connectionDeletedAt"] != nil || !m.B("enabled") { + available, reason = false, "model_connection_unavailable" + } else if m.S("credentialId") == "" { + available, reason = false, "model_credential_required" + } else if model := findModel(c, m.S("modelId")); model == nil { + available, reason = false, "model_not_found" + } else { + modelName = model.S("name") + } + o["modelName"] = modelName + o["available"] = available + o["unavailableReason"] = reason + return o +} diff --git a/internal/core/model_slot_mappings_db_test.go b/internal/core/model_slot_mappings_db_test.go new file mode 100644 index 000000000..fbcdac80e --- /dev/null +++ b/internal/core/model_slot_mappings_db_test.go @@ -0,0 +1,141 @@ +package core + +// DB tests for the personal executor-slot mappings API (model_slot_mappings.go, specs decision +// 20261015-executor-slot-mappings D1): owner-only CRUD over `/api/v1/me/executor-slot-mappings`, +// driven through Store.Public — the real route, replay and CAS machinery. `default` is never a +// mapping (it IS the personal default model), and a mapping survives a broken connection with its +// unavailability computed in the view instead of the row vanishing. + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" +) + +// pathSlot extracts the :slot segment of a mappings path for the direct s.Public calls. +func pathSlot(path string) string { + if i := strings.LastIndex(path, "/"); i >= 0 { + return path[i+1:] + } + return "" +} + +// slotRequest drives one public API call against the mappings surface. SlotName is normally +// filled by the router from the path; calling s.Public directly must pass it explicitly. +func slotRequest(t *testing.T, s *Store, c *Claims, method, path, slot, key string, body Object) Object { + t.Helper() + o, _, err := s.Public(context.Background(), &PublicRequest{Method: method, Path: path, SlotName: slot, Key: key, Body: body, Identity: c}) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return o +} + +func TestExecutorSlotMappingsLifecycle(t *testing.T) { + s := commandStore(t) + owner, _ := modelUser(t, s, "slot-owner") + conn, _ := configuredModel(t, s, owner) + + // Create: version starts at 1, the view resolves the model label and reports availability. + body := Object{"agentCli": opencodeAgentCLI, "connectionId": conn.S("id"), "modelId": "vendor/model-v1", "version": int64(0)} + view := slotRequest(t, s, owner, "PUT", "/api/v1/me/executor-slot-mappings/strong", "strong", uuid.NewString(), body).O("resource") + if view.S("slotName") != "strong" || view.N("version") != 1 || view.S("modelName") != "Model V1" || !view.B("available") || view.S("connectionName") != "Personal Provider" { + t.Fatalf("created view = %v", view) + } + + // Update is a CAS upsert: the next write needs the current version, and bumps it. + body["version"] = int64(1) + body["modelId"] = "vendor/model-v1" + view = slotRequest(t, s, owner, "PUT", "/api/v1/me/executor-slot-mappings/strong", "strong", uuid.NewString(), body).O("resource") + if view.N("version") != 2 { + t.Fatalf("upsert version = %d; want 2", view.N("version")) + } + body["version"] = int64(1) + if _, _, err := s.Public(context.Background(), &PublicRequest{Method: "PUT", Path: "/api/v1/me/executor-slot-mappings/strong", SlotName: "strong", Body: body, Identity: owner}); err == nil || ErrorCode(err).Code != "version_conflict" { + t.Fatalf("stale version: err=%v; want version_conflict", err) + } + body["version"] = int64(2) + + // List shows the single mapping, owner-scoped. + list := slotRequest(t, s, owner, "GET", "/api/v1/me/executor-slot-mappings", "", "", nil) + items, ok := list["items"].([]Object) + if !ok || len(items) != 1 || items[0].S("slotName") != "strong" { + t.Fatalf("list = %v", list) + } + if other, _ := modelUser(t, s, "slot-other"); len(slotRequest(t, s, other, "GET", "/api/v1/me/executor-slot-mappings", "", "", nil)["items"].([]Object)) != 0 { + t.Fatal("cross-user list leak") + } + + // Writes are gated on the slot name, the agent CLI and the referenced connection's state. + for _, tc := range []struct { + path string + body Object + fault string + note string + }{ + {"/api/v1/me/executor-slot-mappings/default", Object{"agentCli": opencodeAgentCLI, "connectionId": conn.S("id"), "modelId": "vendor/model-v1", "version": int64(2)}, "invalid_slot_name", "default is the personal default model, never a mapping"}, + {"/api/v1/me/executor-slot-mappings/Bad", Object{"agentCli": opencodeAgentCLI, "connectionId": conn.S("id"), "modelId": "vendor/model-v1", "version": int64(2)}, "invalid_slot_name", "name pattern"}, + {"/api/v1/me/executor-slot-mappings/strong", Object{"agentCli": "other/cli", "connectionId": conn.S("id"), "modelId": "vendor/model-v1", "version": int64(2)}, "unsupported_agent_cli", "plugin identity"}, + {"/api/v1/me/executor-slot-mappings/strong", Object{"agentCli": opencodeAgentCLI, "connectionId": uuid.NewString(), "modelId": "vendor/model-v1", "version": int64(2)}, "not_found", "foreign connection"}, + {"/api/v1/me/executor-slot-mappings/strong", Object{"agentCli": opencodeAgentCLI, "connectionId": conn.S("id"), "modelId": "vendor/nope", "version": int64(2)}, "model_not_found", "unknown model"}, + } { + if _, _, err := s.Public(context.Background(), &PublicRequest{Method: "PUT", Path: tc.path, SlotName: pathSlot(tc.path), Body: tc.body, Identity: owner}); err == nil || ErrorCode(err).Code != tc.fault { + t.Fatalf("%s: err=%v; want %s", tc.note, err, tc.fault) + } + } + + // A disabled connection keeps the mapping but the view reports the break, and further writes + // against the disabled connection are refused (the gate would resolve nothing). + modelBodyPayload := modelBody("openai-completions") + modelBodyPayload["version"] = conn.N("version") + modelBodyPayload["enabled"] = false + modelRequest(t, s, owner, "PUT", "/api/v1/me/model-connections", conn.S("id"), "", modelBodyPayload) + view = slotRequest(t, s, owner, "GET", "/api/v1/me/executor-slot-mappings", "", "", nil)["items"].([]Object)[0] + if view.B("available") || view.S("unavailableReason") != "model_connection_unavailable" { + t.Fatalf("disabled connection view = %v", view) + } + if _, _, err := s.Public(context.Background(), &PublicRequest{Method: "PUT", Path: "/api/v1/me/executor-slot-mappings/strong", SlotName: "strong", Body: Object{"agentCli": opencodeAgentCLI, "connectionId": conn.S("id"), "modelId": "vendor/model-v1", "version": int64(2)}, Identity: owner}); err == nil || ErrorCode(err).Code != "model_connection_disabled" { + t.Fatalf("write against disabled connection: err=%v; want model_connection_disabled", err) + } +} + +func TestExecutorSlotMappingsDeleteCASAndReplay(t *testing.T) { + s := commandStore(t) + owner, _ := modelUser(t, s, "slot-del") + conn, _ := configuredModel(t, s, owner) + path := "/api/v1/me/executor-slot-mappings/fast" + body := Object{"agentCli": opencodeAgentCLI, "connectionId": conn.S("id"), "modelId": "vendor/model-v1", "version": int64(0)} + slotRequest(t, s, owner, "PUT", path, "fast", uuid.NewString(), body) + + // Missing version is a precondition failure, not a delete. A keyless delete is refused before + // the CAS is even read — replay safety comes first. + if _, _, err := s.Public(context.Background(), &PublicRequest{Method: "DELETE", Path: path, SlotName: "fast", Key: uuid.NewString(), Identity: owner}); err == nil || ErrorCode(err).Code != "version_required" { + t.Fatalf("no version: err=%v; want version_required", err) + } + if _, _, err := s.Public(context.Background(), &PublicRequest{Method: "DELETE", Path: path, SlotName: "fast", Key: uuid.NewString(), Body: Object{"version": int64(2)}, Identity: owner}); err == nil || ErrorCode(err).Code != "version_conflict" { + t.Fatalf("wrong version: err=%v; want version_conflict", err) + } + if _, _, err := s.Public(context.Background(), &PublicRequest{Method: "DELETE", Path: path, SlotName: "fast", Identity: owner}); err == nil || ErrorCode(err).Code != "idempotency_key_required" { + t.Fatalf("no key: err=%v; want idempotency_key_required", err) + } + + // The delete is CAS'd and replayed: the same key returns the captured row again; a fresh key + // over a deleted row is 404. + key := uuid.NewString() + deleted := slotRequest(t, s, owner, "DELETE", path, "fast", key, Object{"version": int64(1)}).O("resource") + if deleted.S("slotName") != "fast" { + t.Fatalf("deleted view = %v", deleted) + } + replayed := slotRequest(t, s, owner, "DELETE", path, "fast", key, Object{"version": int64(1)}).O("resource") + if replayed.S("slotName") != "fast" || replayed.N("version") != deleted.N("version") { + t.Fatalf("replay = %v; want the captured delete", replayed) + } + if _, _, err := s.Public(context.Background(), &PublicRequest{Method: "DELETE", Path: path, SlotName: "fast", Key: uuid.NewString(), Body: Object{"version": int64(1)}, Identity: owner}); err == nil || ErrorCode(err).Code != "not_found" { + t.Fatalf("fresh key over deleted row: err=%v; want not_found", err) + } + if len(slotRequest(t, s, owner, "GET", "/api/v1/me/executor-slot-mappings", "", "", nil)["items"].([]Object)) != 0 { + t.Fatal("mapping survived its delete") + } +} diff --git a/internal/core/public.go b/internal/core/public.go index 01a4c51d8..274c24979 100644 --- a/internal/core/public.go +++ b/internal/core/public.go @@ -21,10 +21,12 @@ type PublicRequest struct { ModelConnectionID string // Session filters are independent of resource IDs used by public route dispatch. SessionProjectID, SessionStatus, SessionAgent, SessionFrom, SessionTo string - Limit int - Body Object - Identity *Claims - Person *DirectoryPerson + // SlotName addresses one executor-slot mapping under /api/v1/me/executor-slot-mappings/:slot. + SlotName string + Limit int + Body Object + Identity *Claims + Person *DirectoryPerson } // Public executes one authorized public request in a short database transaction. @@ -56,6 +58,11 @@ func (s *Store) Public(ctx context.Context, r *PublicRequest) (Object, int, erro out, status = personalModelsPublic(t, r, uid) return out } + if strings.HasPrefix(r.Path, "/api/v1/me/executor-slot-mappings") { + var out Object + out, status = personalSlotMappingsPublic(t, r, uid) + return out + } if r.Path == "/api/v1/me/git-identity" { switch r.Method { case "PUT": diff --git a/internal/core/workflow_admission.go b/internal/core/workflow_admission.go index d778ac33b..e9b03de2d 100644 --- a/internal/core/workflow_admission.go +++ b/internal/core/workflow_admission.go @@ -79,12 +79,10 @@ func requireWorkflowRunInput(graph, input Object) { // some slot the frozen graph references. // // The mapping is personal (model-access decision D1): `default` is the user's personal default model -// and is not stored twice, and a named slot is looked up in the user's own slot mappings. There is no -// fallback of any kind — not to `default`, not to the author's `hint`, not to another user's -// configuration — because a node the author declared as needing a strong model running quietly on a -// weak one is the failure this gate exists to prevent. A named slot is therefore reported as -// unconfigured today: personal slot mappings are not implemented yet, and reporting them as -// configurable-but-missing is honest, whereas resolving them to something would not be. +// and is not stored twice, and a named slot is looked up in the user's own slot mappings +// (resolveWorkflowSlotExecutor). There is no fallback of any kind — not to `default`, not to the +// author's `hint`, not to another user's configuration — because a node the author declared as needing +// a strong model running quietly on a weak one is the failure this gate exists to prevent. // // Every gap is collected before refusing, because the point of the refusal is that the user can fix // them: the form shows each slot's description and the nodes that depend on it, and fixing them one @@ -96,12 +94,9 @@ func requireWorkflowExecutorSlots(t *transaction, graph Object, uid string) { } gaps := []any{} for _, reference := range workflowReferencedSlots(workflowNodes(graph)) { - reason := slotReasonNotConfigured - if reference.Name == workflowDefaultSlot { - // defaultExecutorGap is the same policy the OpenCode run binding applies, so "the default - // model is usable" cannot mean two different things in two places. - reason, _, _ = defaultExecutorGap(t, uid) - } + // One resolution for both halves of the gate: the same call the freezing step makes, so a + // slot cannot be admitted by one policy and frozen by another. + reason, _, _ := resolveWorkflowSlotExecutor(t, uid, reference.Name) if reason == "" { continue } diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 3861d1f47..900d6cda5 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -390,11 +390,10 @@ func validateWorkflowSlots(graph Object, nodes []any) []workflowGraphProblem { // and `modelId`, when present, are non-empty strings. A hint never resolves an executor, so it is // checked only far enough to keep the document well formed for the runner that reads it. func validSlotHint(hint any) bool { - o, ok := hint.(map[string]any) + h, ok := asWorkflowObject(hint) if !ok { return false } - h := Object(o) for _, key := range []string{"agentCli", "modelId"} { value, present := h[key] if !present || value == nil { @@ -415,8 +414,8 @@ func workflowSlotDeclarations(graph Object) []Object { declared, _ := graph["executorSlots"].([]any) out := []Object{} for _, item := range declared { - if slot, ok := item.(map[string]any); ok { - out = append(out, Object(slot)) + if slot, ok := asWorkflowObject(item); ok { + out = append(out, slot) } } return out @@ -443,11 +442,10 @@ func workflowReferencedSlots(nodes []any) []workflowSlotReference { index := map[string]int{} out := []workflowSlotReference{} for _, raw := range nodes { - node, ok := raw.(map[string]any) + o, ok := asWorkflowObject(raw) if !ok { continue } - o := Object(node) data := o.O("data") if data.S("kind") != "agent" { continue diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index 4dbc26bd5..64b4acef7 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -67,9 +67,30 @@ func composeWorkflowRun(t *transaction, run Object) Object { } summary["workflowName"] = workflowName summary["definitionSnapshot"] = graph + // The frozen per-slot bindings (mappings decision D3: the run uses these forever). Detail + // read only — the history list stays a plain projection of the run row, like definitionSnapshot. + summary["modelBindings"] = workflowRunBindings(t, run.S("id")) return summary } +// workflowRunBindings projects a run's frozen per-slot model bindings, ordered by slot name so +// the detail page reads deterministically. `revoked` is the only mutable part: the binding row +// itself (connection name, model, protocol) is the frozen truth the session will run against. +func workflowRunBindings(t *transaction, runID string) []any { + rows := t.list("SELECT slot_name, connection_name, model, revoked_at FROM run_model_bindings WHERE workflow_run_id=$1 ORDER BY slot_name", runID) + out := make([]any, 0, len(rows)) + for _, r := range rows { + out = append(out, Object{ + "slot": r.S("slotName"), + "connectionName": r.S("connectionName"), + "modelId": r.O("model").S("id"), + "modelName": r.O("model").S("name"), + "revoked": r["revokedAt"] != nil, + }) + } + return out +} + // createWorkflowRun records one execution against one frozen snapshot. Cloud has no // engine, so the row starts `pending`; a wired Store.WorkflowRunSimulator (dev fixtures // only) fills the trace and advances the status inside the same transaction — a run is @@ -105,6 +126,13 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { if t.simulator != nil { nodeStates, rounds, status := t.simulator.SimulateWorkflowRun(s.O("graph"), input) t.exec("UPDATE workflow_runs SET node_states=$1,rounds=$2,status=$3,started_at=now(),finished_at=now(),version=version+1,updated_at=now() WHERE id=$4", jsonText(nodeStates), jsonText(rounds), status, id) + } else { + // The run-creation gate (mappings decision D2): every slot the snapshot references must + // resolve against the invoker's personal configuration before the run exists, and each + // resolution freezes one binding. Same transaction as the run INSERT — any gap rolls back + // the whole creation, so no run ever survives with an unresolved slot. A simulated run + // (dev fixtures) filled its whole trace above and never executes, so it freezes nothing. + freezeWorkflowRunExecutors(t, id, createdBy, s.O("graph")) } return composeWorkflowRun(t, t.one("SELECT * FROM workflow_runs WHERE id=$1 AND tenant_id=$2", id, r.TenantID)) } diff --git a/internal/core/workflow_run_gate.go b/internal/core/workflow_run_gate.go new file mode 100644 index 000000000..6414e028d --- /dev/null +++ b/internal/core/workflow_run_gate.go @@ -0,0 +1,87 @@ +package core + +// The WorkflowRun creation gate's freezing half (specs decision 20261015-executor-slot-mappings +// D2/D3). Admission (workflow_admission.go) is the half that refuses: it proves the invoker can +// supply an executor for every slot the frozen graph references, and collects every gap before +// saying so. This file is the half that records: each slot the gate admitted is frozen onto the run +// as one run_model_bindings row, inside the run's own creation transaction. +// +// Freezing is what makes a run reproducible. A mapping edited, a connection disabled or a model +// withdrawn after creation cannot change what an already-created run executes, because the session +// starter reads the frozen row rather than resolving the slot again. The plugin version is +// deliberately NOT frozen here — it is read at session start from the run project's space, exactly +// like an IssueRun's. +// +// Resolving a slot is one function (resolveWorkflowSlotExecutor), called by both halves in the same +// transaction, so a slot cannot be admitted by one policy and frozen by another. + +// freezeWorkflowRunExecutors freezes one binding per slot the frozen graph references, including the +// implicit `default`. It runs after admission in the same transaction, so every slot it resolves has +// already been proved resolvable and no fallback to another slot, to the author's hint or to the +// default model ever applies. +func freezeWorkflowRunExecutors(t *transaction, runID, uid string, graph Object) { + for _, reference := range workflowReferencedSlots(workflowNodes(graph)) { + _, connection, model := resolveWorkflowSlotExecutor(t, uid, reference.Name) + b := bindingRow(connection, model) + t.exec(`INSERT INTO run_model_bindings(id,workflow_run_id,slot_name,user_id,connection_id,connection_version,credential_id,connection_name,protocol,base_url,auth_mode,model) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, b.S("id"), runID, reference.Name, uid, b.S("connectionId"), b.N("connectionVersion"), b.S("credentialId"), b.S("connectionName"), b.S("protocol"), b.S("baseUrl"), b.S("authMode"), jsonText(b.O("model"))) + } +} + +// resolveWorkflowSlotExecutor resolves one executor slot against the invoker's personal +// configuration (model-access decision D1): `default` is the personal default model, a named slot is +// the personal mapping. It returns the reason naming the gap, or the connection and the model +// definition the caller freezes. +// +// A named slot with no mapping is `slot_not_configured` — the one gap the user fixes by writing a +// mapping — while a mapped executor that has since broken reports which part broke, because "you +// never configured this" and "the connection you configured was disabled" are different things to do +// about. A named slot is never resolved through the default model: a node the author declared as +// needing a strong model running quietly on a weak one is the failure the gate exists to prevent. +func resolveWorkflowSlotExecutor(t *transaction, uid, name string) (reason string, connection, model Object) { + if name == workflowDefaultSlot { + return defaultExecutorGap(t, uid) + } + m := t.one("SELECT connection_id, model_id FROM executor_slot_mappings WHERE user_id=$1 AND slot_name=$2", uid, name) + if m == nil { + return slotReasonNotConfigured, nil, nil + } + return personalExecutorGap(t, uid, m.S("connectionId"), m.S("modelId")) +} + +// personalExecutorGap reports why one (connection, model) pair out of the user's own configuration +// cannot be used, or returns an empty reason when it can. The personal default model is the same pair +// read through it (defaultExecutorGap), so the default slot and a mapped slot are judged by exactly +// the same rules: the connection must still be the user's, enabled and undeleted, it must still carry +// a credential, and the chosen model must still be one the connection offers. +func personalExecutorGap(t *transaction, uid, connectionID, modelID string) (reason string, connection, model Object) { + c := t.one("SELECT * FROM personal_model_connections WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL AND enabled", connectionID, uid) + if c == nil { + return slotReasonConnectionDown, nil, nil + } + if c.S("credentialId") == "" { + return slotReasonCredentialMissing, c, nil + } + m := findModel(c, modelID) + if m == nil { + return slotReasonModelUnavailable, c, nil + } + return "", c, m +} + +// bindingRow shapes one frozen binding's columns from a resolved connection + model. The connection's +// facts are copied rather than referenced: the binding is the record of what the run was authorized +// against, so editing the connection afterwards cannot rewrite it. +func bindingRow(c, model Object) Object { + return Object{ + "id": newID(), + "connectionId": c.S("id"), + "connectionVersion": c.N("version"), + "credentialId": c.S("credentialId"), + "connectionName": c.S("name"), + "protocol": c.S("protocol"), + "baseUrl": c.S("baseUrl"), + "authMode": c.S("authMode"), + "model": model, + } +} diff --git a/internal/core/workflow_run_gate_db_test.go b/internal/core/workflow_run_gate_db_test.go new file mode 100644 index 000000000..3c66dc4e0 --- /dev/null +++ b/internal/core/workflow_run_gate_db_test.go @@ -0,0 +1,173 @@ +package core + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// gateGraph is a snapshot graph referencing default and strong via two agent nodes. +func gateGraph() Object { + g := wfGraph( + []Object{ + wfNode("s", "start", ""), + wfAgentNode("a1", Object{"prompt": "explore", "executorSlot": "default"}), + wfAgentNode("a2", Object{"prompt": "implement", "executorSlot": "strong"}), + wfNode("o", "output", ""), + }, + [][3]string{{"s", "a1", ""}, {"a1", "a2", ""}, {"a2", "o", ""}}, + ) + g["executorSlots"] = []any{wfSlot("strong", "Heavy lifting")} + return g +} + +// seedSlotGateScene loads a user (via the real /me provisioning), a tenant with membership, a +// workflow whose newest snapshot is gateGraph, plus -- when withExecutor is true -- a configured +// connection (credential + model), the personal default and a strong-slot mapping. +func seedSlotGateScene(t *testing.T, store *Store, withExecutor bool) (tid, wid, uid string) { + t.Helper() + _, uid = modelUser(t, store, "gate-user") + tid, wid, sid, rid := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + graph := jsonText(gateGraph()) + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + exec := func(q string, args ...any) { + tx.exec(q, args...) + } + exec(`INSERT INTO tenants(id,name,status) VALUES($1,'gate','active')`, tid) + exec(`INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')`, tid, uid) + exec(`INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'gate',$3::jsonb)`, wid, tid, graph) + exec(`INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1',$4::jsonb)`, sid, tid, wid, graph) + exec(`INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by) VALUES($1,$2,$3,$4,'gate','pending','{}'::jsonb,$5)`, rid, tid, wid, sid, uid) + if withExecutor { + conn := uuid.NewString() + cred := uuid.NewString() + exec(`INSERT INTO personal_model_credentials(id,user_id,ciphertext,key_id) VALUES($1,$2,repeat('c',64),'test-key')`, cred, uid) + exec(`INSERT INTO personal_model_connections(id,user_id,credential_id,name,protocol,base_url,auth_mode,models,enabled) VALUES($1,$2,$4,'Personal Provider','openai-completions','https://models.example.invalid/v1','bearer',$3::jsonb,true)`, conn, uid, jsonText([]any{map[string]any{"id": "vendor/model-v1", "name": "Model V1", "contextWindow": int64(128000), "maxTokens": int64(8192)}}), cred) + exec(`INSERT INTO personal_model_defaults(user_id,connection_id,model_id,version) VALUES($1,$2,'vendor/model-v1',1)`, uid, conn) + exec(`INSERT INTO executor_slot_mappings(user_id,slot_name,agent_cli,connection_id,model_id,version) VALUES($1,'strong','official/ora-space.opencode',$2,'vendor/model-v1',1)`, uid, conn) + } + return Object{} + }); err != nil { + t.Fatalf("seed scene: %v", err) + } + return tid, wid, uid +} + +// gateAndFreeze is the two halves of the run-creation gate in the order createWorkflowRun runs them: +// admission resolves every referenced slot and refuses the start if one is a gap, then freezing +// records what admission admitted. The graph comes off the snapshot, so both halves read the +// jsonb-decoded document a real run reads rather than a hand-built one. +func gateAndFreeze(t *transaction, tid, wid, runID, uid string) { + s := admitWorkflowRun(t, tid, wid, "", uid) + freezeWorkflowRunExecutors(t, runID, uid, s.O("graph")) +} + +// With the executor configuration in place, the gate freezes one binding per referenced slot -- +// including the implicit default -- carrying the frozen connection facts. +func TestWorkflowRunGateFreezesBindings(t *testing.T) { + store := dispatcherDB(t) + tid, wid, uid := seedSlotGateScene(t, store, true) + runID := runIDOf(t, store, tid) + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + gateAndFreeze(tx, tid, wid, runID, uid) + return Object{} + }); err != nil { + t.Fatalf("gate: %v", err) + } + var total, defaults, owned int + if err := store.Pool.QueryRow(`SELECT count(*), count(*) FILTER (WHERE slot_name='default'), count(*) FILTER (WHERE workflow_run_id=$1) FROM run_model_bindings WHERE workflow_run_id=$1`, runID).Scan(&total, &defaults, &owned); err != nil { + t.Fatal(err) + } + if total != 2 || defaults != 1 || owned != 2 { + t.Fatalf("bindings total=%d default=%d owned=%d; want 2/1/2", total, defaults, owned) + } + var connectionName, modelID string + if err := store.Pool.QueryRow(`SELECT connection_name, model->>'id' FROM run_model_bindings WHERE workflow_run_id=$1 AND slot_name='strong'`, runID).Scan(&connectionName, &modelID); err != nil { + t.Fatal(err) + } + if connectionName != "Personal Provider" || modelID != "vendor/model-v1" { + t.Fatalf("strong binding = %q/%q", connectionName, modelID) + } +} + +// A scene with no executor configuration at all: admission refuses, every gap travels in the one +// structured fault (the slot, the nodes that depend on it and why it cannot be resolved), and the +// rollback leaves no frozen binding behind -- the run cannot survive with a slot it could not resolve. +func TestWorkflowRunGateRefusesUnresolvedSlots(t *testing.T) { + store := dispatcherDB(t) + tid, wid, uid := seedSlotGateScene(t, store, false) + runID := runIDOf(t, store, tid) + _, err := store.transact(context.Background(), func(tx *transaction) Object { + gateAndFreeze(tx, tid, wid, runID, uid) + return Object{} + }) + f := ErrorCode(err) + if f.Code != "workflow_executor_unconfigured" { + t.Fatalf("err=%v; want workflow_executor_unconfigured", err) + } + slots, _ := f.Params["slots"].([]any) + if len(slots) != 2 { + t.Fatalf("slots = %v; want default and strong", slots) + } + first, _ := asWorkflowObject(slots[0]) + second, _ := asWorkflowObject(slots[1]) + if first.S("slot") != "default" || first.S("reason") != slotReasonDefaultMissing { + t.Fatalf("default gap = %v", first) + } + // The unmapped named slot is the one gap a mapping fixes, and it carries the description the + // author wrote -- the only thing the runner has to decide what to map it to. + if second.S("slot") != "strong" || second.S("reason") != slotReasonNotConfigured || second.S("description") != "Heavy lifting" { + t.Fatalf("strong gap = %v", second) + } + if nodes, _ := second["nodeIds"].([]any); len(nodes) != 1 || nodes[0] != "a2" { + t.Fatalf("strong gap nodes = %v", second["nodeIds"]) + } + var bindings int + if err := store.Pool.QueryRow(`SELECT count(*) FROM run_model_bindings WHERE workflow_run_id=$1`, runID).Scan(&bindings); err != nil { + t.Fatal(err) + } + if bindings != 0 { + t.Fatalf("rollback left %d bindings", bindings) + } + + // Configure only the personal default: the one remaining gap is the unmapped named slot, and the + // default slot is no longer reported at all. + conn := uuid.NewString() + cred := uuid.NewString() + if _, err := store.Pool.Exec(`INSERT INTO personal_model_credentials(id,user_id,ciphertext,key_id) VALUES($1,$2,repeat('c',64),'test-key')`, cred, uid); err != nil { + t.Fatal(err) + } + if _, err := store.Pool.Exec(`INSERT INTO personal_model_connections(id,user_id,credential_id,name,protocol,base_url,auth_mode,models,enabled) VALUES($1,$2,$4,'Personal Provider','openai-completions','https://models.example.invalid/v1','bearer',$3::jsonb,true)`, conn, uid, jsonText([]any{map[string]any{"id": "vendor/model-v1", "name": "Model V1"}}), cred); err != nil { + t.Fatal(err) + } + if _, err := store.Pool.Exec(`INSERT INTO personal_model_defaults(user_id,connection_id,model_id,version) VALUES($1,$2,'vendor/model-v1',1)`, uid, conn); err != nil { + t.Fatal(err) + } + _, err = store.transact(context.Background(), func(tx *transaction) Object { + gateAndFreeze(tx, tid, wid, runID, uid) + return Object{} + }) + f = ErrorCode(err) + if f.Code != "workflow_executor_unconfigured" { + t.Fatalf("err=%v; want workflow_executor_unconfigured", err) + } + slots, _ = f.Params["slots"].([]any) + if len(slots) != 1 { + t.Fatalf("slots = %v; want exactly the strong slot", slots) + } + gap, _ := asWorkflowObject(slots[0]) + if gap.S("slot") != "strong" || gap.S("reason") != slotReasonNotConfigured { + t.Fatalf("gap = %v", gap) + } +} + +// runIDOf reads back the single run the scene created. +func runIDOf(t *testing.T, store *Store, tid string) string { + t.Helper() + var id string + if err := store.Pool.QueryRow(`SELECT id FROM workflow_runs WHERE tenant_id=$1`, tid).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go new file mode 100644 index 000000000..d00927037 --- /dev/null +++ b/internal/core/workflow_run_session.go @@ -0,0 +1,265 @@ +package core + +import ( + "fmt" + "time" +) + +// The WorkflowRun session machinery: the scheduler's agent-node execution path (specs decisions +// 0-cloud-side-scheduler D1 条件②/④ and 20261015-executor-slot-mappings D2/D3). Sessions +// serialize per run — dispatchRunExecution enforces one unregistered agent_session work item per +// run — so a session starts only when no node is committed running, and the pass holds until its +// terminal evidence commits. Startup is bounded by an honest deadline: a session that never +// dispatches within the deadline fails its node instead of hanging the run in `running`. +const workflowSessionStartDeadline = 15 * time.Minute + +// settleWorkflowSessionEnded accepts a WorkflowRun's session terminal event. A WorkflowRun has no +// Thread, no `phase` and no Revision delivery: its scheduler's next pass reads this very execution's +// result as the node's terminal evidence (workflowAgentSessionOutcome), which is why this +// settlement commits nothing. Its whole job is to prove the event belongs to this run's own session +// work — the same identity chain the IssueRun branch walks — so an honest terminal event is acked +// and a mis-addressed one still aborts the takeover. +func settleWorkflowSessionEnded(t *transaction, runID, executionID string, ended Object) error { + e := t.one("SELECT * FROM node_executions WHERE execution_id=$1", executionID) + if e == nil { + return fmt.Errorf("sessionEnded: execution %s is not registered", executionID) + } + if e.S("kind") != "agent_session" { + return fmt.Errorf("sessionEnded: execution %s is kind=%q, not agent_session", executionID, e.S("kind")) + } + work := t.one("SELECT * FROM execution_work WHERE id=$1", e.S("workId")) + if work == nil || work.S("workflowRunId") != runID || work.S("kind") != "agent_session" { + return fmt.Errorf("sessionEnded: execution %s does not belong to workflow run %s agent_session work", executionID, runID) + } + if ended.S("outcome") != agentSessionEndedOutcome { + return fmt.Errorf("sessionEnded: run %s execution %s carries outcome %q, not %s", runID, executionID, ended.S("outcome"), agentSessionEndedOutcome) + } + if !sessionEndReasons[ended.S("reason")] { + return fmt.Errorf("sessionEnded: run %s execution %s carries end reason %q, outside the closed set", runID, executionID, ended.S("reason")) + } + return nil +} + +// workflowRunningAgentNode returns the id of the node committed `running`, if any. +func workflowRunningAgentNode(states map[string]workflowNodeState) (string, bool) { + for id, state := range states { + if state.Status == "running" { + return id, true + } + } + return "", false +} + +// workflowAgentSessionOutcome settles a committed running agent node. Terminal evidence (the +// session execution's result) commits the node's terminal state; a dispatched-but-unreported +// session is held (the Node owns it); a session that never dispatched fails the node once the +// startup deadline passes. The run advances on the next tick after any commit. +func workflowAgentSessionOutcome(t *transaction, runID string, version int64, nodeID string, states map[string]workflowNodeState) (map[string]workflowNodeState, bool) { + e := t.one(`SELECT result FROM node_executions + WHERE operation_id=$1 AND kind='agent_session' AND result IS NOT NULL + ORDER BY created_at DESC LIMIT 1`, runID) + if e != nil { + return commitWorkflowAgentTerminal(t, runID, version, nodeID, states, e.O("result")) + } + // Dispatched but not yet terminal: the Node owns the session until it reports. + if t.one("SELECT 1 FROM node_executions WHERE operation_id=$1 AND kind='agent_session' LIMIT 1", runID) != nil { + return states, false + } + // Never dispatched: the startup deadline applies. A node committed running with no registered + // execution past the never dispatched fails honestly — never recorded as succeeded (invariant 5). + state := states[nodeID] + started, err := time.Parse(time.RFC3339, state.StartedAt) + if err == nil && time.Since(started) > workflowSessionStartDeadline { + state.Status = "failed" + state.FinishedAt = nowRFC3339() + state.Output = Object{"reason": "session_start_deadline_exceeded"} + states[nodeID] = state + finishWorkflowRun(t, runID, version, "failed", "session start deadline exceeded", states) + } + return states, false +} + +// workflowSessionEndReason maps a session execution's terminal reason onto the node/run +// vocabulary. agent_failed and interrupted fail the node; cancelled cancels it (and the run); +// idle_timeout and user_ended are natural session ends — the work completed its conversation — +// so they succeed the node (mappings decision D3: the session end reason is the evidence). +func workflowSessionEndReason(reason string) string { + switch reason { + case "agent_failed", "interrupted": + return "failed" + case "cancelled": + return "cancelled" + default: + return "succeeded" + } +} + +// commitWorkflowAgentTerminal commits a running agent node's terminal state from the session +// execution's result document. A failed or cancelled node finishes the run (the wave resolves +// downstream casualties on the next pass); a succeeded node commits and reports that the run can +// continue advancing in the same pass — the next wave executes downstream of it. +func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, nodeID string, states map[string]workflowNodeState, result Object) (map[string]workflowNodeState, bool) { + state := states[nodeID] + state.Status = workflowSessionEndReason(result.S("reason")) + state.FinishedAt = nowRFC3339() + state.Output = Object{"reason": result.S("reason")} + states[nodeID] = state + switch state.Status { + case "failed": + finishWorkflowRun(t, runID, version, "failed", result.S("reason"), states) + return states, false + case "cancelled": + finishWorkflowRun(t, runID, version, "cancelled", result.S("reason"), states) + return states, false + default: + // The node succeeded; downstream waves continue in this same pass. + settleWorkflowRunStates(t, runID, version, "running", "", states) + return states, true + } +} + +// finishWorkflowRun settles the run terminal and does the terminal cleanup: unregistered +// execution work is deleted so claimWorkItem never serves a session of a finished run, and the +// run Workspace's deletion is declared (the operation machinery drains it asynchronously). +// Declaring delete on a busy Project returns busy — a later pass re-declares, so a terminal run +// is never left with a live Workspace forever. +func finishWorkflowRun(t *transaction, runID string, version int64, status, runErr string, states map[string]workflowNodeState) { + settleWorkflowRunStates(t, runID, version, status, runErr, states) + finishWorkflowRunCleanup(t, runID) +} + +// finishWorkflowRunCleanup deletes the run's unregistered execution work and re-declares the +// run Workspace's deletion for a terminal run. Safe to call on any terminal run in any pass. +func finishWorkflowRunCleanup(t *transaction, runID string) { + t.exec("DELETE FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL", runID) + if w := t.one("SELECT * FROM workspaces WHERE workflow_run_id=$1", runID); w != nil { + // Re-declaring an already-declared delete is a no-op; a busy Project leaves the + // declaration to a later pass — a terminal run's Workspace is never left live forever. + deleteWorkflowRunWorkspace(t, runID) + } +} + +// workflowSessionTarget resolves the Workspace's current connected Node the way currentNode does, +// but returns nil instead of refusing: the scheduler waits for admission rather than aborting. +func workflowSessionTarget(t *transaction, wid string) Object { + w := t.one("SELECT * FROM workspaces WHERE id=$1", wid) + if w.S("desiredState") != "running" { + return nil + } + return t.one(`SELECT n.* FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id + WHERE s.workspace_id=$1 AND s.generation=$2 AND s.terminated_at IS NULL AND n.ended_at IS NULL + AND n.initialized AND n.connection_state='connected' AND n.last_seen_at>clock_timestamp()-interval '30 seconds'`, wid, w.N("runtimeGeneration")) +} + +// workflowSessionSpec builds the AgentSession snapshot for a workflow node's session — the same +// wire shape sessionStartSpec produces for an IssueRun. The plugin identity is fixed (slots +// decision D4); the version is read at start from the run project's space_plugins, matching how +// an IssueRun resolves its plugin. The initial turn is the node's prompt rendered as one text +// block; the model binding is the frozen binding of the node's slot. A permanent gap returns a +// named reason instead of an error so the node fails honestly rather than retrying forever. +func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w, target Object) (spec Object, reason string) { + runID := run.S("id") + pv := t.one(`SELECT p.desired_version FROM projects pr JOIN space_plugins p ON p.space_id=pr.space_id + AND p.source_namespace=split_part($3,'/',1) AND p.identifier=split_part($3,'/',2) + WHERE pr.id=$1 AND pr.tenant_id=$2 AND pr.deleted_at IS NULL`, run.S("projectId"), run.S("tenantId"), opencodeAgentCLI) + if pv == nil || pv.S("desiredVersion") == "" { + return nil, "agent_plugin_unavailable" + } + base := w.S("baseCommitId") + if !commitID(base) { + return nil, "workspace_unavailable" + } + checkout := t.one(`SELECT execution_id FROM clone_executions + WHERE workspace_id=$1 AND result->>'outcome'='clone_ready' AND result->>'commit'=$2 + ORDER BY created_at DESC, execution_id DESC LIMIT 1`, w.S("id"), base) + if checkout == nil { + return nil, "workspace_unavailable" + } + git := t.one("SELECT name,email FROM user_git_identities WHERE user_id=$1", run.S("createdBy")) + if git == nil { + var err error + git, err = defaultGitIdentity(t, run.S("createdBy")) + if err != nil { + panic(databaseFailure{err}) + } + } + prompt := node.Data.O("agentConfig").S("prompt") + if prompt == "" { + prompt = "[no task statement]" + } + spec = Object{ + "kind": "agent_session", + "agentPluginId": opencodeAgentCLI, + "agentPluginVersion": pv.S("desiredVersion"), + "checkoutExecutionId": checkout.S("executionId"), + "gitIdentity": git, + "initialTurn": Object{"turnId": newID(), "content": []Object{{"text": prompt}}}, + } + slot := node.Data.O("agentConfig").S("executorSlot") + if slot == "" { + slot = "default" + } + if binding := t.one("SELECT id FROM run_model_bindings WHERE workflow_run_id=$1 AND slot_name=$2", runID, slot); binding != nil { + spec["modelBindingId"] = binding.S("id") + } + return spec, "" +} + +// startWorkflowAgentSession wires one ready agent node into real execution: it provisions (or +// waits for) the run Workspace, waits for a connected Node, builds the session spec from the +// frozen per-slot bindings, commits the node `running` under the run's version CAS, and releases +// the session work item. Anything not yet true returns without writing — the next tick retries; +// anything permanently false fails the node honestly and finishes the run. +func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView, version int64, states map[string]workflowNodeState) { + runID := run.S("id") + if run.S("projectId") == "" { + failWorkflowAgentNode(t, runID, version, states, node, "workflow_run_project_required") + return + } + w := t.one("SELECT * FROM workspaces WHERE workflow_run_id=$1", runID) + if w == nil { + lc := createWorkflowRunWorkspace(t, runID) + if lc.B("busy") { + return + } + w = lc.O("workspace") + } + // Provisioning waits; a failed create operation fails the node — the workspace will never + // become ready, and the run must not hang. + if w.S("observedState") != "ready" || !w.B("admissionOpen") { + op := t.one(`SELECT state FROM operations WHERE workspace_id=$1 AND kind='create_workspace' + ORDER BY created_at DESC, id DESC LIMIT 1`, w.S("id")) + if op != nil && op.S("state") == "failed" { + failWorkflowAgentNode(t, runID, version, states, node, "workspace_unavailable") + } + return + } + target := workflowSessionTarget(t, w.S("id")) + if target == nil { + return // no connected Node yet; the startup deadline eventually fails the node + } + spec, reason := workflowSessionSpec(t, run, node, w, target) + if reason != "" { + failWorkflowAgentNode(t, runID, version, states, node, reason) + return + } + states[node.ID] = workflowNodeState{Status: "running", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339()} + settleWorkflowRunStates(t, runID, version, "running", "", states) + enqueueExecutionWork(t, runID, "agent_session", spec, target, time.Time{}) +} + +// failWorkflowAgentNode commits one agent node failed with a named reason and finishes the run. +func failWorkflowAgentNode(t *transaction, runID string, version int64, states map[string]workflowNodeState, node workflowNodeView, reason string) { + states[node.ID] = workflowNodeState{Status: "failed", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339(), FinishedAt: nowRFC3339(), Output: Object{"reason": reason}} + finishWorkflowRun(t, runID, version, "failed", reason, states) +} + +// workflowAnyCancelled reports whether any committed node was cancelled. +func workflowAnyCancelled(states map[string]workflowNodeState) bool { + for _, state := range states { + if state.Status == "cancelled" { + return true + } + } + return false +} diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go new file mode 100644 index 000000000..9df1e1f08 --- /dev/null +++ b/internal/core/workflow_run_session_db_test.go @@ -0,0 +1,313 @@ +package core + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" +) + +// workflowSessionGraph is a linear start -> agent -> output graph; the agent carries the prompt +// and the implicit default slot. +func workflowSessionGraph() Object { + g := wfGraph( + []Object{wfNode("s", "start", ""), wfAgentNode("a", Object{"prompt": "Do the thing"}), wfNode("o", "output", "")}, + [][3]string{{"s", "a", ""}, {"a", "o", ""}}, + ) + return g +} + +// seedWorkflowSessionScene stages a project-backed run one advance away from session start: the +// binding (the same freeze the creation gate performs), and the project space carries the +// production plugin. Returns the ids the tests read back. +func seedWorkflowSessionScene(t *testing.T, store *Store) (runID, tid, uid, space, project string) { + t.Helper() + _, uid = modelUser(t, store, "wf-session") + tid, wid, sid, rid := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + space, project = uuid.NewString(), uuid.NewString() + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + exec := func(name, q string, args ...any) { + tx.exec(q, args...) + } + exec("tenant", `INSERT INTO tenants(id,name,status) VALUES($1,'wfs','active')`, tid) + exec("memb", `INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')`, tid, uid) + exec("space", `INSERT INTO collab_workspaces(id,tenant_id,name,slug,created_by) VALUES($1,$2,'wfs','wfs-'||left($4::text,8),$3)`, space, tid, uid, project) + exec("project", `INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,$4,'wfs','https://example.invalid/wfs.git','main','active')`, project, tid, uid, space) + exec("main", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref) VALUES($1,$2,$3,$3,'verified_request',$4,'main','running','ready',1,'HEAD')`, uuid.NewString(), tid, uid, project) + exec("plugin", `INSERT INTO space_plugins(space_id,tenant_id,source_namespace,identifier,desired_state,desired_version,observed_state) VALUES($1,$2,'official','ora-space.opencode','installed','1.0.0','installed')`, space, tid) + graph, _ := json.Marshal(workflowSessionGraph()) + exec("workflow", `INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'wfs',$3::jsonb)`, wid, tid, string(graph)) + exec("snapshot", `INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1',$4::jsonb)`, sid, tid, wid, string(graph)) + exec("run", `INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) VALUES($1,$2,$3,$4,'disp','pending','{}'::jsonb,$5,$6)`, rid, tid, wid, sid, uid, project) + cred := uuid.NewString() + conn := uuid.NewString() + exec("credential", `INSERT INTO personal_model_credentials(id,user_id,ciphertext,key_id) VALUES($1,$2,repeat('c',64),'test-key')`, cred, uid) + exec("connection", `INSERT INTO personal_model_connections(id,user_id,credential_id,name,protocol,base_url,auth_mode,models,enabled) VALUES($1,$2,$3,'Personal Provider','openai-completions','https://models.example.invalid/v1','bearer',$4::jsonb,true)`, conn, uid, cred, jsonText([]any{map[string]any{"id": "vendor/model-v1", "name": "Model V1"}})) + exec("default", `INSERT INTO personal_model_defaults(user_id,connection_id,model_id,version) VALUES($1,$2,'vendor/model-v1',1)`, uid, conn) + freezeWorkflowRunExecutors(tx, rid, uid, workflowSessionGraph()) + return Object{} + }); err != nil { + t.Fatalf("seed scene: %v", err) + } + return rid, tid, uid, space, project +} + +// settleSessionWorkspace completes the run Workspace's create operation the way the control plane +// does — the operation settles, admission opens with a recorded baseline and checkout — and returns +// the Workspace id. +func settleSessionWorkspace(t *testing.T, store *Store, runID string) string { + t.Helper() + var wsID, createOp string + if err := store.Pool.QueryRow(`SELECT w.id, o.id FROM workspaces w JOIN operations o ON o.workspace_id=w.id AND o.kind='create_workspace' WHERE w.workflow_run_id=$1`, runID).Scan(&wsID, &createOp); err != nil { + t.Fatalf("read provisioned workspace: %v", err) + } + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + settleCreateWorkspaceOperation(t, tx, createOp) + runWorkspaceSettled(tx, runID, "ready") + tx.exec(`UPDATE workspaces SET observed_state='ready', admission_open=true, base_commit_id=$2, version=version+1 WHERE id=$1`, wsID, baseCommit) + tx.exec(`INSERT INTO clone_executions(execution_id,operation_id,node_id,node_operation_id,workspace_id,input,result,dispatched_epoch) VALUES($1,$2,'node-checkout','op-checkout',$3,'{"kind":"clone"}',jsonb_build_object('outcome','clone_ready','commit',$4::text),1)`, "exec-checkout-"+wsID[:8], createOp, wsID, baseCommit) + return Object{} + }); err != nil { + t.Fatalf("settle create: %v", err) + } + return wsID +} + +// startSessionNode connects a Node to the settled Workspace, advances until the agent node is +// committed running, and returns the Node's service id and the released session spec. +func startSessionNode(t *testing.T, store *Store, runID, wsID string) (string, Object) { + t.Helper() + _, nodeService := bindConnectedNode(t, store, wsID) + if _, err := store.Pool.Exec(`UPDATE node_instances SET model_proxy=true WHERE workspace_id=$1`, wsID); err != nil { + t.Fatal(err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance to session start: %v", err) + } + status, _, states, _ := readWorkflowRun(t, store, runID) + agent, _ := asWorkflowObject(states["a"]) + if status != "running" || agent == nil || agent.S("status") != "running" { + t.Fatalf("after session start: %q states=%v; want agent a running", status, states) + } + var rawSpec []byte + if err := store.Pool.QueryRow(`SELECT input FROM execution_work WHERE workflow_run_id=$1 AND kind='agent_session'`, runID).Scan(&rawSpec); err != nil { + t.Fatalf("read session work: %v", err) + } + spec := Object{} + if err := json.Unmarshal(rawSpec, &spec); err != nil { + t.Fatal(err) + } + if spec.S("agentPluginId") != opencodeAgentCLI || spec.S("agentPluginVersion") != "1.0.0" || spec.S("modelBindingId") == "" { + t.Fatalf("spec = %v", spec) + } + if spec.O("initialTurn").S("turnId") == "" || spec.O("initialTurn")["content"] == nil || spec.S("checkoutExecutionId") != "exec-checkout-"+wsID[:8] { + t.Fatalf("spec turn/checkout = %v", spec) + } + if git := spec.O("gitIdentity"); git.S("name") == "" || git.S("email") == "" { + t.Fatalf("git identity = %v", git) + } + return nodeService, spec +} + +// dispatchSession dispatches the released session work from the Node and advances: the pass holds +// the committed running node until terminal evidence arrives. +func dispatchSession(t *testing.T, store *Store, runID, nodeService string, spec Object) string { + t.Helper() + execution := "exec-" + runID[:8] + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + nodeDispatch(tx, &ControlRequest{Body: Object{"operationId": runID, "executionId": execution, "nodeId": nodeService, "input": spec, "epoch": int64(1)}}) + return Object{} + }); err != nil { + t.Fatalf("dispatch: %v", err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance after dispatch: %v", err) + } + _, _, held, _ := readWorkflowRun(t, store, runID) + h, _ := asWorkflowObject(held["a"]) + if h == nil || h.S("status") != "running" { + t.Fatalf("dispatched session held: %v", held) + } + return execution +} + +// reportSessionEnd reports the session's terminal event from the Node and runs the settling pass. +func reportSessionEnd(t *testing.T, store *Store, runID, execution, nodeService, reason string) { + t.Helper() + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + executionResult(tx, &ControlRequest{Body: Object{"operationId": runID, "executionId": execution, "result": Object{"outcome": "agent_session_ended", "reason": reason, "node": Object{"nodeId": nodeService}}, "sequence": int64(1), "event": "ended"}}, true) + return Object{} + }); err != nil { + t.Fatalf("report result: %v", err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance after result: %v", err) + } +} + +// The wired end to end: one advance provisions the run Workspace, the create settles, a +// connected Node releases the session work item, the Node dispatches and reports, and the run +// settles succeeded with its Workspace deletion declared. +func TestWorkflowSessionRunsEndToEnd(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + status, _, states, _ := readWorkflowRun(t, store, runID) + if status != "running" || len(states) != 0 { + t.Fatalf("after advance 1: status=%q states=%v; want running with no node states", status, states) + } + wsID := settleSessionWorkspace(t, store, runID) + // No connected Node yet: the pass holds without committing a node state. + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 2: %v", err) + } + if _, _, held, _ := readWorkflowRun(t, store, runID); len(held) != 0 { + t.Fatalf("advance 2 committed states %v without a connected node", held) + } + nodeService, spec := startSessionNode(t, store, runID, wsID) + execution := dispatchSession(t, store, runID, nodeService, spec) + // The Node reports a natural end; the run drains to succeeded and cleans up. + reportSessionEnd(t, store, runID, execution, nodeService, "idle_timeout") + var runErr string + status, runErr, states, _ = readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q states=%v; want succeeded", status, runErr, states) + } + agent, _ := asWorkflowObject(states["a"]) + outs, _ := asWorkflowObject(states["o"]) + if agent == nil || outs == nil || agent.S("status") != "succeeded" || outs.S("status") != "succeeded" { + t.Fatalf("node states = %v", states) + } + if reason := agent.O("output").S("reason"); reason != "idle_timeout" { + t.Fatalf("agent output reason = %q; want idle_timeout", reason) + } + // Terminal cleanup: no unregistered work, and the Workspace deletion is declared. + var unregistered int + if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL`, runID).Scan(&unregistered); err != nil { + t.Fatal(err) + } + if unregistered != 0 { + t.Fatalf("%d unregistered work items survived", unregistered) + } + var deletes int + if err := store.Pool.QueryRow(`SELECT count(*) FROM operations o JOIN workspaces w ON w.id=o.workspace_id WHERE w.workflow_run_id=$1 AND o.kind='delete_workspace'`, runID).Scan(&deletes); err != nil { + t.Fatal(err) + } + if deletes != 1 { + t.Fatalf("%d delete operations declared; want 1", deletes) + } +} + +// A session that ends `agent_failed` fails its node and the run with that reason — the evidence +// decides, not a policy — and the terminal cleanup still runs. +func TestWorkflowSessionFailureFailsRun(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec := startSessionNode(t, store, runID, wsID) + execution := dispatchSession(t, store, runID, nodeService, spec) + reportSessionEnd(t, store, runID, execution, nodeService, "agent_failed") + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "agent_failed" { + t.Fatalf("run settled %q error %q states=%v; want failed/agent_failed", status, runErr, states) + } + agent, _ := asWorkflowObject(states["a"]) + if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "agent_failed" { + t.Fatalf("agent state = %v; want failed with agent_failed", states["a"]) + } + var unregistered int + if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL`, runID).Scan(&unregistered); err != nil { + t.Fatal(err) + } + if unregistered != 0 { + t.Fatalf("%d unregistered work items survived a failed run", unregistered) + } +} + +// A session that never dispatches fails honestly once the startup deadline passes: the node and the +// run carry the named reason instead of hanging in `running` forever. +func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + startSessionNode(t, store, runID, wsID) + // The Node never picks the work item up: the committed start ages past the deadline. + stale := time.Now().Add(-workflowSessionStartDeadline - time.Minute).UTC().Format(time.RFC3339) + if _, err := store.Pool.Exec(`UPDATE workflow_runs SET node_states=jsonb_set(node_states,'{a,startedAt}',to_jsonb($2::text)) WHERE id=$1`, runID, stale); err != nil { + t.Fatal(err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance past deadline: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "session start deadline exceeded" { + t.Fatalf("run settled %q error %q states=%v; want the deadline failure", status, runErr, states) + } + agent, _ := asWorkflowObject(states["a"]) + if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "session_start_deadline_exceeded" { + t.Fatalf("agent state = %v; want the deadline failure", states["a"]) + } + var unregistered int + if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL`, runID).Scan(&unregistered); err != nil { + t.Fatal(err) + } + if unregistered != 0 { + t.Fatalf("%d undispatched work items survived", unregistered) + } +} + +// The model grant's live binding resolves a WorkflowRun session through the workflow branch: the +// frozen slot binding, the dispatched session execution and the confirmed runtime binding authorize +// it, and an execution that is not this run's session is refused rather than resolved to some other +// run. +func TestWorkflowLiveModelBindingResolvesRun(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec := startSessionNode(t, store, runID, wsID) + execution := dispatchSession(t, store, runID, nodeService, spec) + // The runtime confirms the Node binding once the session is dispatched — the state the model + // gateway's certificate-bound lookups run against. + if _, err := store.Pool.Exec(`UPDATE runtime_controls SET binding_confirmed=true, + bound_sandbox_id=(SELECT sandbox_instance_id FROM node_instances WHERE workspace_id=$1 AND ended_at IS NULL) + WHERE workspace_id=$1`, wsID); err != nil { + t.Fatal(err) + } + var bindingID, tenant string + var generation int64 + if err := store.Pool.QueryRow(`SELECT id FROM run_model_bindings WHERE workflow_run_id=$1 AND slot_name='default'`, runID).Scan(&bindingID); err != nil { + t.Fatalf("read frozen binding: %v", err) + } + if err := store.Pool.QueryRow(`SELECT tenant_id, runtime_generation FROM workspaces WHERE id=$1`, wsID).Scan(&tenant, &generation); err != nil { + t.Fatal(err) + } + scope := ModelRuntimeScope{TenantID: tenant, WorkspaceID: wsID, Generation: generation} + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + b := liveModelBinding(tx, scope, bindingID, execution) + if b.S("runId") != runID || b.S("nodeId") != nodeService { + t.Fatalf("binding = %v; want run %s on node %s", b, runID, nodeService) + } + return Object{} + }); err != nil { + t.Fatalf("live binding: %v", err) + } + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + liveModelBinding(tx, scope, bindingID, uuid.NewString()) + return Object{} + }); err == nil || ErrorCode(err).Code != "model_grant_revoked" { + t.Fatalf("foreign execution: err=%v; want model_grant_revoked", err) + } +} diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 900e2f6fa..b2829ecf5 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -96,6 +96,46 @@ func (s *Store) AdvanceWorkflowRunsOnce(ctx context.Context) error { _ = err } } + return s.redeclareWorkflowRunWorkspaceDeletes(ctx) +} + +// redeclareWorkflowRunWorkspaceDeletes re-declares the Workspace deletion of every terminal run +// still holding a live Workspace. finishWorkflowRunCleanup declares delete in the pass that +// settles the run; a busy Project (another operation in flight) returns busy, so the declaration +// waits for a later pass — this second scan is the retry. Idempotent: deleteWorkflowRunWorkspace +// treats an already-declared or already-deleted Workspace as done. +func (s *Store) redeclareWorkflowRunWorkspaceDeletes(ctx context.Context) error { + rows, err := s.Pool.QueryContext(ctx, ` + SELECT r.id FROM workflow_runs r + JOIN workspaces w ON w.workflow_run_id=r.id + WHERE r.status IN ('failed','cancelled','succeeded') AND r.deleted_at IS NULL + AND w.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM operations o WHERE o.workspace_id=w.id AND o.kind='delete_workspace' + AND o.state IN ('queued','running','retry_wait','blocked','succeeded')) + ORDER BY r.created_at, r.id LIMIT $1`, workflowAdvanceBatchSize) + if err != nil { + return err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return err + } + for _, id := range ids { + if _, err := s.transact(ctx, func(t *transaction) Object { + finishWorkflowRunCleanup(t, id) + return Object{} + }); err != nil { + _ = err + } + } return nil } @@ -396,6 +436,19 @@ func advanceWorkflowRun(t *transaction, runID string) { return } states := workflowStatesFromJSON(run.O("nodeStates")) + // A committed running agent node settles from its session execution's terminal evidence before + // anything else runs: sessions serialize per run (one unregistered agent_session work item per + // run), and the next wave waits for the terminal commit. The pass returns after any outcome — + // a settled node lets the next tick execute its downstream wave. + if nodeID, running := workflowRunningAgentNode(states); running { + if _, cont := workflowAgentSessionOutcome(t, runID, version, nodeID, states); !cont { + return + } + // A settled session commits its terminal state and continues the pass; that commit bumped + // the row's version, so the wave it unblocks must CAS against the new one — a stale version + // would make the pass's final settle a silent no-op and leave the run in `running`. + version = workflowRunVersion(t, runID, version) + } for { projection := projectWorkflowNodes(graph, states) node, ready := projection.readyNode() @@ -403,18 +456,11 @@ func advanceWorkflowRun(t *transaction, runID string) { break } if node.Kind == "agent" { - // Phase-1 boundary: agent execution through the control plane is the next step of - // the same decision. Failing the run here is the honest terminal state — the node - // never ran, so it is never recorded as succeeded, and the run names the gap. - states[node.ID] = workflowNodeState{ - Status: "failed", - DisplayName: workflowNodeDisplayName(node), - StartedAt: nowRFC3339(), - FinishedAt: nowRFC3339(), - Output: Object{"reason": "agent_execution_not_wired"}, - } - settleWorkflowRunStates(t, runID, version, "failed", - "agent node "+node.ID+" is not wired to execution yet", states) + // The session path: provision the Workspace, wait for a connected Node, build the + // spec from the frozen per-slot bindings, commit running and release the work item. + // The pass returns either way — a started session commits running, and the next tick + // settles it from terminal evidence. + startWorkflowAgentSession(t, run, node, version, states) return } states[node.ID] = executeWorkflowNode(node, run.O("input"), states) @@ -426,10 +472,14 @@ func advanceWorkflowRun(t *transaction, runID string) { // succeeded otherwise; the failure check reads committed state, never the pass's own reasoning. states = workflowInactiveStates(graph, states) if projectWorkflowNodes(graph, states).hasFailure() { - settleWorkflowRunStates(t, runID, version, "failed", workflowRunFailureReason(graph, states), states) + finishWorkflowRun(t, runID, version, "failed", workflowRunFailureReason(graph, states), states) return } - settleWorkflowRunStates(t, runID, version, "succeeded", "", states) + if workflowAnyCancelled(states) { + finishWorkflowRun(t, runID, version, "cancelled", "", states) + return + } + finishWorkflowRun(t, runID, version, "succeeded", "", states) } // workflowInactiveStates adds a committed inactive state for every node the projection resolved as @@ -466,6 +516,17 @@ func settleWorkflowRunStates(t *transaction, runID string, version int64, status WHERE id=$1 AND version=$5 AND deleted_at IS NULL`, runID, jsonText(encoded), status, runErr, version) } +// workflowRunVersion re-reads the row's version after a mid-pass settle bumped it: one pass commits +// more than once (a settled session, then the wave it unblocks), and every later commit CASes +// against the version the earlier one left behind. A row that vanished keeps the caller's version, +// so the following settle fails its CAS and the pass writes nothing. +func workflowRunVersion(t *transaction, runID string, fallback int64) int64 { + if row := t.one("SELECT version FROM workflow_runs WHERE id=$1", runID); row != nil { + return row.N("version") + } + return fallback +} + // executeWorkflowNode commits one ready non-agent node's result from committed state. start reports // what the run was kicked off with; condition evaluates its cases against prior node outputs and // commits the selected branch (first match wins, else when nothing matched); output is a terminal diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 7defa254f..987542bdd 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -1,10 +1,10 @@ package core // DB tests for the Cloud-side workflow scheduler's advance pass (workflow_scheduler.go): a pending -// run is claimed and settled from the frozen snapshot's graph, agent nodes fail the run honestly -// until the execution wiring lands, and the pass is idempotent against already-advanced runs. Each -// test is a real isolated schema (dispatcherDB), and the graph reaches the pass the way production -// delivers it — jsonb round-tripped, so every document is plain map[string]any. +// run is claimed and settled from the frozen snapshot's graph, an agent node a run cannot execute +// fails it honestly, and the pass is idempotent against already-advanced runs. Each test is a real +// isolated schema (dispatcherDB), and the graph reaches the pass the way production delivers it — +// jsonb round-tripped, so every document is plain map[string]any. import ( "context" @@ -94,9 +94,10 @@ func TestWorkflowAdvanceSucceedsLinearRun(t *testing.T) { } } -// A ready agent node settles the run failed with an explicit reason: the node never ran, so it is -// never recorded as succeeded (invariant 5) and nothing downstream of it committed a state. -func TestWorkflowAdvanceFailsRunOnUnwiredAgentNode(t *testing.T) { +// A ready agent node on a run with no project settles the run failed with an explicit reason: the +// session has nowhere to run, so the node is never recorded as succeeded (invariant 5) and nothing +// downstream of it committed a state. +func TestWorkflowAdvanceFailsRunOnProjectlessAgentNode(t *testing.T) { store := dispatcherDB(t) runID := seedWorkflowRunScene(t, store, wfGraph( []Object{wfNode("s", "start", ""), wfNode("a", "agent", "worker"), wfNode("o", "output", "")}, @@ -110,12 +111,8 @@ func TestWorkflowAdvanceFailsRunOnUnwiredAgentNode(t *testing.T) { if status != "failed" { t.Fatalf("run settled %q; want failed", status) } - // The unwired-agent path is the one failure that writes a run error of its own rather than - // reusing the node's reason, and the point of it is that the run names the node. The assertion - // is the whole sentence: a substring check on the id would assert nothing here, because the node - // is named "a" and every non-empty string contains it. - if runErr != "agent node a is not wired to execution yet" { - t.Fatalf("run error %q; want it to name the unwired agent node", runErr) + if runErr != "workflow_run_project_required" { + t.Fatalf("run error %q; want workflow_run_project_required", runErr) } agent, ok := asWorkflowObject(states["a"]) if !ok { @@ -124,11 +121,11 @@ func TestWorkflowAdvanceFailsRunOnUnwiredAgentNode(t *testing.T) { if agent.S("status") != "failed" { t.Fatalf("agent node status = %q; want failed", agent.S("status")) } - if agent.O("output").S("reason") != "agent_execution_not_wired" { - t.Fatalf("agent node reason = %q; want agent_execution_not_wired", agent.O("output").S("reason")) + if agent.O("output").S("reason") != "workflow_run_project_required" { + t.Fatalf("agent node reason = %q; want workflow_run_project_required", agent.O("output").S("reason")) } if _, ran := states["o"]; ran { - t.Fatal("output node downstream of the unwired agent committed a state") + t.Fatal("output node downstream of the failed agent committed a state") } if _, ran := states["s"]; !ran { t.Fatal("start node before the agent did not commit its state") diff --git a/internal/core/workflow_scheduler_test.go b/internal/core/workflow_scheduler_test.go index 4f6a40f89..539f0c5d1 100644 --- a/internal/core/workflow_scheduler_test.go +++ b/internal/core/workflow_scheduler_test.go @@ -32,6 +32,18 @@ func wfGraph(nodes []Object, edges [][3]string) Object { return Object{"nodes": nodeList, "edges": edgeList} } +// wfSlot declares one executor slot for a graph under test. +func wfSlot(name, description string) Object { + return Object{"name": name, "description": description} +} + +// wfAgentNode is an agent node whose agentConfig the test authors directly. +func wfAgentNode(id string, config Object) Object { + node := wfNode(id, "agent", "") + node["data"].(Object)["agentConfig"] = config + return node +} + // A start→output graph runs to drained in two waves: start readies, then output, then nothing. func TestWorkflowProjectionLinearGraph(t *testing.T) { graph := readWorkflowGraph(wfGraph( From 893f7a64150567de9d050c12ae960d680a629a76 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 10:18:52 +0800 Subject: [PATCH 11/26] fix(core): settle each workflow agent node from its own session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run's agent nodes execute serially through the run's single Workspace (D4), so a run has as many agent sessions in its life as it has agent nodes. Two run-scoped identities assumed the opposite and together made every node after the first unreachable: * workflowAgentSessionOutcome resolved the running node's terminal evidence as "the newest agent_session result for this run", so the second node inherited the first node's result and was committed succeeded with the first node's reason without ever executing — a fabricated success, and a violation of invariant 5; * dispatchRunExecution refused any agent_session for a run that already had one, and 0025's one_session_per_run index refused it at the storage layer, so the second node's dispatch could never register. The identity chain graph node -> work item -> execution is now explicit: the scheduler records the graph node when it releases a node's session work, the dispatch copies it onto the execution it registers, and every evidence lookup is scoped to the node being settled. The lifetime uniqueness splits by owner at the granularity the design intends: an IssueRun still runs one session in its life, a WorkflowRun one per agent node. Duplicate dispatch stays bounded without the per-run check: a node's work item is consumed by its dispatch, a replayed execution is the same fact, and a second execution against a consumed work item is refused. TestWorkflowSessionDispatchIsNotRepeatable guards all three, including that a settled node's spec cannot be replayed onto the next node's released work. Co-Authored-By: Claude Code --- internal/core/agent_run_delivery.go | 2 +- internal/core/agent_run_session_settle.go | 2 +- internal/core/agent_run_session_start.go | 2 +- internal/core/execution_work.go | 24 +- .../0038_workflow_execution_node.sql | 39 +++ internal/core/node_execution.go | 14 +- internal/core/workflow_run_session.go | 22 +- internal/core/workflow_run_session_db_test.go | 266 ++++++++++++++++-- 8 files changed, 322 insertions(+), 49 deletions(-) create mode 100644 internal/core/migrations/0038_workflow_execution_node.sql diff --git a/internal/core/agent_run_delivery.go b/internal/core/agent_run_delivery.go index d0a492bd0..56d931a1a 100644 --- a/internal/core/agent_run_delivery.go +++ b/internal/core/agent_run_delivery.go @@ -187,6 +187,6 @@ func (s *Store) settleDelivery(t *transaction, runID, executionID string, settle JOIN execution_work w ON w.id = e.work_id WHERE w.run_id=$1 AND w.kind='deliver_revision'`, runID).N("n") availableAt := time.Now().UTC().Add(deliveryRetryBackoff(int(attempts))) - enqueueExecutionWork(t, runID, "deliver_revision", spec, sessionStartTarget(t, o.S("workspaceId")), availableAt) + enqueueExecutionWork(t, runID, "deliver_revision", spec, sessionStartTarget(t, o.S("workspaceId")), "", availableAt) return nil } diff --git a/internal/core/agent_run_session_settle.go b/internal/core/agent_run_session_settle.go index 008ea6015..c78759ed3 100644 --- a/internal/core/agent_run_session_settle.go +++ b/internal/core/agent_run_session_settle.go @@ -155,7 +155,7 @@ func (s *Store) settleSessionEnded(t *transaction, runID, executionID string, en if err != nil { return err } - enqueueExecutionWork(t, runID, "deliver_revision", spec, sessionStartTarget(t, o.S("workspaceId")), time.Time{}) + enqueueExecutionWork(t, runID, "deliver_revision", spec, sessionStartTarget(t, o.S("workspaceId")), "", time.Time{}) // Last, so lastSeq is the high-water mark this transaction is about to commit. `ended` is a // state-only change with a cleared idleSince, and the discards change per-turn status: both diff --git a/internal/core/agent_run_session_start.go b/internal/core/agent_run_session_start.go index c9206bb7b..b538caa72 100644 --- a/internal/core/agent_run_session_start.go +++ b/internal/core/agent_run_session_start.go @@ -92,7 +92,7 @@ func (s *Store) startAgentSession(t *transaction, runID string) error { if err != nil { return err } - enqueueExecutionWork(t, runID, "agent_session", spec, sessionStartTarget(t, wid), time.Time{}) + enqueueExecutionWork(t, runID, "agent_session", spec, sessionStartTarget(t, wid), "", time.Time{}) return nil } diff --git a/internal/core/execution_work.go b/internal/core/execution_work.go index ad8d46a21..f04c81df9 100644 --- a/internal/core/execution_work.go +++ b/internal/core/execution_work.go @@ -9,15 +9,18 @@ import ( // Repeating the same unregistered input returns the original item. A different input conflicts. func (s *Store) EnqueueExecutionWork(ctx context.Context, runID, kind string, input, target Object, availableAt time.Time) (Object, error) { return s.transact(ctx, func(t *transaction) Object { - return enqueueExecutionWork(t, runID, kind, input, target, availableAt) + return enqueueExecutionWork(t, runID, kind, input, target, "", availableAt) }) } -func enqueueExecutionWork(t *transaction, runID, kind string, input, target Object, availableAt time.Time) Object { +// graphNode names the workflow graph node a WorkflowRun's session work belongs to, so the scheduler +// can read that node's own execution back (workflowAgentSessionOutcome). IssueRun work has no graph +// and passes "". +func enqueueExecutionWork(t *transaction, runID, kind string, input, target Object, graphNode string, availableAt time.Time) Object { require(validID(runID), 400, "invalid_dispatch") run := t.one("SELECT * FROM issue_runs WHERE id=$1 AND deleted_at IS NULL", runID) if run == nil { - return enqueueWorkflowExecutionWork(t, runID, kind, input, target, availableAt) + return enqueueWorkflowExecutionWork(t, runID, kind, input, target, graphNode, availableAt) } require(t.one("SELECT run_id FROM agent_run_residuals WHERE run_id=$1", runID) == nil, 409, "dispatch_conflict") if kind == "deliver_revision" { @@ -59,21 +62,26 @@ func enqueueExecutionWork(t *transaction, runID, kind string, input, target Obje // enqueueWorkflowExecutionWork releases one workflow run's session work for Controllers to claim. // D8: the item registers against workflow_runs — exactly one unregistered item per run (the // workflow partial index), kind fixed to agent_session, and no delivery variant (D7 defers -// 成果交付). Repeating the same unregistered input returns the original item; a different input conflicts. -func enqueueWorkflowExecutionWork(t *transaction, runID, kind string, input, target Object, availableAt time.Time) Object { +// 成果交付). graphNode records which graph node's session this is: a run has one agent node +// running at a time but several over its life (D4), and the scheduler reads the node's terminal +// evidence back through this link. Repeating the same unregistered input returns the original item; +// a different input conflicts. +func enqueueWorkflowExecutionWork(t *transaction, runID, kind string, input, target Object, graphNode string, availableAt time.Time) Object { require(t.one("SELECT id FROM workflow_runs WHERE id=$1 AND deleted_at IS NULL", runID) != nil, 404, "not_found") require(kind == "agent_session", 400, "invalid_dispatch") require(input.S("kind") == kind, 400, "invalid_dispatch") require(target.S("workspaceId") != "" && target.S("sandboxInstanceId") != "" && target.S("nodeId") != "", 400, "invalid_dispatch") if existing := t.one("SELECT * FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL", runID); existing != nil { - require(existing.S("kind") == kind && jsonText(existing.O("input")) == jsonText(input) && jsonText(existing.O("target")) == jsonText(target), 409, "dispatch_conflict") + require(existing.S("kind") == kind && existing.S("workflowNodeId") == graphNode && jsonText(existing.O("input")) == jsonText(input) && jsonText(existing.O("target")) == jsonText(target), 409, "dispatch_conflict") return existing } id := newID() + // Work released without a graph node (the public seam) stores NULL, not the empty string: the + // column's CHECK and the scheduler's node-scoped lookups both read "no node" as NULL. if availableAt.IsZero() { - t.exec("INSERT INTO execution_work(id,workflow_run_id,kind,input,target) VALUES($1,$2,$3,$4,$5)", id, runID, kind, jsonText(input), jsonText(target)) + t.exec("INSERT INTO execution_work(id,workflow_run_id,kind,input,target,workflow_node_id) VALUES($1,$2,$3,$4,$5,NULLIF($6,''))", id, runID, kind, jsonText(input), jsonText(target), graphNode) } else { - t.exec("INSERT INTO execution_work(id,workflow_run_id,kind,input,target,available_at) VALUES($1,$2,$3,$4,$5,$6)", id, runID, kind, jsonText(input), jsonText(target), availableAt) + t.exec("INSERT INTO execution_work(id,workflow_run_id,kind,input,target,workflow_node_id,available_at) VALUES($1,$2,$3,$4,$5,NULLIF($6,''),$7)", id, runID, kind, jsonText(input), jsonText(target), graphNode, availableAt) } t.workSignals = append(t.workSignals, runID) return t.one("SELECT * FROM execution_work WHERE id=$1", id) diff --git a/internal/core/migrations/0038_workflow_execution_node.sql b/internal/core/migrations/0038_workflow_execution_node.sql new file mode 100644 index 000000000..96787e299 --- /dev/null +++ b/internal/core/migrations/0038_workflow_execution_node.sql @@ -0,0 +1,39 @@ +-- The graph node a WorkflowRun's session belongs to (specs decisions/cloud/workflow +-- 0-cloud-side-scheduler-owns-workflow-execution.md D1 条件② and D4; D8 owns the WorkflowRun +-- attribution of execution_work on Cloud's own tables). +-- +-- D4 executes one run's agent nodes serially through the run's single Workspace, so a run has as +-- many agent sessions in its life as it has agent nodes. Two run-scoped identities assumed the +-- opposite and made every node after the first unreachable: +-- +-- * the scheduler resolved a running node's terminal evidence as "the newest agent_session result +-- for this run", so the second node inherited the first node's result and was committed +-- succeeded without ever executing (invariant 5: a node with no committed result is never +-- recorded as succeeded); +-- * one_session_per_run (0025) allowed one agent_session per run for its lifetime, so the second +-- node's dispatch could never register. +-- +-- Both are corrected to the granularity the design intends — per graph node. The graph node is +-- recorded where the session is released (the work item, which the scheduler owns) and where the +-- execution is registered (the node execution), and the lifetime uniqueness is split by owner: an +-- IssueRun still runs one session in its life, a WorkflowRun one per agent node. + +-- 1. The graph node a work item admits. IssueRun work and tenant clone work carry no graph node. +ALTER TABLE execution_work ADD COLUMN workflow_node_id text + CHECK (workflow_node_id IS NULL OR length(workflow_node_id) BETWEEN 1 AND 200); +ALTER TABLE execution_work ADD CONSTRAINT execution_work_node_belongs_to_workflow_run + CHECK (workflow_node_id IS NULL OR workflow_run_id IS NOT NULL); + +-- 2. The graph node an execution ran for. The dispatch copies it off the work item it consumes, the +-- way it already copies work_id. It is denormalized rather than read through work_id because a +-- partial index predicate cannot join. +ALTER TABLE node_executions ADD COLUMN workflow_node_id text + CHECK (workflow_node_id IS NULL OR length(workflow_node_id) BETWEEN 1 AND 200); + +-- 3. Split the lifetime uniqueness by owner. IssueRuns keep 0025's rule exactly (they have no graph +-- node); WorkflowRuns get one session per agent node instead of one per run. +DROP INDEX one_session_per_run; +CREATE UNIQUE INDEX one_session_per_run ON node_executions(operation_id) + WHERE kind='agent_session' AND workflow_node_id IS NULL; +CREATE UNIQUE INDEX one_session_per_workflow_node ON node_executions(operation_id, workflow_node_id) + WHERE kind='agent_session' AND workflow_node_id IS NOT NULL; diff --git a/internal/core/node_execution.go b/internal/core/node_execution.go index 47cac23cb..eeddb85b3 100644 --- a/internal/core/node_execution.go +++ b/internal/core/node_execution.go @@ -62,14 +62,22 @@ func dispatchRunExecution(t *transaction, r *ControlRequest, runID, execution, n if input.S("modelBindingId") != "" { require(current.B("modelProxy"), 409, "model_proxy_required") } - require(t.one("SELECT execution_id FROM node_executions WHERE operation_id=$1 AND kind='agent_session'", runID) == nil, 409, "dispatch_conflict") + // An IssueRun runs exactly one agent session in its life. A WorkflowRun runs one per agent + // node (D4 executes them serially through one Workspace), so its sessions are bounded by the + // work item this dispatch consumes — the unregistered item is gone once dispatched (below), + // and the in-flight check that follows admits only one at a time. The per-run lifetime + // uniqueness would refuse every node after the first. + if w.S("issueRunId") == runID { + require(t.one("SELECT execution_id FROM node_executions WHERE operation_id=$1 AND kind='agent_session'", runID) == nil, 409, "dispatch_conflict") + } } if input.S("kind") == "deliver_revision" { require(t.one("SELECT execution_id FROM node_executions WHERE execution_id=$1 AND operation_id=$2 AND workspace_id=$3 AND kind='agent_session' AND result->>'outcome'='agent_session_ended'", input.S("sessionExecutionId"), runID, wid) != nil, 409, "dispatch_conflict") } require(t.one("SELECT execution_id FROM node_executions WHERE operation_id=$1 AND kind IN ('agent_session','deliver_revision') AND result IS NULL AND terminated_by_force_stop_id IS NULL", runID) == nil, 409, "dispatch_conflict") - t.exec("INSERT INTO node_executions(execution_id,kind,operation_id,work_id,workspace_id,node_id,node_operation_id,input,dispatched_epoch) VALUES($1,$2,$3,$4,$5,$6,$1,$7,$8)", - execution, input.S("kind"), runID, work.S("id"), wid, node, jsonText(input), r.Body.N("epoch")) + t.exec(`INSERT INTO node_executions(execution_id,kind,operation_id,work_id,workspace_id,node_id,node_operation_id,input,dispatched_epoch,workflow_node_id) + VALUES($1,$2,$3,$4,$5,$6,$1,$7,$8,NULLIF($9,''))`, + execution, input.S("kind"), runID, work.S("id"), wid, node, jsonText(input), r.Body.N("epoch"), work.S("workflowNodeId")) t.exec("UPDATE execution_work SET execution_id=$2 WHERE id=$1", work.S("id"), execution) return t.one("SELECT * FROM node_executions WHERE execution_id=$1", execution) } diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index d00927037..05b42b3bf 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -50,19 +50,25 @@ func workflowRunningAgentNode(states map[string]workflowNodeState) (string, bool return "", false } -// workflowAgentSessionOutcome settles a committed running agent node. Terminal evidence (the -// session execution's result) commits the node's terminal state; a dispatched-but-unreported -// session is held (the Node owns it); a session that never dispatched fails the node once the -// startup deadline passes. The run advances on the next tick after any commit. +// workflowAgentSessionOutcome settles a committed running agent node. Terminal evidence (this +// node's own session execution's result) commits the node's terminal state; a +// dispatched-but-unreported session is held (the Node owns it); a session that never dispatched +// fails the node once the startup deadline passes. The run advances on the next tick after any +// commit. +// +// Every lookup is scoped to the node being settled. A run has several agent sessions over its life +// (D4 executes its agent nodes serially through one Workspace), so evidence read at run scope — +// "the newest agent_session result for this run" — would let the second node inherit the first +// node's result and be recorded succeeded without ever executing (invariant 5). func workflowAgentSessionOutcome(t *transaction, runID string, version int64, nodeID string, states map[string]workflowNodeState) (map[string]workflowNodeState, bool) { e := t.one(`SELECT result FROM node_executions - WHERE operation_id=$1 AND kind='agent_session' AND result IS NOT NULL - ORDER BY created_at DESC LIMIT 1`, runID) + WHERE operation_id=$1 AND workflow_node_id=$2 AND kind='agent_session' AND result IS NOT NULL`, runID, nodeID) if e != nil { return commitWorkflowAgentTerminal(t, runID, version, nodeID, states, e.O("result")) } // Dispatched but not yet terminal: the Node owns the session until it reports. - if t.one("SELECT 1 FROM node_executions WHERE operation_id=$1 AND kind='agent_session' LIMIT 1", runID) != nil { + if t.one(`SELECT execution_id FROM node_executions + WHERE operation_id=$1 AND workflow_node_id=$2 AND kind='agent_session'`, runID, nodeID) != nil { return states, false } // Never dispatched: the startup deadline applies. A node committed running with no registered @@ -245,7 +251,7 @@ func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView } states[node.ID] = workflowNodeState{Status: "running", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339()} settleWorkflowRunStates(t, runID, version, "running", "", states) - enqueueExecutionWork(t, runID, "agent_session", spec, target, time.Time{}) + enqueueExecutionWork(t, runID, "agent_session", spec, target, node.ID, time.Time{}) } // failWorkflowAgentNode commits one agent node failed with a named reason and finishes the run. diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 9df1e1f08..2a7e0971e 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -19,10 +19,32 @@ func workflowSessionGraph() Object { return g } +// workflowTwoAgentGraph is start -> agent a1 -> agent a2 -> output: one run with two agent nodes, +// which D4 executes serially through the run's single Workspace. +func workflowTwoAgentGraph() Object { + return wfGraph( + []Object{ + wfNode("s", "start", ""), + wfAgentNode("a1", Object{"prompt": "First"}), + wfAgentNode("a2", Object{"prompt": "Second"}), + wfNode("o", "output", ""), + }, + [][3]string{{"s", "a1", ""}, {"a1", "a2", ""}, {"a2", "o", ""}}, + ) +} + // seedWorkflowSessionScene stages a project-backed run one advance away from session start: the // binding (the same freeze the creation gate performs), and the project space carries the // production plugin. Returns the ids the tests read back. func seedWorkflowSessionScene(t *testing.T, store *Store) (runID, tid, uid, space, project string) { + t.Helper() + return seedWorkflowSessionSceneGraph(t, store, workflowSessionGraph()) +} + +// seedWorkflowSessionSceneGraph is seedWorkflowSessionScene for a caller-supplied graph: the +// snapshot, the live document and the frozen bindings all carry the same document, the way +// publishing freezes a version. +func seedWorkflowSessionSceneGraph(t *testing.T, store *Store, graph Object) (runID, tid, uid, space, project string) { t.Helper() _, uid = modelUser(t, store, "wf-session") tid, wid, sid, rid := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() @@ -37,16 +59,16 @@ func seedWorkflowSessionScene(t *testing.T, store *Store) (runID, tid, uid, spac exec("project", `INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,$4,'wfs','https://example.invalid/wfs.git','main','active')`, project, tid, uid, space) exec("main", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref) VALUES($1,$2,$3,$3,'verified_request',$4,'main','running','ready',1,'HEAD')`, uuid.NewString(), tid, uid, project) exec("plugin", `INSERT INTO space_plugins(space_id,tenant_id,source_namespace,identifier,desired_state,desired_version,observed_state) VALUES($1,$2,'official','ora-space.opencode','installed','1.0.0','installed')`, space, tid) - graph, _ := json.Marshal(workflowSessionGraph()) - exec("workflow", `INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'wfs',$3::jsonb)`, wid, tid, string(graph)) - exec("snapshot", `INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1',$4::jsonb)`, sid, tid, wid, string(graph)) + graphJSON, _ := json.Marshal(graph) + exec("workflow", `INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'wfs',$3::jsonb)`, wid, tid, string(graphJSON)) + exec("snapshot", `INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1',$4::jsonb)`, sid, tid, wid, string(graphJSON)) exec("run", `INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) VALUES($1,$2,$3,$4,'disp','pending','{}'::jsonb,$5,$6)`, rid, tid, wid, sid, uid, project) cred := uuid.NewString() conn := uuid.NewString() exec("credential", `INSERT INTO personal_model_credentials(id,user_id,ciphertext,key_id) VALUES($1,$2,repeat('c',64),'test-key')`, cred, uid) exec("connection", `INSERT INTO personal_model_connections(id,user_id,credential_id,name,protocol,base_url,auth_mode,models,enabled) VALUES($1,$2,$3,'Personal Provider','openai-completions','https://models.example.invalid/v1','bearer',$4::jsonb,true)`, conn, uid, cred, jsonText([]any{map[string]any{"id": "vendor/model-v1", "name": "Model V1"}})) exec("default", `INSERT INTO personal_model_defaults(user_id,connection_id,model_id,version) VALUES($1,$2,'vendor/model-v1',1)`, uid, conn) - freezeWorkflowRunExecutors(tx, rid, uid, workflowSessionGraph()) + freezeWorkflowRunExecutors(tx, rid, uid, graph) return Object{} }); err != nil { t.Fatalf("seed scene: %v", err) @@ -76,8 +98,9 @@ func settleSessionWorkspace(t *testing.T, store *Store, runID string) string { } // startSessionNode connects a Node to the settled Workspace, advances until the agent node is -// committed running, and returns the Node's service id and the released session spec. -func startSessionNode(t *testing.T, store *Store, runID, wsID string) (string, Object) { +// committed running, and returns the Node's service id and the released session spec. graphNode +// names which agent node of the graph the pass is expected to start. +func startSessionNode(t *testing.T, store *Store, runID, wsID, graphNode string) (string, Object) { t.Helper() _, nodeService := bindConnectedNode(t, store, wsID) if _, err := store.Pool.Exec(`UPDATE node_instances SET model_proxy=true WHERE workspace_id=$1`, wsID); err != nil { @@ -87,18 +110,11 @@ func startSessionNode(t *testing.T, store *Store, runID, wsID string) (string, O t.Fatalf("advance to session start: %v", err) } status, _, states, _ := readWorkflowRun(t, store, runID) - agent, _ := asWorkflowObject(states["a"]) + agent, _ := asWorkflowObject(states[graphNode]) if status != "running" || agent == nil || agent.S("status") != "running" { - t.Fatalf("after session start: %q states=%v; want agent a running", status, states) - } - var rawSpec []byte - if err := store.Pool.QueryRow(`SELECT input FROM execution_work WHERE workflow_run_id=$1 AND kind='agent_session'`, runID).Scan(&rawSpec); err != nil { - t.Fatalf("read session work: %v", err) - } - spec := Object{} - if err := json.Unmarshal(rawSpec, &spec); err != nil { - t.Fatal(err) + t.Fatalf("after session start: %q states=%v; want agent %s running", status, states, graphNode) } + spec := sessionWorkSpec(t, store, runID, graphNode) if spec.S("agentPluginId") != opencodeAgentCLI || spec.S("agentPluginVersion") != "1.0.0" || spec.S("modelBindingId") == "" { t.Fatalf("spec = %v", spec) } @@ -111,22 +127,37 @@ func startSessionNode(t *testing.T, store *Store, runID, wsID string) (string, O return nodeService, spec } +// sessionWorkSpec reads back the unregistered session work item a graph node's start released. +func sessionWorkSpec(t *testing.T, store *Store, runID, graphNode string) Object { + t.Helper() + var rawSpec []byte + if err := store.Pool.QueryRow(`SELECT input FROM execution_work + WHERE workflow_run_id=$1 AND workflow_node_id=$2 AND execution_id IS NULL`, runID, graphNode).Scan(&rawSpec); err != nil { + t.Fatalf("read session work for node %s: %v", graphNode, err) + } + spec := Object{} + if err := json.Unmarshal(rawSpec, &spec); err != nil { + t.Fatal(err) + } + return spec +} + // dispatchSession dispatches the released session work from the Node and advances: the pass holds // the committed running node until terminal evidence arrives. -func dispatchSession(t *testing.T, store *Store, runID, nodeService string, spec Object) string { +func dispatchSession(t *testing.T, store *Store, runID, graphNode, nodeService string, spec Object) string { t.Helper() - execution := "exec-" + runID[:8] + execution := "exec-" + graphNode + "-" + runID[:8] if _, err := store.transact(context.Background(), func(tx *transaction) Object { nodeDispatch(tx, &ControlRequest{Body: Object{"operationId": runID, "executionId": execution, "nodeId": nodeService, "input": spec, "epoch": int64(1)}}) return Object{} }); err != nil { - t.Fatalf("dispatch: %v", err) + t.Fatalf("dispatch node %s: %v", graphNode, err) } if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { t.Fatalf("advance after dispatch: %v", err) } _, _, held, _ := readWorkflowRun(t, store, runID) - h, _ := asWorkflowObject(held["a"]) + h, _ := asWorkflowObject(held[graphNode]) if h == nil || h.S("status") != "running" { t.Fatalf("dispatched session held: %v", held) } @@ -168,8 +199,8 @@ func TestWorkflowSessionRunsEndToEnd(t *testing.T) { if _, _, held, _ := readWorkflowRun(t, store, runID); len(held) != 0 { t.Fatalf("advance 2 committed states %v without a connected node", held) } - nodeService, spec := startSessionNode(t, store, runID, wsID) - execution := dispatchSession(t, store, runID, nodeService, spec) + nodeService, spec := startSessionNode(t, store, runID, wsID, "a") + execution := dispatchSession(t, store, runID, "a", nodeService, spec) // The Node reports a natural end; the run drains to succeeded and cleans up. reportSessionEnd(t, store, runID, execution, nodeService, "idle_timeout") var runErr string @@ -202,6 +233,153 @@ func TestWorkflowSessionRunsEndToEnd(t *testing.T) { } } +// A run's agent nodes execute serially and settle from their own evidence. The second node starts on +// the pass that settles the first, so it must be committed running and carry no terminal state: a +// run-scoped evidence lookup (the newest agent_session result for the run) hands it the first node's +// result and records it succeeded without it ever executing — and the per-run session uniqueness +// refuses its dispatch outright, so the node can neither run nor be settled honestly. +func TestWorkflowSessionRunsTwoAgentNodesInOrder(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionSceneGraph(t, store, workflowTwoAgentGraph()) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec1 := startSessionNode(t, store, runID, wsID, "a1") + execution1 := dispatchSession(t, store, runID, "a1", nodeService, spec1) + reportSessionEnd(t, store, runID, execution1, nodeService, "idle_timeout") + + // One pass settled a1 and started a2. The loop keeps ticking while a2's session is in flight — + // and a2 must still be running: it has no execution of its own, and a run-scoped evidence lookup + // hands it a1's result and records it succeeded without it ever executing. + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance while a2 is in flight: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + a1, _ := asWorkflowObject(states["a1"]) + a2, _ := asWorkflowObject(states["a2"]) + if status != "running" || runErr != "" { + t.Fatalf("run settled %q error %q states=%v; want running with a2 in flight", status, runErr, states) + } + if a1 == nil || a1.S("status") != "succeeded" || a1.O("output").S("reason") != "idle_timeout" { + t.Fatalf("a1 state = %v; want succeeded/idle_timeout", states["a1"]) + } + if a2 == nil || a2.S("status") != "running" { + t.Fatalf("a2 state = %v; want running — a node with no result of its own is never succeeded", states["a2"]) + } + if _, ran := states["o"]; ran { + t.Fatalf("the output node ran before a2 settled: %v", states) + } + + // The second node dispatches on its own work item and settles from its own result. + spec2 := sessionWorkSpec(t, store, runID, "a2") + execution2 := dispatchSession(t, store, runID, "a2", nodeService, spec2) + if execution2 == execution1 { + t.Fatalf("both nodes dispatched as %s; each node needs its own execution", execution1) + } + reportSessionEnd(t, store, runID, execution2, nodeService, "user_ended") + + status, runErr, states, _ = readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q states=%v; want succeeded", status, runErr, states) + } + a1, _ = asWorkflowObject(states["a1"]) + a2, _ = asWorkflowObject(states["a2"]) + outs, _ := asWorkflowObject(states["o"]) + if a1 == nil || a1.S("status") != "succeeded" || a2 == nil || a2.S("status") != "succeeded" || outs == nil || outs.S("status") != "succeeded" { + t.Fatalf("node states = %v; want both agents and the output succeeded", states) + } + // Each node carries its own evidence: a2's reason is its own, not a1's. + if reason := a2.O("output").S("reason"); reason != "user_ended" { + t.Fatalf("a2 output reason = %q; want user_ended (a1 ended idle_timeout)", reason) + } + + // Two executions, each registered against the work item of the node it ran for. + rows, err := store.Pool.Query(`SELECT ew.workflow_node_id, ne.execution_id, ne.result->>'reason' + FROM node_executions ne JOIN execution_work ew ON ew.id=ne.work_id + WHERE ew.workflow_run_id=$1 AND ne.kind='agent_session' ORDER BY ew.workflow_node_id`, runID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + type session struct{ node, execution, reason string } + var sessions []session + for rows.Next() { + var s session + if err := rows.Scan(&s.node, &s.execution, &s.reason); err != nil { + t.Fatal(err) + } + sessions = append(sessions, s) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + want := []session{{"a1", execution1, "idle_timeout"}, {"a2", execution2, "user_ended"}} + if len(sessions) != len(want) { + t.Fatalf("registered sessions = %v; want one per agent node", sessions) + } + for i, w := range want { + if sessions[i] != w { + t.Fatalf("session %d = %v; want %v", i, sessions[i], w) + } + } +} + +// Dispatch stays bounded now that the per-run agent_session uniqueness applies to IssueRuns alone: +// the work item is consumed by its dispatch, so no second execution can register against a node's +// work, and a replayed dispatch is the same fact rather than a second session. +func TestWorkflowSessionDispatchIsNotRepeatable(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionSceneGraph(t, store, workflowTwoAgentGraph()) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec1 := startSessionNode(t, store, runID, wsID, "a1") + dispatch := func(executionID string, input Object) error { + _, err := store.transact(context.Background(), func(tx *transaction) Object { + nodeDispatch(tx, &ControlRequest{Body: Object{"operationId": runID, "executionId": executionID, "nodeId": nodeService, "input": input, "epoch": int64(1)}}) + return Object{} + }) + return err + } + execution1 := "exec-a1-" + runID[:8] + if err := dispatch(execution1, spec1); err != nil { + t.Fatalf("first dispatch: %v", err) + } + // The same execution and input is the same fact replayed, not a second session. + if err := dispatch(execution1, spec1); err != nil { + t.Fatalf("replayed dispatch: %v", err) + } + // A second execution cannot register against the consumed work item: a run runs one session at + // a time, and a node's work item names exactly that node's session. + if err := dispatch("exec-a1-again-"+runID[:8], spec1); ErrorCode(err).Code != "dispatch_conflict" { + t.Fatalf("second execution for the same node: err = %v; want dispatch_conflict", err) + } + var executions int + if err := store.Pool.QueryRow(`SELECT count(*) FROM node_executions WHERE operation_id=$1 AND kind='agent_session'`, runID).Scan(&executions); err != nil { + t.Fatal(err) + } + if executions != 1 { + t.Fatalf("%d agent_session executions registered; want 1", executions) + } + + // Once a1 settles, its dispatch cannot be replayed onto a2's released work item: the input a + // work item carries is the session it admits, so a settled node's spec is refused rather than + // run a second time as the next node. + reportSessionEnd(t, store, runID, execution1, nodeService, "idle_timeout") + if err := dispatch("exec-a1-late-"+runID[:8], spec1); ErrorCode(err).Code != "dispatch_conflict" { + t.Fatalf("late dispatch of a settled node: err = %v; want dispatch_conflict", err) + } + var stillOne int + if err := store.Pool.QueryRow(`SELECT count(*) FROM node_executions WHERE operation_id=$1 AND kind='agent_session'`, runID).Scan(&stillOne); err != nil { + t.Fatal(err) + } + if stillOne != 1 { + t.Fatalf("%d agent_session executions after the late dispatch; want 1", stillOne) + } +} + // A session that ends `agent_failed` fails its node and the run with that reason — the evidence // decides, not a policy — and the terminal cleanup still runs. func TestWorkflowSessionFailureFailsRun(t *testing.T) { @@ -211,8 +389,8 @@ func TestWorkflowSessionFailureFailsRun(t *testing.T) { t.Fatalf("advance 1: %v", err) } wsID := settleSessionWorkspace(t, store, runID) - nodeService, spec := startSessionNode(t, store, runID, wsID) - execution := dispatchSession(t, store, runID, nodeService, spec) + nodeService, spec := startSessionNode(t, store, runID, wsID, "a") + execution := dispatchSession(t, store, runID, "a", nodeService, spec) reportSessionEnd(t, store, runID, execution, nodeService, "agent_failed") status, runErr, states, _ := readWorkflowRun(t, store, runID) if status != "failed" || runErr != "agent_failed" { @@ -240,7 +418,7 @@ func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { t.Fatalf("advance 1: %v", err) } wsID := settleSessionWorkspace(t, store, runID) - startSessionNode(t, store, runID, wsID) + startSessionNode(t, store, runID, wsID, "a") // The Node never picks the work item up: the committed start ages past the deadline. stale := time.Now().Add(-workflowSessionStartDeadline - time.Minute).UTC().Format(time.RFC3339) if _, err := store.Pool.Exec(`UPDATE workflow_runs SET node_states=jsonb_set(node_states,'{a,startedAt}',to_jsonb($2::text)) WHERE id=$1`, runID, stale); err != nil { @@ -266,6 +444,40 @@ func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { } } +// A replayed terminal receipt for a settled run changes nothing: the run's status, its committed +// node states and its version are exactly what the first settle left, and the extra passes write +// nothing. Late or duplicate evidence is a no-op (invariant 3). +func TestWorkflowSessionLateReceiptIsANoOp(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec := startSessionNode(t, store, runID, wsID, "a") + execution := dispatchSession(t, store, runID, "a", nodeService, spec) + reportSessionEnd(t, store, runID, execution, nodeService, "idle_timeout") + status, runErr, states, version := readWorkflowRun(t, store, runID) + if status != "succeeded" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + // The Node replays the same terminal fact, and the loop keeps ticking. + reportSessionEnd(t, store, runID, execution, nodeService, "idle_timeout") + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance after replay: %v", err) + } + replayedStatus, replayedErr, replayedStates, replayedVersion := readWorkflowRun(t, store, runID) + if replayedStatus != status || replayedErr != runErr { + t.Fatalf("replay moved the run to %q error %q; want %q error %q", replayedStatus, replayedErr, status, runErr) + } + if replayedVersion != version { + t.Fatalf("replay bumped the version to %d; want %d", replayedVersion, version) + } + if jsonText(replayedStates) != jsonText(states) { + t.Fatalf("replay rewrote the node states:\n got %v\nwant %v", replayedStates, states) + } +} + // The model grant's live binding resolves a WorkflowRun session through the workflow branch: the // frozen slot binding, the dispatched session execution and the confirmed runtime binding authorize // it, and an execution that is not this run's session is refused rather than resolved to some other @@ -277,8 +489,8 @@ func TestWorkflowLiveModelBindingResolvesRun(t *testing.T) { t.Fatalf("advance 1: %v", err) } wsID := settleSessionWorkspace(t, store, runID) - nodeService, spec := startSessionNode(t, store, runID, wsID) - execution := dispatchSession(t, store, runID, nodeService, spec) + nodeService, spec := startSessionNode(t, store, runID, wsID, "a") + execution := dispatchSession(t, store, runID, "a", nodeService, spec) // The runtime confirms the Node binding once the session is dispatched — the state the model // gateway's certificate-bound lookups run against. if _, err := store.Pool.Exec(`UPDATE runtime_controls SET binding_confirmed=true, From a2aba18b74e36dee250150fd0eb78aad0df85088 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 10:50:20 +0800 Subject: [PATCH 12/26] fix(core): refuse a cyclic graph at publish and at advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first phase executes a DAG and nothing else (#67 结论 4, D1 条件④), but nothing enforced it. A cyclic document published fine, and a run of one settled `succeeded`: a cycle reachable from the start went round once and stopped with its back edge silently dropped, and a cycle nothing reached left its nodes unresolved with no state at all — the run reported success for a document whose author drew a loop, and for nodes that produced no result (invariant 5). Both layers are closed by one walk, so a graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph: - `workflowGraphCycle` (workflow_scheduler.go) is a three-colour depth-first walk over the same reader the run uses. It names the source of the back edge it found, which is on a cycle by construction — not a node merely downstream of one, and not a node between two cycles, which is where a peel-based detector lands. - The publish gate refuses with `graph_cycle` naming that node. - The advance pass settles such a run `failed` with `graph has a cycle` before the first node runs, so a snapshot frozen before the gate existed neither executes as a DAG it is not nor settles succeeded. The no-start refusal beside the new one now finishes through `finishWorkflowRun` like every other terminal settle, so a terminal run never holds live resources (D7) even when it is a run that was already in flight when the check landed. Regression evidence — new tests against the pre-fix implementation: - `TestValidateWorkflowGraph`: `problems = []`, want `[graph_cycle]` for a self-loop, a cycle behind the start, a cycle with nodes downstream of it, and a node between two cycles. - `TestWorkflowAdvanceFailsACyclicSnapshot` and `TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart`: `run settled "succeeded" error ""`, want `failed/graph has a cycle`. Co-Authored-By: Claude Code --- internal/core/workflow_graph.go | 25 +++ internal/core/workflow_graph_test.go | 46 ++++++ internal/core/workflow_scheduler.go | 62 ++++++- internal/core/workflow_scheduler_db_test.go | 172 ++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) diff --git a/internal/core/workflow_graph.go b/internal/core/workflow_graph.go index 900d6cda5..91154c1a1 100644 --- a/internal/core/workflow_graph.go +++ b/internal/core/workflow_graph.go @@ -131,10 +131,35 @@ func validateWorkflowGraph(graph Object) []workflowGraphProblem { } problems = append(problems, validateWorkflowNodes(nodes)...) problems = append(problems, validateWorkflowEdges(graph, nodes)...) + problems = append(problems, validateWorkflowAcyclic(graph)...) problems = append(problems, validateWorkflowSlots(graph, nodes)...) return problems } +// validateWorkflowAcyclic refuses a graph that contains a cycle. The first phase executes a DAG and +// nothing else (#67 结论 4, D1 条件④): the projection that decides what runs is a topological read of +// committed state, so a node on a cycle either waits for an upstream that waits for it or goes round +// once and drops its back edge, and neither is what its author drew. Publishing would freeze a +// document whose run cannot mean what it says, which is exactly what this gate exists to prevent. +// Loops and iterations are not part of the first phase, so a graph that wants one is refused rather +// than executed as something else. +// +// The reported node is the source of the back edge the walk found, which is on a cycle by +// construction; a node merely downstream of one is not reported, because the author has nothing to +// fix there. The walk itself is shared with the run's advance pass — see workflowGraphCycle — so a +// graph Cloud refuses to publish and a graph Cloud refuses to run are the same graph. +// +// The graph is read by the same reader the run uses, so a document whose nodes are not a list is +// simply empty here: validateWorkflowNodes has already said so, and this pass must not pile a second, +// less useful complaint on top of it. +func validateWorkflowAcyclic(graph Object) []workflowGraphProblem { + source := workflowGraphCycle(readWorkflowGraph(graph)) + if source == "" { + return nil + } + return []workflowGraphProblem{{Code: "graph_cycle", NodeID: source}} +} + // validateWorkflowNodes checks node identity, kind, agent configuration and the start-node count. The // editor allows at most one Start node, and a run needs exactly one entry point, so zero and two are // both refused: a graph with none is unfinished, and one with two has no defined place to begin. diff --git a/internal/core/workflow_graph_test.go b/internal/core/workflow_graph_test.go index dad47157b..b3d4c313d 100644 --- a/internal/core/workflow_graph_test.go +++ b/internal/core/workflow_graph_test.go @@ -246,6 +246,52 @@ func TestValidateWorkflowGraph(t *testing.T) { graph: workflowGraph([]any{graphStart("s")}, []any{graphEdge("s", "gone")}, nil), want: []any{Object{"code": "edge_endpoint_unknown", "nodeId": "gone"}}, }, + { + // A DAG that reconverges: two paths lead into `c`, which is a join and not a cycle. It is + // the shape a cycle test most easily refuses by accident, so it is here as the legal half + // of that pair. + name: "a diamond", + graph: workflowGraph( + []any{graphStart("s"), graphAgent("a", "", false), graphAgent("b", "", false), graphAgent("c", "", false)}, + []any{graphEdge("s", "a"), graphEdge("s", "b"), graphEdge("a", "c"), graphEdge("b", "c")}, + nil), + }, + { + // The shortest cycle there is, and one the editor can produce by dragging an edge back onto + // its own source. + name: "a self-loop", + graph: workflowGraph([]any{graphStart("s"), graphAgent("a", "", false)}, []any{graphEdge("s", "a"), graphEdge("a", "a")}, nil), + want: []any{Object{"code": "graph_cycle", "nodeId": "a"}}, + }, + { + // The back edge is `b→a`, so `b` is the node reported: it is on the cycle by construction, + // and it is the one whose outgoing edge the author has to delete. + name: "a cycle behind the start node", + graph: workflowGraph([]any{graphStart("s"), graphAgent("a", "", false), graphAgent("b", "", false)}, []any{graphEdge("s", "a"), graphEdge("a", "b"), graphEdge("b", "a")}, nil), + want: []any{Object{"code": "graph_cycle", "nodeId": "b"}}, + }, + { + // Nodes downstream of a cycle are not reported: they are casualties, not faults, and the + // author would have nothing to fix at them. + name: "a cycle with nodes downstream of it", + graph: workflowGraph( + []any{graphStart("s"), graphAgent("a", "", false), graphAgent("b", "", false), graphAgent("c", "", false), graphAgent("o", "", false)}, + []any{graphEdge("s", "a"), graphEdge("a", "b"), graphEdge("b", "a"), graphEdge("b", "c"), graphEdge("c", "o")}, + nil), + want: []any{Object{"code": "graph_cycle", "nodeId": "b"}}, + }, + { + // `v` sits between two cycles without being on either. A detector that peels nodes with no + // incoming or no outgoing edge leaves `v` in the remainder — nothing ever drains it — so a + // report built that way cannot tell the node the author must fix from one they must not + // touch. The walk names a back edge's source instead, which is on a cycle by construction. + name: "a node between two cycles", + graph: workflowGraph( + []any{graphStart("s"), graphAgent("a", "", false), graphAgent("u", "", false), graphAgent("v", "", false), graphAgent("w", "", false), graphAgent("x", "", false)}, + []any{graphEdge("s", "a"), graphEdge("a", "u"), graphEdge("u", "a"), graphEdge("u", "v"), graphEdge("v", "w"), graphEdge("w", "x"), graphEdge("x", "w")}, + nil), + want: []any{Object{"code": "graph_cycle", "nodeId": "u"}}, + }, { // The nameless slot's description is fine and is not also reported: a problem list the user // has to read is worth keeping to the violations that are actually there. diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index b2829ecf5..bf796a3bc 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -202,6 +202,55 @@ func readWorkflowGraph(graph Object) workflowGraphView { return out } +// workflowGraphCycle reports whether the graph contains a cycle, naming the source of the back edge the +// walk found — a node that is on a cycle by construction — or "" when the graph is a DAG. +// +// It is a depth-first walk with three colours: a node still on the current path that is reached again +// closes a cycle. The walk is deterministic — it roots at nodes in document order and follows edges in +// document order — so the node it names is too. Two cheaper-looking tests are wrong here and are worth +// naming so they are not reintroduced: peeling nodes with no incoming or no outgoing edge leaves the +// remainder including nodes that are merely *between* two cycles, and unwinding the stack at a back +// edge misses nodes on a cycle reached by a shortcut edge. +// +// Both the publish gate and the run's own advance read this one walk, so a graph Cloud refuses to +// publish and a graph Cloud refuses to run are the same graph. +func workflowGraphCycle(graph workflowGraphView) string { + const ( + unvisited = 0 + onPath = 1 + settled = 2 + ) + state := map[string]int{} + cycleSource := "" + var walk func(id string) + walk = func(id string) { + state[id] = onPath + for _, edge := range graph.Edges { + if edge.S("source") != id { + continue + } + next := edge.S("target") + switch state[next] { + case onPath: + // A back edge: `next` is an ancestor on the current path, so the path from `next` + // back to `id` plus this edge is a cycle. The first one found wins. + if cycleSource == "" { + cycleSource = id + } + case unvisited: + walk(next) + } + } + state[id] = settled + } + for _, node := range graph.Nodes { + if state[node.ID] == unvisited { + walk(node.ID) + } + } + return cycleSource +} + // workflowNodeState is one node's committed execution state on the run row. The shape is the one the // run viewer already renders (the simulator writes the same keys): status is the shared run/node // vocabulary, output carries what the node produced, and an inactive state marks a node behind a @@ -432,7 +481,18 @@ func advanceWorkflowRun(t *transaction, runID string) { if graph.Start == "" { // A snapshot predating the publish gate can lack a start node; there is no defined // place to begin, so the run settles failed instead of hanging in running. - settleWorkflowRunStates(t, runID, version, "failed", "graph has no start node", nil) + finishWorkflowRun(t, runID, version, "failed", "graph has no start node", nil) + return + } + // A cyclic graph is not executable: the projection is a topological read of committed state, so a + // node on a cycle either waits forever for an upstream that waits for it, or — when the cycle is + // reachable from the start — goes round once and stops with the back edge silently dropped, which + // would let the run report success for a document whose author asked for a loop. The graph gate + // refuses to publish one; a snapshot frozen before that gate existed still reaches here, and + // neither outcome is honest for it. Nothing has run yet at this point, so the run settles failed + // with no node states — no node is recorded as having executed, which is the truth. + if workflowGraphCycle(graph) != "" { + finishWorkflowRun(t, runID, version, "failed", "graph has a cycle", nil) return } states := workflowStatesFromJSON(run.O("nodeStates")) diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 987542bdd..99a505f21 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -132,6 +132,178 @@ func TestWorkflowAdvanceFailsRunOnProjectlessAgentNode(t *testing.T) { } } +// Publishing a new version — and editing the live document — while a run is in flight cannot change +// what that run executes: every pass joins the run's own snapshot_id, so the graph it runs is the one +// frozen at creation (invariant 1, "编辑、发布或插件升级都不改变在途运行执行的内容"). The run here is +// still `pending` when version 2 lands, which is the window the invariant is about. +func TestWorkflowAdvanceIgnoresARepublishedVersion(t *testing.T) { + store := dispatcherDB(t) + frozen := wfGraph( + []Object{wfNode("s", "start", ""), wfNode("o1", "output", "")}, + [][3]string{{"s", "o1", ""}}, + ) + runID := seedWorkflowRunScene(t, store, frozen) + var snapshotID string + if err := store.Pool.QueryRow(`SELECT snapshot_id FROM workflow_runs WHERE id=$1`, runID).Scan(&snapshotID); err != nil { + t.Fatal(err) + } + // The author edits the live document and publishes version 2 after the run exists. + republished := wfGraph( + []Object{wfNode("s", "start", ""), wfNode("o2", "output", "")}, + [][3]string{{"s", "o2", ""}}, + ) + raw, err := json.Marshal(republished) + if err != nil { + t.Fatal(err) + } + if _, err := store.Pool.Exec(`UPDATE workflows SET graph=$2::jsonb, version=version+1 WHERE id=(SELECT workflow_id FROM workflow_runs WHERE id=$1)`, runID, string(raw)); err != nil { + t.Fatal(err) + } + if _, err := store.Pool.Exec(`INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) + SELECT $2,tenant_id,workflow_id,2,'v2',$3::jsonb FROM workflow_runs WHERE id=$1`, runID, uuid.NewString(), string(raw)); err != nil { + t.Fatal(err) + } + + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + if _, ran := states["o1"]; !ran { + t.Fatalf("the frozen version's node did not commit: %v", states) + } + if _, ran := states["o2"]; ran { + t.Fatalf("the republished version's node committed: %v", states) + } + var after string + if err := store.Pool.QueryRow(`SELECT snapshot_id FROM workflow_runs WHERE id=$1`, runID).Scan(&after); err != nil { + t.Fatal(err) + } + if after != snapshotID { + t.Fatalf("run snapshot moved to %s; want the frozen %s", after, snapshotID) + } +} + +// A snapshot that predates the publish gate can carry a node kind the first phase does not execute. +// Such a node must fail honestly with its own reason: the run settles failed and the unsupported +// node is never recorded as succeeded (#69 leaves this judgement to the execution side, D2 refuses +// these kinds at publish for everything published after the gate). +func TestWorkflowAdvanceFailsAnUnsupportedNodeKind(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunScene(t, store, wfGraph( + []Object{wfNode("s", "start", ""), wfNode("l", "loop", "iterate"), wfNode("o", "output", "")}, + [][3]string{{"s", "l", ""}, {"l", "o", ""}}, + )) + + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "unsupported_node_kind" { + t.Fatalf("run settled %q error %q; want failed/unsupported_node_kind", status, runErr) + } + node, ok := asWorkflowObject(states["l"]) + if !ok { + t.Fatalf("the unsupported node has no committed state: %v", states) + } + if node.S("status") != "failed" || node.O("output").S("reason") != "unsupported_node_kind" { + t.Fatalf("unsupported node state = %v; want failed with its reason", states["l"]) + } + if _, ran := states["o"]; ran { + t.Fatalf("the node downstream of the unsupported one committed a state: %v", states) + } +} + +// A DAG that reconverges is legal and drains: two paths lead into `c`, and the wave visits `c` once +// after both of them committed. The cycle refusal must not read a join as one — refusing a legal +// document is worse than missing an illegal one, and a join is the shape the two are easiest to +// confuse. +func TestWorkflowAdvanceSucceedsAReconvergentGraph(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunScene(t, store, wfGraph( + []Object{wfNode("s", "start", ""), wfNode("a", "output", ""), wfNode("b", "output", ""), wfNode("c", "output", ""), wfNode("o", "output", "")}, + [][3]string{{"s", "a", ""}, {"s", "b", ""}, {"a", "c", ""}, {"b", "c", ""}, {"c", "o", ""}}, + )) + + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" { + t.Fatalf("run settled %q error %q; want succeeded", status, runErr) + } + for _, id := range []string{"s", "a", "b", "c", "o"} { + state, ok := asWorkflowObject(states[id]) + if !ok { + t.Fatalf("node %s has no committed state: %v", id, states) + } + if state.S("status") != "succeeded" { + t.Fatalf("node %s status = %q; want succeeded", id, state.S("status")) + } + } +} + +// A snapshot frozen before the publish gate can carry a cycle, and such a run must not settle +// succeeded: a cycle reachable from the start goes round once and stops with its back edge silently +// dropped, so the pass used to report success for a document whose author drew a loop — the node on +// the back edge never produced a result for the edge that pointed back at it. The run settles failed +// instead, and because nothing executed, no node is recorded as having executed: a state written here +// would claim work that never happened. +func TestWorkflowAdvanceFailsACyclicSnapshot(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunScene(t, store, wfGraph( + []Object{wfNode("s", "start", ""), wfNode("a", "output", ""), wfNode("b", "output", "")}, + [][3]string{{"s", "a", ""}, {"a", "b", ""}, {"b", "a", ""}}, + )) + + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, version := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "graph has a cycle" { + t.Fatalf("run settled %q error %q; want failed/graph has a cycle", status, runErr) + } + if len(states) != 0 { + t.Fatalf("a refused cyclic run committed node states: %v", states) + } + if version != 3 { // claim +1, settle +1, and no node ever committed + t.Fatalf("version = %d; want 3", version) + } + // The refusal is terminal: the retry loop never rescans the run, so it cannot drift to succeeded. + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("second advance: %v", err) + } + if status, _, _, after := readWorkflowRun(t, store, runID); status != "failed" || after != version { + t.Fatalf("second pass moved the run: %q version %d; want failed version %d", status, after, version) + } +} + +// A cycle nothing reaches is refused the same way. The projection would leave these nodes unresolved +// — each waits for an upstream that waits for it — and the run used to settle succeeded with them +// carrying no state at all, which is the plainest form of "succeeded with nodes that never ran". +func TestWorkflowAdvanceFailsAHistoricalCycleBehindTheStart(t *testing.T) { + store := dispatcherDB(t) + runID := seedWorkflowRunScene(t, store, wfGraph( + []Object{wfNode("s", "start", ""), wfNode("o", "output", ""), wfNode("a", "output", ""), wfNode("b", "output", "")}, + [][3]string{{"s", "o", ""}, {"a", "b", ""}, {"b", "a", ""}}, + )) + + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "graph has a cycle" { + t.Fatalf("run settled %q error %q; want failed/graph has a cycle", status, runErr) + } + for _, id := range []string{"s", "o", "a", "b"} { + if _, ran := states[id]; ran { + t.Fatalf("node %s committed a state on a refused run: %v", id, states) + } + } +} + // The pass is idempotent: a second tick over a settled run rescans nothing and leaves the committed // row untouched — no second claim, no second settle, no version drift. func TestWorkflowAdvanceIsIdempotent(t *testing.T) { From b9a888dd97854a5d592826ac0d58ce6ae3acb1c7 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 11:00:31 +0800 Subject: [PATCH 13/26] fix(core): freeze the plugin version in the run's creation transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run's execution identity is frozen at creation — snapshot, input, initiator, Project, Workspace binding, per-slot model bindings (D3). The plugin version was the one member missing. `workflowSessionSpec` read the space's *current* version at session start, so an upgrade between create and dispatch changed what an already-created run executed, while its own record said nothing about either version. The gate's comment claimed this matched the IssueRun path; it did not — `agent_run_settle.go` pins agentPluginVersion in the run input at create and never re-reads it. This makes the two paths agree. The freeze is the same read moved earlier, not a new rule: it reads the space's desired state, so an install still converging freezes the version it is converging to. It runs in the creation transaction beside resolveWorkflowRunProject, because the plugin lives in the Project's space. 0039 adds a nullable plugin_version and backfills nothing. A pre-existing row was never frozen and there is no recoverable answer for it — the space's current version is not what such a run was created against, so writing it would record a fact that never held. Such a run fails at its first agent node with the new reason `agent_plugin_version_unfrozen` rather than guessing; the reason is its own code so those rows stay countable. Whether they should instead be backfilled or refused is an open owner decision and this change records no answer to it. Refusing at creation when no version can be frozen is likewise left to the owner: the executor-slot gate beside it does refuse, but that would turn a request the API accepts today into a 400. Old-code failure evidence (both cases, against the pre-fix live read): spec = map[agentPluginId:official/ora-space.opencode agentPluginVersion:2.0.0 ...] — an in-flight upgrade to 2.0.0 changed the session spec of a run created against 1.0.0 run settled "running" error ""; want failed/agent_plugin_version_unfrozen — a run with no frozen version proceeded to session start instead of failing Tests: TestWorkflowRunFreezesPluginVersionAgainstUpgrade and TestWorkflowSessionRefusesAnUnfrozenPluginVersion in workflow_run_session_db_test.go, plus the existing scene now freezes the same values the creation transaction does. go test ./internal/core/ -run 'TestWorkflow' -count=1 go test ./internal/core/ -count=1 (36.4s, pass) go test ./integration/... (only the known TestGitServerAuthorizesDeliveryPushFromCloudState, which needs git http-backend) Also pins 0039 to eol=lf in .gitattributes: CheckSchema re-hashes the embedded bytes on every boot, so an unpinned migration has a platform-dependent checksum. 0034-0038 are left unpinned deliberately — pinning them would change bytes a database has already recorded. Co-Authored-By: Claude Code --- .gitattributes | 4 + .../0039_workflow_run_plugin_version.sql | 27 ++++ internal/core/workflow_run.go | 40 +++++- internal/core/workflow_run_gate.go | 9 +- internal/core/workflow_run_session.go | 24 ++-- internal/core/workflow_run_session_db_test.go | 116 +++++++++++++++++- 6 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 internal/core/migrations/0039_workflow_run_plugin_version.sql diff --git a/.gitattributes b/.gitattributes index 9ef377e92..f05ed145c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,7 @@ internal/core/migrations/0033_revision_resume.sql text eol=lf # Everything introduced since main is pinned here instead, while no released database has recorded # it yet; after merge that would no longer be true. internal/core/migrations/0039_workflow_run_identity.sql text eol=lf + +internal/core/migrations/0039_executor_slot_bindings.sql text eol=lf +internal/core/migrations/0040_workflow_execution_node.sql text eol=lf +internal/core/migrations/0041_workflow_run_plugin_version.sql text eol=lf diff --git a/internal/core/migrations/0039_workflow_run_plugin_version.sql b/internal/core/migrations/0039_workflow_run_plugin_version.sql new file mode 100644 index 000000000..5e1882fe6 --- /dev/null +++ b/internal/core/migrations/0039_workflow_run_plugin_version.sql @@ -0,0 +1,27 @@ +-- Freeze the plugin version a WorkflowRun executes with (specs decisions/cloud/workflow +-- 0-cloud-side-scheduler-owns-workflow-execution.md D3: the execution identity — snapshot, input, +-- initiator, Project, run Workspace binding, per-slot model bindings, plugin version — is frozen in +-- the run's creation transaction). +-- +-- Why it has to be frozen. A run executes over minutes; a space's plugin version moves whenever +-- somebody upgrades. Reading the version at session start made an already-created run's execution +-- content depend on a decision taken after it was created, which is the one thing the frozen identity +-- exists to prevent: a run started against 1.0.0 and dispatched after an upgrade to 2.0.0 would have +-- executed 2.0.0 while its own record said nothing about either. The frozen value is what the session +-- spec carries, so the plugin identity is as reproducible as the snapshot, the model bindings and the +-- Project already are. +-- +-- Nullable, and deliberately not backfilled. Rows written before this migration were never frozen, and +-- there is no recoverable answer for them: the space's current version is not what such a run was +-- created against, so writing it would record a fact that never held. A run with no frozen version +-- fails at its first agent node with `agent_plugin_version_unfrozen` rather than guessing — see +-- workflowSessionSpec. Whether such rows should instead be backfilled, refused or executed under some +-- other rule is an open owner decision; this migration records no answer to it. +-- +-- A run with no Project also leaves this NULL: the plugin lives in the Project's space, so a run that +-- named no Project has no space to read one from. Such a run already fails at its first agent node +-- (workflow_run_project_required) before the plugin version is ever needed. +-- +-- Additive; edits no applied migration. + +ALTER TABLE workflow_runs ADD COLUMN plugin_version text; diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index 64b4acef7..cf7180826 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -104,7 +104,10 @@ func workflowRunBindings(t *transaction, runID string) []any { // which is why the uid is part of the request rather than inferred later. // // The run's Project comes from the caller (D3): see resolveWorkflowRunProject for why it is stated -// rather than inferred, and why a request that names none is refused rather than accepted. +// rather than inferred, and why a request that names none is refused rather than accepted. The plugin +// version is read here rather than at session start for the same reason — see +// resolveWorkflowRunPluginVersion — so that upgrading the space's plugin while the run is in flight +// cannot change what it executes. func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { w := workflow(t, r.TenantID, r.WorkflowID) name := w.S("name") @@ -122,7 +125,8 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { require(r.Identity != nil, 401, "user_credential_required") createdBy := identityWithAlias(t, r.Identity).S("id") projectID := resolveWorkflowRunProject(t, r, uid) - t.exec("INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)", id, r.TenantID, r.WorkflowID, s.S("id"), name, "pending", jsonText(input), createdBy, projectID) + pluginVersion := resolveWorkflowRunPluginVersion(t, r.TenantID, projectID) + t.exec("INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id,plugin_version) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", id, r.TenantID, r.WorkflowID, s.S("id"), name, "pending", jsonText(input), createdBy, projectID, pluginVersion) if t.simulator != nil { nodeStates, rounds, status := t.simulator.SimulateWorkflowRun(s.O("graph"), input) t.exec("UPDATE workflow_runs SET node_states=$1,rounds=$2,status=$3,started_at=now(),finished_at=now(),version=version+1,updated_at=now() WHERE id=$4", jsonText(nodeStates), jsonText(rounds), status, id) @@ -176,3 +180,35 @@ func resolveWorkflowRunProject(t *transaction, r *PublicRequest, uid string) any require(p.S("lifecycle") != "deleting", 409, "resource_unavailable") return p.S("id") } + +// resolveWorkflowRunPluginVersion reads the plugin version the run will execute with, so the creation +// transaction freezes it alongside the rest of the run's identity (D3). The plugin lives in the +// Project's space, which is why this takes the Project the same transaction just froze: a run that +// named none has no space to read from and freezes nothing. +// +// It is the same read the session start used to make, moved earlier rather than changed. The version +// is whatever the space's desired state says at creation; nothing here consults the observed state, so +// an install still converging freezes the version it is converging to, and a removal freezes the +// version it is removing from — both are what the run's author asked for at the moment they asked. +// +// A missing plugin freezes nothing rather than refusing the creation. The run's Workspace provisions +// and its first agent node then fails honestly with `agent_plugin_version_unfrozen`, which is where +// every other permanent gap in a run's identity is reported. Refusing at creation is a defensible +// alternative — the executor-slot gate beside this does refuse — but it would turn a request the API +// accepts today into a 400, so it stays an owner decision rather than a change made here. +func resolveWorkflowRunPluginVersion(t *transaction, tenantID string, projectID any) any { + pid := "" + if s, ok := projectID.(string); ok { + pid = s + } + if pid == "" { + return nil + } + pv := t.one(`SELECT p.desired_version FROM projects pr JOIN space_plugins p ON p.space_id=pr.space_id + AND p.source_namespace=split_part($3,'/',1) AND p.identifier=split_part($3,'/',2) + WHERE pr.id=$1 AND pr.tenant_id=$2 AND pr.deleted_at IS NULL`, pid, tenantID, opencodeAgentCLI) + if pv == nil || pv.S("desiredVersion") == "" { + return nil + } + return pv.S("desiredVersion") +} diff --git a/internal/core/workflow_run_gate.go b/internal/core/workflow_run_gate.go index 6414e028d..55467dd40 100644 --- a/internal/core/workflow_run_gate.go +++ b/internal/core/workflow_run_gate.go @@ -8,9 +8,12 @@ package core // // Freezing is what makes a run reproducible. A mapping edited, a connection disabled or a model // withdrawn after creation cannot change what an already-created run executes, because the session -// starter reads the frozen row rather than resolving the slot again. The plugin version is -// deliberately NOT frozen here — it is read at session start from the run project's space, exactly -// like an IssueRun's. +// starter reads the frozen row rather than resolving the slot again. +// +// The plugin version is frozen too, but not here: it is part of the invocation identity rather than of +// a slot's resolution, so the creation transaction reads it beside the Project it needs — see +// resolveWorkflowRunPluginVersion. What matters is the same in both places: the session starter reads +// the frozen value and never resolves it again. // // Resolving a slot is one function (resolveWorkflowSlotExecutor), called by both halves in the same // transaction, so a slot cannot be admitted by one policy and frozen by another. diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index 05b42b3bf..1befbe7bc 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -159,17 +159,21 @@ func workflowSessionTarget(t *transaction, wid string) Object { // workflowSessionSpec builds the AgentSession snapshot for a workflow node's session — the same // wire shape sessionStartSpec produces for an IssueRun. The plugin identity is fixed (slots -// decision D4); the version is read at start from the run project's space_plugins, matching how -// an IssueRun resolves its plugin. The initial turn is the node's prompt rendered as one text -// block; the model binding is the frozen binding of the node's slot. A permanent gap returns a -// named reason instead of an error so the node fails honestly rather than retrying forever. +// decision D4) and its version is the one the creation transaction froze (D3), never the space's +// current version: the IssueRun path pins agentPluginVersion in the run input at create and never +// re-reads it (see agent_run_settle.go), and a workflow run now does the same, so an upgrade during +// the run cannot change what it executes. The initial turn is the node's prompt rendered as one +// text block; the model binding is the frozen binding of the node's slot. A permanent gap returns +// a named reason instead of an error so the node fails honestly rather than retrying forever. func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w, target Object) (spec Object, reason string) { runID := run.S("id") - pv := t.one(`SELECT p.desired_version FROM projects pr JOIN space_plugins p ON p.space_id=pr.space_id - AND p.source_namespace=split_part($3,'/',1) AND p.identifier=split_part($3,'/',2) - WHERE pr.id=$1 AND pr.tenant_id=$2 AND pr.deleted_at IS NULL`, run.S("projectId"), run.S("tenantId"), opencodeAgentCLI) - if pv == nil || pv.S("desiredVersion") == "" { - return nil, "agent_plugin_unavailable" + // A run created before the plugin version was frozen carries none, and there is no honest value to + // substitute: the space's current version is not what this run was created against. It is reported + // as its own reason rather than as `agent_plugin_unavailable` so that rows affected by the missing + // freeze are countable, because what should happen to them is still an open owner decision. + pv := run.S("pluginVersion") + if pv == "" { + return nil, "agent_plugin_version_unfrozen" } base := w.S("baseCommitId") if !commitID(base) { @@ -196,7 +200,7 @@ func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w, t spec = Object{ "kind": "agent_session", "agentPluginId": opencodeAgentCLI, - "agentPluginVersion": pv.S("desiredVersion"), + "agentPluginVersion": pv, "checkoutExecutionId": checkout.S("executionId"), "gitIdentity": git, "initialTurn": Object{"turnId": newID(), "content": []Object{{"text": prompt}}}, diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 2a7e0971e..4cac4e0d3 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -2,6 +2,7 @@ package core import ( "context" + "database/sql" "encoding/json" "testing" "time" @@ -34,8 +35,8 @@ func workflowTwoAgentGraph() Object { } // seedWorkflowSessionScene stages a project-backed run one advance away from session start: the -// binding (the same freeze the creation gate performs), and the project space carries the -// production plugin. Returns the ids the tests read back. +// binding and the plugin version (the same freezes the creation transaction performs), and the +// project space carries the production plugin. Returns the ids the tests read back. func seedWorkflowSessionScene(t *testing.T, store *Store) (runID, tid, uid, space, project string) { t.Helper() return seedWorkflowSessionSceneGraph(t, store, workflowSessionGraph()) @@ -62,7 +63,7 @@ func seedWorkflowSessionSceneGraph(t *testing.T, store *Store, graph Object) (ru graphJSON, _ := json.Marshal(graph) exec("workflow", `INSERT INTO workflows(id,tenant_id,name,graph) VALUES($1,$2,'wfs',$3::jsonb)`, wid, tid, string(graphJSON)) exec("snapshot", `INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'v1',$4::jsonb)`, sid, tid, wid, string(graphJSON)) - exec("run", `INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id) VALUES($1,$2,$3,$4,'disp','pending','{}'::jsonb,$5,$6)`, rid, tid, wid, sid, uid, project) + exec("run", `INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,input,created_by,project_id,plugin_version) VALUES($1,$2,$3,$4,'disp','pending','{}'::jsonb,$5,$6,$7)`, rid, tid, wid, sid, uid, project, resolveWorkflowRunPluginVersion(tx, tid, project)) cred := uuid.NewString() conn := uuid.NewString() exec("credential", `INSERT INTO personal_model_credentials(id,user_id,ciphertext,key_id) VALUES($1,$2,repeat('c',64),'test-key')`, cred, uid) @@ -409,6 +410,115 @@ func TestWorkflowSessionFailureFailsRun(t *testing.T) { } } +// readFrozenPluginVersion reads the plugin version off the run row: the frozen identity is a stored +// fact, so the only way to tell "the API froze it" from "something resolved it later" is to look at +// what the creation transaction left behind. +func readFrozenPluginVersion(t *testing.T, store *Store, runID string) string { + t.Helper() + var frozen sql.NullString + if err := store.Pool.QueryRow(`SELECT plugin_version FROM workflow_runs WHERE id=$1`, runID).Scan(&frozen); err != nil { + t.Fatalf("read run plugin version: %v", err) + } + return frozen.String +} + +// pluginVersionOf runs the creation-time resolver in its own transaction and returns what it would +// freeze right now — the same read createWorkflowRun makes, so a run created at this moment freezes +// exactly this. +func pluginVersionOf(t *testing.T, store *Store, tid, project string) string { + t.Helper() + frozen := "" + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + frozen, _ = resolveWorkflowRunPluginVersion(tx, tid, project).(string) + return Object{} + }); err != nil { + t.Fatalf("resolve plugin version: %v", err) + } + return frozen +} + +// An upgrade of the space's plugin while a run is in flight cannot change what that run executes: the +// version is frozen in the creation transaction and the session spec carries the frozen one. Before +// this, the version was read at session start, so a run created against 1.0.0 and dispatched after an +// operator upgraded to 2.0.0 executed 2.0.0 — the run's execution content decided by a change made +// after it was created, which is the one thing the frozen identity exists to prevent. +func TestWorkflowRunFreezesPluginVersionAgainstUpgrade(t *testing.T) { + store := dispatcherDB(t) + runID, tid, _, space, project := seedWorkflowSessionScene(t, store) + if frozen := readFrozenPluginVersion(t, store, runID); frozen != "1.0.0" { + t.Fatalf("run froze plugin version %q; want the space's 1.0.0", frozen) + } + + // The upgrade an operator performs without knowing a run is open. + if _, err := store.Pool.Exec(`UPDATE space_plugins SET desired_version='2.0.0', version=version+1 WHERE space_id=$1`, space); err != nil { + t.Fatalf("upgrade plugin: %v", err) + } + + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance to workspace create: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + _, spec := startSessionNode(t, store, runID, wsID, "a") + if spec.S("agentPluginVersion") != "1.0.0" { + t.Fatalf("session plugin version = %q; want the frozen 1.0.0", spec.S("agentPluginVersion")) + } + // The freeze reads the live value at creation rather than a constant: a run created after the + // upgrade freezes 2.0.0, and the run already in flight still carries 1.0.0. + if after := pluginVersionOf(t, store, tid, project); after != "2.0.0" { + t.Fatalf("a run created after the upgrade would freeze %q; want 2.0.0", after) + } + if frozen := readFrozenPluginVersion(t, store, runID); frozen != "1.0.0" { + t.Fatalf("the upgrade rewrote the in-flight run's frozen version to %q", frozen) + } +} + +// A run created before the version was frozen carries none, and the session start must not read the +// space to find one: the space still has the plugin installed here, so a live read would succeed and +// would silently execute a version the run was never created against. The node fails with its own +// reason instead, which is what makes such rows countable while the compatibility rule is undecided. +func TestWorkflowSessionRefusesAnUnfrozenPluginVersion(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, space, _ := seedWorkflowSessionScene(t, store) + if _, err := store.Pool.Exec(`UPDATE workflow_runs SET plugin_version=NULL WHERE id=$1`, runID); err != nil { + t.Fatalf("clear frozen version: %v", err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance to workspace create: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + bindConnectedNode(t, store, wsID) + if _, err := store.Pool.Exec(`UPDATE node_instances SET model_proxy=true WHERE workspace_id=$1`, wsID); err != nil { + t.Fatal(err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance to session start: %v", err) + } + + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != "agent_plugin_version_unfrozen" { + t.Fatalf("run settled %q error %q; want failed/agent_plugin_version_unfrozen", status, runErr) + } + agent, _ := asWorkflowObject(states["a"]) + if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "agent_plugin_version_unfrozen" { + t.Fatalf("agent state = %v; want the unfrozen-version failure", states["a"]) + } + // Nothing was dispatched: the run must not have reached the Node with a version read from the space. + var work int + if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1`, runID).Scan(&work); err != nil { + t.Fatal(err) + } + if work != 0 { + t.Fatalf("%d work items were released for a run with no frozen plugin version", work) + } + var installed string + if err := store.Pool.QueryRow(`SELECT desired_version FROM space_plugins WHERE space_id=$1`, space).Scan(&installed); err != nil { + t.Fatal(err) + } + if installed != "1.0.0" { + t.Fatalf("the space's plugin moved to %q; the case no longer proves a live read was refused", installed) + } +} + // A session that never dispatches fails honestly once the startup deadline passes: the node and the // run carry the named reason instead of hanging in `running` forever. func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { From 3eab7a8df5573be6d3e03823242795a6f7998d0a Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 11:17:23 +0800 Subject: [PATCH 14/26] test(core): assert the run Workspace release on every terminal status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D7 release was asserted on the succeeded path only. A run that fails or is cancelled while holding a live Workspace is the case where a missing release matters most — the clone, the sandbox and the Project's operation slot stay occupied with nothing left to notice — and neither the agent_failed path nor the startup-deadline path checked that the deletion was declared. assertRunWorkspaceReleased collects both halves of the release (no unregistered work survives; exactly one delete_workspace operation is declared) and the three terminal-status tests call it, so the failure and cancelled paths are now held to the same contract as the succeeded one. The `cancelled` status had no coverage at all; TestWorkflowSessionCancelledReleasesTheRunWorkspace adds it, because a run that is cancelled is not a run that failed and the two settle through different branches. Failure evidence for the new assertions — with finishWorkflowRunCleanup's deleteWorkflowRunWorkspace call removed, all four tests fail: --- FAIL: TestWorkflowSessionRunsEndToEnd workflow_run_session_db_test.go:221: 0 workspace deletions declared for a terminal run; want 1 --- FAIL: TestWorkflowSessionFailureFailsRun workflow_run_session_db_test.go:391: 0 workspace deletions declared for a terminal run; want 1 --- FAIL: TestWorkflowSessionCancelledReleasesTheRunWorkspace workflow_run_session_db_test.go:415: 0 workspace deletions declared for a terminal run; want 1 --- FAIL: TestWorkflowSessionStartDeadlineFailsNode workflow_run_session_db_test.go:579: 0 workspace deletions declared for a terminal run; want 1 Tests: go test ./internal/core/ -count=1 (35.4s, pass) This does NOT close F. A session that dispatched and never reports still holds its run `running` forever, and no evidence-based fix exists for it: the only committed signal a workflow run's execution can carry is terminated_by_force_stop_id, and the sole writer of that column is reachable only through POST /workspaces/:wid/force-stop, which resolves the Workspace through store.go's workspace() — and that excludes workflow_run_id IS NOT NULL, so a run Workspace 404s there and can never be force-stopped. The remaining real-world trigger is a Node that vanishes, and reconcileRuntimeControls already rules out inferring death from it ("A missing connection, an empty new table or elapsed time alone proves neither"), while the IssueRun sibling bounds exactly that case with AgentRestoreTimeout because a disconnected Node may legitimately restore. Bounding an in-flight session is therefore a timeout-policy decision, not a defect fix, and is reported to the owner rather than invented here. Co-Authored-By: Claude Code --- internal/core/workflow_run_session_db_test.go | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 4cac4e0d3..10b6371ad 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -218,20 +218,7 @@ func TestWorkflowSessionRunsEndToEnd(t *testing.T) { t.Fatalf("agent output reason = %q; want idle_timeout", reason) } // Terminal cleanup: no unregistered work, and the Workspace deletion is declared. - var unregistered int - if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL`, runID).Scan(&unregistered); err != nil { - t.Fatal(err) - } - if unregistered != 0 { - t.Fatalf("%d unregistered work items survived", unregistered) - } - var deletes int - if err := store.Pool.QueryRow(`SELECT count(*) FROM operations o JOIN workspaces w ON w.id=o.workspace_id WHERE w.workflow_run_id=$1 AND o.kind='delete_workspace'`, runID).Scan(&deletes); err != nil { - t.Fatal(err) - } - if deletes != 1 { - t.Fatalf("%d delete operations declared; want 1", deletes) - } + assertRunWorkspaceReleased(t, store, runID) } // A run's agent nodes execute serially and settle from their own evidence. The second node starts on @@ -401,12 +388,56 @@ func TestWorkflowSessionFailureFailsRun(t *testing.T) { if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "agent_failed" { t.Fatalf("agent state = %v; want failed with agent_failed", states["a"]) } + assertRunWorkspaceReleased(t, store, runID) +} + +// A session that reports `cancelled` cancels its node and the run. Cancelled is a third terminal +// status with its own settlement path — a run that is cancelled rather than failed is not a run that +// failed, and the two are asserted separately rather than inferred from each other. +func TestWorkflowSessionCancelledReleasesTheRunWorkspace(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec := startSessionNode(t, store, runID, wsID, "a") + execution := dispatchSession(t, store, runID, "a", nodeService, spec) + reportSessionEnd(t, store, runID, execution, nodeService, "cancelled") + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "cancelled" || runErr != "cancelled" { + t.Fatalf("run settled %q error %q states=%v; want cancelled/cancelled", status, runErr, states) + } + agent, _ := asWorkflowObject(states["a"]) + if agent == nil || agent.S("status") != "cancelled" || agent.O("output").S("reason") != "cancelled" { + t.Fatalf("agent state = %v; want cancelled carrying the session's own reason", states["a"]) + } + assertRunWorkspaceReleased(t, store, runID) +} + +// assertRunWorkspaceReleased asserts the D7 half of a terminal settle: the run's Workspace has its +// deletion declared, and no unregistered session work survives to be claimed. Every terminal status +// owes this, and the failing and cancelled ones owe it most — a run that stops holding a live +// Workspace is exactly the case where a missing release leaves the clone, the sandbox and the +// Project's operation slot occupied with nothing left to notice. Declaring the delete is a +// declaration rather than a completed deletion: the operation machinery drains it asynchronously, +// and re-declaring on a later pass is a no-op. +func assertRunWorkspaceReleased(t *testing.T, store *Store, runID string) { + t.Helper() var unregistered int if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL`, runID).Scan(&unregistered); err != nil { - t.Fatal(err) + t.Fatalf("count unregistered work: %v", err) } if unregistered != 0 { - t.Fatalf("%d unregistered work items survived a failed run", unregistered) + t.Fatalf("%d unregistered work items survived a terminal run", unregistered) + } + var deletes int + if err := store.Pool.QueryRow(`SELECT count(*) FROM operations o JOIN workspaces w ON w.id=o.workspace_id + WHERE w.workflow_run_id=$1 AND o.kind='delete_workspace'`, runID).Scan(&deletes); err != nil { + t.Fatalf("count delete operations: %v", err) + } + if deletes != 1 { + t.Fatalf("%d workspace deletions declared for a terminal run; want 1", deletes) } } @@ -545,13 +576,7 @@ func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "session_start_deadline_exceeded" { t.Fatalf("agent state = %v; want the deadline failure", states["a"]) } - var unregistered int - if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1 AND execution_id IS NULL`, runID).Scan(&unregistered); err != nil { - t.Fatal(err) - } - if unregistered != 0 { - t.Fatalf("%d undispatched work items survived", unregistered) - } + assertRunWorkspaceReleased(t, store, runID) } // A replayed terminal receipt for a settled run changes nothing: the run's status, its committed From a81de39688a6212af8db1ce2f4ec410ef093e770 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 11:29:01 +0800 Subject: [PATCH 15/26] fix(core): stop stamping finished_at on a run that is still executing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settleWorkflowRunStates wrote finished_at=now() on every settle, but it is also how a pass reports progress: committing an agent node `running`, and settling one so the next wave starts. A run whose session had just been dispatched therefore carried the moment it "finished" while it was still executing. The projection is what a client reads — a non-empty finishedAt is how the run viewer renders a run as over — and the contract already declared the field nullable (WorkflowRun's `startedAt finishedAt` optional list), so the implementation was the side that disagreed with its own contract. Only a terminal status writes the timestamp now, and COALESCE keeps the first terminal write so a later pass re-declaring a terminal status cannot move the moment the run stopped. started_at is untouched: it is set once where the run leaves `pending`. Old-code failure evidence — with the committed settle restored, the regression test fails on the first progress settle: --- FAIL: TestWorkflowRunningCarriesNoFinishedAt workflow_run_session_db_test.go:447: a run whose session just started carries finished_at "2026-10-17T03:19:50Z"; want none TestWorkflowRunningCarriesNoFinishedAt also asserts the terminal write lands, and that a later pass cannot move it. Tests: go test ./internal/core/ -run 'TestWorkflow' -count=1 (8.3s, pass) go test ./internal/core/ -count=1 (pass) Co-Authored-By: Claude Code --- internal/core/workflow_run_session_db_test.go | 49 +++++++++++++++++++ internal/core/workflow_scheduler.go | 24 +++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 10b6371ad..803ed27ae 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -415,6 +415,55 @@ func TestWorkflowSessionCancelledReleasesTheRunWorkspace(t *testing.T) { assertRunWorkspaceReleased(t, store, runID) } +// readRunFinishedAt reads the run's terminal timestamp; "" means the run has not finished, which is +// what the projection's finishedAt carries. +func readRunFinishedAt(t *testing.T, store *Store, runID string) string { + t.Helper() + var finished sql.NullTime + if err := store.Pool.QueryRow(`SELECT finished_at FROM workflow_runs WHERE id=$1`, runID).Scan(&finished); err != nil { + t.Fatalf("read run finished_at: %v", err) + } + if !finished.Valid { + return "" + } + return finished.Time.UTC().Format(time.RFC3339) +} + +// A run that is still executing carries no finished_at. The timestamp is the run's terminal moment, +// and the projection is what a client reads: a non-empty finishedAt is how it renders a run as over. +// The progress passes stamp it too — committing an agent node running, and settling one so the next +// wave starts — so a live run used to report the moment it "finished" while its session was still +// executing. Only a terminal status writes it, and the first terminal write wins. +func TestWorkflowRunningCarriesNoFinishedAt(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec := startSessionNode(t, store, runID, wsID, "a") + // The pass that committed the node running is a progress settle: the run is `running`. + if finished := readRunFinishedAt(t, store, runID); finished != "" { + t.Fatalf("a run whose session just started carries finished_at %q; want none", finished) + } + execution := dispatchSession(t, store, runID, "a", nodeService, spec) + if finished := readRunFinishedAt(t, store, runID); finished != "" { + t.Fatalf("a run with a dispatched session carries finished_at %q; want none", finished) + } + reportSessionEnd(t, store, runID, execution, nodeService, "idle_timeout") + finished := readRunFinishedAt(t, store, runID) + if finished == "" { + t.Fatal("a succeeded run carries no finished_at; want the terminal moment") + } + // A later pass re-declaring the terminal status cannot move the moment the run stopped. + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance after terminal: %v", err) + } + if again := readRunFinishedAt(t, store, runID); again != finished { + t.Fatalf("finished_at moved from %q to %q on a later pass", finished, again) + } +} + // assertRunWorkspaceReleased asserts the D7 half of a terminal settle: the run's Workspace has its // deletion declared, and no unregistered session work survives to be claimed. Every terminal status // owes this, and the failing and cancelled ones owe it most — a run that stops holding a live diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index bf796a3bc..7b950fd97 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -562,18 +562,34 @@ func workflowInactiveStates(graph workflowGraphView, states map[string]workflowN // compare-and-swap that makes a late or duplicate pass a no-op (invariant 3). Zero affected rows // (a concurrent pass committed first) discards this pass's local view; the next tick recomputes // from the committed row. +// +// finished_at is the run's terminal timestamp, so only a terminal status writes it. This function is +// also how a pass reports progress — committing a node `running`, or settling one and letting the +// next wave start — and those passes used to stamp finished_at too, so a run that was still +// executing carried the moment it "finished". The projection is what a client reads: `finishedAt` +// non-empty is how it renders a run as over, so the field has to mean what it says. COALESCE keeps +// the first terminal write, so a later pass re-declaring a terminal status cannot move the moment +// the run actually stopped. started_at is not this function's business: it is set once, where the +// run first leaves `pending`. func settleWorkflowRunStates(t *transaction, runID string, version int64, status, runErr string, states map[string]workflowNodeState) { + // The statuses a run never leaves. `pending`, `running` and `awaiting_input` are the others the + // column admits, and all three are states a run is still in. + terminal := status == "succeeded" || status == "failed" || status == "cancelled" if states == nil { - t.exec(`UPDATE workflow_runs SET status=$2, error=$3, finished_at=now(), version=version+1, updated_at=now() - WHERE id=$1 AND version=$4 AND deleted_at IS NULL`, runID, status, runErr, version) + t.exec(`UPDATE workflow_runs SET status=$2, error=$3, + finished_at=CASE WHEN $5 THEN COALESCE(finished_at,now()) ELSE finished_at END, + version=version+1, updated_at=now() + WHERE id=$1 AND version=$4 AND deleted_at IS NULL`, runID, status, runErr, version, terminal) return } encoded := Object{} for id, state := range states { encoded[id] = state.object() } - t.exec(`UPDATE workflow_runs SET node_states=$2, status=$3, error=$4, finished_at=now(), version=version+1, updated_at=now() - WHERE id=$1 AND version=$5 AND deleted_at IS NULL`, runID, jsonText(encoded), status, runErr, version) + t.exec(`UPDATE workflow_runs SET node_states=$2, status=$3, error=$4, + finished_at=CASE WHEN $6 THEN COALESCE(finished_at,now()) ELSE finished_at END, + version=version+1, updated_at=now() + WHERE id=$1 AND version=$5 AND deleted_at IS NULL`, runID, jsonText(encoded), status, runErr, version, terminal) } // workflowRunVersion re-reads the row's version after a mid-pass settle bumped it: one pass commits From 1f0ec13162cc7c1810ae39909f10640589e162b4 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 11:47:21 +0800 Subject: [PATCH 16/26] fix(core): record a failed node's reason where the contract says it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run's nodeStates carries an `error` field — the published OpenAPI description names it, run-node-state.ts types it as `error?: string`, and the run viewer renders it in its own Error section keyed on it (run-inspector.tsx) — but nothing on the server ever wrote it, and the simulator did not either. A failed node reached the viewer with an empty Error section while its reason sat inside Output.reason, so the one place the UI looks was always blank. The implementation was the side disagreeing with its own contract, so it moves: workflowNodeState gains Error, and the three writers that can fail a node set it — the session terminal commit (the session's end reason), failWorkflowAgentNode, and the never-dispatched startup deadline. A cancelled node carries no error: it was cancelled, not broken, and `cancelled` is a distinct terminal status the viewer renders as itself. An idle or succeeded node has nothing to report. Old-code failure evidence — with the committed commitWorkflowAgentTerminal restored, the regression test fails on the first failed node: failed agent error = ""; want agent_failed failed agent error = ""; want session_start_deadline_exceeded The description said only "status, displayName, startedAt, finishedAt, output", which is why the field read as optional-in-practice; it now says what is emitted and when. Regenerated through the project's flow (task openapi, then npm run api:generate) and verified idempotent with diff --strip-trailing-cr. The change is additive inside an additionalProperties object, so no client breaks. Tests: go test ./internal/core/ -count=1 (pass) npm run typecheck && npx vitest run src/features/workflows/run (pass) Co-Authored-By: Claude Code --- api/openapi.json | 2 +- frontend/src/api/generated.schemas.ts | 4 ++-- internal/contract/openapi.go | 2 +- internal/core/workflow_run_session.go | 7 ++++++- internal/core/workflow_run_session_db_test.go | 14 ++++++++++++++ internal/core/workflow_scheduler.go | 13 ++++++++++++- 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index ad48863d4..395408108 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4470,7 +4470,7 @@ }, "nodeStates": { "additionalProperties": true, - "description": "Per-node execution state keyed by node id: status, output, error, timestamps.", + "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed.", "type": "object" }, "rounds": { diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index 78811aaa5..ccd807e78 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -1866,7 +1866,7 @@ export type WorkflowRunDefinitionSnapshot = { [key: string]: unknown } | null; export type WorkflowRunInput = { [key: string]: unknown }; /** - * Per-node execution state keyed by node id: status, output, error, timestamps. + * Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed. */ export type WorkflowRunNodeStates = { [key: string]: unknown }; @@ -1911,7 +1911,7 @@ export interface WorkflowRun { */ modelBindings?: WorkflowRunModelBinding[] | null; name: string; - /** Per-node execution state keyed by node id: status, output, error, timestamps. */ + /** Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed. */ nodeStates: WorkflowRunNodeStates; rounds: WorkflowRunRoundsItem[]; snapshotId: string; diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index 3d3cbb297..adcc1fbb1 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -133,7 +133,7 @@ func Document() map[string]any { workflowRunProps := properties(s, "WorkflowRun") workflowRunProps["status"] = enumeration("pending", "running", "awaiting_input", "succeeded", "failed", "cancelled") workflowRunProps["input"] = obj{"type": "object", "additionalProperties": true, "description": "The kickoff input passed to the run's start node."} - workflowRunProps["nodeStates"] = obj{"type": "object", "additionalProperties": true, "description": "Per-node execution state keyed by node id: status, output, error, timestamps."} + workflowRunProps["nodeStates"] = obj{"type": "object", "additionalProperties": true, "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed."} workflowRunProps["rounds"] = array(obj{"type": "object", "additionalProperties": true}) // definitionSnapshot lives only on a detail read (the history list trips without it); // it is optional so one schema serves both list rows and the composed run page. diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index 1befbe7bc..21f2da522 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -78,6 +78,7 @@ func workflowAgentSessionOutcome(t *transaction, runID string, version int64, no if err == nil && time.Since(started) > workflowSessionStartDeadline { state.Status = "failed" state.FinishedAt = nowRFC3339() + state.Error = "session_start_deadline_exceeded" state.Output = Object{"reason": "session_start_deadline_exceeded"} states[nodeID] = state finishWorkflowRun(t, runID, version, "failed", "session start deadline exceeded", states) @@ -109,6 +110,10 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no state.Status = workflowSessionEndReason(result.S("reason")) state.FinishedAt = nowRFC3339() state.Output = Object{"reason": result.S("reason")} + // Only a failed node carries an error: a cancelled node was cancelled, not broken. + if state.Status == "failed" { + state.Error = result.S("reason") + } states[nodeID] = state switch state.Status { case "failed": @@ -260,7 +265,7 @@ func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView // failWorkflowAgentNode commits one agent node failed with a named reason and finishes the run. func failWorkflowAgentNode(t *transaction, runID string, version int64, states map[string]workflowNodeState, node workflowNodeView, reason string) { - states[node.ID] = workflowNodeState{Status: "failed", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339(), FinishedAt: nowRFC3339(), Output: Object{"reason": reason}} + states[node.ID] = workflowNodeState{Status: "failed", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339(), FinishedAt: nowRFC3339(), Error: reason, Output: Object{"reason": reason}} finishWorkflowRun(t, runID, version, "failed", reason, states) } diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 803ed27ae..0b25743bd 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -388,6 +388,12 @@ func TestWorkflowSessionFailureFailsRun(t *testing.T) { if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "agent_failed" { t.Fatalf("agent state = %v; want failed with agent_failed", states["a"]) } + // A failed node carries its reason as `error`, the field the run viewer's Error section reads. + // It was described in the contract and read by the client but never written, so a failed node + // showed an empty Error section while its reason sat unrendered inside output. + if got := agent.S("error"); got != "agent_failed" { + t.Fatalf("failed agent error = %q; want agent_failed", got) + } assertRunWorkspaceReleased(t, store, runID) } @@ -412,6 +418,11 @@ func TestWorkflowSessionCancelledReleasesTheRunWorkspace(t *testing.T) { if agent == nil || agent.S("status") != "cancelled" || agent.O("output").S("reason") != "cancelled" { t.Fatalf("agent state = %v; want cancelled carrying the session's own reason", states["a"]) } + // A cancelled node was cancelled, not broken, so it carries no error: the viewer's Error section + // is for nodes that failed. + if _, present := agent["error"]; present { + t.Fatalf("a cancelled node carries error %v; want none", agent["error"]) + } assertRunWorkspaceReleased(t, store, runID) } @@ -625,6 +636,9 @@ func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "session_start_deadline_exceeded" { t.Fatalf("agent state = %v; want the deadline failure", states["a"]) } + if got := agent.S("error"); got != "session_start_deadline_exceeded" { + t.Fatalf("failed agent error = %q; want session_start_deadline_exceeded", got) + } assertRunWorkspaceReleased(t, store, runID) } diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 7b950fd97..f3f3e0d3f 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -260,12 +260,21 @@ type workflowNodeState struct { DisplayName string StartedAt string FinishedAt string - Output Object + // Error is the node's failure reason, and only a failed node has one: a cancelled node was + // cancelled, not broken, and an idle or succeeded one has nothing to report. The run viewer + // renders it in its own Error section, which is why it is a field of the state rather than + // something the viewer has to dig out of Output — a node's Output is what it produced, and a + // failed node produced nothing. + Error string + Output Object } // object renders the state in the shape the run viewer reads (and the simulator writes). func (s workflowNodeState) object() Object { out := Object{"status": s.Status, "displayName": s.DisplayName, "startedAt": s.StartedAt, "finishedAt": s.FinishedAt} + if s.Error != "" { + out["error"] = s.Error + } if s.Output != nil { out["output"] = s.Output } @@ -288,6 +297,7 @@ func workflowStatesFromJSON(raw Object) map[string]workflowNodeState { DisplayName: node.S("displayName"), StartedAt: node.S("startedAt"), FinishedAt: node.S("finishedAt"), + Error: node.S("error"), } if output, ok := asWorkflowObject(node["output"]); ok { state.Output = output @@ -626,6 +636,7 @@ func executeWorkflowNode(node workflowNodeView, input Object, states map[string] // Unreachable: the ready pass only yields executable kinds, and the agent case returned // earlier. A future kind that reaches here must not be recorded as success. state.Status = "failed" + state.Error = "unsupported_node_kind" state.Output = Object{"reason": "unsupported_node_kind"} } return state From ff1f550f073498e9d42cd4b0a7fd581d79a3dfdd Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 11:47:55 +0800 Subject: [PATCH 17/26] fix(core): keep Cloud-internal columns off the Controller wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0038 added node_executions.workflow_node_id, and every Controller-facing read of that table was `SELECT *`. t.one/t.list serialise a row through row_to_json, so the new column appeared in response bodies as `workflowNodeId` — and the NodeExecution schema is closed. Two integration tests have been failing since 0038, not since any change in this branch: Post "http://127.0.0.1:.../internal/v1/operations/claim": OpenAPI response mismatch ...: Error at "/pluginExecutions/0": property "workflowNodeId" is unsupported The Controller is the side that is right, and the ADR says so twice over: the first gRPC contract covers only the lease and clone closure, so node_* work keeps being served by JSON /internal/v1 alone, whose view fields correspond verbatim to the desktop's interface (controller-integration D2, D7). The proto ExecutionRecord declares no such field either — it is Cloud-internal routing, denormalised onto the row so the one_session_per_workflow_node partial index has a predicate and so the scheduler can attribute a result without joining through execution_work. Nothing in Cloud reads it back off a row; every reader queries it in SQL. So the rows that reach a response body are projected explicitly through nodeExecutionColumns — the table minus that column — the way workflowRunSummary already projects a run row, which is why 0039's new column never reached the wire. Widening the schema was the alternative and is not taken: it would change a contract the Controller owns to describe a field the Controller has no use for. TestControllerExecutionProjectionIsExplicit pins the projection against information_schema, so a column added later must be classified deliberately — onto the wire, or into nodeExecutionInternalColumns — instead of drifting onto one or off the other by omission. Its teeth, with last_event_sequence removed from the projection: the Controller-facing projection drifted from the table: projected: [created_at dispatched_epoch execution_id input kind node_id ...] table minus internal: [created_at dispatched_epoch execution_id input kind last_event_sequence node_id ...] Tests: go test ./integration/ -run 'TestStartPluginPlanSurvivesSelectionChangesAndExecutionFailures' -count=1 (pass, was failing) go test ./internal/core/ -run 'TestControllerExecutionProjectionIsExplicit' -count=1 (pass) Co-Authored-By: Claude Code --- internal/core/control.go | 4 +- internal/core/control_projection_db_test.go | 62 +++++++++++++++++++++ internal/core/node_execution.go | 25 ++++++--- 3 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 internal/core/control_projection_db_test.go diff --git a/internal/core/control.go b/internal/core/control.go index bbe9ec218..50c9778df 100644 --- a/internal/core/control.go +++ b/internal/core/control.go @@ -198,7 +198,9 @@ func snapshot(t *transaction, o Object) Object { // Retired plugin effects stay in the table as history and are not part of the live snapshot. "effects": t.list("SELECT * FROM external_effects WHERE operation_id=$1 AND kind NOT IN ('plugin_ensure','plugin_delete') ORDER BY created_at,id", o.S("id")), "clones": t.list("SELECT * FROM clone_executions WHERE operation_id=$1 ORDER BY created_at,execution_id", o.S("id")), - "pluginExecutions": t.list("SELECT * FROM node_executions WHERE operation_id=$1 AND kind IN ('install_plugins','remove_plugins') ORDER BY created_at,execution_id", o.S("id")), + // nodeExecutionColumns, not `*`: the table carries Cloud-internal columns the Controller's + // closed ExecutionRecord contract has no field for. See its doc comment. + "pluginExecutions": t.list("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE operation_id=$1 AND kind IN ('install_plugins','remove_plugins') ORDER BY created_at,execution_id", o.S("id")), } if input := pluginInputOf(o); len(input) > 0 { out["pluginInput"] = input diff --git a/internal/core/control_projection_db_test.go b/internal/core/control_projection_db_test.go new file mode 100644 index 000000000..e39b6b41c --- /dev/null +++ b/internal/core/control_projection_db_test.go @@ -0,0 +1,62 @@ +package core + +import ( + "sort" + "strings" + "testing" +) + +// nodeExecutionInternalColumns are the columns of node_executions that stay Cloud-side. Every other +// column of the table is on the Controller wire and must appear in nodeExecutionColumns. +// +// workflow_node_id is the graph node a session ran for. It is denormalized onto the row (migration +// 0038) so the one_session_per_workflow_node partial index has a predicate to test and so the +// scheduler can attribute a result without joining through execution_work. No Controller contract +// declares it: the proto ExecutionRecord has no such field, and the JSON binding's NodeExecution +// schema is closed. +var nodeExecutionInternalColumns = map[string]bool{ + "workflow_node_id": true, +} + +// TestControllerExecutionProjectionIsExplicit is the regression guard for the contract break +// migration 0038 caused. The Controller-facing reads of node_executions were `SELECT *`, so adding +// the column silently put workflowNodeId into response bodies the closed NodeExecution schema +// forbids, and the integration suite failed with: +// +// Error at "/pluginExecutions/0": property "workflowNodeId" is unsupported +// +// A row that reaches a response body is now projected through nodeExecutionColumns instead. This +// test pins that the projection covers the table exactly: a column added later must be classified +// deliberately — onto the wire, or into nodeExecutionInternalColumns — instead of drifting onto one +// or off the other by omission. +func TestControllerExecutionProjectionIsExplicit(t *testing.T) { + store := dispatcherDB(t) + rows, err := store.Pool.Query(`SELECT column_name FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name='node_executions'`) + if err != nil { + t.Fatalf("read node_executions columns: %v", err) + } + defer rows.Close() + var table []string + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + t.Fatalf("scan column: %v", err) + } + if !nodeExecutionInternalColumns[column] { + table = append(table, column) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("read node_executions columns: %v", err) + } + projected := strings.Split(nodeExecutionColumns, ",") + for i := range projected { + projected[i] = strings.TrimSpace(projected[i]) + } + sort.Strings(table) + sort.Strings(projected) + if strings.Join(projected, ",") != strings.Join(table, ",") { + t.Fatalf("the Controller-facing projection drifted from the table:\n projected: %v\n table minus internal: %v", projected, table) + } +} diff --git a/internal/core/node_execution.go b/internal/core/node_execution.go index eeddb85b3..9d600059a 100644 --- a/internal/core/node_execution.go +++ b/internal/core/node_execution.go @@ -1,5 +1,16 @@ package core +// nodeExecutionColumns is the Controller-facing projection of node_executions: every column of the +// table except workflow_node_id. That column is Cloud-internal routing — which graph node a session +// ran for — and no Controller contract has a field for it: the proto ExecutionRecord declares none, +// and the JSON binding's NodeExecution schema is closed, so a `SELECT *` that carried the column +// failed the response contract the moment migration 0038 added it. +// +// So a row that reaches a response body is projected explicitly, the way workflowRunSummary projects +// a run row, rather than read whole. A column added to the table therefore does not reach the wire by +// itself; putting one there is a deliberate edit to this list and to the schema it must satisfy. +const nodeExecutionColumns = `execution_id,kind,operation_id,work_id,workspace_id,node_id,node_operation_id,input,result,dispatched_epoch,last_event_sequence,terminated_by_force_stop_id,created_at,updated_at` + // nodeDispatch registers a plugin, session or delivery execution before any Node sees it. // The same execution and input is idempotent. A different input or Node conflicts. func nodeDispatch(t *transaction, r *ControlRequest) Object { @@ -10,7 +21,7 @@ func nodeDispatch(t *transaction, r *ControlRequest) Object { } require(execution != "" && node != "" && len(input) > 0, 400, "invalid_dispatch") require(t.one("SELECT execution_id FROM clone_executions WHERE execution_id=$1", execution) == nil, 409, "dispatch_conflict") - if existing := t.one("SELECT * FROM node_executions WHERE execution_id=$1", execution); existing != nil { + if existing := t.one("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE execution_id=$1", execution); existing != nil { require(existing.S("operationId") == operation && existing.S("nodeId") == node && jsonText(existing.O("input")) == jsonText(input), 409, "dispatch_conflict") return existing } @@ -38,7 +49,7 @@ func dispatchPluginExecution(t *transaction, r *ControlRequest, operation, execu require(t.one("SELECT execution_id FROM node_executions WHERE operation_id=$1 AND kind IN ('install_plugins','remove_plugins') AND terminated_by_force_stop_id IS NULL AND (result IS NULL OR result->>'outcome'='plugins_result')", operation) == nil, 409, "dispatch_conflict") t.exec("INSERT INTO node_executions(execution_id,kind,operation_id,workspace_id,node_id,node_operation_id,input,dispatched_epoch) VALUES($1,$2,$3,$4,$5,$1,$6,$7)", execution, input.S("kind"), operation, wid, node, jsonText(input), r.Body.N("epoch")) - return t.one("SELECT * FROM node_executions WHERE execution_id=$1", execution) + return t.one("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE execution_id=$1", execution) } func dispatchRunExecution(t *transaction, r *ControlRequest, runID, execution, node string, input Object) Object { @@ -79,7 +90,7 @@ func dispatchRunExecution(t *transaction, r *ControlRequest, runID, execution, n VALUES($1,$2,$3,$4,$5,$6,$1,$7,$8,NULLIF($9,''))`, execution, input.S("kind"), runID, work.S("id"), wid, node, jsonText(input), r.Body.N("epoch"), work.S("workflowNodeId")) t.exec("UPDATE execution_work SET execution_id=$2 WHERE id=$1", work.S("id"), execution) - return t.one("SELECT * FROM node_executions WHERE execution_id=$1", execution) + return t.one("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE execution_id=$1", execution) } func executionResult(t *transaction, r *ControlRequest, withReceipt bool) Object { @@ -130,7 +141,7 @@ func nodeExecutionResult(t *transaction, r *ControlRequest, withReceipt bool) Ob if fresh && e.S("kind") == "deliver_revision" && revisionSuccess(result) { settleVerifiedRevision(t, r, e) } - return t.one("SELECT * FROM node_executions WHERE execution_id=$1", execution) + return t.one("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE execution_id=$1", execution) } func validateNodeResult(e, result Object) { @@ -180,7 +191,7 @@ func recordNodeReceipt(t *transaction, execution string, sequence int64, event s func lookupExecution(t *transaction, execution string) Object { e := t.one("SELECT * FROM clone_executions WHERE execution_id=$1", execution) if e == nil { - e = t.one("SELECT * FROM node_executions WHERE execution_id=$1", execution) + e = t.one("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE execution_id=$1", execution) } require(e != nil, 404, "not_found") return e @@ -190,10 +201,10 @@ func pendingExecutions(t *transaction, node string) Object { var clones, nodes []Object if node == "" { clones = t.list("SELECT * FROM clone_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL ORDER BY created_at,execution_id") - nodes = t.list("SELECT * FROM node_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL ORDER BY created_at,execution_id") + nodes = t.list("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL ORDER BY created_at,execution_id") } else { clones = t.list("SELECT * FROM clone_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL AND node_id=$1 ORDER BY created_at,execution_id", node) - nodes = t.list("SELECT * FROM node_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL AND node_id=$1 ORDER BY created_at,execution_id", node) + nodes = t.list("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL AND node_id=$1 ORDER BY created_at,execution_id", node) } return Object{"executions": append(clones, nodes...)} } From ef8d42ff7ce7b6f55fb0eee320cdbde37bc90fb7 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 11:48:16 +0800 Subject: [PATCH 18/26] test(integration): enumerate 0039's column in the upgrade comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestRevisionUpgradePreservesPublishedWorkflowSchema compares the workflows, workflow_snapshots and workflow_runs rows a published database carries against the same rows after the later migrations run. It projects the run row with `to_jsonb(r) - ARRAY['created_by','project_id']` — the columns 0035 added, which land NULL on published rows: an intended shape change, not a data change. 0039 adds plugin_version the same way, so the unextended array compared two different shapes and the test failed. revision_upgrade_test.go:112: Revision upgrade changed published workflow data or migration identities This extends the array and the comment beside it. It is the test's own documented extension point, not a relaxed assertion: the row data it guards is still compared in full, and removing an entry from the array still fails it. This failure came in with 0039 and was missed. The commit that added 0039 reported the integration suite as failing only on the known git http-backend test; it actually had three failures, of which this is one and TestStartPluginPlanSurvivesSelectionChangesAndExecutionFailures (fixed by the projection commit before this) is another. Tests: go test ./integration/ -run 'TestRevisionUpgradePreservesPublishedWorkflowSchema' -count=1 (pass, was failing) Co-Authored-By: Claude Code --- integration/revision_upgrade_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/integration/revision_upgrade_test.go b/integration/revision_upgrade_test.go index 860523b1f..d1f2c77d2 100644 --- a/integration/revision_upgrade_test.go +++ b/integration/revision_upgrade_test.go @@ -97,6 +97,11 @@ func TestRevisionUpgradePreservesPublishedWorkflowSchema(t *testing.T) { // NULL on published rows, which is an intended shape change, not a data change — the // comparison keeps guarding the row data the published schema carried. const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r) - ARRAY['created_by','project_id'])::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` + // The run document strips the columns later migrations added to the published row: 0038's + // identity columns and 0041's frozen plugin version. They land as NULL on published rows, + // which is an intended shape change, not a data change — the comparison keeps guarding the + // row data the published schema carried. + const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r) - ARRAY['created_by','project_id','plugin_version'])::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` const ledger = `SELECT jsonb_object_agg(version,checksum)::text FROM schema_migrations WHERE version <> '0027_verified_revisions.sql' AND version < '0030'` var beforeDocuments, beforeLedger string must(t, pool.QueryRow(documents, run).Scan(&beforeDocuments)) From 717dc53066f05e11c0dc3ed5012ad20394a390ff Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 13:39:38 +0800 Subject: [PATCH 19/26] fix(core): clear the format and lint gates on the workflow tree `go run ./cmd/checkformat` and `go tool golangci-lint run` are both CI gates on every PR, and this branch failed both. None of it was the CRLF trap the repo has been bitten by before: `gofumpt -d` named real rewrites. - gofumpt wanted three things restored: the key alignment in control.go's operation snapshot, where adding the longer pluginExecutions line broke the block around it; spaces around the concatenation in pendingExecutions, where the line crossed the length at which the no-space form reads worse; and the multi-line call arguments in workflow_graph_test.go closed on their own line. - misspell: "colours" in the cycle walk's doc comment. - gocritic hugeParam: workflowNodeState is 88 bytes now that a failed node carries its reason, so object() takes a pointer. The one caller renders a range variable, so nothing changes but the copy. Co-Authored-By: Claude Code --- internal/core/control.go | 4 ++-- internal/core/node_execution.go | 2 +- internal/core/workflow_graph_test.go | 9 ++++++--- internal/core/workflow_scheduler.go | 8 +++++--- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/core/control.go b/internal/core/control.go index 50c9778df..c0dd49a66 100644 --- a/internal/core/control.go +++ b/internal/core/control.go @@ -196,8 +196,8 @@ func snapshot(t *transaction, o Object) Object { "sandboxes": t.list("SELECT s.* FROM sandbox_instances s JOIN workspaces w ON w.id=s.workspace_id WHERE w.project_id=$1 ORDER BY s.id", o.S("projectId")), "nodes": t.list("SELECT n.* FROM node_instances n JOIN sandbox_instances s ON s.id=n.sandbox_instance_id JOIN workspaces w ON w.id=s.workspace_id WHERE w.project_id=$1 ORDER BY n.id", o.S("projectId")), // Retired plugin effects stay in the table as history and are not part of the live snapshot. - "effects": t.list("SELECT * FROM external_effects WHERE operation_id=$1 AND kind NOT IN ('plugin_ensure','plugin_delete') ORDER BY created_at,id", o.S("id")), - "clones": t.list("SELECT * FROM clone_executions WHERE operation_id=$1 ORDER BY created_at,execution_id", o.S("id")), + "effects": t.list("SELECT * FROM external_effects WHERE operation_id=$1 AND kind NOT IN ('plugin_ensure','plugin_delete') ORDER BY created_at,id", o.S("id")), + "clones": t.list("SELECT * FROM clone_executions WHERE operation_id=$1 ORDER BY created_at,execution_id", o.S("id")), // nodeExecutionColumns, not `*`: the table carries Cloud-internal columns the Controller's // closed ExecutionRecord contract has no field for. See its doc comment. "pluginExecutions": t.list("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE operation_id=$1 AND kind IN ('install_plugins','remove_plugins') ORDER BY created_at,execution_id", o.S("id")), diff --git a/internal/core/node_execution.go b/internal/core/node_execution.go index 9d600059a..21adabfcc 100644 --- a/internal/core/node_execution.go +++ b/internal/core/node_execution.go @@ -201,7 +201,7 @@ func pendingExecutions(t *transaction, node string) Object { var clones, nodes []Object if node == "" { clones = t.list("SELECT * FROM clone_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL ORDER BY created_at,execution_id") - nodes = t.list("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL ORDER BY created_at,execution_id") + nodes = t.list("SELECT " + nodeExecutionColumns + " FROM node_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL ORDER BY created_at,execution_id") } else { clones = t.list("SELECT * FROM clone_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL AND node_id=$1 ORDER BY created_at,execution_id", node) nodes = t.list("SELECT "+nodeExecutionColumns+" FROM node_executions WHERE result IS NULL AND terminated_by_force_stop_id IS NULL AND node_id=$1 ORDER BY created_at,execution_id", node) diff --git a/internal/core/workflow_graph_test.go b/internal/core/workflow_graph_test.go index b3d4c313d..0698c3d53 100644 --- a/internal/core/workflow_graph_test.go +++ b/internal/core/workflow_graph_test.go @@ -254,7 +254,8 @@ func TestValidateWorkflowGraph(t *testing.T) { graph: workflowGraph( []any{graphStart("s"), graphAgent("a", "", false), graphAgent("b", "", false), graphAgent("c", "", false)}, []any{graphEdge("s", "a"), graphEdge("s", "b"), graphEdge("a", "c"), graphEdge("b", "c")}, - nil), + nil, + ), }, { // The shortest cycle there is, and one the editor can produce by dragging an edge back onto @@ -277,7 +278,8 @@ func TestValidateWorkflowGraph(t *testing.T) { graph: workflowGraph( []any{graphStart("s"), graphAgent("a", "", false), graphAgent("b", "", false), graphAgent("c", "", false), graphAgent("o", "", false)}, []any{graphEdge("s", "a"), graphEdge("a", "b"), graphEdge("b", "a"), graphEdge("b", "c"), graphEdge("c", "o")}, - nil), + nil, + ), want: []any{Object{"code": "graph_cycle", "nodeId": "b"}}, }, { @@ -289,7 +291,8 @@ func TestValidateWorkflowGraph(t *testing.T) { graph: workflowGraph( []any{graphStart("s"), graphAgent("a", "", false), graphAgent("u", "", false), graphAgent("v", "", false), graphAgent("w", "", false), graphAgent("x", "", false)}, []any{graphEdge("s", "a"), graphEdge("a", "u"), graphEdge("u", "a"), graphEdge("u", "v"), graphEdge("v", "w"), graphEdge("w", "x"), graphEdge("x", "w")}, - nil), + nil, + ), want: []any{Object{"code": "graph_cycle", "nodeId": "u"}}, }, { diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index f3f3e0d3f..7961be01d 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -205,7 +205,7 @@ func readWorkflowGraph(graph Object) workflowGraphView { // workflowGraphCycle reports whether the graph contains a cycle, naming the source of the back edge the // walk found — a node that is on a cycle by construction — or "" when the graph is a DAG. // -// It is a depth-first walk with three colours: a node still on the current path that is reached again +// It is a depth-first walk with three colors: a node still on the current path that is reached again // closes a cycle. The walk is deterministic — it roots at nodes in document order and follows edges in // document order — so the node it names is too. Two cheaper-looking tests are wrong here and are worth // naming so they are not reintroduced: peeling nodes with no incoming or no outgoing edge leaves the @@ -269,8 +269,10 @@ type workflowNodeState struct { Output Object } -// object renders the state in the shape the run viewer reads (and the simulator writes). -func (s workflowNodeState) object() Object { +// object renders the state in the shape the run viewer reads (and the simulator writes). The receiver +// is a pointer because the state is large enough that copying it per render is waste, and its one +// caller already holds it in a range variable. +func (s *workflowNodeState) object() Object { out := Object{"status": s.Status, "displayName": s.DisplayName, "startedAt": s.StartedAt, "finishedAt": s.FinishedAt} if s.Error != "" { out["error"] = s.Error From ca551f9325eb248ba5f092685f06b5776b02d202 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 16:31:56 +0800 Subject: [PATCH 20/26] fix(core): drop the unreachable project-less path from the plugin freeze Rebasing onto the new #68 head made `resolveWorkflowRunPluginVersion`'s empty -Project branch unreachable: creation now refuses a run that names no Project, so the Project this function reads the space from is always present. The branch and the comment describing it ("a run that named none has no space to read from and freezes nothing") are removed rather than left as a guard for a case the creation gate no longer admits; the value is passed straight to the query. The rest of the function is unchanged: a missing plugin still freezes nothing and is still reported later as `agent_plugin_version_unfrozen`, which remains an owner decision rather than something to change here. Co-Authored-By: Claude Code --- internal/core/workflow_run.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index cf7180826..eb11b1555 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -183,8 +183,8 @@ func resolveWorkflowRunProject(t *transaction, r *PublicRequest, uid string) any // resolveWorkflowRunPluginVersion reads the plugin version the run will execute with, so the creation // transaction freezes it alongside the rest of the run's identity (D3). The plugin lives in the -// Project's space, which is why this takes the Project the same transaction just froze: a run that -// named none has no space to read from and freezes nothing. +// Project's space, which is why this takes the Project the same transaction just froze: the Project is +// what names the space to read the version from, and creation no longer admits a run without one. // // It is the same read the session start used to make, moved earlier rather than changed. The version // is whatever the space's desired state says at creation; nothing here consults the observed state, so @@ -197,16 +197,9 @@ func resolveWorkflowRunProject(t *transaction, r *PublicRequest, uid string) any // alternative — the executor-slot gate beside this does refuse — but it would turn a request the API // accepts today into a 400, so it stays an owner decision rather than a change made here. func resolveWorkflowRunPluginVersion(t *transaction, tenantID string, projectID any) any { - pid := "" - if s, ok := projectID.(string); ok { - pid = s - } - if pid == "" { - return nil - } pv := t.one(`SELECT p.desired_version FROM projects pr JOIN space_plugins p ON p.space_id=pr.space_id AND p.source_namespace=split_part($3,'/',1) AND p.identifier=split_part($3,'/',2) - WHERE pr.id=$1 AND pr.tenant_id=$2 AND pr.deleted_at IS NULL`, pid, tenantID, opencodeAgentCLI) + WHERE pr.id=$1 AND pr.tenant_id=$2 AND pr.deleted_at IS NULL`, projectID, tenantID, opencodeAgentCLI) if pv == nil || pv.S("desiredVersion") == "" { return nil } From 31494d1864b0018c9c980b74befaee440ec22439 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 17:14:32 +0800 Subject: [PATCH 21/26] fix(core): scope a workflow session's model grant to its own slot, and stop silent gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #72. Six fixes, each with the reason it is not cosmetic. The model grant's workflow branch matched a binding by its run alone. That is exact for an IssueRun, which owns one binding (0033's `run_id ... UNIQUE`), but a WorkflowRun owns one per executor slot since 0036 dropped that uniqueness, so the same test admitted every slot's binding at once. The binding id is the caller's to state — a model mTLS certificate is bound to a tenant, workspace and runtime generation, never to a slot — so node A holding `fast` could present node B's `slow` binding and be handed B's credential and model. The binding must now be the one this execution was dispatched with, read from `e.input->>'modelBindingId'`, which Cloud writes at enqueue time and no request body can restate. The new test proves the theft is refused and that the fix is what refuses it. `workflowSessionSpec` started a session even when the node's slot had no frozen binding. The run-creation gate freezes one per referenced slot, so a missing row is a run whose freeze never happened, not a state the next tick repairs — and starting anyway handed the Node a session with no model to run on, the silent downgrade the gate exists to prevent. The node now fails under the gate's own reason, `workflow_executor_unconfigured`. Its unused `target` parameter went with it. `.gitattributes` left 0036 and 0038 unpinned, on a comment that argued a database had already recorded their bytes. That was true of 0034, which is on main, and false of these: `origin/main` stops at 0034, so nothing released has ever applied them and this is the last moment pinning them is free. They are pinned now, and the comment says which migration the old argument actually applies to. `personalSlotMappingsPublic` recorded a zero status into the idempotency ledger: the named result was never assigned on the PUT and DELETE paths, so a replayed write answered 0. It is set to 200 before the switch, the way the connection routes do it. The migration READMEs now describe 0036, 0038 and 0039, which they did not. Co-Authored-By: Claude Code --- internal/core/migrations/README.en.md | 32 +++++++ internal/core/migrations/README.md | 25 +++++ internal/core/model_grants.go | 18 +++- internal/core/model_slot_mappings.go | 4 + internal/core/workflow_run_session.go | 16 +++- internal/core/workflow_run_session_db_test.go | 92 +++++++++++++++++++ 6 files changed, 179 insertions(+), 8 deletions(-) diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index 3a48209e0..0743ad220 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -129,3 +129,35 @@ reworked, because a polymorphic reference cannot be a foreign key, and the liven code that grants on it. Additive: columns, constraints and indexes, no backfill. `TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` covers fresh, 0038 upgrade and rerun. + + +## 0036: Executor slot bindings + +`0036_executor_slot_bindings.sql` lets executor slots reach the runtime. A `run_model_bindings` row's +owner becomes a discriminator pair — exactly one of `run_id` / `workflow_run_id` — and the previous +whole-table UNIQUE(run_id) splits into one (run, slot) uniqueness per owner, so a run may carry several +bindings at once: a WorkflowRun freezes one row per slot its graph references, while an IssueRun keeps +its single binding under the reserved `default` slot. Adds `executor_slot_mappings` (slot name -> +executor), the invoker-owned resolution table the creation gate reads; `default` is deliberately +excluded there because it IS the personal default model, never a stored mapping. Additive: it adds +columns, constraints and a table without rewriting any existing binding row. + +## 0038: Workflow execution node + +`0038_workflow_execution_node.sql` adds `workflow_node_id` to `execution_work` and `node_executions`, +with a constraint tying a work item's node to the graph of the run the item belongs to. It is +Cloud-internal routing: the Controller-facing projection of `node_executions` +(nodeExecutionColumns) deliberately omits it, because no Controller contract declares it. Additive, +no backfill. + +## 0039: Run-level plugin version + +`0039_workflow_run_plugin_version.sql` adds a nullable `plugin_version` to `workflow_runs`, folding the +plugin version into the run identity the creation transaction freezes (snapshot, input, initiator, +Project, run Workspace binding, per-slot model bindings, plugin version). Session start reads the +frozen value, so an upgrade can no longer change what an already-created run executes. Nullable and +deliberately not backfilled: rows written before this migration were never frozen, and writing the +space's current version would record a fact that never held. Such a run fails at its first agent node +with `agent_plugin_version_unfrozen` rather than guessing; whether those rows should be backfilled, +refused or executed under some other rule remains an open owner decision this migration records no +answer to. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index c30eefdb0..1468c66e9 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -125,3 +125,28 @@ Workspace 可以属于 WorkflowRun;`execution_work` 同样变为「恰有一 外键,存活校验移到授予它的代码里。只新增列、约束与索引,不回填。 `TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` 验证空库、从 0038 升级及重复迁移。 + + +## 0036:执行器槽位绑定 + +`0036_executor_slot_bindings.sql` 让执行器槽位抵达运行时。`run_model_bindings` 的所有者变为判别对 +(`run_id` 与 `workflow_run_id` 恰有其一),原先整表的 UNIQUE(run_id) 拆分为每种所有者各一条 +(run, slot) 唯一索引,于是一次运行可同时持有多条绑定;WorkflowRun 为图上引用的每个槽位冻结一行, +IssueRun 继续在保留槽位 `default` 下持有其唯一绑定。新增 `executor_slot_mappings`(槽位名 → 执行器) +作为调用者私有的解析表,`default` 被显式排除——它就是个人默认模型本身,不是一条可存储的映射。 +只新增列、约束与表,不改写既有绑定行。 + +## 0038:工作流执行节点 + +`0038_workflow_execution_node.sql` 给 `execution_work` 与 `node_executions` 各加一列 +`workflow_node_id`,并加约束保证工作项所属节点确实属于该工作项所在的运行图。它是 Cloud 内部的路由 +字段:`node_executions` 面向 Controller 的投影(nodeExecutionColumns)刻意不含该列,因为它不是任何 +Controller 契约的字段。只新增,不回填。 + +## 0039:运行级插件版本 + +`0039_workflow_run_plugin_version.sql` 给 `workflow_runs` 加可空列 `plugin_version`,把插件版本并入 +创建事务冻结的运行身份(快照、输入、发起人、Project、运行 Workspace 绑定、逐槽位模型绑定、插件版本)。 +会话启动读冻结值,升级不再改变已创建运行的执行内容。可空且刻意不回填:本迁移之前的行从未冻结过, +把空间当前版本写进去会记录一个从未成立的事实;这类运行在首个 agent 节点以 `agent_plugin_version_unfrozen` +失败而不是猜测。这些行应回填、拒绝还是按其他规则执行,仍是 owner 待决事项,本迁移不记录答案。 diff --git a/internal/core/model_grants.go b/internal/core/model_grants.go index 30dc8ca3f..1ef69ce33 100644 --- a/internal/core/model_grants.go +++ b/internal/core/model_grants.go @@ -80,9 +80,20 @@ func liveIssueRunModelBinding(t *transaction, scope ModelRuntimeScope, bindingID // liveWorkflowModelBinding resolves a binding against a live WorkflowRun session execution. A // workflow run has no Thread: the run's `running` status and the session execution's open result -// are the liveness evidence, and the binding must belong to the run (workflow_run_id) the session -// execution registered against — correspondence registered by Cloud at dispatch, never trusted -// from a body. +// are the liveness evidence. +// +// Belonging to the run is not enough here, which is where this differs from its IssueRun +// counterpart. An IssueRun owns exactly one binding (0033's `run_id ... UNIQUE`), so "the binding's +// run is the execution's run" names one row. A WorkflowRun owns one binding per executor slot +// (0036 drops that uniqueness), so the same test admits every slot's binding at once — and the +// binding id is the caller's to state, since a model mTLS certificate is bound to a tenant, +// workspace and runtime generation, never to a slot. Node A holding slot `fast` could therefore +// present node B's `slow` binding id and be handed B's credential and model. +// +// So the binding must be the one this execution was dispatched with. `e.input->>'modelBindingId'` +// is that value: it is written by Cloud when the work item is enqueued, copied into the execution +// row at dispatch, and never read from a request body. A session dispatched with no binding leaves +// it NULL, which matches nothing — correct, since such a session has no binding to redeem. func liveWorkflowModelBinding(t *transaction, scope ModelRuntimeScope, bindingID, executionID string) Object { return t.one(`SELECT b.*, e.execution_id,e.node_id,e.work_id,w.id AS workspace_id,w.runtime_generation FROM run_model_bindings b JOIN workflow_runs r ON r.id=b.workflow_run_id AND r.deleted_at IS NULL @@ -93,6 +104,7 @@ func liveWorkflowModelBinding(t *transaction, scope ModelRuntimeScope, bindingID JOIN tenants tenant ON tenant.id=r.tenant_id JOIN tenant_memberships member ON member.tenant_id=r.tenant_id AND member.user_id=b.user_id WHERE b.id=$1 AND e.execution_id=$2 AND r.tenant_id=$3 AND w.id=$4 AND w.runtime_generation=$5 + AND e.input->>'modelBindingId' = b.id::text AND b.revoked_at IS NULL AND r.status='running' AND e.result IS NULL AND e.terminated_by_force_stop_id IS NULL diff --git a/internal/core/model_slot_mappings.go b/internal/core/model_slot_mappings.go index 6e0234afa..0e2297675 100644 --- a/internal/core/model_slot_mappings.go +++ b/internal/core/model_slot_mappings.go @@ -30,6 +30,10 @@ func personalSlotMappingsPublic(t *transaction, r *PublicRequest, uid string) (o } _, hasVersion := r.Body["version"] require(hasVersion, 428, "version_required") + // Every write answers 200 with the resource, the way the connection routes do, and that is also + // the status the idempotency ledger records — a replayed DELETE returns this value, so leaving + // the named result at its zero would hand the caller a 0 and store one. + status = 200 switch r.Method { case "PUT": old := slotMapping(t, uid, r.SlotName) diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index 21f2da522..b35aae95d 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -170,7 +170,7 @@ func workflowSessionTarget(t *transaction, wid string) Object { // the run cannot change what it executes. The initial turn is the node's prompt rendered as one // text block; the model binding is the frozen binding of the node's slot. A permanent gap returns // a named reason instead of an error so the node fails honestly rather than retrying forever. -func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w, target Object) (spec Object, reason string) { +func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w Object) (spec Object, reason string) { runID := run.S("id") // A run created before the plugin version was frozen carries none, and there is no honest value to // substitute: the space's current version is not what this run was created against. It is reported @@ -212,11 +212,17 @@ func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w, t } slot := node.Data.O("agentConfig").S("executorSlot") if slot == "" { - slot = "default" + slot = workflowDefaultSlot } - if binding := t.one("SELECT id FROM run_model_bindings WHERE workflow_run_id=$1 AND slot_name=$2", runID, slot); binding != nil { - spec["modelBindingId"] = binding.S("id") + // The run-creation gate freezes one binding per referenced slot, so a missing row here is not a + // transient state waiting to be retried — it is a run whose freeze never happened. Starting the + // session regardless would hand the Node a session with no model to run on, which is the silent + // downgrade the gate exists to prevent, so the node fails under the gate's own reason. + binding := t.one("SELECT id FROM run_model_bindings WHERE workflow_run_id=$1 AND slot_name=$2", runID, slot) + if binding == nil { + return nil, "workflow_executor_unconfigured" } + spec["modelBindingId"] = binding.S("id") return spec, "" } @@ -253,7 +259,7 @@ func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView if target == nil { return // no connected Node yet; the startup deadline eventually fails the node } - spec, reason := workflowSessionSpec(t, run, node, w, target) + spec, reason := workflowSessionSpec(t, run, node, w) if reason != "" { failWorkflowAgentNode(t, runID, version, states, node, reason) return diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 0b25743bd..00bc291b8 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -69,6 +69,10 @@ func seedWorkflowSessionSceneGraph(t *testing.T, store *Store, graph Object) (ru exec("credential", `INSERT INTO personal_model_credentials(id,user_id,ciphertext,key_id) VALUES($1,$2,repeat('c',64),'test-key')`, cred, uid) exec("connection", `INSERT INTO personal_model_connections(id,user_id,credential_id,name,protocol,base_url,auth_mode,models,enabled) VALUES($1,$2,$3,'Personal Provider','openai-completions','https://models.example.invalid/v1','bearer',$4::jsonb,true)`, conn, uid, cred, jsonText([]any{map[string]any{"id": "vendor/model-v1", "name": "Model V1"}})) exec("default", `INSERT INTO personal_model_defaults(user_id,connection_id,model_id,version) VALUES($1,$2,'vendor/model-v1',1)`, uid, conn) + // A named slot too, so a graph that references one freezes a second binding on the same run. + // 0036 keys a workflow binding by (run, slot) rather than by run alone, which is what makes + // "belongs to this run" too weak a test for a session to redeem one (liveWorkflowModelBinding). + exec("slot", `INSERT INTO executor_slot_mappings(user_id,slot_name,agent_cli,connection_id,model_id,version) VALUES($1,'strong','official/ora-space.opencode',$2,'vendor/model-v1',1)`, uid, conn) freezeWorkflowRunExecutors(tx, rid, uid, graph) return Object{} }); err != nil { @@ -642,6 +646,39 @@ func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { assertRunWorkspaceReleased(t, store, runID) } +// The run-creation gate freezes one binding per slot the graph references, so a node whose slot has +// no frozen binding is a run whose freeze never happened — not a state the next tick can fix. The +// session must not start without a model to run on: the node fails under the gate's own reason, the +// way it does for an unresolvable slot at creation, instead of handing the Node a binding-less +// session that would run on whatever the Node defaults to. +func TestWorkflowSessionWithoutFrozenBindingFailsTheNode(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + bindConnectedNode(t, store, wsID) + if _, err := store.Pool.Exec(`UPDATE node_instances SET model_proxy=true WHERE workspace_id=$1`, wsID); err != nil { + t.Fatal(err) + } + // Everything the session needs is in place except the freeze itself. + if _, err := store.Pool.Exec(`DELETE FROM run_model_bindings WHERE workflow_run_id=$1`, runID); err != nil { + t.Fatalf("drop the frozen binding: %v", err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance to session start: %v", err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + agent, _ := asWorkflowObject(states["a"]) + if status != "failed" || runErr != "workflow_executor_unconfigured" { + t.Fatalf("run settled %q error %q; want failed/workflow_executor_unconfigured", status, runErr) + } + if agent == nil || agent.S("status") != "failed" || agent.S("error") != "workflow_executor_unconfigured" { + t.Fatalf("node a = %v; want failed under the same reason", states["a"]) + } +} + // A replayed terminal receipt for a settled run changes nothing: the run's status, its committed // node states and its version are exactly what the first settle left, and the extra passes write // nothing. Late or duplicate evidence is a no-op (invariant 3). @@ -721,3 +758,58 @@ func TestWorkflowLiveModelBindingResolvesRun(t *testing.T) { t.Fatalf("foreign execution: err=%v; want model_grant_revoked", err) } } + +// A run freezes one binding per executor slot (gateGraph references `default` and `strong`), so +// "the binding belongs to this execution's run" admits more than one row. A session dispatched for +// node a1 under `default` must not be able to name `strong`'s binding and be handed that slot's +// credential and model: the binding id is the caller's to state, because a model mTLS certificate is +// bound to a tenant, workspace and runtime generation and never to a slot. +// +// What settles it is the execution's own frozen modelBindingId, which Cloud wrote when it enqueued +// the work and which no request body can restate. +func TestWorkflowLiveModelBindingRefusesAnotherSlotsBinding(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionSceneGraph(t, store, gateGraph()) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance 1: %v", err) + } + wsID := settleSessionWorkspace(t, store, runID) + nodeService, spec := startSessionNode(t, store, runID, wsID, "a1") + execution := dispatchSession(t, store, runID, "a1", nodeService, spec) + if _, err := store.Pool.Exec(`UPDATE runtime_controls SET binding_confirmed=true, + bound_sandbox_id=(SELECT sandbox_instance_id FROM node_instances WHERE workspace_id=$1 AND ended_at IS NULL) + WHERE workspace_id=$1`, wsID); err != nil { + t.Fatal(err) + } + var own, other, tenant string + var generation int64 + if err := store.Pool.QueryRow(`SELECT id FROM run_model_bindings WHERE workflow_run_id=$1 AND slot_name='default'`, runID).Scan(&own); err != nil { + t.Fatalf("read the default binding: %v", err) + } + if err := store.Pool.QueryRow(`SELECT id FROM run_model_bindings WHERE workflow_run_id=$1 AND slot_name='strong'`, runID).Scan(&other); err != nil { + t.Fatalf("read the strong binding: %v", err) + } + if spec.S("modelBindingId") != own || own == other { + t.Fatalf("dispatched with %q; want the default slot's %q, a distinct row from strong's %q", spec.S("modelBindingId"), own, other) + } + if err := store.Pool.QueryRow(`SELECT tenant_id, runtime_generation FROM workspaces WHERE id=$1`, wsID).Scan(&tenant, &generation); err != nil { + t.Fatal(err) + } + scope := ModelRuntimeScope{TenantID: tenant, WorkspaceID: wsID, Generation: generation} + // The slot the session was dispatched under still resolves, so the refusal below is about the + // binding and not about the scene failing some other gate. + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + if b := liveModelBinding(tx, scope, own, execution); b.S("runId") != runID || b.S("nodeId") != nodeService { + t.Fatalf("binding = %v; want run %s on node %s", b, runID, nodeService) + } + return Object{} + }); err != nil { + t.Fatalf("own slot's binding: %v", err) + } + if _, err := store.transact(context.Background(), func(tx *transaction) Object { + liveModelBinding(tx, scope, other, execution) + return Object{} + }); err == nil || ErrorCode(err).Code != "model_grant_revoked" { + t.Fatalf("another slot's binding for this execution: err=%v; want model_grant_revoked", err) + } +} From 5a63258d7b7959e84e02c465f40318c47cec3729 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 17:20:30 +0800 Subject: [PATCH 22/26] docs(contract): describe the slot-mapping DELETE as the user-scoped write it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #72, non-blocking item. The idempotency-key heuristic names the personal routes as user-scoped by prefix, and `/api/v1/me/model-` was the whole list. `/api/v1/me/executor-slot-mappings/:slot` is the same kind of route — a (user_id, slot_name) row, with no tenant anywhere in the handler — but it missed the prefix and was documented as "Scoped to tenant and user", a boundary nothing checks. Regenerated; the one line above is the entire diff. Co-Authored-By: Claude Code --- api/openapi.json | 2 +- internal/contract/openapi.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 395408108..37ec1e9b8 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -5446,7 +5446,7 @@ } }, { - "description": "Scoped to tenant and user. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", + "description": "Scoped to the verified user before tenant membership exists. Same key and canonical method/path/body returns the original response before version validation; changed request is 409.", "in": "header", "name": "Idempotency-Key", "required": true, diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index adcc1fbb1..83de9f82a 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -405,7 +405,10 @@ func Document() map[string]any { credentialWrite := r.Method == "PUT" && strings.HasPrefix(r.Path, "/api/v1/me/model-connections/") && strings.HasSuffix(r.Path, "/credential") if public && (r.Method == "POST" || r.Method == "DELETE" || credentialWrite) && r.Path != "/api/v1/me/git-identity" { keyScope := "Scoped to tenant and user." - if strings.HasPrefix(r.Path, "/api/v1/join/") || strings.HasPrefix(r.Path, "/api/v1/me/model-") { + // The personal routes are keyed on the verified user alone: a slot mapping is a + // (user_id, slot_name) row and a personal connection is the user's, so neither is scoped + // to a tenant and saying it is would describe a boundary the handler never checks. + if strings.HasPrefix(r.Path, "/api/v1/join/") || strings.HasPrefix(r.Path, "/api/v1/me/model-") || strings.HasPrefix(r.Path, "/api/v1/me/executor-slot-mappings") { keyScope = "Scoped to the verified user before tenant membership exists." } parameters = append(parameters, obj{"name": "Idempotency-Key", "in": "header", "required": true, "schema": obj{"type": "string", "minLength": 1, "maxLength": 200}, "description": keyScope + " Same key and canonical method/path/body returns the original response before version validation; changed request is 409."}) From ee9d1e6368dc643c4e102b8c4d6a7f42dafa544e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 17 Oct 2026 19:25:10 +0800 Subject: [PATCH 23/26] fix(core): seed the session test's Project through its Binding 0037 (cloud#75, already on main) moved a Project's repository into repository_bindings, so the session test's direct seed no longer matched the schema. It now inserts the Project and its Binding together, the idiom every other direct seed in this repository uses. Co-Authored-By: Claude Code --- internal/core/workflow_run_session_db_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 00bc291b8..6416be083 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -57,7 +57,7 @@ func seedWorkflowSessionSceneGraph(t *testing.T, store *Store, graph Object) (ru exec("tenant", `INSERT INTO tenants(id,name,status) VALUES($1,'wfs','active')`, tid) exec("memb", `INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')`, tid, uid) exec("space", `INSERT INTO collab_workspaces(id,tenant_id,name,slug,created_by) VALUES($1,$2,'wfs','wfs-'||left($4::text,8),$3)`, space, tid, uid, project) - exec("project", `INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,repository_url,default_branch,lifecycle) VALUES($1,$2,$3,$4,'wfs','https://example.invalid/wfs.git','main','active')`, project, tid, uid, space) + exec("project", `WITH p AS (INSERT INTO projects(id,tenant_id,owner_user_id,space_id,name,lifecycle) VALUES($1,$2,$3,$4,'wfs','active') RETURNING id,tenant_id) INSERT INTO repository_bindings(id,tenant_id,project_id,repository_url,branch) SELECT gen_random_uuid(),tenant_id,id,'https://example.invalid/wfs.git','main' FROM p`, project, tid, uid, space) exec("main", `INSERT INTO workspaces(id,tenant_id,owner_user_id,creator_user_id,creator_evidence,project_id,kind,desired_state,observed_state,runtime_generation,requested_ref) VALUES($1,$2,$3,$3,'verified_request',$4,'main','running','ready',1,'HEAD')`, uuid.NewString(), tid, uid, project) exec("plugin", `INSERT INTO space_plugins(space_id,tenant_id,source_namespace,identifier,desired_state,desired_version,observed_state) VALUES($1,$2,'official','ora-space.opencode','installed','1.0.0','installed')`, space, tid) graphJSON, _ := json.Marshal(graph) From e32286a3f366531cfcf81c901e6243e042f821d5 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Sat, 17 Oct 2026 22:01:44 +0800 Subject: [PATCH 24/26] chore(core): renumber the session-execution migrations to 0040-0042 The stacked PR below now takes 0039 (cloud#62 landed its own 0038 on main), so this branch shifts one further: 0040_executor_slot_bindings.sql, 0041_workflow_execution_node.sql and 0042_workflow_run_plugin_version.sql, in the same order. Migration README entries (zh and en), the eol pins, the run_model_bindings/execution_work comments and the upgrade comparison all follow the new numbers; the identity columns the comparison strips stay 0039. Co-Authored-By: Claude Code --- .gitattributes | 6 +++--- integration/revision_upgrade_test.go | 2 +- internal/core/control_projection_db_test.go | 4 ++-- ..._bindings.sql => 0040_executor_slot_bindings.sql} | 0 ...ion_node.sql => 0041_workflow_execution_node.sql} | 0 ...sion.sql => 0042_workflow_run_plugin_version.sql} | 0 internal/core/migrations/README.en.md | 12 ++++++------ internal/core/migrations/README.md | 12 ++++++------ internal/core/model_grants.go | 2 +- internal/core/model_run_binding.go | 2 +- internal/core/node_execution.go | 2 +- internal/core/workflow_run_session_db_test.go | 2 +- 12 files changed, 22 insertions(+), 22 deletions(-) rename internal/core/migrations/{0036_executor_slot_bindings.sql => 0040_executor_slot_bindings.sql} (100%) rename internal/core/migrations/{0038_workflow_execution_node.sql => 0041_workflow_execution_node.sql} (100%) rename internal/core/migrations/{0039_workflow_run_plugin_version.sql => 0042_workflow_run_plugin_version.sql} (100%) diff --git a/.gitattributes b/.gitattributes index f05ed145c..cb96bd689 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,6 @@ internal/core/migrations/0033_revision_resume.sql text eol=lf # it yet; after merge that would no longer be true. internal/core/migrations/0039_workflow_run_identity.sql text eol=lf -internal/core/migrations/0039_executor_slot_bindings.sql text eol=lf -internal/core/migrations/0040_workflow_execution_node.sql text eol=lf -internal/core/migrations/0041_workflow_run_plugin_version.sql text eol=lf +internal/core/migrations/0040_executor_slot_bindings.sql text eol=lf +internal/core/migrations/0041_workflow_execution_node.sql text eol=lf +internal/core/migrations/0042_workflow_run_plugin_version.sql text eol=lf diff --git a/integration/revision_upgrade_test.go b/integration/revision_upgrade_test.go index d1f2c77d2..cef52fd2c 100644 --- a/integration/revision_upgrade_test.go +++ b/integration/revision_upgrade_test.go @@ -98,7 +98,7 @@ func TestRevisionUpgradePreservesPublishedWorkflowSchema(t *testing.T) { // comparison keeps guarding the row data the published schema carried. const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r) - ARRAY['created_by','project_id'])::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` // The run document strips the columns later migrations added to the published row: 0038's - // identity columns and 0041's frozen plugin version. They land as NULL on published rows, + // identity columns and 0042's frozen plugin version. They land as NULL on published rows, // which is an intended shape change, not a data change — the comparison keeps guarding the // row data the published schema carried. const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r) - ARRAY['created_by','project_id','plugin_version'])::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` diff --git a/internal/core/control_projection_db_test.go b/internal/core/control_projection_db_test.go index e39b6b41c..c0c6e9cd9 100644 --- a/internal/core/control_projection_db_test.go +++ b/internal/core/control_projection_db_test.go @@ -10,7 +10,7 @@ import ( // column of the table is on the Controller wire and must appear in nodeExecutionColumns. // // workflow_node_id is the graph node a session ran for. It is denormalized onto the row (migration -// 0038) so the one_session_per_workflow_node partial index has a predicate to test and so the +// 0041) so the one_session_per_workflow_node partial index has a predicate to test and so the // scheduler can attribute a result without joining through execution_work. No Controller contract // declares it: the proto ExecutionRecord has no such field, and the JSON binding's NodeExecution // schema is closed. @@ -19,7 +19,7 @@ var nodeExecutionInternalColumns = map[string]bool{ } // TestControllerExecutionProjectionIsExplicit is the regression guard for the contract break -// migration 0038 caused. The Controller-facing reads of node_executions were `SELECT *`, so adding +// migration 0041 caused. The Controller-facing reads of node_executions were `SELECT *`, so adding // the column silently put workflowNodeId into response bodies the closed NodeExecution schema // forbids, and the integration suite failed with: // diff --git a/internal/core/migrations/0036_executor_slot_bindings.sql b/internal/core/migrations/0040_executor_slot_bindings.sql similarity index 100% rename from internal/core/migrations/0036_executor_slot_bindings.sql rename to internal/core/migrations/0040_executor_slot_bindings.sql diff --git a/internal/core/migrations/0038_workflow_execution_node.sql b/internal/core/migrations/0041_workflow_execution_node.sql similarity index 100% rename from internal/core/migrations/0038_workflow_execution_node.sql rename to internal/core/migrations/0041_workflow_execution_node.sql diff --git a/internal/core/migrations/0039_workflow_run_plugin_version.sql b/internal/core/migrations/0042_workflow_run_plugin_version.sql similarity index 100% rename from internal/core/migrations/0039_workflow_run_plugin_version.sql rename to internal/core/migrations/0042_workflow_run_plugin_version.sql diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index 0743ad220..189545efd 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -131,9 +131,9 @@ code that grants on it. Additive: columns, constraints and indexes, no backfill. `TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` covers fresh, 0038 upgrade and rerun. -## 0036: Executor slot bindings +## 0040: Executor slot bindings -`0036_executor_slot_bindings.sql` lets executor slots reach the runtime. A `run_model_bindings` row's +`0040_executor_slot_bindings.sql` lets executor slots reach the runtime. A `run_model_bindings` row's owner becomes a discriminator pair — exactly one of `run_id` / `workflow_run_id` — and the previous whole-table UNIQUE(run_id) splits into one (run, slot) uniqueness per owner, so a run may carry several bindings at once: a WorkflowRun freezes one row per slot its graph references, while an IssueRun keeps @@ -142,17 +142,17 @@ executor), the invoker-owned resolution table the creation gate reads; `default` excluded there because it IS the personal default model, never a stored mapping. Additive: it adds columns, constraints and a table without rewriting any existing binding row. -## 0038: Workflow execution node +## 0041: Workflow execution node -`0038_workflow_execution_node.sql` adds `workflow_node_id` to `execution_work` and `node_executions`, +`0041_workflow_execution_node.sql` adds `workflow_node_id` to `execution_work` and `node_executions`, with a constraint tying a work item's node to the graph of the run the item belongs to. It is Cloud-internal routing: the Controller-facing projection of `node_executions` (nodeExecutionColumns) deliberately omits it, because no Controller contract declares it. Additive, no backfill. -## 0039: Run-level plugin version +## 0042: Run-level plugin version -`0039_workflow_run_plugin_version.sql` adds a nullable `plugin_version` to `workflow_runs`, folding the +`0042_workflow_run_plugin_version.sql` adds a nullable `plugin_version` to `workflow_runs`, folding the plugin version into the run identity the creation transaction freezes (snapshot, input, initiator, Project, run Workspace binding, per-slot model bindings, plugin version). Session start reads the frozen value, so an upgrade can no longer change what an already-created run executes. Nullable and diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 1468c66e9..90e48aaaf 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -127,25 +127,25 @@ Workspace 可以属于 WorkflowRun;`execution_work` 同样变为「恰有一 `TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` 验证空库、从 0038 升级及重复迁移。 -## 0036:执行器槽位绑定 +## 0040:执行器槽位绑定 -`0036_executor_slot_bindings.sql` 让执行器槽位抵达运行时。`run_model_bindings` 的所有者变为判别对 +`0040_executor_slot_bindings.sql` 让执行器槽位抵达运行时。`run_model_bindings` 的所有者变为判别对 (`run_id` 与 `workflow_run_id` 恰有其一),原先整表的 UNIQUE(run_id) 拆分为每种所有者各一条 (run, slot) 唯一索引,于是一次运行可同时持有多条绑定;WorkflowRun 为图上引用的每个槽位冻结一行, IssueRun 继续在保留槽位 `default` 下持有其唯一绑定。新增 `executor_slot_mappings`(槽位名 → 执行器) 作为调用者私有的解析表,`default` 被显式排除——它就是个人默认模型本身,不是一条可存储的映射。 只新增列、约束与表,不改写既有绑定行。 -## 0038:工作流执行节点 +## 0041:工作流执行节点 -`0038_workflow_execution_node.sql` 给 `execution_work` 与 `node_executions` 各加一列 +`0041_workflow_execution_node.sql` 给 `execution_work` 与 `node_executions` 各加一列 `workflow_node_id`,并加约束保证工作项所属节点确实属于该工作项所在的运行图。它是 Cloud 内部的路由 字段:`node_executions` 面向 Controller 的投影(nodeExecutionColumns)刻意不含该列,因为它不是任何 Controller 契约的字段。只新增,不回填。 -## 0039:运行级插件版本 +## 0042:运行级插件版本 -`0039_workflow_run_plugin_version.sql` 给 `workflow_runs` 加可空列 `plugin_version`,把插件版本并入 +`0042_workflow_run_plugin_version.sql` 给 `workflow_runs` 加可空列 `plugin_version`,把插件版本并入 创建事务冻结的运行身份(快照、输入、发起人、Project、运行 Workspace 绑定、逐槽位模型绑定、插件版本)。 会话启动读冻结值,升级不再改变已创建运行的执行内容。可空且刻意不回填:本迁移之前的行从未冻结过, 把空间当前版本写进去会记录一个从未成立的事实;这类运行在首个 agent 节点以 `agent_plugin_version_unfrozen` diff --git a/internal/core/model_grants.go b/internal/core/model_grants.go index 1ef69ce33..5a85d6a53 100644 --- a/internal/core/model_grants.go +++ b/internal/core/model_grants.go @@ -85,7 +85,7 @@ func liveIssueRunModelBinding(t *transaction, scope ModelRuntimeScope, bindingID // Belonging to the run is not enough here, which is where this differs from its IssueRun // counterpart. An IssueRun owns exactly one binding (0033's `run_id ... UNIQUE`), so "the binding's // run is the execution's run" names one row. A WorkflowRun owns one binding per executor slot -// (0036 drops that uniqueness), so the same test admits every slot's binding at once — and the +// (0040 drops that uniqueness), so the same test admits every slot's binding at once — and the // binding id is the caller's to state, since a model mTLS certificate is bound to a tenant, // workspace and runtime generation, never to a slot. Node A holding slot `fast` could therefore // present node B's `slow` binding id and be handed B's credential and model. diff --git a/internal/core/model_run_binding.go b/internal/core/model_run_binding.go index a1b045672..459d750f5 100644 --- a/internal/core/model_run_binding.go +++ b/internal/core/model_run_binding.go @@ -48,7 +48,7 @@ func bindRunModel(t *transaction, runID, uid string, input Object) { } } id := newID() - // An IssueRun binds the implicit `default` slot: the run_model_bindings migration (0036) + // An IssueRun binds the implicit `default` slot: the run_model_bindings migration (0040) // carries slot_name for both run kinds, so a session's binding lookup is uniform // (workflow_run_session.go resolves the same way). t.exec(`INSERT INTO run_model_bindings(id,run_id,slot_name,user_id,connection_id,connection_version,credential_id,connection_name,protocol,base_url,auth_mode,model) diff --git a/internal/core/node_execution.go b/internal/core/node_execution.go index 21adabfcc..f22a59ef2 100644 --- a/internal/core/node_execution.go +++ b/internal/core/node_execution.go @@ -4,7 +4,7 @@ package core // table except workflow_node_id. That column is Cloud-internal routing — which graph node a session // ran for — and no Controller contract has a field for it: the proto ExecutionRecord declares none, // and the JSON binding's NodeExecution schema is closed, so a `SELECT *` that carried the column -// failed the response contract the moment migration 0038 added it. +// failed the response contract the moment migration 0041 added it. // // So a row that reaches a response body is projected explicitly, the way workflowRunSummary projects // a run row, rather than read whole. A column added to the table therefore does not reach the wire by diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 6416be083..879c4579b 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -70,7 +70,7 @@ func seedWorkflowSessionSceneGraph(t *testing.T, store *Store, graph Object) (ru exec("connection", `INSERT INTO personal_model_connections(id,user_id,credential_id,name,protocol,base_url,auth_mode,models,enabled) VALUES($1,$2,$3,'Personal Provider','openai-completions','https://models.example.invalid/v1','bearer',$4::jsonb,true)`, conn, uid, cred, jsonText([]any{map[string]any{"id": "vendor/model-v1", "name": "Model V1"}})) exec("default", `INSERT INTO personal_model_defaults(user_id,connection_id,model_id,version) VALUES($1,$2,'vendor/model-v1',1)`, uid, conn) // A named slot too, so a graph that references one freezes a second binding on the same run. - // 0036 keys a workflow binding by (run, slot) rather than by run alone, which is what makes + // 0040 keys a workflow binding by (run, slot) rather than by run alone, which is what makes // "belongs to this run" too weak a test for a session to redeem one (liveWorkflowModelBinding). exec("slot", `INSERT INTO executor_slot_mappings(user_id,slot_name,agent_cli,connection_id,model_id,version) VALUES($1,'strong','official/ora-space.opencode',$2,'vendor/model-v1',1)`, uid, conn) freezeWorkflowRunExecutors(tx, rid, uid, graph) From 31c9ca7d614789da095a81b89a9dc314c7515b0d Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Mon, 19 Oct 2026 10:00:20 +0800 Subject: [PATCH 25/26] docs(workflows): describe the executor slot mappings and real agent sessions The workflow documents said named slot mappings were unimplemented and that an Agent node fails the run with a reason; both are now the code this PR lands, so the slot section points at the personal mapping endpoints and the scheduling section describes the session path (run Workspace, frozen per-slot binding, one session at a time, bounded startup, workspace deletion at the terminal state). model-access records that one run may carry several bindings. Both languages change together, per the Documentation rules. Co-Authored-By: Claude Code --- docs/models/model-access.en.md | 3 +++ docs/models/model-access.md | 2 ++ docs/workflows/workflows.en.md | 17 ++++++++++++----- docs/workflows/workflows.md | 10 +++++++--- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/models/model-access.en.md b/docs/models/model-access.en.md index dd52c9b3d..373501d70 100644 --- a/docs/models/model-access.en.md +++ b/docs/models/model-access.en.md @@ -63,6 +63,9 @@ same time. interaction and run roll back together, returning `409 model_default_required`, `model_credential_required` or `model_connection_unavailable` respectively. Other Agents are unaffected. - Later changes to the connection or the default model do not change runs already created. +- A run may carry several bindings: a workflow run freezes one per slot its graph references, in the creation + transaction, and each node's session redeems only the binding of the slot it references (see + [Workflows](../workflows/workflows.en.md#executor-slots)). - The session execution's input carries only an opaque `modelBindingId`; neither short-lived tokens nor the upstream key ever enter execution inputs or the Controller's control messages. - When registering a session dispatch, Cloud checks that the current Node declared the model proxy capability in diff --git a/docs/models/model-access.md b/docs/models/model-access.md index 85184be7f..d0c2690c3 100644 --- a/docs/models/model-access.md +++ b/docs/models/model-access.md @@ -45,6 +45,8 @@ - 发起者没有默认模型、没有配置 Key 或连接不可用时,整条评论、交互和运行一起回滚,分别返回 `409 model_default_required`、 `model_credential_required`、`model_connection_unavailable`。其他 Agent 的行为不受影响。 - 之后修改连接或默认模型,不会改变已经创建的运行。 +- 一次运行可以有多份绑定:工作流运行在创建事务里为图引用的**每个槽位**各冻结一份,每个节点的会话只取用它引用的那个槽位的绑定(见 + [工作流](../workflows/workflows.md#执行器槽位))。 - 会话执行的输入只带一个不透明的 `modelBindingId`;临时令牌和上游 Key 都不进入执行输入或 Controller 的控制消息。 - 登记会话派发时,Cloud 检查当前 Node 握手时声明了模型代理能力,没有这项能力的旧 Node 不能执行使用个人模型的会话 (`409 model_proxy_required`);签发模型授权时同样要求这项能力。启动期间确认 Node 缺少能力时的结算见 diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index 79746d019..7c01271f2 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -51,8 +51,9 @@ Agent nodes do not hard-code an Agent CLI or model ID in the graph; they referen refused with `409 workflow_executor_unconfigured`, whose parameters list each slot, the nodes depending on it, the reason (such as `slot_not_configured`, `default_model_missing`, `credential_missing`) and the slot description, so the frontend can show everything that needs configuring at once. -- Personal mappings for named slots are not implemented yet, so any graph referencing a named slot is currently - reported as unconfigured; graphs referencing only `default` are judged by the initiator's default model. +- A named slot's mapping is each user's own configuration (`GET`, `PUT`, `DELETE /me/executor-slot-mappings/:slot`: + slot name to agent CLI, model connection and model). `default` cannot be stored as a mapping — it is the personal + default model itself, maintained separately in the personal model settings. ## Runs @@ -99,9 +100,15 @@ seconds): scheduling transaction. `condition` activates only the outgoing edge for the chosen branch, and an outgoing edge with no branch label counts as `else`; nodes on branches not taken are marked inactive and never run. A run's graph is a directed acyclic graph with no loops or iterations. -- **Agent nodes currently fail the run with an explicit reason.** An Agent node needs a run Workspace and a real Node - session, and that execution path is not wired yet; until it is, a run that reaches an Agent node fails immediately - with a reason that names the gap, instead of hanging in `running` or reporting success for work that never happened. +- **An Agent node runs in a real session.** The first time a run schedules an Agent node, Cloud creates the run's + Workspace (isolated, owned by this run) in the Project the run froze, and once that Workspace has a connected Node + it takes the model binding frozen at creation for the slot the node references and hands the session work item to + the Controller; the session's terminal state is the node's terminal state. One session runs at a time per run — + a node on the next edge starts only after the previous one settles — and the wait before a session starts is + bounded. A Workspace that cannot be created fails the node as `workspace_unavailable`; a session that finds its + frozen plugin version or slot binding missing fails as `agent_plugin_version_unfrozen` or + `workflow_executor_unconfigured` — never hanging in `running` or reporting success for work that never happened. + Once the run reaches a terminal state, Cloud declares the deletion of its Workspace. Development fixtures (`collaboration.development_fixtures`) install a deterministic run simulator that fills in the execution trace at run creation and says in its output that nothing really ran; production never installs it. diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index 2f647692d..a46d4db9f 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -36,7 +36,8 @@ Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执 - 运行开始时,按**发起者自己**的配置解析每个被引用的槽位,不回退到 `default`、不采用作者的建议、也不借用别人的配置。任何一个槽位解析 不了,启动就以 `409 workflow_executor_unconfigured` 拒绝,参数里逐个列出槽位、依赖它的节点、原因(例如 `slot_not_configured`、 `default_model_missing`、`credential_missing`)以及槽位说明,前端可以一次性展示所有需要补的配置。 -- 个人的具名槽位映射还没有实现,所以目前引用了具名槽位的图都会报告为未配置;只引用 `default` 的图按发起者的默认模型判断。 +- 具名槽位的映射是每个用户自己的配置(`GET`、`PUT`、`DELETE /me/executor-slot-mappings/:slot`:槽位名到 agent CLI、模型连接与模型)。`default` 不能被 + 存成一条映射——它就是个人默认模型本身,单独在个人模型设置里维护。 ## 运行 @@ -72,8 +73,11 @@ WorkflowRun 由 Cloud server 里的调度循环推进(`AdvanceWorkflowRunsOnce 推进不产生效果;没有提交结果的节点不会被记成成功。 - `start`、`condition`、`output` 是已提交状态的纯函数,在调度事务里直接计算并提交结果。`condition` 只激活与所选分支对应的出边,没有标注分支的 出边视为 `else`;未被选中的分支上的节点标为未激活,永远不会执行。运行的图是有向无环图,没有循环和迭代。 -- **Agent 节点目前会让运行以明确的原因失败。** Agent 节点需要一个运行 Workspace 和一次真实的 Node 会话,这条执行链路还没有接入;在此之前, - 调度到 Agent 节点的运行立即失败并说明原因,而不是停在 `running` 或报告没有发生的成功。 +- **Agent 节点在一次真实会话里执行。** 运行第一次调度到 Agent 节点时,Cloud 在运行冻结的 Project 里创建这次运行的 Workspace(isolated,属于该运行), + 等它有连上的 Node 后,按该节点引用的槽位取出创建时冻结的模型绑定,把会话工作项交给 Controller;会话的终态就是该节点的终态。同一运行同一时刻只有一个会话, + 下一条边上的节点等它结束才开始,会话开始前的等待有上限。运行 Workspace 建不出来时节点以 `workspace_unavailable` 失败,会话开始时发现冻结的插件版本或槽位绑定 + 缺失时以 `agent_plugin_version_unfrozen`、`workflow_executor_unconfigured` 失败,都不会停在 `running` 或报告没有发生的成功。运行进入终态后,Cloud 声明删除 + 这次运行的 Workspace。 开发夹具(`collaboration.development_fixtures`)会装入一个确定性的运行模拟器,在创建运行时就填好执行轨迹,并在输出里说明没有真实执行;生产环境 不装它。工作流 IssueRun 在生产中目前没有执行端,保持 `queued`(见 [Issue 协作](../collaboration/interactions.md#端口issue-只拥有消费侧的契约))。 From a6b703bdc23fd42e9843bac24f921d69d1ff3d80 Mon Sep 17 00:00:00 2001 From: Kyber5323 Date: Mon, 19 Oct 2026 10:12:49 +0800 Subject: [PATCH 26/26] fix(core): drop the duplicated upgrade query the rebase resolution left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving this branch's rebase onto the run-identity PR kept both sides of one conflict in the revision upgrade test, so `documents` was declared twice — a redeclaration the typecheck gate rejects before any test runs. Keep the later migration's query, whose comment names the identity migration as 0039, and drop the stray blank lines the same resolution left in `.gitattributes` and the two migrations READMEs. Co-Authored-By: Claude Code --- .gitattributes | 1 - integration/revision_upgrade_test.go | 6 +----- internal/core/migrations/README.en.md | 1 - internal/core/migrations/README.md | 1 - 4 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.gitattributes b/.gitattributes index cb96bd689..c64a16280 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,7 +18,6 @@ internal/core/migrations/0033_revision_resume.sql text eol=lf # Everything introduced since main is pinned here instead, while no released database has recorded # it yet; after merge that would no longer be true. internal/core/migrations/0039_workflow_run_identity.sql text eol=lf - internal/core/migrations/0040_executor_slot_bindings.sql text eol=lf internal/core/migrations/0041_workflow_execution_node.sql text eol=lf internal/core/migrations/0042_workflow_run_plugin_version.sql text eol=lf diff --git a/integration/revision_upgrade_test.go b/integration/revision_upgrade_test.go index cef52fd2c..ad9aba62a 100644 --- a/integration/revision_upgrade_test.go +++ b/integration/revision_upgrade_test.go @@ -93,11 +93,7 @@ func TestRevisionUpgradePreservesPublishedWorkflowSchema(t *testing.T) { must(t, err) } must(t, tx.Commit()) - // The run document strips the identity columns a later migration (0039) added: they land as - // NULL on published rows, which is an intended shape change, not a data change — the - // comparison keeps guarding the row data the published schema carried. - const documents = `SELECT jsonb_build_object('workflow',to_jsonb(w),'snapshot',to_jsonb(s),'run',to_jsonb(r) - ARRAY['created_by','project_id'])::text FROM workflows w JOIN workflow_snapshots s ON s.workflow_id=w.id JOIN workflow_runs r ON r.snapshot_id=s.id WHERE r.id=$1` - // The run document strips the columns later migrations added to the published row: 0038's + // The run document strips the columns later migrations added to the published row: 0039's // identity columns and 0042's frozen plugin version. They land as NULL on published rows, // which is an intended shape change, not a data change — the comparison keeps guarding the // row data the published schema carried. diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index 189545efd..bf64bf2ad 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -130,7 +130,6 @@ code that grants on it. Additive: columns, constraints and indexes, no backfill. `TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` covers fresh, 0038 upgrade and rerun. - ## 0040: Executor slot bindings `0040_executor_slot_bindings.sql` lets executor slots reach the runtime. A `run_model_bindings` row's diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 90e48aaaf..774664f1a 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -126,7 +126,6 @@ Workspace 可以属于 WorkflowRun;`execution_work` 同样变为「恰有一 `TestMigration0039WorkflowRunIdentityAppliesFreshAndUpgrades` 验证空库、从 0038 升级及重复迁移。 - ## 0040:执行器槽位绑定 `0040_executor_slot_bindings.sql` 让执行器槽位抵达运行时。`run_model_bindings` 的所有者变为判别对