From 9e0fcb2e94d5f206512649bd78f70e4b4b3cc20c Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:21:03 -0700 Subject: [PATCH 01/19] fix(core): preserve null workflow run timestamps Pending WorkflowRun details must retain JSON null for startedAt and finishedAt instead of invalid empty date-time strings. TestPendingWorkflowRunDetailPreservesNullTimestamps directly checks both fields over HTTP. Build and full core/integration validation are logged per commit; the known Windows Git integration failure remains unchanged. --- integration/workflow_run_timestamp_test.go | 26 ++++++++++++++++++++++ internal/core/workflow_run.go | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 integration/workflow_run_timestamp_test.go diff --git a/integration/workflow_run_timestamp_test.go b/integration/workflow_run_timestamp_test.go new file mode 100644 index 000000000..3006bf58e --- /dev/null +++ b/integration/workflow_run_timestamp_test.go @@ -0,0 +1,26 @@ +package integration + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestPendingWorkflowRunDetailPreservesNullTimestamps(t *testing.T) { + f := setup(t) + wid := createWorkflow(t, f, "Pending timestamps", "pending-timestamps-wf", nil) + snapshot := publishWorkflow(t, f, wid, "pending-timestamps-pub") + rid := uuid.NewString() + _, err := f.store.Pool.Exec(`INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name) + VALUES($1,$2,$3,$4,'Pending timestamps')`, rid, f.tid, wid, snapshot.S("id")) + must(t, err) + run := f.call("GET", f.path("/workflows/"+wid+"/runs/"+rid), nil, "", 200) + if run.S("status") != "pending" { + t.Fatalf("run status = %v, want pending", run["status"]) + } + for _, field := range []string{"startedAt", "finishedAt"} { + if value, present := run[field]; !present || value != nil { + t.Fatalf("pending run %s = %#v (present=%v), want JSON null", field, value, present) + } + } +} diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index bb9937d41..9da0989a1 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -42,8 +42,8 @@ func workflowRunSummary(run Object) Object { "nodeStates": run.O("nodeStates"), "rounds": rounds, "error": run.S("error"), - "startedAt": run.S("startedAt"), - "finishedAt": run.S("finishedAt"), + "startedAt": run["startedAt"], + "finishedAt": run["finishedAt"], "createdAt": run.S("createdAt"), "updatedAt": run.S("updatedAt"), } From 6c92e30e37bae97c5d96c6063b3899ae396398cb Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:26:59 -0700 Subject: [PATCH 02/19] feat(core): bind workflow confirms to real run records Create and link WorkflowRun/IssueRun atomically for Confirm and manual starts, bypass legacy dispatch, and enforce tenant-scoped one-to-one references in migration 0036. Settle all unlinked non-terminal history, retain editable terminal rows, and validate constraints on every database. Preserve the admission base live-form divergence gate verbatim. HTTP, concurrency, snapshot freeze and fresh/seeded migration regressions accompany this change; full core/integration and build checks are logged at this commit. --- docs/development/agent/api-reference.md | 2 +- docs/development/agent/architecture.md | 4 +- docs/development/agent/database.md | 2 +- integration/workflow_interaction_test.go | 34 ++- .../workflow_invocation_migration_test.go | 171 ++++++++++++ integration/workflow_invocation_test.go | 258 ++++++++++++++++++ internal/core/README.en.md | 10 + internal/core/README.md | 8 + internal/core/issue_interactions.go | 12 +- internal/core/issue_run_lifecycle.go | 13 +- internal/core/issue_runs.go | 3 +- .../0036_workflow_invocation_reference.sql | 24 ++ internal/core/migrations/README.en.md | 13 + internal/core/migrations/README.md | 11 + internal/core/public.go | 2 +- internal/core/workflow_invocation.go | 25 ++ internal/core/workflow_run.go | 16 +- 17 files changed, 577 insertions(+), 31 deletions(-) create mode 100644 integration/workflow_invocation_migration_test.go create mode 100644 integration/workflow_invocation_test.go create mode 100644 internal/core/migrations/0036_workflow_invocation_reference.sql create mode 100644 internal/core/workflow_invocation.go diff --git a/docs/development/agent/api-reference.md b/docs/development/agent/api-reference.md index ed60501e9..34ac51fc1 100644 --- a/docs/development/agent/api-reference.md +++ b/docs/development/agent/api-reference.md @@ -173,7 +173,7 @@ workflow_not_available` (3B-1) is **SUPERSEDED**. | GET | `/collaboration/forms/{formRef}` | query `?issueId=` | `FormDescriptor` (`{formRef,title?,description?,fields[]}`) | | GET | `/workflows/{wfid}/snapshots/{snapshotId}/form` | query `?issueId=` | `WorkflowSnapshotForm` (`{snapshotId,formRef,title?,description?,fields[]}`) | | POST 🔑 | `/issues/{iid}/collaboration/assist` | `targetId`*, `values`(object) | `{suggestedValues, suggestedContextRefs, explanations?}` — **stateless, suggest only, no side effects** | -| POST 🔑 | `/issues/{iid}/interactions/{ixid}/confirm` | `values`(object)*, `contextRefs`(array of `{refType,refId}`) | `{resource: IssueRun}` (the initial run, `status=queued`; dispatch is post-commit) | +| POST 🔑 | `/issues/{iid}/interactions/{ixid}/confirm` | `values`(object)*, `contextRefs`(array of `{refType,refId}`) | `{resource: IssueRun}` (`status=queued`, linked by `workflowInvocationRef` to one pending WorkflowRun; no legacy dispatch) | - `formRef` is an **opaque** provider token (not a UUID) and appears in a path segment. - `?issueId=` is **optional** and additive. Omitted (or empty) ⇒ the descriptor is exactly the one this diff --git a/docs/development/agent/architecture.md b/docs/development/agent/architecture.md index aa46dceb0..387e7c217 100644 --- a/docs/development/agent/architecture.md +++ b/docs/development/agent/architecture.md @@ -179,7 +179,9 @@ the fixture Agent/Team targets with stable IDs (`internal/collab`: **Implemented (Wave 3B-2, migration `0012` — formerly `0010`/`0009`; renumbered in the workspace integration):** - Workflow Form Mode end-to-end: `@Workflow` → `GET /collaboration/forms/{formRef}` → dynamic form → - optional AI Assist → **explicit Confirm** → `IssueRun` → mock execution → Timeline. + optional AI Assist → **explicit Confirm** → one `WorkflowRun` and linked `IssueRun` in the same + transaction. Linked IssueRuns bypass legacy/mock dispatch. Execution advancement and its Timeline + projection await scheduler integration. - New ports `FormDescriptorProvider` + `InputAssistProvider` (both nil ⇒ 503). `FormDescriptorProvider` is now the production adapter `core.WorkflowFormDescriptors` (a workflow's Start variables projected onto renderable fields); only `MockInputAssistProvider` is still a fixture (deterministic, no LLM). diff --git a/docs/development/agent/database.md b/docs/development/agent/database.md index 4b2aa1ce3..232b500a9 100644 --- a/docs/development/agent/database.md +++ b/docs/development/agent/database.md @@ -68,7 +68,7 @@ access immediately. Earlier 0006–0013 entries above describe immutable migrati | `issues` | board card | tenant_id, creator_user_id, assignee_type/assignee_id (polymorphic), assignee_user_id? (mirror), project_ref?, parent_issue_id?, title, description, status, priority, position, number, properties(jsonb), version, deleted_at | | `issue_statuses` | status catalog | tenant_id, key, name, category, color, icon, is_system, position, deleted_at · UNIQUE(tenant_id,key) | | `issue_comments` | comment thread | tenant_id, issue_id, author_user_id? (mirror), author_type/author_id (ActorRef), parent_id? (threading), body, seq, version, deleted_at · UNIQUE(issue_id,seq) | -| `issue_runs` | issue-owned run lifecycle | tenant_id, issue_id, executor_type/executor_id, status, external_execution_id?/execution_context_ref?/workflow_invocation_ref? (opaque refs), input/result/error, trigger_evidence_*, version, deleted_at | +| `issue_runs` | issue-owned run lifecycle | tenant_id, issue_id, executor_type/executor_id, status, external_execution_id?/execution_context_ref? (opaque refs), workflow_invocation_ref? (UNIQUE composite FK with tenant_id to workflow_runs; required for non-terminal workflow runs, forbidden for other executors), input/result/error, trigger_evidence_*, version, deleted_at | | `issue_activities` | append-only timeline projection | tenant_id, issue_id, actor_type/actor_id, action, details(jsonb), seq, created_at | | `issue_context_refs` | issue→external reference | tenant_id, issue_id, ref_type, ref_id, created_at · hard delete | | `issue_interactions` | `@` interaction spine | tenant_id, issue_id, comment_id, target_type/type, target_id, mode(mention/task/form), task, run_id?, input(jsonb) · one row per selected target · `run_id IS NULL` = unconfirmed, `input` = confirmed form values | diff --git a/integration/workflow_interaction_test.go b/integration/workflow_interaction_test.go index b66332811..50bc17c34 100644 --- a/integration/workflow_interaction_test.go +++ b/integration/workflow_interaction_test.go @@ -843,6 +843,10 @@ func TestWorkflowConfirmReadsThePreRenameVersionKey(t *testing.T) { if stored.S("snapshotId") != "1.2.3" { t.Fatalf("the author's variable was overwritten by the version selection: %v", stored) } + linked := f.call("GET", f.path("/workflows/"+owned+"/runs/"+run.S("workflowInvocationRef")), nil, "", 200) + if linked.O("input").S("snapshotId") != "1.2.3" || linked.S("snapshotId") != ownedOlder.S("id") { + t.Fatalf("the invocation confused the author's variable with the selected snapshot: %v", linked) + } if _, kept := stored["version"]; kept { t.Fatalf("the pre-rename key was stored: %v", stored) } @@ -1047,7 +1051,7 @@ func TestWorkflowAssistIsSideEffectFree(t *testing.T) { func TestWorkflowConfirmValidatesAndExecutes(t *testing.T) { f := setup(t) wid := reviewWorkflow(t, f) - publishWorkflow(t, f, wid, "wf-confirm-pub") + published := publishWorkflow(t, f, wid, "wf-confirm-pub") iss := f.call("POST", f.path("/issues"), core.Object{"title": "Confirm me", "description": "desc"}, "wf-confirm-issue", 200).O("resource") it := workflowComment(t, f, wid, iss.S("id"), "wf-confirm-1", "configure the review") @@ -1076,8 +1080,7 @@ func TestWorkflowConfirmValidatesAndExecutes(t *testing.T) { } created := f.call("POST", confirmPath(f, iss.S("id"), it.S("id")), core.Object{"values": validValues()}, "wf-confirm-ok", 200).O("resource") - // The confirm response is the committed enqueue: dispatch is post-commit, so execution has not been - // observed yet (§19 ordering). The frontend re-reads the run/timeline afterwards. + // Confirm commits the linked execution intent; the workflow scheduler owns later advancement. if created.S("status") != "queued" || created.S("executorType") != "workflow" || created.S("executorId") != wid { t.Fatalf("workflow run wrong at confirm time: %v", created) } @@ -1085,10 +1088,18 @@ func TestWorkflowConfirmValidatesAndExecutes(t *testing.T) { if created.S("triggerEvidenceKind") != "interaction" || created.S("triggerEvidenceRefId") != it.S("id") { t.Fatalf("workflow run provenance wrong: %v", created) } - // Re-read: the mock dispatcher drove it to completion through the observer seam. + // The WorkflowRun owns execution. The legacy mock dispatcher must not fabricate a separate + // execution or advance this IssueRun through its unrelated observer lifecycle. run := f.call("GET", f.path("/issues/"+iss.S("id")+"/runs/"+created.S("id")), nil, "", 200) - if run.S("status") != "completed" || run.S("externalExecutionId") == "" { - t.Fatalf("workflow run did not complete through the observer: %v", run) + if run.S("status") != "queued" || run.S("workflowInvocationRef") == "" || run.S("externalExecutionId") != "" { + t.Fatalf("workflow run was not linked or still used legacy dispatch: %v", run) + } + linked := f.call("GET", f.path("/workflows/"+wid+"/runs/"+run.S("workflowInvocationRef")), nil, "", 200) + if linked.S("snapshotId") != published.S("id") || run.O("input").S("workflowSnapshotId") != linked.S("snapshotId") { + t.Fatalf("linked workflow run does not execute the frozen snapshot: issue=%v workflow=%v", run, linked) + } + if n := f.scalar("SELECT count(*) FROM workflow_runs WHERE id=$1 AND tenant_id=$2", run.S("workflowInvocationRef"), f.tid); n != 1 { + t.Fatalf("confirm created %d linked WorkflowRuns", n) } // Exactly one run, and the interaction is now confirmed with its own persisted input. @@ -1106,7 +1117,7 @@ func TestWorkflowConfirmValidatesAndExecutes(t *testing.T) { t.Fatalf("the descriptor must never be stored as interaction input") } - // The run snapshot is the effective execution-time input, and it equals what the dispatcher got. + // The IssueRun snapshot preserves the effective invocation input and its issue context. input := run.O("input") if input.S("task") != "" || input.O("interactionValues").S("repository") != "ora-space/cloud" { t.Fatalf("run snapshot wrong: %v", input) @@ -1118,7 +1129,7 @@ func TestWorkflowConfirmValidatesAndExecutes(t *testing.T) { t.Fatalf("run snapshot lost the recent-comment window: %v", input) } - // Timeline: comment, enqueued, started, progress, system message, completed — and NO workflow-authored comment. + // Without scheduler advancement, only enqueue is visible; legacy dispatch adds no fake execution. tl := issueItems(f.call("GET", f.path("/issues/"+iss.S("id")+"/timeline"), nil, "", 200)) actions := []string{} for _, e := range tl { @@ -1126,7 +1137,7 @@ func TestWorkflowConfirmValidatesAndExecutes(t *testing.T) { actions = append(actions, e.S("action")) } } - want := []string{"run.enqueued", "run.started", "run.progress", "run.message", "run.completed"} + want := []string{"run.enqueued"} if len(actions) != len(want) { t.Fatalf("workflow timeline actions = %v, want %v", actions, want) } @@ -1140,11 +1151,6 @@ func TestWorkflowConfirmValidatesAndExecutes(t *testing.T) { t.Fatalf("a workflow must never author content: %v", e) } } - message := tl[4] - if message.S("authorType") != "system" || message.O("details").S("executorType") != "workflow" || - message.O("details").S("executorId") != wid || message.O("details").S("message") == "" { - t.Fatalf("workflow message activity wrong: %v", message) - } // seq is strictly increasing and unique across the merged timeline. for i := 1; i < len(tl); i++ { if tl[i].N("seq") <= tl[i-1].N("seq") { diff --git a/integration/workflow_invocation_migration_test.go b/integration/workflow_invocation_migration_test.go new file mode 100644 index 000000000..aba699c5a --- /dev/null +++ b/integration/workflow_invocation_migration_test.go @@ -0,0 +1,171 @@ +package integration + +import ( + "database/sql" + "testing" + + "github.com/google/uuid" +) + +const workflowInvocationMigration = "0036_workflow_invocation_reference.sql" + +// seedInvocationWorkflowRun creates real rows for the composite reference under test. The graph is +// deliberately minimal: these tests exercise database integrity rather than graph admission. +func seedInvocationWorkflowRun(t *testing.T, pool *sql.DB, tenantID string) string { + t.Helper() + wid, sid, rid := uuid.NewString(), uuid.NewString(), uuid.NewString() + _, err := pool.Exec(`INSERT INTO workflows(id,tenant_id,name) VALUES($1,$2,$3)`, wid, tenantID, "Invocation "+wid) + must(t, err) + _, err = pool.Exec(`INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'Snapshot','{}')`, sid, tenantID, wid) + must(t, err) + _, err = pool.Exec(`INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name) VALUES($1,$2,$3,$4,'Invocation')`, rid, tenantID, wid, sid) + must(t, err) + return rid +} + +func insertInvocationIssueRun(pool *sql.DB, seed skeletonSeed, executorType string, reference any) (string, error) { + id := uuid.NewString() + _, err := pool.Exec(`INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,workflow_invocation_ref) + VALUES($1,$2,$3,$4,$5,$6)`, id, seed.tenantID, seed.issueID, executorType, uuid.NewString(), reference) + return id, err +} + +// assertInvocationConstraintValidation requires the same validated schema for fresh and upgraded data. +func assertInvocationConstraintValidation(t *testing.T, pool *sql.DB) { + t.Helper() + for _, constraint := range []struct { + table, name string + validated bool + }{ + {"issue_runs", "issue_runs_workflow_invocation_shape", true}, + {"issue_runs", "issue_runs_workflow_invocation_fk", true}, + {"issue_runs", "issue_runs_workflow_invocation_uniq", true}, + {"workflow_runs", "workflow_runs_id_tenant_uniq", true}, + } { + var validated bool + must(t, pool.QueryRow(`SELECT convalidated FROM pg_constraint WHERE conrelid=$1::regclass AND conname=$2`, constraint.table, constraint.name).Scan(&validated)) + if validated != constraint.validated { + t.Fatalf("%s validation = %v, want %v", constraint.name, validated, constraint.validated) + } + } +} + +func TestWorkflowInvocationMigrationFreshConstraints(t *testing.T) { + pool, _ := testSchema(t, "test_invocation_fresh_") + store := newStoreOnSchema(t, pool) + migratedTwice(t, store) + seed := seedOwnershipChain(t, pool) + assertInvocationConstraintValidation(t, pool) + + // A valid same-tenant reference succeeds and remains a durable one-to-one link. + ref := seedInvocationWorkflowRun(t, pool, seed.tenantID) + id, err := insertInvocationIssueRun(pool, seed, "workflow", ref) + must(t, err) + var linked string + must(t, pool.QueryRow(`SELECT workflow_invocation_ref FROM issue_runs WHERE id=$1`, id).Scan(&linked)) + if linked != ref { + t.Fatalf("valid reference = %q, want %q", linked, ref) + } + _, err = insertInvocationIssueRun(pool, seed, "workflow", nil) + wantPGError(t, err, "23514") + _, err = insertInvocationIssueRun(pool, seed, "workflow", uuid.NewString()) + wantPGError(t, err, "23503") + _, err = insertInvocationIssueRun(pool, seed, "workflow", ref) + wantPGError(t, err, "23505") + _, err = insertInvocationIssueRun(pool, seed, "agent", seedInvocationWorkflowRun(t, pool, seed.tenantID)) + wantPGError(t, err, "23514") + + // The referenced UUID exists, but belongs to another tenant. The composite FK must reject it. + foreignTenant := uuid.NewString() + tx, err := pool.Begin() + must(t, err) + defer func() { _ = tx.Rollback() }() + _, err = tx.Exec(`INSERT INTO tenants(id,name,status) VALUES($1,'Foreign tenant','active')`, foreignTenant) + must(t, err) + _, err = tx.Exec(`INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')`, foreignTenant, seed.userID) + must(t, err) + must(t, tx.Commit()) + foreignRef := seedInvocationWorkflowRun(t, pool, foreignTenant) + _, err = insertInvocationIssueRun(pool, seed, "workflow", foreignRef) + wantPGError(t, err, "23503") +} + +func TestWorkflowInvocationMigrationUpgradePreservesHistory(t *testing.T) { + pool, _ := testSchema(t, "test_invocation_upgrade_") + applyMigrationsUpTo(t, pool, migrationsBefore(t, workflowInvocationMigration)) + seed := seedOwnershipChain(t, pool) + ids := map[string]string{} + for _, status := range []string{"queued", "dispatched", "running", "deferred", "completed", "failed", "cancelled"} { + id := uuid.NewString() + ids[status] = id + _, err := pool.Exec(`INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,status,input,failure_reason) + VALUES($1,$2,$3,'workflow',$4,$5,'{"topic":"historical"}','existing-history')`, id, seed.tenantID, seed.issueID, uuid.NewString(), status) + must(t, err) + } + rowJSON := func(id string) string { + t.Helper() + var out string + must(t, pool.QueryRow(`SELECT row_to_json(ir)::text FROM issue_runs ir WHERE id=$1`, id).Scan(&out)) + return out + } + terminalBefore := map[string]string{} + for _, status := range []string{"completed", "failed", "cancelled"} { + terminalBefore[status] = rowJSON(ids[status]) + } + agentBefore := rowJSON(seed.runID) + store := newStoreOnSchema(t, pool) + migratedTwice(t, store) + assertInvocationConstraintValidation(t, pool) + + for _, before := range []string{"queued", "dispatched", "running", "deferred"} { + var status, reason, topic string + var reference sql.NullString + var completed bool + var version int + must(t, pool.QueryRow(`SELECT status,failure_reason,input->>'topic',workflow_invocation_ref,completed_at IS NOT NULL,version + FROM issue_runs WHERE id=$1`, ids[before]).Scan(&status, &reason, &topic, &reference, &completed, &version)) + if status != "failed" || reason != "workflow_execution_unavailable_before_invocation_linkage" || topic != "historical" || reference.Valid || !completed || version != 2 { + t.Fatalf("historical %s settlement: status=%q reason=%q topic=%q ref=%v completed=%v version=%d", before, status, reason, topic, reference, completed, version) + } + } + for status, before := range terminalBefore { + if got := rowJSON(ids[status]); got != before { + t.Fatalf("terminal %s workflow history changed: before=%s after=%s", status, before, got) + } + } + if got := rowJSON(seed.runID); got != agentBefore { + t.Fatalf("ordinary agent row changed: before=%s after=%s", agentBefore, got) + } + var issues, workflowRuns int + must(t, pool.QueryRow(`SELECT count(*) FROM issue_runs`).Scan(&issues)) + must(t, pool.QueryRow(`SELECT count(*) FROM workflow_runs`).Scan(&workflowRuns)) + if issues != 8 || workflowRuns != 0 { + t.Fatalf("upgrade lost history or invented execution: IssueRuns=%d WorkflowRuns=%d, want 8/0", issues, workflowRuns) + } + + // A queued invocation must link execution; historical terminal rows remain editable. + _, err := insertInvocationIssueRun(pool, seed, "workflow", nil) + wantPGError(t, err, "23514") + for _, id := range ids { + _, err = pool.Exec(`UPDATE issue_runs SET trigger_summary='retained history' WHERE id=$1`, id) + must(t, err) + var summary string + must(t, pool.QueryRow(`SELECT trigger_summary FROM issue_runs WHERE id=$1`, id).Scan(&summary)) + if summary != "retained history" { + t.Fatalf("historical row %s was not editable", id) + } + } + ref := seedInvocationWorkflowRun(t, pool, seed.tenantID) + validID, err := insertInvocationIssueRun(pool, seed, "workflow", ref) + must(t, err) + _, err = pool.Exec(`UPDATE issue_runs SET workflow_invocation_ref=NULL WHERE id=$1`, validID) + wantPGError(t, err, "23514") +} + +func TestWorkflowInvocationMigrationUpgradeWithoutLegacyValidates(t *testing.T) { + pool, _ := testSchema(t, "test_invocation_clean_upgrade_") + applyMigrationsUpTo(t, pool, migrationsBefore(t, workflowInvocationMigration)) + seedOwnershipChain(t, pool) + migratedTwice(t, newStoreOnSchema(t, pool)) + assertInvocationConstraintValidation(t, pool) +} diff --git a/integration/workflow_invocation_test.go b/integration/workflow_invocation_test.go new file mode 100644 index 000000000..57b6cea8e --- /dev/null +++ b/integration/workflow_invocation_test.go @@ -0,0 +1,258 @@ +package integration + +import ( + "context" + "reflect" + "sort" + "sync" + "testing" + + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/core" +) + +// TestWorkflowConfirmCreatesLinkedWorkflowRun checks the persisted execution identity across the +// public confirm boundary, rather than accepting a legacy IssueRun simulation as execution evidence. +func TestWorkflowConfirmCreatesLinkedWorkflowRun(t *testing.T) { + f := setup(t) + wid := createWorkflow(t, f, "Linked workflow", "inv-link-wf", nil) + snapshot := publishWorkflow(t, f, wid, "inv-link-pub") + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Linked execution"}, "inv-link-issue", 200).O("resource").S("id") + interaction := workflowComment(t, f, wid, iid, "inv-link-comment", "run") + run := f.call("POST", confirmPath(f, iid, interaction.S("id")), core.Object{"values": core.Object{ + "repository": "ora-space/cloud", "branch": "main", "snapshotId": snapshot.S("id"), + }}, "inv-link-confirm", 200).O("resource") + if run.S("workflowInvocationRef") == "" { + t.Fatalf("confirmed workflow IssueRun has no linked WorkflowRun: %v", run) + } + workflowRun := f.call("GET", f.path("/workflows/"+wid+"/runs/"+run.S("workflowInvocationRef")), nil, "", 200) + if workflowRun.S("snapshotId") != snapshot.S("id") || run.O("input").S("workflowSnapshotId") != snapshot.S("id") { + t.Fatalf("linked runs disagree about the selected snapshot: issue=%v workflow=%v", run, workflowRun) + } + if n := f.scalar("SELECT count(*) FROM workflow_runs WHERE workflow_id=$1", wid); n != 1 { + t.Fatalf("confirm created %d WorkflowRuns, want exactly one", n) + } +} + +// assertUnclaimedWorkflowInvocation checks the entire refused-start transaction, including activity +// and the interaction claim; a status assertion alone would miss a partially committed start. +func assertUnclaimedWorkflowInvocation(t *testing.T, f *fixture, iid, interactionID string, activities int) { + t.Helper() + if n := f.scalar("SELECT count(*) FROM issue_runs WHERE issue_id=$1", iid); n != 0 { + t.Fatalf("refused confirm left %d IssueRuns", n) + } + if n := f.scalar("SELECT count(*) FROM workflow_runs"); n != 0 { + t.Fatalf("refused confirm left %d WorkflowRuns", n) + } + if n := f.scalar("SELECT count(*) FROM issue_activities WHERE issue_id=$1", iid); n != activities { + t.Fatalf("refused confirm changed activity count from %d to %d", activities, n) + } + if n := f.scalar("SELECT count(*) FROM issue_interactions WHERE id=$1 AND run_id IS NULL AND input='{}'::jsonb", interactionID); n != 1 { + t.Fatal("refused confirm claimed the interaction or changed its input") + } +} + +func invocationVariable(name string) core.Object { + return core.Object{"name": name, "displayName": name, "fieldType": "text-input", "valueType": "string", "required": true} +} + +func TestWorkflowConfirmAdmissionRollsBackBothRuns(t *testing.T) { + for _, scenario := range []string{"unpublished", "wrong-workflow-snapshot", "foreign-snapshot", "missing-input", "invalid-input-type"} { + t.Run(scenario, func(t *testing.T) { + f := setup(t) + wid := createWorkflow(t, f, "Admission workflow", "inv-gate-wf", []any{invocationVariable("topic")}) + values := core.Object{"repository": "ora-space/cloud", "branch": "main", "topic": "auth"} + status, code := 400, "" + if scenario != "unpublished" { + values["snapshotId"] = publishWorkflow(t, f, wid, "inv-gate-pub").S("id") + } else { + code = "workflow_no_published_snapshot" + } + switch scenario { + case "wrong-workflow-snapshot": + other := createWorkflow(t, f, "Other workflow", "inv-gate-other", nil) + values["snapshotId"] = publishWorkflow(t, f, other, "inv-gate-other-pub").S("id") + status = 404 + case "foreign-snapshot": + other, err := f.store.Bootstrap(context.Background(), "Other tenant", "corp", "inv-other", "Other") + must(t, err) + tenant, subject := f.tid, f.user.Subject + f.tid, f.user.Subject = other.S("tenantId"), "inv-other" + foreign := createWorkflow(t, f, "Foreign workflow", "inv-gate-foreign", nil) + values["snapshotId"] = publishWorkflow(t, f, foreign, "inv-gate-foreign-pub").S("id") + f.tid, f.user.Subject = tenant, subject + status = 404 + case "missing-input": + delete(values, "topic") + code = "required_field_missing" + case "invalid-input-type": + values["topic"] = 42 + code = "invalid_field_value" + } + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Rejected start"}, "inv-gate-issue", 200).O("resource").S("id") + it := workflowComment(t, f, wid, iid, "inv-gate-comment", "run") + activities := f.scalar("SELECT count(*) FROM issue_activities WHERE issue_id=$1", iid) + refusal := f.call("POST", confirmPath(f, iid, it.S("id")), core.Object{"values": values}, "inv-gate-confirm", status) + if code != "" && refusal.S("code") != code { + t.Fatalf("refused start code = %q, want %q: %v", refusal.S("code"), code, refusal) + } + assertUnclaimedWorkflowInvocation(t, f, iid, it.S("id"), activities) + }) + } +} + +func TestWorkflowConfirmFreezesHistoricalSchemaAndInput(t *testing.T) { + f := setup(t) + graph := func(variables []any) core.Object { + out := startInputGraph(variables) + out["globalVariables"] = []any{core.Object{"name": "review.mode", "valueType": "string", "value": "default"}} + return out + } + wid := f.call("POST", f.path("/workflows"), core.Object{ + "name": "Historical workflow", "graph": graph([]any{invocationVariable("topic")}), + }, "inv-old-wf", 200).O("resource").S("id") + older := publishWorkflow(t, f, wid, "inv-old-pub-1") + edit := func(variables []any, zoom float64) { + t.Helper() + live := f.call("GET", f.path("/workflows/"+wid), nil, "", 200) + updated := graph(variables) + updated["viewport"] = core.Object{"x": 0, "y": 0, "zoom": zoom} + f.call("PUT", f.path("/workflows/"+wid), core.Object{ + "graph": updated, "version": live.N("version"), + }, "", 200) + } + // A newer graph with the same fields keeps the live-form compatibility gate satisfied. + edit([]any{invocationVariable("topic")}, 2) + newer := publishWorkflow(t, f, wid, "inv-old-pub-2") + if reflect.DeepEqual(older.O("graph"), newer.O("graph")) { + t.Fatal("historical snapshot test needs distinct published definitions") + } + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Historical start"}, "inv-old-issue", 200).O("resource").S("id") + it := workflowComment(t, f, wid, iid, "inv-old-comment", "run old version") + created := f.call("POST", confirmPath(f, iid, it.S("id")), core.Object{"values": core.Object{ + "repository": "ora-space/cloud", "branch": "main", "snapshotId": older.S("id"), "topic": "auth", "review.mode": "strict", + }}, "inv-old-confirm", 200).O("resource") + ref := created.S("workflowInvocationRef") + if ref == "" { + t.Fatal("historical confirm did not link a WorkflowRun") + } + path := f.path("/workflows/" + wid + "/runs/" + ref) + before := f.call("GET", path, nil, "", 200) + if before.S("snapshotId") != older.S("id") || created.O("input").S("workflowSnapshotId") != older.S("id") { + t.Fatalf("historical snapshot was not selected: issue=%v workflow=%v", created, before) + } + if !reflect.DeepEqual(before.O("input"), core.Object{"topic": "auth", "review.mode": "strict"}) { + t.Fatalf("execution input must contain selected-schema variables only: %v", before.O("input")) + } + if !reflect.DeepEqual(before.O("definitionSnapshot"), older.O("graph")) { + t.Fatalf("definition does not match selected frozen document: %v", before) + } + edit([]any{invocationVariable("replacement")}, 3) + publishWorkflow(t, f, wid, "inv-old-pub-3") + after := f.call("GET", path, nil, "", 200) + for _, field := range []string{"snapshotId", "input", "definitionSnapshot"} { + if !reflect.DeepEqual(after[field], before[field]) { + t.Fatalf("editing and republishing changed frozen %s: before=%v after=%v", field, before[field], after[field]) + } + } +} + +func TestWorkflowConfirmInvocationIdempotency(t *testing.T) { + f := setup(t) + wid := createWorkflow(t, f, "Replay workflow", "inv-idem-wf", nil) + publishWorkflow(t, f, wid, "inv-idem-pub") + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Replay start"}, "inv-idem-issue", 200).O("resource").S("id") + it := workflowComment(t, f, wid, iid, "inv-idem-comment", "run") + body := core.Object{"values": core.Object{"repository": "ora-space/cloud", "branch": "main"}} + path := confirmPath(f, iid, it.S("id")) + first := f.call("POST", path, body, "inv-idem-confirm", 200) + replay := f.call("POST", path, body, "inv-idem-confirm", 200) + if !reflect.DeepEqual(first, replay) { + t.Fatalf("same-key confirm did not replay its original response: first=%v replay=%v", first, replay) + } + if first.O("resource").S("workflowInvocationRef") == "" { + t.Fatal("replayed response has no WorkflowRun identity") + } + refusal := f.call("POST", path, body, "inv-idem-second", 409) + if refusal.S("code") != "interaction_already_confirmed" { + t.Fatalf("second confirm fault = %v", refusal) + } + if n := f.scalar("SELECT count(*) FROM issue_runs WHERE issue_id=$1", iid); n != 1 { + t.Fatalf("replay created %d IssueRuns", n) + } + if n := f.scalar("SELECT count(*) FROM workflow_runs WHERE workflow_id=$1", wid); n != 1 { + t.Fatalf("replay created %d WorkflowRuns", n) + } +} + +func TestWorkflowConfirmConcurrentInvocationCreatesExactlyOnePair(t *testing.T) { + f := setup(t) + wid := createWorkflow(t, f, "Concurrent workflow", "inv-race-wf", nil) + publishWorkflow(t, f, wid, "inv-race-pub") + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Concurrent start"}, "inv-race-issue", 200).O("resource").S("id") + it := workflowComment(t, f, wid, iid, "inv-race-comment", "run") + path := confirmPath(f, iid, it.S("id")) + start := make(chan struct{}) + var wg sync.WaitGroup + bodies, statuses, errs := make([]core.Object, 2), make([]int, 2), make([]error, 2) + for i := range 2 { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + bodies[i], statuses[i], errs[i] = callRaw(f, "POST", path, core.Object{"values": core.Object{ + "repository": "ora-space/cloud", "branch": "main", + }}, "inv-race-"+uuid.NewString()) + }(i) + } + close(start) + wg.Wait() + var accepted core.Object + for i := range 2 { + must(t, errs[i]) + if statuses[i] == 200 { + accepted = bodies[i].O("resource") + } else if bodies[i].S("code") != "interaction_already_confirmed" { + t.Fatalf("concurrent refusal = %v", bodies[i]) + } + } + sort.Ints(statuses) + if !reflect.DeepEqual(statuses, []int{200, 409}) { + t.Fatalf("concurrent statuses = %v, want [200 409]", statuses) + } + if n := f.scalar("SELECT count(*) FROM issue_runs WHERE issue_id=$1", iid); n != 1 { + t.Fatalf("concurrent confirm created %d IssueRuns", n) + } + if n := f.scalar("SELECT count(*) FROM workflow_runs WHERE workflow_id=$1", wid); n != 1 { + t.Fatalf("concurrent confirm created %d WorkflowRuns", n) + } + if n := f.scalar("SELECT count(*) FROM issue_runs ir JOIN workflow_runs wr ON wr.id=ir.workflow_invocation_ref AND wr.tenant_id=ir.tenant_id JOIN issue_interactions ix ON ix.run_id=ir.id WHERE ix.id=$1 AND ir.id=$2 AND wr.id=$3", it.S("id"), accepted.S("id"), accepted.S("workflowInvocationRef")); n != 1 { + t.Fatal("winning confirm did not atomically persist the interaction and same-tenant run pair") + } +} + +func TestManualWorkflowInvocationCreatesLinkedRun(t *testing.T) { + f := setup(t) + wid := createWorkflow(t, f, "Manual workflow", "inv-manual-wf", []any{invocationVariable("topic")}) + snapshot := publishWorkflow(t, f, wid, "inv-manual-pub") + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Manual start"}, "inv-manual-issue", 200).O("resource").S("id") + status, body := manualWorkflowRun(t, f, iid, wid, "inv-manual-start", core.Object{"topic": "auth"}) + if status != 200 { + t.Fatalf("manual start status = %d: %v", status, body) + } + run := body.O("resource") + if run.S("workflowInvocationRef") == "" { + t.Fatal("manual workflow IssueRun has no WorkflowRun identity") + } + linked := f.call("GET", f.path("/workflows/"+wid+"/runs/"+run.S("workflowInvocationRef")), nil, "", 200) + if linked.S("snapshotId") != snapshot.S("id") || run.O("input").S("workflowSnapshotId") != snapshot.S("id") { + t.Fatalf("manual run pair froze different snapshots: issue=%v workflow=%v", run, linked) + } + if linked.O("input").S("topic") != "auth" { + t.Fatalf("manual WorkflowRun lost its execution input: %v", linked) + } + if n := f.scalar("SELECT count(*) FROM issue_runs ir JOIN workflow_runs wr ON wr.id=ir.workflow_invocation_ref AND wr.tenant_id=ir.tenant_id WHERE ir.issue_id=$1", iid); n != 1 { + t.Fatalf("manual start created %d valid linked pairs, want 1", n) + } +} diff --git a/internal/core/README.en.md b/internal/core/README.en.md index 6e1397855..bd1f5318c 100644 --- a/internal/core/README.en.md +++ b/internal/core/README.en.md @@ -55,3 +55,13 @@ See [Database migrations](migrations/README.en.md), [Core contract](../../docs/c own private metadata, immutable ciphertext references, run snapshots and temporary-token digests. Core never receives or decrypts upstream API keys. See [personal model connections](../../docs/model-connections.en.md) for freezing, authorization, renewal and revocation rules. + +## Workflow invocation linkage + +Workflow Confirm and manual IssueRuns create the execution row through `insertWorkflowRun` and +write `issue_runs.workflow_invocation_ref` in the same transaction; direct starts use the same +insertion function. Confirm retains the 409 gate for field differences between the live form and the +selected snapshot, then validates that published snapshot. +Execution input contains only declared Start/global variables. Platform fields stay on the IssueRun, +whose `input.workflowSnapshotId` matches the linked execution. Linked IssueRuns bypass the legacy +Dispatcher, including development doubles. diff --git a/internal/core/README.md b/internal/core/README.md index 66fd308de..b7037c869 100644 --- a/internal/core/README.md +++ b/internal/core/README.md @@ -55,3 +55,11 @@ `model_connections.go`、`model_credentials.go`、`model_run_binding.go` 和 `model_grants.go` 管理用户私有连接、不可变密文引用、运行快照与临时令牌摘要。Core 不接收或解密上游 API Key; 冻结、权限、续期与撤销规则见 [个人模型连接](../../docs/model-connections.md)。 + +## Workflow 调用关联 + +Workflow Confirm 和 manual IssueRun 在同一事务内通过 `insertWorkflowRun` 创建执行行, +再写入 `issue_runs.workflow_invocation_ref`;直接启动也使用该创建函数。 +Confirm 保留活稿表单与所选快照字段不一致时的 409 门禁,校验 schema 和执行图来自所选发布快照。执行输入只含快照声明的 +Start 与全局变量;平台字段保留在 IssueRun,`input.workflowSnapshotId` 与关联运行一致。 +有引用的 IssueRun 不进入 legacy Dispatcher;开发 mock 的完成状态不能替代 WorkflowRun 状态。 diff --git a/internal/core/issue_interactions.go b/internal/core/issue_interactions.go index 4ec0731f2..5ddac4cc0 100644 --- a/internal/core/issue_interactions.go +++ b/internal/core/issue_interactions.go @@ -109,9 +109,9 @@ func applyCommentTargets(t *transaction, i Object, r *PublicRequest, uid, commen // confirmInteraction is the ONLY transition that turns a Workflow interaction into an execution intent // (§38.7). It re-resolves the target server-side, re-validates the submitted values, persists them as -// the interaction's Issue-owned input, creates the initial run, and claims the interaction with a -// compare-and-set on `run_id IS NULL`. Dispatch is the caller's post-commit responsibility, so no -// external effect happens inside this transaction (§38.22). +// the interaction's Issue-owned input, creates the WorkflowRun and linked IssueRun, and claims the +// interaction with a compare-and-set on `run_id IS NULL`. WorkflowRun owns execution; this path never +// hands the linked IssueRun to the legacy dispatcher or performs an external effect (§38.22). // // The values are validated against the schema of the *version being confirmed* rather than the live // document the form was rendered from, and against the same admission gate the other two entries use. @@ -132,7 +132,7 @@ func applyCommentTargets(t *transaction, i Object, r *PublicRequest, uid, commen // client rendered, which is the live document's. When the two have diverged the confirm refuses with // `workflow_version_fields_changed` and names the fields, rather than validating values the user was // shown against a schema they were never shown — see workflowFieldDivergence. -func confirmInteraction(t *transaction, r *PublicRequest, uid string, dispatches *[]dispatchTarget) Object { +func confirmInteraction(t *transaction, r *PublicRequest, uid string) Object { it := interaction(t, r.TenantID, r.IssueID, r.InteractionID) require(it.S("mode") == "form", 409, "interaction_not_confirmable") require(it["runId"] == nil, 409, "interaction_already_confirmed") @@ -152,11 +152,9 @@ func confirmInteraction(t *transaction, r *PublicRequest, uid string, dispatches validateFormValues(versionDescriptor, values, true) input := buildRunContext(t, i, "", targetType, targetID, values, appliedContextRefs(r.Body)) - freezeWorkflowSnapshot(input, s.S("id")) - runObj := enqueueRun(t, r.TenantID, i.S("id"), targetType, targetID, input, "interaction", it.S("id"), "user", uid) + runObj := enqueueWorkflowInvocation(t, r.TenantID, i.S("id"), w, s, values, input, "interaction", it.S("id"), uid) claimed := t.execRows("UPDATE issue_interactions SET input=$3, run_id=$4 WHERE id=$1 AND tenant_id=$2 AND run_id IS NULL", it.S("id"), r.TenantID, jsonText(values), runObj.S("id")) require(claimed == 1, 409, "interaction_already_confirmed") - *dispatches = append(*dispatches, dispatchTarget{tenantID: r.TenantID, runID: runObj.S("id")}) return Object{"resource": runObj} } diff --git a/internal/core/issue_run_lifecycle.go b/internal/core/issue_run_lifecycle.go index 870df0976..2574b831b 100644 --- a/internal/core/issue_run_lifecycle.go +++ b/internal/core/issue_run_lifecycle.go @@ -72,6 +72,12 @@ func runActivityDetails(runID, executorType, executorID string, extra Object) Ob // trigger_evidence provenance seam, and appends the run.enqueued Timeline activity. It never // dispatches: dispatch is the caller's post-commit responsibility. func enqueueRun(t *transaction, tid, iid, executorType, executorID string, input Object, triggerKind string, triggerRef any, actorType, actorID string) Object { + return enqueueRunWithInvocation(t, tid, iid, executorType, executorID, input, triggerKind, triggerRef, actorType, actorID, "") +} + +// enqueueRunWithInvocation inserts the reference with the IssueRun itself so PostgreSQL can enforce +// the invocation shape immediately; no transient unlinked workflow IssueRun is ever inserted. +func enqueueRunWithInvocation(t *transaction, tid, iid, executorType, executorID string, input Object, triggerKind string, triggerRef any, actorType, actorID, workflowRunID string) Object { require(t.one("SELECT id FROM issue_runs WHERE issue_id=$1 AND executor_type=$2 AND executor_id=$3 AND status IN ('queued','dispatched') AND deleted_at IS NULL", iid, executorType, executorID) == nil, 409, "pending_run_exists") if input == nil { input = Object{} @@ -85,7 +91,7 @@ func enqueueRun(t *transaction, tid, iid, executorType, executorID string, input delete(input, "workflowSnapshotId") } id := newID() - t.exec("INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,input,status,trigger_evidence_kind,trigger_evidence_ref_id) VALUES($1,$2,$3,$4,$5,$6,'queued',$7,$8)", id, tid, iid, executorType, executorID, jsonText(input), triggerKind, triggerRef) + t.exec("INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,input,status,trigger_evidence_kind,trigger_evidence_ref_id,workflow_invocation_ref) VALUES($1,$2,$3,$4,$5,$6,'queued',$7,$8,$9)", id, tid, iid, executorType, executorID, jsonText(input), triggerKind, triggerRef, nullable(workflowRunID)) if executorType == "agent" { // Caller-supplied private bindings cannot authorize a model connection. The production // OpenCode path always creates its own owner-scoped binding in this transaction. @@ -113,6 +119,11 @@ func (s *Store) dispatchRun(ctx context.Context, tenantID, runID string) error { if o == nil || o.S("status") != "queued" { return Object{} } + if o.S("workflowInvocationRef") != "" { + // WorkflowRun owns execution. A legacy dispatcher (including dev doubles) must never + // create a second execution or advance its linked IssueRun independently. + return Object{} + } if o.S("executorType") == "agent" && isSpaceAgentRun(t, tenantID, o.S("executorId")) { // A real Space Agent run is claimed by AgentRunDispatcher (Claim + Busy), independent of // whether the legacy ExecutionDispatcher is wired (nil in production). This routing is the diff --git a/internal/core/issue_runs.go b/internal/core/issue_runs.go index 89324f160..537e9e389 100644 --- a/internal/core/issue_runs.go +++ b/internal/core/issue_runs.go @@ -94,8 +94,7 @@ func createRun(t *transaction, r *PublicRequest, uid string) Object { if executorType == "workflow" { w := workflow(t, r.TenantID, executorID) s := admitWorkflowRun(t, r.TenantID, w.S("id"), "", uid) - requireWorkflowRunInput(s.O("graph"), input) - freezeWorkflowSnapshot(input, s.S("id")) + return enqueueWorkflowInvocation(t, r.TenantID, i.S("id"), w, s, input, input, "", nil, uid) } if executorType == "agent" { // Authoritative Space Agent (IssueRun D1): reuses the same evidence resolver as the comment diff --git a/internal/core/migrations/0036_workflow_invocation_reference.sql b/internal/core/migrations/0036_workflow_invocation_reference.sql new file mode 100644 index 000000000..9b8c5d8bd --- /dev/null +++ b/internal/core/migrations/0036_workflow_invocation_reference.sql @@ -0,0 +1,24 @@ +-- Retire execution intents accepted before WorkflowRun linkage existed. A status-only settlement +-- cannot invent the snapshot/input those historical rows would have executed. +UPDATE issue_runs +SET status = 'failed', + failure_reason = 'workflow_execution_unavailable_before_invocation_linkage', + completed_at = now(), updated_at = now(), version = version + 1 +WHERE executor_type = 'workflow' AND workflow_invocation_ref IS NULL + AND status NOT IN ('completed', 'failed', 'cancelled'); + +ALTER TABLE workflow_runs + ADD CONSTRAINT workflow_runs_id_tenant_uniq UNIQUE (id, tenant_id); + +ALTER TABLE issue_runs + ADD CONSTRAINT issue_runs_workflow_invocation_fk + FOREIGN KEY (workflow_invocation_ref, tenant_id) REFERENCES workflow_runs(id, tenant_id), + ADD CONSTRAINT issue_runs_workflow_invocation_uniq UNIQUE (workflow_invocation_ref), + ADD CONSTRAINT issue_runs_workflow_invocation_shape + CHECK ((executor_type <> 'workflow' AND workflow_invocation_ref IS NULL) + OR (executor_type = 'workflow' AND (workflow_invocation_ref IS NOT NULL + OR status IN ('completed', 'failed', 'cancelled')))) NOT VALID; + +-- Historical terminal rows remain editable without inventing executions. New invocations enqueue +-- non-terminal rows and must carry a reference. Fresh and upgraded databases validate the same shape. +ALTER TABLE issue_runs VALIDATE CONSTRAINT issue_runs_workflow_invocation_shape; diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index b2aacc1e1..d8abb3488 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -101,3 +101,16 @@ Both `0033` files retain their complete filenames and original SQL. The complete ## 0034: Agent startup windows and residual ownership Adds startup_ready_at, startup_deadline_at, startup_resume, startup_ending_deadline_at and agent_run_residuals without rewriting historical outcomes. The serialized policy scan freezes legacy active windows once. Residual ownership commits with failed/done; late true terminal facts add evidence without granting cleanup authority. Fresh/upgrade/repeat coverage: TestMigration0034StartupWindowsPreserveExistingRunOnUpgrade. + +## 0036: WorkflowRun invocation references + +`0036_workflow_invocation_reference.sql` first settles every non-terminal unlinked historical +workflow IssueRun (including running and deferred) as failed, with +`workflow_execution_unavailable_before_invocation_linkage`. It then adds a tenant-scoped composite +foreign key and a unique invocation reference. The CHECK requires a reference for non-terminal +workflow executors and forbids it for other executors. Historical terminal workflow rows may retain +NULL references and remain editable; their input and outcomes are preserved without inventing runs. +The CHECK is unconditionally validated, so fresh and upgraded databases enforce the same schema. +0035 is reserved for the earlier WorkflowRun identity migration in #68; invocation linkage follows as +0036. Fresh, seeded upgrade, repeated migration, +constraint validation and historical update coverage is in `integration/workflow_invocation_migration_test.go`. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 954286022..80a39ff47 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -100,3 +100,14 @@ Cloud 领域方法。冻结与撤销规则见 [模型连接契约](../../../docs ## 0034:Agent 启动窗口与残留责任 新增 startup_ready_at、startup_deadline_at、startup_resume、startup_ending_deadline_at 及 agent_run_residuals。迁移不改历史结果;活动存量由串行策略扫描一次固定窗口。残留账本与 failed/done 同事务提交,迟到真实终态仅补证据,不代替清理授权。新库/升级/重复执行见 TestMigration0034StartupWindowsPreserveExistingRunOnUpgrade。 + +## 0036:WorkflowRun 调用引用 + +`0036_workflow_invocation_reference.sql` 先将所有未关联的历史非终态 workflow IssueRun +(含 running、deferred)结算为 failed,原因是 +`workflow_execution_unavailable_before_invocation_linkage`,再添加租户一致的复合外键及唯一引用。 +CHECK 要求非终态 workflow 行具有引用,其他 executor 不得携带引用。历史终态 workflow 行可保留 +NULL 引用并继续更新;迁移保留其输入和结果,不虚构执行。CHECK 无条件 VALIDATE,空库和升级库 +采用同一份已验证约束。0035 留给依赖链中更早的 #68 WorkflowRun 身份迁移,本调用关联迁移使用 0036。 +空库、种子升级、重复迁移、约束验证状态及 +历史行更新测试见 `integration/workflow_invocation_migration_test.go`。 diff --git a/internal/core/public.go b/internal/core/public.go index 8207079a8..519d68a3f 100644 --- a/internal/core/public.go +++ b/internal/core/public.go @@ -231,7 +231,7 @@ func (s *Store) Public(ctx context.Context, r *PublicRequest) (Object, int, erro out = assistWorkflow(t, r) case strings.Contains(r.Path, "/interactions"): require(r.Method == "POST" && strings.HasSuffix(r.Path, "/confirm"), 404, "not_found") - out = confirmInteraction(t, r, uid, &dispatches) + out = confirmInteraction(t, r, uid) case strings.Contains(r.Path, "/comments"): switch r.Method { case "POST": diff --git a/internal/core/workflow_invocation.go b/internal/core/workflow_invocation.go new file mode 100644 index 000000000..5b04ccc86 --- /dev/null +++ b/internal/core/workflow_invocation.go @@ -0,0 +1,25 @@ +package core + +// workflowInvocationInput isolates the pending form-to-execution contract: only Start and global +// variables are execution input. Platform launch fields and Issue context remain on the IssueRun. +// A platform-named key declared by the author is still a workflow variable and must survive. +func workflowInvocationInput(graph, values Object) Object { + input := Object{} + fields := workflowInputFields(graph) + for index := range fields { + if value, ok := values[fields[index].Key]; ok { + input[fields[index].Key] = value + } + } + return input +} + +// enqueueWorkflowInvocation creates both sides of an invocation inside the caller's transaction. +// Snapshot selection/admission and initiator resolution remain at the entry point; project/workspace +// identity and slot binding will join the shared insertion seam when #70 supplies those columns. +func enqueueWorkflowInvocation(t *transaction, tid, iid string, workflow, snapshot, values, issueInput Object, triggerKind string, triggerRef any, uid string) Object { + executionInput := workflowInvocationInput(snapshot.O("graph"), values) + invocation := insertWorkflowRun(t, tid, workflow.S("id"), snapshot, executionInput, workflow.S("name")) + freezeWorkflowSnapshot(issueInput, snapshot.S("id")) + return enqueueRunWithInvocation(t, tid, iid, "workflow", workflow.S("id"), issueInput, triggerKind, triggerRef, "user", uid, invocation.S("id")) +} diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index 9da0989a1..29f5b7948 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -89,12 +89,22 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { } s := admitWorkflowRun(t, r.TenantID, r.WorkflowID, r.Body.S("snapshotId"), uid) 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)) + created := insertWorkflowRun(t, r.TenantID, r.WorkflowID, s, input, name) + id := created.S("id") 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)) } + +// insertWorkflowRun is the shared creation seam after admission. The caller owns the transaction +// and the admitted snapshot; #70 can add its identity and slot-binding writes here without changing +// the IssueRun linkage. This preflight writes only columns that already exist. +func insertWorkflowRun(t *transaction, tid, wid string, snapshot, input Object, name string) Object { + require(snapshot.S("tenantId") == tid && snapshot.S("workflowId") == wid, 404, "not_found") + requireWorkflowRunInput(snapshot.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,'pending',$6)", id, tid, wid, snapshot.S("id"), name, jsonText(input)) + return t.one("SELECT * FROM workflow_runs WHERE id=$1 AND tenant_id=$2", id, tid) +} From 7c63e823064754cc129165bdf7439fd096ed5f99 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:33:59 -0700 Subject: [PATCH 03/19] feat(core): project workflow states into issue activity Project authoritative state in the caller transaction, write system activities and summaries, and preserve rollback/idempotency. Supplement run.started before direct terminal outcomes only with actual start evidence; awaiting_input leaves the IssueRun unchanged without rejecting the source transaction. PostgreSQL regressions cover these paths. Scheduler wiring remains deferred; full core/integration and build checks are logged at this commit. --- internal/core/README.en.md | 5 + internal/core/README.md | 5 + internal/core/workflow_issue_projection.go | 78 +++++ .../core/workflow_issue_projection_test.go | 272 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 internal/core/workflow_issue_projection.go create mode 100644 internal/core/workflow_issue_projection_test.go diff --git a/internal/core/README.en.md b/internal/core/README.en.md index bd1f5318c..a225df6f3 100644 --- a/internal/core/README.en.md +++ b/internal/core/README.en.md @@ -65,3 +65,8 @@ selected snapshot, then validates that published snapshot. Execution input contains only declared Start/global variables. Platform fields stay on the IssueRun, whose `input.workflowSnapshotId` matches the linked execution. Linked IssueRuns bypass the legacy Dispatcher, including development doubles. + +`projectWorkflowRunToIssueRun` projects authoritative state, timestamps and errors inside the caller's +transaction. Output summaries are `system` `run.message` activities, never workflow-authored comments. +PostgreSQL tests call this function directly. Scheduler calls, execution identity and slot bindings +remain executor integration work; this preflight does not establish actual Node execution. diff --git a/internal/core/README.md b/internal/core/README.md index b7037c869..ef04b72dc 100644 --- a/internal/core/README.md +++ b/internal/core/README.md @@ -63,3 +63,8 @@ Workflow Confirm 和 manual IssueRun 在同一事务内通过 `insertWorkflowRun Confirm 保留活稿表单与所选快照字段不一致时的 409 门禁,校验 schema 和执行图来自所选发布快照。执行输入只含快照声明的 Start 与全局变量;平台字段保留在 IssueRun,`input.workflowSnapshotId` 与关联运行一致。 有引用的 IssueRun 不进入 legacy Dispatcher;开发 mock 的完成状态不能替代 WorkflowRun 状态。 + +`projectWorkflowRunToIssueRun` 在调用方事务内从 WorkflowRun 投影状态、时间及错误, +输出摘要写为 `system` 的 `run.message` 活动,不创建 workflow 作者评论。 +该函数已由 PostgreSQL 测试直接调用;调度器调用、执行身份和槽位绑定仍待执行器接入, +此预备实现不代表实际 Node 执行已经完成。 diff --git a/internal/core/workflow_issue_projection.go b/internal/core/workflow_issue_projection.go new file mode 100644 index 000000000..c42d2696a --- /dev/null +++ b/internal/core/workflow_issue_projection.go @@ -0,0 +1,78 @@ +package core + +import "strings" + +// projectWorkflowRunToIssueRun mirrors the committed-in-this-transaction workflow state onto its +// optional IssueRun. The scheduler must call it after its state compare-and-set, in the same +// Store.transact boundary, so a projection or activity failure rolls back the workflow transition. +// Direct workflow runs have no IssueRun and are a no-op. The caller supplies the execution's output +// summary because per-node output has no canonical summary field in workflow_runs. awaiting_input +// has no IssueRun counterpart; it leaves the current projection untouched without blocking the +// caller's workflow transition. +func projectWorkflowRunToIssueRun(t *transaction, tid, workflowRunID, outputSummary string) Object { + w := t.one("SELECT * FROM workflow_runs WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL", workflowRunID, tid) + require(w != nil, 404, "not_found") + i := t.one("SELECT * FROM issue_runs WHERE workflow_invocation_ref=$1 AND tenant_id=$2 AND executor_type='workflow' AND deleted_at IS NULL FOR UPDATE", workflowRunID, tid) + if i == nil { + return nil + } + require(i.S("executorId") == w.S("workflowId"), 409, "workflow_run_projection_conflict") + var status, action string + switch w.S("status") { + case "pending": + status = "queued" + case "running": + status, action = "running", "run.started" + case "awaiting_input": + return run(t, tid, i.S("issueId"), i.S("id")) + case "succeeded": + status, action = "completed", "run.completed" + case "failed": + status, action = "failed", "run.failed" + case "cancelled": + status, action = "cancelled", "run.cancelled" + default: + require(false, 409, "workflow_run_state_not_projectable") + } + if i.S("status") == status { + return run(t, tid, i.S("issueId"), i.S("id")) + } + // A terminal IssueRun cannot be reopened or silently given a different outcome. Recovery must + // project the authoritative current WorkflowRun, never replay a stale transition argument. + require(i.S("status") == "queued" || i.S("status") == "dispatched" || i.S("status") == "running", 409, "workflow_run_projection_conflict") + require(status != "queued", 409, "workflow_run_projection_conflict") + terminal := status == "completed" || status == "failed" || status == "cancelled" + if status == "running" { + require(w.S("startedAt") != "", 409, "workflow_run_projection_incomplete") + } + if terminal { + require(w.S("finishedAt") != "", 409, "workflow_run_projection_incomplete") + } + message := strings.TrimSpace(outputSummary) + require(len(message) <= 20000, 400, "invalid_input") + reason := "" + if status == "failed" || status == "cancelled" { + reason = w.S("error") + } + t.exec(`UPDATE issue_runs i SET status=$3, started_at=w.started_at, + completed_at=w.finished_at, error=$4, failure_reason=$4, + version=i.version+1, updated_at=now() + FROM workflow_runs w WHERE i.id=$1 AND i.tenant_id=$2 + AND w.id=i.workflow_invocation_ref AND w.tenant_id=i.tenant_id`, i.S("id"), tid, status, reason) + details := runActivityDetails(i.S("id"), "workflow", i.S("executorId"), Object{"workflowInvocationRef": workflowRunID}) + // A scheduler may finish a workflow within one transaction, before an intermediate running + // projection exists. Only authoritative start evidence permits the missing lifecycle activity; + // cancellation before execution must never fabricate a start. + if terminal && (i.S("status") == "queued" || i.S("status") == "dispatched") && w.S("startedAt") != "" { + appendActivity(t, tid, i.S("issueId"), "system", nil, "run.started", details) + } + if terminal && message != "" { + messageDetails := runActivityDetails(i.S("id"), "workflow", i.S("executorId"), Object{"workflowInvocationRef": workflowRunID, "message": message}) + appendActivity(t, tid, i.S("issueId"), "system", nil, "run.message", messageDetails) + } + if status == "failed" { + details["failureReason"] = reason + } + appendActivity(t, tid, i.S("issueId"), "system", nil, action, details) + return run(t, tid, i.S("issueId"), i.S("id")) +} diff --git a/internal/core/workflow_issue_projection_test.go b/internal/core/workflow_issue_projection_test.go new file mode 100644 index 000000000..461f956b2 --- /dev/null +++ b/internal/core/workflow_issue_projection_test.go @@ -0,0 +1,272 @@ +package core + +import ( + "context" + "reflect" + "testing" +) + +type workflowProjectionScene struct { + tenant, issue, workflow, workflowRun, issueRun string +} + +// The scene stages a linked run only; scheduler invocation is deliberately outside this test's +// boundary. Every projection below consumes the real WorkflowRun row in its caller's transaction. +func seedWorkflowProjection(t *testing.T, store *Store) workflowProjectionScene { + t.Helper() + s := workflowProjectionScene{newID(), newID(), newID(), newID(), newID()} + _, err := store.transact(context.Background(), func(tx *transaction) Object { + user, snapshot := newID(), newID() + tx.exec("INSERT INTO users(id,display_name,status) VALUES($1,'projection','active')", user) + tx.exec("INSERT INTO tenants(id,name,status) VALUES($1,'projection','active')", s.tenant) + tx.exec("INSERT INTO tenant_memberships(tenant_id,user_id,role,status) VALUES($1,$2,'admin','active')", s.tenant, user) + tx.exec("INSERT INTO issues(id,tenant_id,creator_user_id,title,number) VALUES($1,$2,$3,'projection',1)", s.issue, s.tenant, user) + tx.exec("INSERT INTO workflows(id,tenant_id,name) VALUES($1,$2,'projection')", s.workflow, s.tenant) + tx.exec("INSERT INTO workflow_snapshots(id,tenant_id,workflow_id,version,name,graph) VALUES($1,$2,$3,1,'projection','{}')", snapshot, s.tenant, s.workflow) + tx.exec("INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name) VALUES($1,$2,$3,$4,'projection')", s.workflowRun, s.tenant, s.workflow, snapshot) + tx.exec("INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,workflow_invocation_ref) VALUES($1,$2,$3,'workflow',$4,$5)", s.issueRun, s.tenant, s.issue, s.workflow, s.workflowRun) + appendActivity(tx, s.tenant, s.issue, "user", user, "run.enqueued", runActivityDetails(s.issueRun, "workflow", s.workflow, nil)) + return Object{} + }) + if err != nil { + t.Fatalf("seed linked workflow: %v", err) + } + return s +} + +func projectWorkflowState(t *testing.T, store *Store, s workflowProjectionScene, state, summary string) Object { + t.Helper() + out, err := store.transact(context.Background(), func(tx *transaction) Object { + tx.exec(`UPDATE workflow_runs SET status=$2, + started_at=CASE WHEN $2='pending' THEN NULL ELSE '2026-10-15 10:00:00+00'::timestamptz END, + finished_at=CASE WHEN $2 IN ('succeeded','failed','cancelled') THEN '2026-10-15 10:01:00+00'::timestamptz ELSE NULL END, + error=CASE WHEN $2 IN ('failed','cancelled') THEN 'execution reason' ELSE '' END + WHERE id=$1 AND tenant_id=$3`, s.workflowRun, state, s.tenant) + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, summary) + }) + if err != nil { + t.Fatalf("project %s: %v", state, err) + } + return out +} + +func workflowProjectionActivities(t *testing.T, store *Store, s workflowProjectionScene) []Object { + t.Helper() + out, err := store.transact(context.Background(), func(tx *transaction) Object { + return Object{"items": tx.list("SELECT * FROM issue_activities WHERE tenant_id=$1 AND issue_id=$2 ORDER BY seq", s.tenant, s.issue)} + }) + if err != nil { + t.Fatalf("activities: %v", err) + } + return out["items"].([]Object) +} + +func TestWorkflowIssueProjectionStatesAndActivities(t *testing.T) { + store := dispatcherDB(t) + for _, tc := range []struct{ state, status, action string }{ + {"succeeded", "completed", "run.completed"}, + {"failed", "failed", "run.failed"}, + {"cancelled", "cancelled", "run.cancelled"}, + } { + t.Run(tc.state, func(t *testing.T) { + s := seedWorkflowProjection(t, store) + pending := projectWorkflowState(t, store, s, "pending", "") + if pending.S("status") != "queued" || pending.N("version") != 1 { + t.Fatalf("pending must stay queued without a write: %v", pending) + } + running := projectWorkflowState(t, store, s, "running", "") + if running.S("status") != "running" || running.S("startedAt") == "" || running.S("completedAt") != "" { + t.Fatalf("running projection: %v", running) + } + repeatedRunning, err := store.transact(context.Background(), func(tx *transaction) Object { + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "") + }) + if err != nil || !reflect.DeepEqual(running, repeatedRunning) || len(workflowProjectionActivities(t, store, s)) != 2 { + t.Fatalf("running repeat changed row or activities: err=%v before=%v after=%v", err, running, repeatedRunning) + } + out := projectWorkflowState(t, store, s, tc.state, " execution output ") + if out.S("status") != tc.status { + t.Fatalf("status = %s, want %s", out.S("status"), tc.status) + } + var timestampsMatch bool + if err := store.Pool.QueryRow(`SELECT i.started_at=w.started_at AND i.completed_at=w.finished_at + FROM issue_runs i JOIN workflow_runs w ON w.id=i.workflow_invocation_ref WHERE i.id=$1`, s.issueRun).Scan(×tampsMatch); err != nil || !timestampsMatch { + t.Fatalf("timestamps must mirror source: match=%v err=%v", timestampsMatch, err) + } + wantReason := "execution reason" + if tc.state == "succeeded" { + wantReason = "" + } + if out.S("failureReason") != wantReason || out.S("error") != wantReason { + t.Fatalf("terminal reason: %v", out) + } + activities := workflowProjectionActivities(t, store, s) + var actions []string + for index, a := range activities { + actions = append(actions, a.S("action")) + if index == 0 { + continue + } + if a.S("actorType") != "system" || a["actorId"] != nil || a.O("details").S("workflowInvocationRef") != s.workflowRun || a.O("details").S("runId") != s.issueRun { + t.Fatalf("system activity provenance: %v", a) + } + } + if !reflect.DeepEqual(actions, []string{"run.enqueued", "run.started", "run.message", tc.action}) || activities[2].O("details").S("message") != "execution output" { + t.Fatalf("activity sequence or summary: %v", activities) + } + if tc.state == "failed" && activities[3].O("details").S("failureReason") != wantReason { + t.Fatalf("failed activity reason: %v", activities[3]) + } + var comments int + if err := store.Pool.QueryRow("SELECT count(*) FROM issue_comments WHERE issue_id=$1", s.issue).Scan(&comments); err != nil || comments != 0 { + t.Fatalf("workflow output must not author any comment: count=%d err=%v", comments, err) + } + repeated, err := store.transact(context.Background(), func(tx *transaction) Object { + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "a replay must not replace the first summary") + }) + if err != nil || !reflect.DeepEqual(out, repeated) || !reflect.DeepEqual(activities, workflowProjectionActivities(t, store, s)) { + t.Fatalf("repeat changed row or activities: err=%v before=%v after=%v", err, out, repeated) + } + }) + } +} + +func TestWorkflowIssueProjectionDirectTerminalIncludesStart(t *testing.T) { + store := dispatcherDB(t) + for _, initial := range []string{"queued", "dispatched"} { + for _, tc := range []struct{ state, action string }{ + {"succeeded", "run.completed"}, + {"failed", "run.failed"}, + {"cancelled", "run.cancelled"}, + } { + t.Run(initial+"_to_"+tc.state, func(t *testing.T) { + s := seedWorkflowProjection(t, store) + if _, err := store.Pool.Exec("UPDATE issue_runs SET status=$2 WHERE id=$1", s.issueRun, initial); err != nil { + t.Fatal(err) + } + out := projectWorkflowState(t, store, s, tc.state, "direct terminal output") + activities := workflowProjectionActivities(t, store, s) + var actions []string + for _, activity := range activities { + actions = append(actions, activity.S("action")) + } + if !reflect.DeepEqual(actions, []string{"run.enqueued", "run.started", "run.message", tc.action}) { + t.Fatalf("direct terminal must retain lifecycle ordering: %v", actions) + } + if activities[1].S("actorType") != "system" || activities[1]["actorId"] != nil || activities[1].O("details").S("workflowInvocationRef") != s.workflowRun { + t.Fatalf("supplemented start provenance: %v", activities[1]) + } + repeated, err := store.transact(context.Background(), func(tx *transaction) Object { + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "direct terminal output") + }) + if err != nil || !reflect.DeepEqual(out, repeated) || !reflect.DeepEqual(activities, workflowProjectionActivities(t, store, s)) { + t.Fatalf("direct terminal repeat changed row or activities: err=%v", err) + } + }) + } + } +} + +func TestWorkflowIssueProjectionAwaitingInputPreservesIssueRunAndCommits(t *testing.T) { + store := dispatcherDB(t) + for _, initial := range []string{"pending", "running"} { + t.Run(initial, func(t *testing.T) { + s := seedWorkflowProjection(t, store) + before := projectWorkflowState(t, store, s, initial, "") + activities := workflowProjectionActivities(t, store, s) + out, err := store.transact(context.Background(), func(tx *transaction) Object { + tx.exec("UPDATE workflow_runs SET status='awaiting_input',version=version+1,input=$2 WHERE id=$1", s.workflowRun, jsonText(Object{"commitWitness": true})) + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "no output activity while waiting") + }) + if err != nil || !reflect.DeepEqual(before, out) || !reflect.DeepEqual(activities, workflowProjectionActivities(t, store, s)) { + t.Fatalf("awaiting_input must preserve IssueRun and activity history: err=%v before=%v after=%v", err, before, out) + } + var status string + var committed bool + if err := store.Pool.QueryRow("SELECT status,input->>'commitWitness'='true' FROM workflow_runs WHERE id=$1", s.workflowRun).Scan(&status, &committed); err != nil || status != "awaiting_input" || !committed { + t.Fatalf("caller's workflow update did not commit: status=%s witness=%v err=%v", status, committed, err) + } + }) + } +} + +func TestWorkflowIssueProjectionCancellationBeforeStart(t *testing.T) { + store := dispatcherDB(t) + s := seedWorkflowProjection(t, store) + _, err := store.transact(context.Background(), func(tx *transaction) Object { + tx.exec("UPDATE workflow_runs SET status='cancelled',finished_at=now(),error='cancelled before start' WHERE id=$1", s.workflowRun) + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "") + }) + if err != nil { + t.Fatal(err) + } + activities := workflowProjectionActivities(t, store, s) + if len(activities) != 2 || activities[1].S("action") != "run.cancelled" { + t.Fatalf("cancellation must not fabricate a start or output: %v", activities) + } + var complete, unstarted bool + if err := store.Pool.QueryRow("SELECT completed_at IS NOT NULL,started_at IS NULL FROM issue_runs WHERE id=$1", s.issueRun).Scan(&complete, &unstarted); err != nil || !complete || !unstarted { + t.Fatalf("cancelled timestamps: complete=%v unstarted=%v err=%v", complete, unstarted, err) + } +} + +func TestWorkflowIssueProjectionActivityFailureRollsBack(t *testing.T) { + store := dispatcherDB(t) + s := seedWorkflowProjection(t, store) + if _, err := store.Pool.Exec("ALTER TABLE issue_activities ADD CONSTRAINT reject_projection_failure CHECK(action <> 'run.failed')"); err != nil { + t.Fatal(err) + } + _, err := store.transact(context.Background(), func(tx *transaction) Object { + tx.exec("UPDATE workflow_runs SET status='failed',finished_at=now(),error='failure' WHERE id=$1", s.workflowRun) + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "must roll back too") + }) + if err == nil { + t.Fatal("activity failure must abort caller transaction") + } + var workflowState, issueState string + var version int + if err := store.Pool.QueryRow(`SELECT w.status,i.status,i.version FROM workflow_runs w + JOIN issue_runs i ON i.workflow_invocation_ref=w.id WHERE w.id=$1`, s.workflowRun).Scan(&workflowState, &issueState, &version); err != nil { + t.Fatal(err) + } + if workflowState != "pending" || issueState != "queued" || version != 1 || len(workflowProjectionActivities(t, store, s)) != 1 { + t.Fatalf("partial transition escaped rollback: workflow=%s issue=%s version=%d", workflowState, issueState, version) + } +} + +func TestWorkflowIssueProjectionScopeAndInvalidStates(t *testing.T) { + store := dispatcherDB(t) + s := seedWorkflowProjection(t, store) + _, err := store.transact(context.Background(), func(tx *transaction) Object { + return projectWorkflowRunToIssueRun(tx, newID(), s.workflowRun, "") + }) + if ErrorCode(err).Code != "not_found" { + t.Fatalf("foreign tenant: %v", err) + } + for _, state := range []string{"running", "failed"} { + _, err := store.transact(context.Background(), func(tx *transaction) Object { + tx.exec("UPDATE workflow_runs SET status=$2 WHERE id=$1", s.workflowRun, state) + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "") + }) + if err == nil { + t.Fatalf("state %s without required timestamps must fail", state) + } + } + projectWorkflowState(t, store, s, "succeeded", "") + _, err = store.transact(context.Background(), func(tx *transaction) Object { + tx.exec("UPDATE workflow_runs SET status='failed',error='different outcome' WHERE id=$1", s.workflowRun) + return projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "") + }) + if ErrorCode(err).Code != "workflow_run_projection_conflict" { + t.Fatalf("terminal rewrite: %v", err) + } + // A direct WorkflowRun remains valid without an IssueRun and must not emit issue activities. + _, err = store.transact(context.Background(), func(tx *transaction) Object { + tx.exec("DELETE FROM issue_runs WHERE id=$1", s.issueRun) + require(projectWorkflowRunToIssueRun(tx, s.tenant, s.workflowRun, "") == nil, 500, "internal_error") + return Object{} + }) + if err != nil { + t.Fatalf("unlinked direct run: %v", err) + } +} From 006078ca6b667c2ce28fe4050bd0cd3421f5cfa9 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:40:13 -0700 Subject: [PATCH 04/19] feat(frontend): link confirmed workflow runs and reuse interactions Keep one interaction through confirm retries, show authoritative WorkflowRun status and link to its detail page. Historical rows without an invocation reference retain IssueRun status and have no run link. When the user selects a published version, load its snapshot form with issue context, render that version's fields, discard obsolete answers and retain the selected snapshot instead of the descriptor's newest-version default. Component/API regressions and bilingual module docs accompany the feature. The existing backend field-divergence gate stays unchanged; no polling or backend endpoint is added. The final rebased tree is validated with Go build, full core/integration packages and focused frontend gates. Race, per-commit rebuilds and HTTP/browser rechecks are explicitly excluded from this rebase follow-up. --- frontend/src/features/issues/README.en.md | 4 + frontend/src/features/issues/README.md | 4 + frontend/src/features/issues/api.test.tsx | 88 ++++++- frontend/src/features/issues/api.ts | 23 ++ .../features/issues/components/README.en.md | 8 + .../src/features/issues/components/README.md | 8 + .../issues/components/activity-panel.test.tsx | 70 +++++- .../issues/components/activity-panel.tsx | 35 ++- .../workflow-interaction-composer.tsx | 168 +++++++++++--- .../components/workflow-interaction.test.tsx | 217 +++++++++++++++++- frontend/src/features/issues/types.ts | 2 + 11 files changed, 585 insertions(+), 42 deletions(-) diff --git a/frontend/src/features/issues/README.en.md b/frontend/src/features/issues/README.en.md index b5bbbb048..efae32116 100644 --- a/frontend/src/features/issues/README.en.md +++ b/frontend/src/features/issues/README.en.md @@ -34,3 +34,7 @@ MSW stands in for the real Cloud API; under `onUnhandledRequest: 'error'` every Recovery reloads and property saves are mutually exclusive: properties and the reload button remain locked until the original save error has been handled. Failed reloads retain the error; a later save uses the loaded version and reports its own failure independently. Detail tests use SessionProvider and CurrentSpaceProvider to cover versioned association/removal, retained values and GET reload after conflicts, delayed GET/PUT exclusion and error visibility, ordinary comments creating no Run, and the Thread of a Run created through Task Mode. Component evidence never substitutes for real-provider multi-turn, terminal-state and resource-release acceptance. + +`IssueRun.workflowInvocationRef` retains the server's WorkflowRun reference for activity status and navigation; the API test checks that list reads preserve it. Workflow confirmation exclusion, retry and detail-link interaction tests live in `components/`. + +`useWorkflowSnapshotForm` reuses the generated snapshot-form GET with tenant/workflow/snapshot/issue-scoped query keys. The API test varies every scope so versions and Issues cannot share the wrong field defaults. diff --git a/frontend/src/features/issues/README.md b/frontend/src/features/issues/README.md index 7c50925ba..c2af1e21e 100644 --- a/frontend/src/features/issues/README.md +++ b/frontend/src/features/issues/README.md @@ -30,3 +30,7 @@ MSW 模拟真实 Cloud 接口;`onUnhandledRequest: 'error'` 下每个测试都 恢复重读与属性保存互斥:重读期间锁住属性和重读按钮,处理原保存错误后才解锁;失败重读保留错误,后续保存仍以读取到的版本提交并独立显示失败。 详情测试经过 SessionProvider 和 CurrentSpaceProvider,验证关联/取消关联的版本号、冲突后保留及 GET 重读、延迟 GET/PUT 的互斥与错误可见性,以及普通评论不创建 Run、Task Mode 创建的同 Run Thread 可见。组件证据不替代真实供应商多轮、终态和资源释放的联合验收。 + +`IssueRun.workflowInvocationRef` 保留服务端返回的 WorkflowRun 引用,供活动栏读取真实运行状态并导航;API 测试检查列表没有丢失该关联。Workflow 确认去重、失败重试和详情链接的交互测试位于 `components/`。 + +`useWorkflowSnapshotForm` 复用生成的 snapshot form GET,查询键包含 tenant/workflow/snapshot/issue;API 测试逐项切换这些范围,避免不同版本或 Issue 共用表单默认值。 diff --git a/frontend/src/features/issues/api.test.tsx b/frontend/src/features/issues/api.test.tsx index 414ee2a13..690b813db 100644 --- a/frontend/src/features/issues/api.test.tsx +++ b/frontend/src/features/issues/api.test.tsx @@ -4,7 +4,15 @@ import { http, HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' import { server } from '@/test/msw-server' import type { FormDescriptor, Issue } from './types' -import { useCreateIssue, useFormDescriptor, useIssues, useMoveIssue, useUpdateIssue } from './api' +import { + useCreateIssue, + useFormDescriptor, + useIssues, + useMoveIssue, + useRuns, + useUpdateIssue, + useWorkflowSnapshotForm, +} from './api' function wrapper(queryClient: QueryClient) { return function Wrapper({ children }: { children: React.ReactNode }) { @@ -47,6 +55,28 @@ describe('useIssues', () => { }) }) +describe('useRuns', () => { + it('preserves the WorkflowRun reference for confirmed interaction navigation', async () => { + const linkedRun = { + id: 'r1', + executorType: 'workflow', + executorId: 'wf1', + workflowInvocationRef: 'wr1', + status: 'queued', + } + server.use( + http.get('/api/v1/tenants/t1/issues/i1/runs', () => + HttpResponse.json({ items: [linkedRun], nextCursor: '' }), + ), + ) + const { result } = renderHook(() => useRuns('t1', 'i1'), { + wrapper: wrapper(new QueryClient()), + }) + + await waitFor(() => expect(result.current.data).toEqual([linkedRun])) + }) +}) + describe('useUpdateIssue', () => { it('sends the version alongside the patch and caches the returned issue', async () => { let body: unknown @@ -180,3 +210,59 @@ describe('useMoveIssue', () => { expect(queryClient.getQueryData(['issue', 't1', 'i1'])).toEqual(moved) }) }) + +describe('useWorkflowSnapshotForm', () => { + it.each([ + { + scope: 'tenant', + change: { tenant: 't2' }, + path: '/tenants/t2/workflows/wf1/snapshots/s1/form?issueId=i1', + }, + { + scope: 'workflow', + change: { workflow: 'wf2' }, + path: '/tenants/t1/workflows/wf2/snapshots/s1/form?issueId=i1', + }, + { + scope: 'snapshot', + change: { snapshot: 's2' }, + path: '/tenants/t1/workflows/wf1/snapshots/s2/form?issueId=i1', + }, + { + scope: 'issue', + change: { issue: 'i2' }, + path: '/tenants/t1/workflows/wf1/snapshots/s1/form?issueId=i2', + }, + ])('separates the descriptor cache when $scope changes', async ({ change, path }) => { + const queryClient = new QueryClient() + const requests: string[] = [] + server.use( + http.get( + '/api/v1/tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form', + ({ request, params }) => { + requests.push(new URL(request.url).pathname + new URL(request.url).search) + return HttpResponse.json({ + formRef: params['wfid'], + snapshotId: params['snapshotId'], + fields: [], + }) + }, + ), + ) + const initial = { tenant: 't1', workflow: 'wf1', snapshot: 's1', issue: 'i1' } + const { result, rerender } = renderHook( + (props: typeof initial) => + useWorkflowSnapshotForm(props.tenant, props.workflow, props.snapshot, props.issue), + { wrapper: wrapper(queryClient), initialProps: initial }, + ) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + rerender({ ...initial, ...change }) + await waitFor(() => expect(requests).toHaveLength(2)) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(requests).toEqual([ + '/api/v1/tenants/t1/workflows/wf1/snapshots/s1/form?issueId=i1', + `/api/v1${path}`, + ]) + expect(queryClient.getQueryCache().getAll()).toHaveLength(2) + }) +}) diff --git a/frontend/src/features/issues/api.ts b/frontend/src/features/issues/api.ts index 4c3af23a4..f24255e51 100644 --- a/frontend/src/features/issues/api.ts +++ b/frontend/src/features/issues/api.ts @@ -1,4 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { getApiV1TenantsTidWorkflowsWfidSnapshotsSnapshotIdForm } from '@/api/workflows/workflows' import { AXIOS_INSTANCE } from '@/lib/api-client' import type { AssistSuggestion, @@ -312,6 +313,28 @@ export function useFormDescriptor(tid: string, formRef: string | undefined, issu }) } +/** Reads a chosen frozen version's form, isolated by tenant, workflow, snapshot and issue context. */ +export function useWorkflowSnapshotForm( + tid: string, + workflowId: string, + snapshotId: string | undefined, + issueId: string, +) { + return useQuery({ + queryKey: ['workflow-snapshot-form', tid, workflowId, snapshotId ?? '', issueId], + queryFn: ({ signal }) => + getApiV1TenantsTidWorkflowsWfidSnapshotsSnapshotIdForm( + tid, + workflowId, + snapshotId ?? '', + { issueId }, + undefined, + signal, + ), + enabled: !!tid && !!workflowId && !!snapshotId && !!issueId, + }) +} + /** * Asks for AI suggestions for a Workflow form that has **not** been confirmed yet. The form is a draft * until the user confirms it, so this route is deliberately stateless: no interaction needs to exist, diff --git a/frontend/src/features/issues/components/README.en.md b/frontend/src/features/issues/components/README.en.md index 210838b38..d4bfcc13c 100644 --- a/frontend/src/features/issues/components/README.en.md +++ b/frontend/src/features/issues/components/README.en.md @@ -26,6 +26,14 @@ Depends on `features/issues/api`, `features/issues/types`, `features/issues/pres Creation drafts remount by tenant/space; activity drafts by tenant/space/issue, including navigation to cached scopes. Project association follows server results; versioned updates disable property edits until settled and keep stored values on refusal. Project guidance targets the `issue-project` selector. Targets come only from the real directory; the server remains authoritative for space compatibility. +Workflow confirmation locks duplicate submissions, editing and removal from interaction creation onward; a rejected confirm reuses that interaction on retry, including after a version switch. Confirmed rows use `features/workflows/api` to read the WorkflowRun referenced by `IssueRun.workflowInvocationRef`; detail links combine `executorId`, that reference and the route's workspace slug. + +The initial form reads the live descriptor by `formRef + issueId`. Changing the `snapshotId` select loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. An author's text variable with the same name remains ordinary input. Reading a form is not run admission; Confirm's `workflow_version_fields_changed` gate is unchanged. + +Historical runs without a WorkflowRun reference retain their IssueRun status, make no WorkflowRun request and show no detail link. Linked runs always use the WorkflowRun's authoritative status. + ## Testing Component tests use MSW; properties and Task Mode→Thread are covered by `issue-detail-page.test.tsx`. Loading, empty, refused and retry states are distinct; creation, ordinary-comment and target failures retain drafts, while scope changes clear them. + +Workflow tests cover double-click exclusion, rejected confirms reusing an interaction across version switches, snapshot fields/defaults/echoes, pending and failed reads, and WorkflowRun status and detail links. diff --git a/frontend/src/features/issues/components/README.md b/frontend/src/features/issues/components/README.md index 09f61945f..339e41b12 100644 --- a/frontend/src/features/issues/components/README.md +++ b/frontend/src/features/issues/components/README.md @@ -26,6 +26,14 @@ 创建草稿按 tenant/space 重挂,活动草稿按 tenant/space/issue 重挂;切到已缓存的其他作用域也不能继承草稿。关联项目以服务端结果为准,版本化更新等待期间禁止再改属性,拒绝保留原值。缺项目提示定位到 `issue-project` 选择器;目标必须来自真实目录,空间合法性仍由服务端校验。 +Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与取消;确认失败后仍用同一 interaction 重试,包括切换版本后的重试。确认行通过 `features/workflows/api` 读取 `IssueRun.workflowInvocationRef` 对应的 WorkflowRun 状态,详情链接使用 `executorId`、该引用和当前路由的空间 slug。 + +初始表单按 `formRef + issueId` 读取活稿。用户切换 `snapshotId` 下拉选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试;作者自定义的同名文本字段仍是普通输入。读取表单不代表服务端准入通过,Confirm 的 `workflow_version_fields_changed` 门禁保持不变。 + +历史运行没有 WorkflowRun 引用时,确认行仍显示 IssueRun 状态,不请求 WorkflowRun,也不显示运行详情链接;有关联的运行始终以 WorkflowRun 状态为准。 + ## 测试 组件测试用 MSW;属性栏和 Task Mode→Thread 经 `issue-detail-page.test.tsx` 覆盖。测试区分加载、空列表、拒绝、重试;创建、普通评论和目标提交失败保留草稿,作用域切换则清空草稿。 + +Workflow 测试覆盖双击互斥、确认失败后复用 interaction(含切换版本)、版本字段/default/回显切换、读取等待与失败,以及 WorkflowRun 状态和详情链接。 diff --git a/frontend/src/features/issues/components/activity-panel.test.tsx b/frontend/src/features/issues/components/activity-panel.test.tsx index f248619e6..be81242fa 100644 --- a/frontend/src/features/issues/components/activity-panel.test.tsx +++ b/frontend/src/features/issues/components/activity-panel.test.tsx @@ -4,7 +4,7 @@ import { http, HttpResponse } from 'msw' import { describe, expect, it, vi } from 'vitest' import { useState } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { renderWithProviders } from '@/test/render' +import { renderAtRoute, renderWithProviders } from '@/test/render' import { server } from '@/test/msw-server' import { installSignedInSession } from '@/test/cloud-handlers' import type { @@ -266,6 +266,74 @@ describe('ActivityPanel', () => { expect(screen.queryByRole('button', { name: '审阅' })).not.toBeInTheDocument() }) + it('reads WorkflowRun status and links its identity within the current workspace', async () => { + servePanel([], [interaction({ runId: 'r1' })]) + server.use( + http.get('/api/v1/tenants/t1/issues/i1/runs', () => + HttpResponse.json({ + items: [ + { + id: 'r1', + executorId: 'wf-executor', + executorType: 'workflow', + status: 'queued', + workflowInvocationRef: 'workflow-run-1', + }, + ], + nextCursor: '', + }), + ), + http.get('/api/v1/tenants/t1/workflows/wf-executor/runs/workflow-run-1', () => + HttpResponse.json({ id: 'workflow-run-1', status: 'succeeded' }), + ), + ) + renderAtRoute( + '/w/:workspaceSlug/issues/:issueId', + , + '/w/team-space/issues/i1', + ) + + expect(await screen.findByText('已确认 · 运行 succeeded')).toBeInTheDocument() + expect(screen.queryByText(/运行 queued/)).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: '运行详情' })).toHaveAttribute( + 'href', + '/w/team-space/workflows/wf-executor/runs/workflow-run-1', + ) + }) + + it.each([null, undefined])( + 'keeps historical IssueRun status without a detail link when the workflow reference is %s', + async (workflowInvocationRef) => { + servePanel([], [interaction({ runId: 'legacy-run' })]) + const workflowRequest = vi.fn<() => void>() + server.use( + http.get('/api/v1/tenants/t1/issues/i1/runs', () => + HttpResponse.json({ + items: [ + { + id: 'legacy-run', + executorId: 'wf1', + executorType: 'workflow', + status: 'failed', + workflowInvocationRef, + }, + ], + nextCursor: '', + }), + ), + http.get('/api/v1/tenants/t1/workflows/wf1/runs/:runId', () => { + workflowRequest() + return HttpResponse.json({ status: 'succeeded' }) + }), + ) + renderWithProviders() + + expect(await screen.findByText('已确认 · 运行 failed')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: '运行详情' })).not.toBeInTheDocument() + expect(workflowRequest).not.toHaveBeenCalled() + }, + ) + it('renders a workflow progress/message activity with its human-readable detail boxed', async () => { servePanel([ comment(), diff --git a/frontend/src/features/issues/components/activity-panel.tsx b/frontend/src/features/issues/components/activity-panel.tsx index f0091815f..b73127a29 100644 --- a/frontend/src/features/issues/components/activity-panel.tsx +++ b/frontend/src/features/issues/components/activity-panel.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { Link, useParams } from 'react-router-dom' import { useCurrentSpace } from '@/features/spaces/current-space' import { AgentRequestFailure } from './agent-request-failure' import { ActorAvatar, type AvatarActor } from '@/components/common/actor-avatar' @@ -16,6 +17,8 @@ import { import { PendingTargets } from '@/features/issues/components/pending-targets' import { TargetPicker } from '@/features/issues/components/target-picker' import { memberNameById } from '@/features/issues/present' +import { useWorkflowRun } from '@/features/workflows/api' +import { workspacePaths } from '@/lib/paths' import { cn } from '@/lib/utils' import type { CollaborationTargetSummary, @@ -115,20 +118,40 @@ function ActivityItem({ } /** A confirmed workflow interaction: it is history now, and never offers a second configuration. */ -function ConfirmedWorkflowRow({ name, run }: { name: string; run: IssueRun | undefined }) { +function ConfirmedWorkflowRow({ + slug, + name, + run, +}: { + slug: string + name: string + run: IssueRun | undefined +}) { + const { workspaceSlug } = useParams<{ workspaceSlug: string }>() + const runId = run?.workflowInvocationRef ?? undefined + const workflow = useWorkflowRun(slug, run?.executorId, runId) + const status = runId ? workflow.data?.status : run?.status + const path = workspacePaths(workspaceSlug ?? slug) return (
Workflow {name} - 已确认{run ? ` · 运行 ${run.status}` : ''} + 已确认{status ? ` · 运行 ${status}` : ''} + {workflow.isError ? 运行状态加载失败 : null} + {run && runId ? ( + + 运行详情 + + ) : null}
) } /** Confirmed workflow interactions, shown as status rows next to the history (§39, §40). */ function ConfirmedWorkflows(props: { + slug: string interactions: IssueInteraction[] runs: IssueRun[] catalog: CollaborationTargetSummary[] @@ -140,6 +163,7 @@ function ConfirmedWorkflows(props: { {confirmed.map((item) => ( candidate.id === item.runId)} /> @@ -224,7 +248,12 @@ function IssueActivity({ slug, issueId, members }: ActivityPanelProps) { )} - +
diff --git a/frontend/src/features/issues/components/workflow-interaction-composer.tsx b/frontend/src/features/issues/components/workflow-interaction-composer.tsx index 7d6f7e1a2..1d33d0c4f 100644 --- a/frontend/src/features/issues/components/workflow-interaction-composer.tsx +++ b/frontend/src/features/issues/components/workflow-interaction-composer.tsx @@ -1,5 +1,6 @@ import { ChevronDown, ChevronUp, Sparkles, Workflow, X } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useRef, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' @@ -8,6 +9,7 @@ import { useAssistWorkflow, useConfirmWorkflow, useFormDescriptor, + useWorkflowSnapshotForm, } from '@/features/issues/api' import { AssistSuggestions } from '@/features/issues/components/assist-suggestions' import { ConfirmReview } from '@/features/issues/components/confirm-review' @@ -22,16 +24,21 @@ import type { } from '@/features/issues/types' /** Seeds the editable values from the descriptor's declared defaults. */ -function seedValues(descriptor: FormDescriptor): FormValues { +function seedValues(descriptor: FormDescriptor, snapshotId?: string): FormValues { const values: FormValues = {} for (const field of descriptor.fields) { if (field.defaultValue !== undefined && field.defaultValue !== null) { values[field.key] = field.defaultValue } } + if (snapshotId && descriptor.fields.some(isSnapshotSelector)) values['snapshotId'] = snapshotId return values } +function isSnapshotSelector(field: FormField): boolean { + return field.key === 'snapshotId' && field.type === 'select' +} + /** Required fields the user has not filled yet — the only client-side gate on Review (§38.15). */ function missingRequired(descriptor: FormDescriptor, values: FormValues): FormField[] { return descriptor.fields.filter((field) => { @@ -96,18 +103,11 @@ function mergeContextRefs(applied: ContextRefRef[], picked: ContextRefRef[]): Co } /** Editable form state for one Workflow interaction. Pure UI state — nothing here is persisted. */ -function useWorkflowForm(descriptor: FormDescriptor | undefined) { - const [values, setValues] = useState({}) +function useWorkflowForm(descriptor: FormDescriptor, snapshotId?: string) { + const [values, setValues] = useState(() => seedValues(descriptor, snapshotId)) const [contextRefs, setContextRefs] = useState([]) const [suggestion, setSuggestion] = useState(null) const [appliedKeys, setAppliedKeys] = useState([]) - const seeded = useRef(null) - - useEffect(() => { - if (!descriptor || seeded.current === descriptor.formRef) return - seeded.current = descriptor.formRef - setValues(seedValues(descriptor)) - }, [descriptor]) function setField(key: string, next: unknown) { setValues((current) => ({ ...current, [key]: next })) @@ -174,11 +174,16 @@ function WorkflowFormBody(props: { missing: FormField[] onAssist: () => void onReview: () => void + onFieldChange: (key: string, next: unknown) => void }) { const { descriptor, form } = props return (
- + {form.suggestion ? ( void onRemove: () => void }) { @@ -255,6 +262,7 @@ function ComposerHeader({ type="button" className="ml-auto rounded p-0.5 hover:bg-muted" onClick={onRemove} + disabled={pending} aria-label="取消" > @@ -278,6 +286,7 @@ function ComposerBody(props: { onReview: () => void onBack: () => void onConfirm: () => void + onFieldChange: (key: string, next: unknown) => void }) { if (props.reviewing) { return ( @@ -307,6 +316,7 @@ function ComposerBody(props: { missing={props.missing} onAssist={props.onAssist} onReview={props.onReview} + onFieldChange={props.onFieldChange} /> {props.assistFailed ? (

AI 建议当前不可用。

@@ -315,6 +325,53 @@ function ComposerBody(props: { ) } +/** Keeps one interaction across retries and locks before React can render a second click. */ +function useWorkflowSubmission({ + slug, + issueId, + target, + onConfirmed, +}: { + slug: string + issueId: string + target: CollaborationTargetSummary + onConfirmed: () => void +}) { + const interactionId = useRef(null) + const locked = useRef(false) + const confirm = useConfirmWorkflow(slug, issueId) + const submission = useMutation({ + mutationFn: async (input: { values: FormValues; contextRefs?: ContextRefRef[] }) => { + if (!interactionId.current) { + const interaction = await openWorkflowInteraction( + slug, + issueId, + target.id, + `@${target.displayName}`, + ) + interactionId.current = interaction.id + } + return confirm.mutateAsync({ interactionId: interactionId.current, ...input }) + }, + onSuccess: onConfirmed, + onError: () => { + locked.current = false + }, + }) + + function submit(input: { values: FormValues; contextRefs?: ContextRefRef[] }) { + if (locked.current) return + locked.current = true + submission.mutate(input) + } + + return { + submit, + isPending: submission.isPending || submission.isSuccess, + isError: submission.isError, + } +} + /** * The Form Mode configuration panel for a workflow the user has selected but not yet submitted. It is * a **draft**: the comment, the interaction and the run are all created only when 确认执行 is pressed, @@ -334,23 +391,72 @@ export function WorkflowInteractionComposer({ onRemove: () => void }) { const formRef = target.interactionDescriptor.formRef ?? undefined - const descriptorQuery = useFormDescriptor(slug, formRef, issueId) - const assist = useAssistWorkflow(slug, issueId) - const confirm = useConfirmWorkflow(slug, issueId) - const [reviewing, setReviewing] = useState(false) - const [collapsed, setCollapsed] = useState(false) + const [snapshotId, setSnapshotId] = useState() + const live = useFormDescriptor(slug, formRef, issueId) + const snapshot = useWorkflowSnapshotForm(slug, target.id, snapshotId, issueId) + const descriptorQuery = snapshotId ? snapshot : live + const confirm = useWorkflowSubmission({ slug, issueId, target, onConfirmed }) const descriptor = descriptorQuery.data - const form = useWorkflowForm(descriptor) if (descriptorQuery.isPending) return if (descriptorQuery.isError || !descriptor) { return ( -

- 当前部署无法加载该 Workflow 的表单(form descriptor 不可用)。 -

+
+

+ 当前部署无法加载该 Workflow 的表单(form descriptor 不可用)。 +

+ +
) } + return ( + + ) +} + +/** A fresh field draft per selected version; submission identity stays with the outer composer. */ +function WorkflowInteractionForm(props: { + slug: string + issueId: string + target: CollaborationTargetSummary + descriptor: FormDescriptor + snapshotId: string | undefined + loading: boolean + confirm: ReturnType + onSnapshotChange: (id: string) => void + onRemove: () => void +}) { + const { slug, issueId, target, descriptor, confirm, onRemove } = props + const assist = useAssistWorkflow(slug, issueId) + const [reviewing, setReviewing] = useState(false) + const [collapsed, setCollapsed] = useState(false) + const form = useWorkflowForm(descriptor, props.snapshotId) + + function setField(key: string, next: unknown) { + if ( + typeof next === 'string' && + descriptor.fields.some((field) => field.key === key && isSnapshotSelector(field)) + ) { + props.onSnapshotChange(next) + } else { + form.setField(key, next) + } + } + const contextRefs = mergeContextRefs( form.contextRefs, contextRefsFromField(form.values[CONTEXT_REFS_KEY]), @@ -358,22 +464,12 @@ export function WorkflowInteractionComposer({ const confirmInput = contextRefs.length > 0 ? { values: form.values, contextRefs } : { values: form.values } - /** Confirm is the first moment anything reaches the server: comment -> interaction -> run. */ - async function submit() { - const interaction = await openWorkflowInteraction( - slug, - issueId, - target.id, - `@${target.displayName}`, - ) - confirm.mutate({ interactionId: interaction.id, ...confirmInput }, { onSuccess: onConfirmed }) - } - return (
setCollapsed((current) => !current)} onRemove={onRemove} /> @@ -381,7 +477,8 @@ export function WorkflowInteractionComposer({ {!collapsed && descriptor.description ? (

{descriptor.description}

) : null} - {!collapsed && ( + {props.loading && } + {!collapsed && !props.loading && ( setReviewing(true)} onBack={() => setReviewing(false)} - onConfirm={() => { - void submit() - }} + onConfirm={() => confirm.submit(confirmInput)} + onFieldChange={setField} /> )}
diff --git a/frontend/src/features/issues/components/workflow-interaction.test.tsx b/frontend/src/features/issues/components/workflow-interaction.test.tsx index 187322ccb..3dc9270fa 100644 --- a/frontend/src/features/issues/components/workflow-interaction.test.tsx +++ b/frontend/src/features/issues/components/workflow-interaction.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' import { describe, expect, it, vi } from 'vitest' @@ -238,6 +238,30 @@ async function reviewAndConfirm(user: ReturnType) { await user.click(await screen.findByRole('button', { name: '确认执行' })) } +function serveOldSnapshot() { + const read = vi.fn<(issueId: string | null) => void>() + server.use( + http.get('/api/v1/tenants/t1/workflows/wf1/snapshots/snap-2/form', ({ request }) => { + read(new URL(request.url).searchParams.get('issueId')) + return HttpResponse.json({ + ...launchDescriptor, + snapshotId: 'snap-2', + fields: [ + ...launchDescriptor.fields.filter((field) => field.key !== 'prompt'), + { + key: 'oldInput', + label: 'Old input', + type: 'text', + required: true, + defaultValue: 'old default', + }, + ], + }) + }), + ) + return read +} + describe('WorkflowInteractionComposer', () => { it('renders exactly the fields the descriptor declares', async () => { serveComposer() @@ -391,6 +415,92 @@ describe('WorkflowInteractionComposer', () => { ) }) + it('locks both confirm clicks while the first interaction is still being created', async () => { + const { comment, confirm } = serveComposer({ descriptor: platformDescriptor }) + let release = vi.fn<() => void>() + const gate = new Promise((resolve) => { + release = vi.fn<() => void>(resolve) + }) + server.use( + http.post('/api/v1/tenants/t1/issues/i1/comments', async ({ request }) => { + comment(await request.json()) + await gate + return HttpResponse.json({ resource: { id: 'c1' } }) + }), + ) + const user = userEvent.setup() + const { onConfirmed } = renderComposer() + await user.click(await screen.findByRole('button', { name: '审阅' })) + const button = screen.getByRole('button', { name: '确认执行' }) + fireEvent.click(button) + fireEvent.click(button) + + try { + await waitFor(() => expect(comment).toHaveBeenCalledTimes(1)) + expect(screen.getByRole('button', { name: '确认中…' })).toBeDisabled() + expect(screen.getByRole('button', { name: '返回修改' })).toBeDisabled() + expect(screen.getByRole('button', { name: '取消' })).toBeDisabled() + expect(confirm).not.toHaveBeenCalled() + } finally { + release() + } + await waitFor(() => expect(onConfirmed).toHaveBeenCalledTimes(1)) + expect(comment).toHaveBeenCalledTimes(1) + expect(confirm).toHaveBeenCalledTimes(1) + }) + + it('reuses its interaction after a rejected confirm, including edits before retry', async () => { + const { comment, confirm } = serveComposer({ descriptor: platformDescriptor }) + let attempts = 0 + server.use( + http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', async ({ request }) => { + confirm(await request.json()) + attempts += 1 + return attempts === 1 + ? HttpResponse.json({ code: 'invalid_input' }, { status: 400 }) + : HttpResponse.json({ resource: run() }) + }), + ) + const user = userEvent.setup() + const { onConfirmed } = renderComposer() + await screen.findByLabelText(/仓库地址/) + await reviewAndConfirm(user) + expect(await screen.findByText('确认失败,请重试。')).toBeInTheDocument() + expect(onConfirmed).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: '返回修改' })) + await user.type(screen.getByLabelText('Notes'), 'retry notes') + await reviewAndConfirm(user) + + await waitFor(() => expect(onConfirmed).toHaveBeenCalledTimes(1)) + expect(comment).toHaveBeenCalledTimes(1) + expect(confirm).toHaveBeenCalledTimes(2) + expect(confirm).toHaveBeenLastCalledWith({ + values: { + repository: 'https://github.com/ora/cloud', + prompt: 'Review the diff', + notes: 'retry notes', + }, + }) + }) + + it('shows interaction creation failures and unlocks the form for a retry', async () => { + serveComposer({ descriptor: platformDescriptor }) + server.use( + http.post('/api/v1/tenants/t1/issues/i1/comments', () => + HttpResponse.json({ code: 'unavailable' }, { status: 503 }), + ), + ) + const user = userEvent.setup() + renderComposer() + await screen.findByLabelText(/仓库地址/) + await reviewAndConfirm(user) + + expect(await screen.findByText('确认失败,请重试。')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '确认执行' })).toBeEnabled() + expect(screen.getByRole('button', { name: '返回修改' })).toBeEnabled() + }) + it('carries the ticked context references, once each even when Assist suggested one too', async () => { const { confirm } = serveComposer({ descriptor: launchDescriptor, @@ -426,4 +536,109 @@ describe('WorkflowInteractionComposer', () => { }), ) }) + + it('renders the selected snapshot defaults and retains its echo instead of the newest default', async () => { + const { confirm } = serveComposer({ descriptor: launchDescriptor }) + const read = serveOldSnapshot() + const user = userEvent.setup() + renderComposer() + await user.type(await screen.findByLabelText(/提示词/), ' obsolete draft') + await user.click(selectTrigger('运行版本')) + await user.click(await screen.findByRole('option', { name: 'v2 · 发布 2' })) + + expect(await screen.findByLabelText(/Old input/)).toHaveValue('old default') + expect(screen.queryByLabelText(/提示词/)).not.toBeInTheDocument() + expect(selectTrigger('运行版本')).toHaveTextContent('v2 · 发布 2') + expect(read).toHaveBeenCalledWith('i1') + await reviewAndConfirm(user) + await waitFor(() => + expect(confirm).toHaveBeenCalledWith({ + values: { + repository: 'https://github.com/ora/cloud', + branch: 'main', + snapshotId: 'snap-2', + oldInput: 'old default', + }, + }), + ) + }) + + it('blocks stale review while a selected snapshot loads and after a read failure', async () => { + serveComposer({ descriptor: launchDescriptor }) + let release = vi.fn<() => void>() + const gate = new Promise((resolve) => { + release = vi.fn<() => void>(resolve) + }) + server.use( + http.get('/api/v1/tenants/t1/workflows/wf1/snapshots/snap-2/form', async () => { + await gate + return HttpResponse.json({ code: 'not_found' }, { status: 404 }) + }), + ) + const user = userEvent.setup() + renderComposer() + await user.click(await screen.findByLabelText(/运行版本/)) + await user.click(await screen.findByRole('option', { name: 'v2 · 发布 2' })) + try { + expect(screen.queryByLabelText(/提示词/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '审阅' })).not.toBeInTheDocument() + } finally { + release() + } + expect(await screen.findByText(/当前部署无法加载/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '审阅' })).not.toBeInTheDocument() + const read = serveOldSnapshot() + await user.click(screen.getByRole('button', { name: '重试加载表单' })) + expect(await screen.findByLabelText(/Old input/)).toHaveValue('old default') + expect(read).toHaveBeenCalledTimes(1) + }) + + it('keeps an authors text variable named snapshotId as ordinary input', async () => { + const { confirm } = serveComposer({ + descriptor: { + formRef: 'security-review', + fields: [ + { key: 'snapshotId', label: 'Author snapshot name', type: 'text', required: true }, + ], + }, + }) + const user = userEvent.setup() + renderComposer() + await user.type(await screen.findByLabelText(/Author snapshot name/), 'my snapshot') + await reviewAndConfirm(user) + await waitFor(() => + expect(confirm).toHaveBeenCalledWith({ values: { snapshotId: 'my snapshot' } }), + ) + }) + + it('keeps the same interaction when a rejected confirm is retried after switching snapshot', async () => { + const { comment, confirm } = serveComposer({ descriptor: launchDescriptor }) + serveOldSnapshot() + server.use( + http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', () => + HttpResponse.json({ code: 'workflow_version_fields_changed' }, { status: 409 }), + ), + ) + const user = userEvent.setup() + const { onConfirmed } = renderComposer() + await screen.findByLabelText(/提示词/) + await reviewAndConfirm(user) + await screen.findByText('确认失败,请重试。') + await user.click(screen.getByRole('button', { name: '返回修改' })) + await user.click(selectTrigger('运行版本')) + await user.click(await screen.findByRole('option', { name: 'v2 · 发布 2' })) + await screen.findByLabelText(/Old input/) + server.use( + http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', async ({ request }) => { + confirm(await request.json()) + return HttpResponse.json({ resource: run() }) + }), + ) + await reviewAndConfirm(user) + await waitFor(() => expect(onConfirmed).toHaveBeenCalledTimes(1)) + expect(comment).toHaveBeenCalledTimes(1) + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ values: expect.objectContaining({ snapshotId: 'snap-2' }) }), + ) + }) }) diff --git a/frontend/src/features/issues/types.ts b/frontend/src/features/issues/types.ts index 0cbb76592..da4ea1020 100644 --- a/frontend/src/features/issues/types.ts +++ b/frontend/src/features/issues/types.ts @@ -88,6 +88,8 @@ export interface IssueRun { issueId: string executorType: 'agent' | 'team' | 'workflow' executorId: string + /** Workflow runs: the linked WorkflowRun id, whose status is authoritative for execution. */ + workflowInvocationRef?: string | null input: Record status: string /** Agent runs: the settled delivery, `{revisionId, deliveryState}`, once the run released its Workspace. */ From 09b75af662336b142cc6596efb7ee749f5668e61 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:43:39 -0700 Subject: [PATCH 05/19] fix(workflow): address invocation migration and form review Move invocation linkage to migration 0037, reserving 0035 and 0036 for the earlier identity and execution work. Explain why the composite foreign key fails closed on unexpected dangling references. Identify the platform snapshot selector using all published snapshot ids from the existing paginated endpoint, preserving an author's same-named select as ordinary input when its options are custom. Explain how to resolve workflow_version_fields_changed in the composer without changing admission policy. Validation: Go build, the core package, all migration and invocation integration cases with a 30-minute timeout, and touched-file frontend format, lint, types, component/API and module gates. No scheduler or identity changes. Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/issues/README.en.md | 2 + frontend/src/features/issues/README.md | 2 + frontend/src/features/issues/api.test.tsx | 70 +++++++++++++++++++ frontend/src/features/issues/api.ts | 28 +++++++- .../features/issues/components/README.en.md | 6 +- .../src/features/issues/components/README.md | 6 +- .../workflow-interaction-composer.tsx | 60 +++++++++++----- .../components/workflow-interaction.test.tsx | 62 +++++++++++++++- .../workflow_invocation_migration_test.go | 2 +- ...=> 0037_workflow_invocation_reference.sql} | 1 + internal/core/migrations/README.en.md | 8 +-- internal/core/migrations/README.md | 6 +- 12 files changed, 223 insertions(+), 30 deletions(-) rename internal/core/migrations/{0036_workflow_invocation_reference.sql => 0037_workflow_invocation_reference.sql} (91%) diff --git a/frontend/src/features/issues/README.en.md b/frontend/src/features/issues/README.en.md index efae32116..697bf2d4f 100644 --- a/frontend/src/features/issues/README.en.md +++ b/frontend/src/features/issues/README.en.md @@ -38,3 +38,5 @@ Detail tests use SessionProvider and CurrentSpaceProvider to cover versioned ass `IssueRun.workflowInvocationRef` retains the server's WorkflowRun reference for activity status and navigation; the API test checks that list reads preserve it. Workflow confirmation exclusion, retry and detail-link interaction tests live in `components/`. `useWorkflowSnapshotForm` reuses the generated snapshot-form GET with tenant/workflow/snapshot/issue-scoped query keys. The API test varies every scope so versions and Issues cannot share the wrong field defaults. + +`useWorkflowPublishedSnapshotIds` caches all published ids by tenant/workflow, following the generated list endpoint's cursors with a page limit of 100 and the caller's abort signal; it stores no graph copies. The activity form uses those ids to identify published-version options. API tests cover the second-page cursor, complete id list and tenant/workflow isolation. diff --git a/frontend/src/features/issues/README.md b/frontend/src/features/issues/README.md index c2af1e21e..e58a226f3 100644 --- a/frontend/src/features/issues/README.md +++ b/frontend/src/features/issues/README.md @@ -34,3 +34,5 @@ MSW 模拟真实 Cloud 接口;`onUnhandledRequest: 'error'` 下每个测试都 `IssueRun.workflowInvocationRef` 保留服务端返回的 WorkflowRun 引用,供活动栏读取真实运行状态并导航;API 测试检查列表没有丢失该关联。Workflow 确认去重、失败重试和详情链接的交互测试位于 `components/`。 `useWorkflowSnapshotForm` 复用生成的 snapshot form GET,查询键包含 tenant/workflow/snapshot/issue;API 测试逐项切换这些范围,避免不同版本或 Issue 共用表单默认值。 + +`useWorkflowPublishedSnapshotIds` 按 tenant/workflow 缓存全部已发布版本 id,使用生成的列表接口逐页读取(每页 100,传递取消信号),不缓存图副本。活动表单用它判断下拉选项是否都属于已发布版本;API 测试验证第二页游标、完整 id 列表及租户/Workflow 隔离。 diff --git a/frontend/src/features/issues/api.test.tsx b/frontend/src/features/issues/api.test.tsx index 690b813db..46584c844 100644 --- a/frontend/src/features/issues/api.test.tsx +++ b/frontend/src/features/issues/api.test.tsx @@ -11,6 +11,7 @@ import { useMoveIssue, useRuns, useUpdateIssue, + useWorkflowPublishedSnapshotIds, useWorkflowSnapshotForm, } from './api' @@ -266,3 +267,72 @@ describe('useWorkflowSnapshotForm', () => { expect(queryClient.getQueryCache().getAll()).toHaveLength(2) }) }) + +describe('useWorkflowPublishedSnapshotIds', () => { + it.each([ + { scope: 'tenant', tenant: 't2', workflow: 'wf1' }, + { scope: 'workflow', tenant: 't1', workflow: 'wf2' }, + ])('follows all pages and isolates ids when $scope changes', async ({ tenant, workflow }) => { + const requests: { path: string; limit: string | null; cursor: string | null }[] = [] + server.use( + http.get('/api/v1/tenants/:tid/workflows/:wfid/snapshots', ({ request, params }) => { + const url = new URL(request.url) + const cursor = url.searchParams.get('cursor') + requests.push({ path: url.pathname, limit: url.searchParams.get('limit'), cursor }) + const prefix = `${String(params['tid'])}/${String(params['wfid'])}` + return HttpResponse.json({ + items: [{ id: `${prefix}/${cursor ? 'second' : 'first'}` }], + nextCursor: cursor ? '' : 'page-two', + }) + }), + ) + const cache = new QueryClient() + const { result, rerender } = renderHook( + (props: { tenant: string; workflow: string }) => + useWorkflowPublishedSnapshotIds(props.tenant, props.workflow), + { wrapper: wrapper(cache), initialProps: { tenant: 't1', workflow: 'wf1' } }, + ) + await waitFor(() => expect(result.current.data).toEqual(['t1/wf1/first', 't1/wf1/second'])) + rerender({ tenant, workflow }) + await waitFor(() => + expect(result.current.data).toEqual([ + `${tenant}/${workflow}/first`, + `${tenant}/${workflow}/second`, + ]), + ) + expect(requests).toEqual([ + { path: '/api/v1/tenants/t1/workflows/wf1/snapshots', limit: '100', cursor: null }, + { path: '/api/v1/tenants/t1/workflows/wf1/snapshots', limit: '100', cursor: 'page-two' }, + { + path: `/api/v1/tenants/${tenant}/workflows/${workflow}/snapshots`, + limit: '100', + cursor: null, + }, + { + path: `/api/v1/tenants/${tenant}/workflows/${workflow}/snapshots`, + limit: '100', + cursor: 'page-two', + }, + ]) + expect( + cache + .getQueryCache() + .getAll() + .map((query) => query.queryKey), + ).toEqual([ + ['workflow-published-snapshot-ids', 't1', 'wf1'], + ['workflow-published-snapshot-ids', tenant, workflow], + ]) + }) + + it.each([ + { tenant: '', workflow: 'wf1' }, + { tenant: 't1', workflow: undefined }, + ])('does not fetch until tenant and workflow ids are present: %j', ({ tenant, workflow }) => { + const { result } = renderHook(() => useWorkflowPublishedSnapshotIds(tenant, workflow), { + wrapper: wrapper(new QueryClient()), + }) + expect(result.current.fetchStatus).toBe('idle') + expect(result.current.data).toBeUndefined() + }) +}) diff --git a/frontend/src/features/issues/api.ts b/frontend/src/features/issues/api.ts index f24255e51..c1d77b05a 100644 --- a/frontend/src/features/issues/api.ts +++ b/frontend/src/features/issues/api.ts @@ -1,5 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { getApiV1TenantsTidWorkflowsWfidSnapshotsSnapshotIdForm } from '@/api/workflows/workflows' +import { + getApiV1TenantsTidWorkflowsWfidSnapshots, + getApiV1TenantsTidWorkflowsWfidSnapshotsSnapshotIdForm, +} from '@/api/workflows/workflows' import { AXIOS_INSTANCE } from '@/lib/api-client' import type { AssistSuggestion, @@ -313,6 +316,29 @@ export function useFormDescriptor(tid: string, formRef: string | undefined, issu }) } +/** Lists every published snapshot id so version-selector checks do not depend on UUID page order. */ +export function useWorkflowPublishedSnapshotIds(tid: string, workflowId: string | undefined) { + return useQuery({ + queryKey: ['workflow-published-snapshot-ids', tid, workflowId ?? ''], + queryFn: ({ signal }) => { + const ids: string[] = [] + async function collectPage(cursor = ''): Promise { + const page = await getApiV1TenantsTidWorkflowsWfidSnapshots( + tid, + workflowId ?? '', + { limit: 100, ...(cursor ? { cursor } : {}) }, + undefined, + signal, + ) + ids.push(...page.items.map((item) => item.id)) + return page.nextCursor ? collectPage(page.nextCursor) : ids + } + return collectPage() + }, + enabled: !!tid && !!workflowId, + }) +} + /** Reads a chosen frozen version's form, isolated by tenant, workflow, snapshot and issue context. */ export function useWorkflowSnapshotForm( tid: string, diff --git a/frontend/src/features/issues/components/README.en.md b/frontend/src/features/issues/components/README.en.md index d4bfcc13c..94d6783a1 100644 --- a/frontend/src/features/issues/components/README.en.md +++ b/frontend/src/features/issues/components/README.en.md @@ -28,7 +28,9 @@ Creation drafts remount by tenant/space; activity drafts by tenant/space/issue, Workflow confirmation locks duplicate submissions, editing and removal from interaction creation onward; a rejected confirm reuses that interaction on retry, including after a version switch. Confirmed rows use `features/workflows/api` to read the WorkflowRun referenced by `IssueRun.workflowInvocationRef`; detail links combine `executorId`, that reference and the route's workspace slug. -The initial form reads the live descriptor by `formRef + issueId`. Changing the `snapshotId` select loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. An author's text variable with the same name remains ordinary input. Reading a form is not run admission; Confirm's `workflow_version_fields_changed` gate is unchanged. +The initial form reads the live descriptor by `formRef + issueId`. A `snapshotId` select is recognized as the platform version selector only when every option belongs to this workflow's published snapshots; the form waits for that list and allows retry after a failed read. An author's same-named text field or select with custom options remains ordinary input. + +Changing the platform version selector loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. Reading a form is not run admission; Confirm's `workflow_version_fields_changed` gate is unchanged. That refusal explains the selected version differs from the current draft and directs the user to publish the draft or pick another version. Historical runs without a WorkflowRun reference retain their IssueRun status, make no WorkflowRun request and show no detail link. Linked runs always use the WorkflowRun's authoritative status. @@ -36,4 +38,4 @@ Historical runs without a WorkflowRun reference retain their IssueRun status, ma Component tests use MSW; properties and Task Mode→Thread are covered by `issue-detail-page.test.tsx`. Loading, empty, refused and retry states are distinct; creation, ordinary-comment and target failures retain drafts, while scope changes clear them. -Workflow tests cover double-click exclusion, rejected confirms reusing an interaction across version switches, snapshot fields/defaults/echoes, pending and failed reads, and WorkflowRun status and detail links. +Workflow tests cover double-click exclusion, rejected confirms reusing an interaction across version switches, snapshot fields/defaults/echoes, an author's same-named select making no snapshot-form request, actionable field-divergence feedback, pending and failed reads, and WorkflowRun status and detail links. diff --git a/frontend/src/features/issues/components/README.md b/frontend/src/features/issues/components/README.md index 339e41b12..356a67eb7 100644 --- a/frontend/src/features/issues/components/README.md +++ b/frontend/src/features/issues/components/README.md @@ -28,7 +28,9 @@ Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与取消;确认失败后仍用同一 interaction 重试,包括切换版本后的重试。确认行通过 `features/workflows/api` 读取 `IssueRun.workflowInvocationRef` 对应的 WorkflowRun 状态,详情链接使用 `executorId`、该引用和当前路由的空间 slug。 -初始表单按 `formRef + issueId` 读取活稿。用户切换 `snapshotId` 下拉选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试;作者自定义的同名文本字段仍是普通输入。读取表单不代表服务端准入通过,Confirm 的 `workflow_version_fields_changed` 门禁保持不变。 +初始表单按 `formRef + issueId` 读取活稿。只有 `snapshotId` select 的全部选项都属于该 Workflow 的已发布快照时,才识别为平台版本选择器;列表读取完成前不开放表单,失败可重试。作者自定义的同名文本字段或含自定义选项的 select 仍是普通输入。 + +用户切换平台版本选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试。读取表单不代表服务端准入通过,Confirm 的 `workflow_version_fields_changed` 门禁保持不变;收到该拒绝时,界面提示所选版本字段与当前草稿不一致,并引导先发布草稿或选择其他版本。 历史运行没有 WorkflowRun 引用时,确认行仍显示 IssueRun 状态,不请求 WorkflowRun,也不显示运行详情链接;有关联的运行始终以 WorkflowRun 状态为准。 @@ -36,4 +38,4 @@ Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与 组件测试用 MSW;属性栏和 Task Mode→Thread 经 `issue-detail-page.test.tsx` 覆盖。测试区分加载、空列表、拒绝、重试;创建、普通评论和目标提交失败保留草稿,作用域切换则清空草稿。 -Workflow 测试覆盖双击互斥、确认失败后复用 interaction(含切换版本)、版本字段/default/回显切换、读取等待与失败,以及 WorkflowRun 状态和详情链接。 +Workflow 测试覆盖双击互斥、确认失败后复用 interaction(含切换版本)、版本字段/default/回显切换、作者同名 select 不发快照表单请求、字段差异拒绝的操作提示、读取等待与失败,以及 WorkflowRun 状态和详情链接。 diff --git a/frontend/src/features/issues/components/workflow-interaction-composer.tsx b/frontend/src/features/issues/components/workflow-interaction-composer.tsx index 1d33d0c4f..4d1d0ea19 100644 --- a/frontend/src/features/issues/components/workflow-interaction-composer.tsx +++ b/frontend/src/features/issues/components/workflow-interaction-composer.tsx @@ -9,6 +9,7 @@ import { useAssistWorkflow, useConfirmWorkflow, useFormDescriptor, + useWorkflowPublishedSnapshotIds, useWorkflowSnapshotForm, } from '@/features/issues/api' import { AssistSuggestions } from '@/features/issues/components/assist-suggestions' @@ -22,6 +23,7 @@ import type { FormField, FormValues, } from '@/features/issues/types' +import { faultCode } from '@/lib/api-client' /** Seeds the editable values from the descriptor's declared defaults. */ function seedValues(descriptor: FormDescriptor, snapshotId?: string): FormValues { @@ -31,12 +33,25 @@ function seedValues(descriptor: FormDescriptor, snapshotId?: string): FormValues values[field.key] = field.defaultValue } } - if (snapshotId && descriptor.fields.some(isSnapshotSelector)) values['snapshotId'] = snapshotId + if (snapshotId) values['snapshotId'] = snapshotId return values } -function isSnapshotSelector(field: FormField): boolean { - return field.key === 'snapshotId' && field.type === 'select' +// An author's snapshotId field suppresses platform injection, so its name and type are insufficient. +function isSnapshotSelector(field: FormField, publishedIds: string[]): boolean { + return ( + field.key === 'snapshotId' && + field.type === 'select' && + !!field.options?.length && + field.options.every((option) => publishedIds.includes(option.value)) + ) +} + +function confirmFailure(error: unknown): string { + if (faultCode(error) === 'workflow_version_fields_changed') { + return '所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。' + } + return '确认失败,请重试。' } /** Required fields the user has not filled yet — the only client-side gate on Review (§38.15). */ @@ -281,7 +296,7 @@ function ComposerBody(props: { assistPending: boolean confirmPending: boolean assistFailed: boolean - confirmFailed: boolean + confirmError: string | undefined onAssist: () => void onReview: () => void onBack: () => void @@ -301,8 +316,10 @@ function ComposerBody(props: { onBack={props.onBack} onConfirm={props.onConfirm} /> - {props.confirmFailed ? ( -

确认失败,请重试。

+ {props.confirmError ? ( +

+ {props.confirmError} +

) : null} ) @@ -368,7 +385,7 @@ function useWorkflowSubmission({ return { submit, isPending: submission.isPending || submission.isSuccess, - isError: submission.isError, + errorMessage: submission.isError ? confirmFailure(submission.error) : undefined, } } @@ -397,15 +414,25 @@ export function WorkflowInteractionComposer({ const descriptorQuery = snapshotId ? snapshot : live const confirm = useWorkflowSubmission({ slug, issueId, target, onConfirmed }) const descriptor = descriptorQuery.data + const candidate = descriptor?.fields.find( + (field) => field.key === 'snapshotId' && field.type === 'select', + ) + const published = useWorkflowPublishedSnapshotIds(slug, candidate ? target.id : undefined) - if (descriptorQuery.isPending) return - if (descriptorQuery.isError || !descriptor) { + if (descriptorQuery.isPending || (candidate && published.isPending)) { + return + } + if (descriptorQuery.isError || !descriptor || (candidate && published.isError)) { return (

当前部署无法加载该 Workflow 的表单(form descriptor 不可用)。

-
@@ -419,6 +446,9 @@ export function WorkflowInteractionComposer({ issueId={issueId} target={target} descriptor={descriptor} + versionField={descriptor.fields.find((field) => + isSnapshotSelector(field, published.data ?? []), + )} snapshotId={snapshot.data?.snapshotId} loading={descriptorQuery.isFetching} confirm={confirm} @@ -434,6 +464,7 @@ function WorkflowInteractionForm(props: { issueId: string target: CollaborationTargetSummary descriptor: FormDescriptor + versionField: FormField | undefined snapshotId: string | undefined loading: boolean confirm: ReturnType @@ -444,13 +475,10 @@ function WorkflowInteractionForm(props: { const assist = useAssistWorkflow(slug, issueId) const [reviewing, setReviewing] = useState(false) const [collapsed, setCollapsed] = useState(false) - const form = useWorkflowForm(descriptor, props.snapshotId) + const form = useWorkflowForm(descriptor, props.versionField ? props.snapshotId : undefined) function setField(key: string, next: unknown) { - if ( - typeof next === 'string' && - descriptor.fields.some((field) => field.key === key && isSnapshotSelector(field)) - ) { + if (typeof next === 'string' && key === props.versionField?.key) { props.onSnapshotChange(next) } else { form.setField(key, next) @@ -488,7 +516,7 @@ function WorkflowInteractionForm(props: { assistPending={assist.isPending} confirmPending={confirm.isPending} assistFailed={assist.isError} - confirmFailed={confirm.isError} + confirmError={confirm.errorMessage} onAssist={() => assist.mutate( { targetId: target.id, values: form.values }, diff --git a/frontend/src/features/issues/components/workflow-interaction.test.tsx b/frontend/src/features/issues/components/workflow-interaction.test.tsx index 3dc9270fa..c247ce7bf 100644 --- a/frontend/src/features/issues/components/workflow-interaction.test.tsx +++ b/frontend/src/features/issues/components/workflow-interaction.test.tsx @@ -180,6 +180,9 @@ function serveComposer(overrides: { descriptor?: unknown; suggestion?: AssistSug http.get('/api/v1/tenants/t1/collaboration/forms/security-review', () => HttpResponse.json(overrides.descriptor ?? descriptor), ), + http.get('/api/v1/tenants/t1/workflows/wf1/snapshots', () => + HttpResponse.json({ items: [{ id: 'snap-3' }, { id: 'snap-2' }], nextCursor: '' }), + ), http.post('/api/v1/tenants/t1/issues/i1/collaboration/assist', async ({ request }) => { assist(await request.json()) return HttpResponse.json(overrides.suggestion ?? suggestion) @@ -611,6 +614,63 @@ describe('WorkflowInteractionComposer', () => { ) }) + it('keeps an authors select named snapshotId as ordinary input when any option is not published', async () => { + const { confirm } = serveComposer({ + descriptor: { + formRef: 'security-review', + fields: [ + { + key: 'snapshotId', + label: 'Author snapshot choice', + type: 'select', + required: true, + defaultValue: 'snap-3', + options: [ + { value: 'snap-3', label: 'Author default' }, + { value: 'custom-choice', label: 'Author custom choice' }, + ], + }, + ], + }, + }) + const formRead = vi.fn<() => void>() + server.use( + http.get('/api/v1/tenants/t1/workflows/wf1/snapshots/:id/form', () => { + formRead() + return HttpResponse.json({ code: 'not_found' }, { status: 404 }) + }), + ) + const user = userEvent.setup() + renderComposer() + await user.click(await screen.findByLabelText(/Author snapshot choice/)) + await user.click(await screen.findByRole('option', { name: 'Author custom choice' })) + expect(selectTrigger('Author snapshot choice')).toHaveTextContent('Author custom choice') + await reviewAndConfirm(user) + await waitFor(() => + expect(confirm).toHaveBeenCalledWith({ values: { snapshotId: 'custom-choice' } }), + ) + expect(formRead).not.toHaveBeenCalled() + }) + + it('explains how to resolve workflow version field divergence after confirm', async () => { + serveComposer({ descriptor: launchDescriptor }) + server.use( + http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', () => + HttpResponse.json({ code: 'workflow_version_fields_changed' }, { status: 409 }), + ), + ) + const user = userEvent.setup() + const { onConfirmed } = renderComposer() + await screen.findByLabelText(/提示词/) + await reviewAndConfirm(user) + expect(await screen.findByRole('alert')).toHaveTextContent( + '所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。', + ) + expect(screen.queryByText('workflow_version_fields_changed')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '返回修改' })).toBeEnabled() + expect(onConfirmed).not.toHaveBeenCalled() + }) + it('keeps the same interaction when a rejected confirm is retried after switching snapshot', async () => { const { comment, confirm } = serveComposer({ descriptor: launchDescriptor }) serveOldSnapshot() @@ -623,7 +683,7 @@ describe('WorkflowInteractionComposer', () => { const { onConfirmed } = renderComposer() await screen.findByLabelText(/提示词/) await reviewAndConfirm(user) - await screen.findByText('确认失败,请重试。') + await screen.findByText('所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。') await user.click(screen.getByRole('button', { name: '返回修改' })) await user.click(selectTrigger('运行版本')) await user.click(await screen.findByRole('option', { name: 'v2 · 发布 2' })) diff --git a/integration/workflow_invocation_migration_test.go b/integration/workflow_invocation_migration_test.go index aba699c5a..1d7afcc1d 100644 --- a/integration/workflow_invocation_migration_test.go +++ b/integration/workflow_invocation_migration_test.go @@ -7,7 +7,7 @@ import ( "github.com/google/uuid" ) -const workflowInvocationMigration = "0036_workflow_invocation_reference.sql" +const workflowInvocationMigration = "0037_workflow_invocation_reference.sql" // seedInvocationWorkflowRun creates real rows for the composite reference under test. The graph is // deliberately minimal: these tests exercise database integrity rather than graph admission. diff --git a/internal/core/migrations/0036_workflow_invocation_reference.sql b/internal/core/migrations/0037_workflow_invocation_reference.sql similarity index 91% rename from internal/core/migrations/0036_workflow_invocation_reference.sql rename to internal/core/migrations/0037_workflow_invocation_reference.sql index 9b8c5d8bd..7229efe3f 100644 --- a/internal/core/migrations/0036_workflow_invocation_reference.sql +++ b/internal/core/migrations/0037_workflow_invocation_reference.sql @@ -12,6 +12,7 @@ ALTER TABLE workflow_runs ALTER TABLE issue_runs ADD CONSTRAINT issue_runs_workflow_invocation_fk + -- workflow_invocation_ref had no writer before this migration, so dangling references are not expected and the FK fails closed. FOREIGN KEY (workflow_invocation_ref, tenant_id) REFERENCES workflow_runs(id, tenant_id), ADD CONSTRAINT issue_runs_workflow_invocation_uniq UNIQUE (workflow_invocation_ref), ADD CONSTRAINT issue_runs_workflow_invocation_shape diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index d8abb3488..d2716ba1f 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -102,15 +102,15 @@ Both `0033` files retain their complete filenames and original SQL. The complete Adds startup_ready_at, startup_deadline_at, startup_resume, startup_ending_deadline_at and agent_run_residuals without rewriting historical outcomes. The serialized policy scan freezes legacy active windows once. Residual ownership commits with failed/done; late true terminal facts add evidence without granting cleanup authority. Fresh/upgrade/repeat coverage: TestMigration0034StartupWindowsPreserveExistingRunOnUpgrade. -## 0036: WorkflowRun invocation references +## 0037: WorkflowRun invocation references -`0036_workflow_invocation_reference.sql` first settles every non-terminal unlinked historical +`0037_workflow_invocation_reference.sql` first settles every non-terminal unlinked historical workflow IssueRun (including running and deferred) as failed, with `workflow_execution_unavailable_before_invocation_linkage`. It then adds a tenant-scoped composite foreign key and a unique invocation reference. The CHECK requires a reference for non-terminal workflow executors and forbids it for other executors. Historical terminal workflow rows may retain NULL references and remain editable; their input and outcomes are preserved without inventing runs. The CHECK is unconditionally validated, so fresh and upgraded databases enforce the same schema. -0035 is reserved for the earlier WorkflowRun identity migration in #68; invocation linkage follows as -0036. Fresh, seeded upgrade, repeated migration, +0035 and 0036 are reserved for the earlier cloud#68 and cloud#72 migrations, respectively; invocation linkage follows as +0037. Fresh, seeded upgrade, repeated migration, constraint validation and historical update coverage is in `integration/workflow_invocation_migration_test.go`. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 80a39ff47..4d3f5ed10 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -101,13 +101,13 @@ Cloud 领域方法。冻结与撤销规则见 [模型连接契约](../../../docs 新增 startup_ready_at、startup_deadline_at、startup_resume、startup_ending_deadline_at 及 agent_run_residuals。迁移不改历史结果;活动存量由串行策略扫描一次固定窗口。残留账本与 failed/done 同事务提交,迟到真实终态仅补证据,不代替清理授权。新库/升级/重复执行见 TestMigration0034StartupWindowsPreserveExistingRunOnUpgrade。 -## 0036:WorkflowRun 调用引用 +## 0037:WorkflowRun 调用引用 -`0036_workflow_invocation_reference.sql` 先将所有未关联的历史非终态 workflow IssueRun +`0037_workflow_invocation_reference.sql` 先将所有未关联的历史非终态 workflow IssueRun (含 running、deferred)结算为 failed,原因是 `workflow_execution_unavailable_before_invocation_linkage`,再添加租户一致的复合外键及唯一引用。 CHECK 要求非终态 workflow 行具有引用,其他 executor 不得携带引用。历史终态 workflow 行可保留 NULL 引用并继续更新;迁移保留其输入和结果,不虚构执行。CHECK 无条件 VALIDATE,空库和升级库 -采用同一份已验证约束。0035 留给依赖链中更早的 #68 WorkflowRun 身份迁移,本调用关联迁移使用 0036。 +采用同一份已验证约束。0035、0036 分别留给依赖链中更早的 cloud#68、cloud#72 迁移,本调用关联迁移使用 0037。 空库、种子升级、重复迁移、约束验证状态及 历史行更新测试见 `integration/workflow_invocation_migration_test.go`。 From 78458700ccb5fe613920d5c80a6381af66a9732c Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:49:09 -0700 Subject: [PATCH 06/19] test(frontend): share the field-divergence confirm setup The two field-divergence tests repeated the same 409 handler, render and confirm steps, which the jscpd duplication gate rejects. Docs-Unchanged: test-only refactor; no behavior or module contract changed Co-Authored-By: Claude Opus 5.5 --- .../components/workflow-interaction.test.tsx | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/frontend/src/features/issues/components/workflow-interaction.test.tsx b/frontend/src/features/issues/components/workflow-interaction.test.tsx index c247ce7bf..b75fddfd6 100644 --- a/frontend/src/features/issues/components/workflow-interaction.test.tsx +++ b/frontend/src/features/issues/components/workflow-interaction.test.tsx @@ -241,6 +241,20 @@ async function reviewAndConfirm(user: ReturnType) { await user.click(await screen.findByRole('button', { name: '确认执行' })) } +/** Renders the composer and confirms once against a server that answers the field-divergence 409. */ +async function confirmIntoFieldsChanged() { + server.use( + http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', () => + HttpResponse.json({ code: 'workflow_version_fields_changed' }, { status: 409 }), + ), + ) + const user = userEvent.setup() + const { onConfirmed } = renderComposer() + await screen.findByLabelText(/提示词/) + await reviewAndConfirm(user) + return { user, onConfirmed } +} + function serveOldSnapshot() { const read = vi.fn<(issueId: string | null) => void>() server.use( @@ -654,15 +668,7 @@ describe('WorkflowInteractionComposer', () => { it('explains how to resolve workflow version field divergence after confirm', async () => { serveComposer({ descriptor: launchDescriptor }) - server.use( - http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', () => - HttpResponse.json({ code: 'workflow_version_fields_changed' }, { status: 409 }), - ), - ) - const user = userEvent.setup() - const { onConfirmed } = renderComposer() - await screen.findByLabelText(/提示词/) - await reviewAndConfirm(user) + const { onConfirmed } = await confirmIntoFieldsChanged() expect(await screen.findByRole('alert')).toHaveTextContent( '所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。', ) @@ -674,15 +680,7 @@ describe('WorkflowInteractionComposer', () => { it('keeps the same interaction when a rejected confirm is retried after switching snapshot', async () => { const { comment, confirm } = serveComposer({ descriptor: launchDescriptor }) serveOldSnapshot() - server.use( - http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', () => - HttpResponse.json({ code: 'workflow_version_fields_changed' }, { status: 409 }), - ), - ) - const user = userEvent.setup() - const { onConfirmed } = renderComposer() - await screen.findByLabelText(/提示词/) - await reviewAndConfirm(user) + const { user, onConfirmed } = await confirmIntoFieldsChanged() await screen.findByText('所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。') await user.click(screen.getByRole('button', { name: '返回修改' })) await user.click(selectTrigger('运行版本')) From 7088e26dbc17d126c69c8bc07dc8d3850b1e05f6 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:19:29 -0700 Subject: [PATCH 07/19] test(workflow): cover imported snapshots in issue invocations Reuse the real .orax, HTTPS marketplace and PostgreSQL harness to prove imported workflows enter the Issue target directory and their selected snapshot forms remain stable across administrators and import-trigger deactivation. Compare pending run bindings across those identities. Verify Confirm links its IssueRun to a WorkflowRun bound to the imported snapshot, direct starts select the same snapshot, and editing the draft cannot replace the frozen graph. After provider uninstall, preserve the old snapshot, publish a new version and create a run from it. Evidence stops at WorkflowRun creation and binding; real node execution remains with #70. No import implementation or specs changes. Validation: go build ./..., the core package, three new import invocation cases and 23 existing invocation/confirm/assets regressions with 30-minute timeouts. The merge has no overlap with this branch's frontend edits, so the conditional frontend gate does not apply. Co-Authored-By: Claude Opus 5.5 --- integration/workflow_asset_invocation_test.go | 215 ++++++++++++++++++ integration/workflow_asset_retained_test.go | 58 +++++ 2 files changed, 273 insertions(+) create mode 100644 integration/workflow_asset_invocation_test.go create mode 100644 integration/workflow_asset_retained_test.go diff --git a/integration/workflow_asset_invocation_test.go b/integration/workflow_asset_invocation_test.go new file mode 100644 index 000000000..c0a1886e7 --- /dev/null +++ b/integration/workflow_asset_invocation_test.go @@ -0,0 +1,215 @@ +package integration + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/wanglongan587/cloud/internal/core" + "github.com/wanglongan587/cloud/internal/workflowasset" +) + +type importedWorkflowEvidence struct { + f *fixture + spaceID string + workflow string + snapshot core.Object +} + +// importWorkflowForInvocation reuses the real .orax/HTTPS/marketplace harness. No workflow or +// snapshot is seeded: the plugin's desired state must produce both through the import worker. +func importWorkflowForInvocation(t *testing.T) *importedWorkflowEvidence { + t.Helper() + f := setup(t) + // These tests prove persisted invocation identity, never a simulated node trace. + f.store.Simulator = nil + document := startPromptGraph([]any{core.Object{ + "name": "releaseName", "displayName": "Imported release", "fieldType": "text-input", + "valueType": "string", "required": true, "value": "package-default", + }}, "Ship the imported release") + document["name"] = "Imported Release Evidence" + document["version"] = 47 + document["globalVariables"] = []any{core.Object{"name": "release.mode", "valueType": "string", "value": "safe"}} + encoded, err := json.Marshal(document) + must(t, err) + pkg := workflowPackage(t, map[string]string{"release.json": string(encoded)}) + packages := newWorkflowPackages(t, map[string][]byte{"/packages/invocation-1.0.0.orax": pkg}) + repo := workflowMarketplace(t, f.root) + publishWorkflowRelease(t, repo, "invocation-kit", "1.0.0", packages.server.URL+"/packages/invocation-1.0.0.orax", pkg) + f.syncMarketplace(t, repo) + f.store.WorkflowAssets = workflowasset.NewFetcher(nil, packages.server.Client().Transport) + sid := f.defaultSpaceID() + f.installWorkflowPlugin(t, sid, "official/invocation-kit", "") + must(t, f.importWorkflowAssets(t)) + item := f.assetImport(t, sid, "official/invocation-kit") + if item == nil || item.S("state") != "completed" { + t.Fatalf("import did not complete: %v", item) + } + imported := assetDocuments(item)["assets/workflows/release.json"] + if imported.S("outcome") != "imported" || imported.S("workflowId") == "" || imported.N("snapshotVersion") != 1 { + t.Fatalf("document was not imported and published: %v", imported) + } + wid := imported.S("workflowId") + snapshots := issueItems(f.call("GET", f.path("/workflows/"+wid+"/snapshots"), nil, "", 200)) + if len(snapshots) != 1 || snapshots[0].S("id") == "" || snapshots[0].N("version") != 1 { + t.Fatalf("import must leave exactly one Cloud-assigned snapshot: %v", snapshots) + } + var expected core.Object + must(t, json.Unmarshal(encoded, &expected)) + if !reflect.DeepEqual(snapshots[0].O("graph"), expected) { + t.Fatalf("published graph differs from the package document: %v", snapshots[0]) + } + return &importedWorkflowEvidence{f: f, spaceID: sid, workflow: wid, snapshot: snapshots[0]} +} + +func importedInvocationInput() core.Object { + return core.Object{"releaseName": "release-from-package", "release.mode": "strict"} +} + +// assertRun checks both the public frozen definition and the real PostgreSQL binding. +// IDs and timestamps differ by invocation; the returned projection names only run semantics. +func (e *importedWorkflowEvidence) assertRun(t *testing.T, user core.Claims, id string, snapshot core.Object) core.Object { + t.Helper() + run := callAs(t, e.f, user, "GET", e.f.path("/workflows/"+e.workflow+"/runs/"+id), "", nil, 200) + if run.S("snapshotId") != snapshot.S("id") || run.S("workflowId") != e.workflow || run.S("status") != "pending" { + t.Fatalf("run did not bind a pending imported invocation: %v", run) + } + if !reflect.DeepEqual(run.O("definitionSnapshot"), snapshot.O("graph")) || !reflect.DeepEqual(run.O("input"), importedInvocationInput()) { + t.Fatalf("run did not freeze the selected graph and input: %v", run) + } + if n := e.f.scalar("SELECT count(*) FROM workflow_runs WHERE id=$1 AND tenant_id=$2 AND workflow_id=$3 AND snapshot_id=$4", id, e.f.tid, e.workflow, snapshot.S("id")); n != 1 { + t.Fatalf("stored WorkflowRun binding = %d rows, want 1", n) + } + return core.Object{"workflowId": run.S("workflowId"), "snapshotId": run.S("snapshotId"), + "status": run.S("status"), "input": run.O("input"), "definitionSnapshot": run.O("definitionSnapshot")} +} + +func TestImportedWorkflowDirectoryAndRunsIgnoreImportTriggerUser(t *testing.T) { + e := importWorkflowForInvocation(t) + f := e.f + trigger := f.user + other, otherID := f.addUser(t, "asset-evidence-admin", "Other asset administrator") + f.call("PUT", f.path("/members/"+otherID), core.Object{"role": "admin", "status": "active", "version": 1}, "", 200) + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Discover imported workflow"}, "asset-directory-issue", 200).O("resource").S("id") + formPath := f.path("/workflows/" + e.workflow + "/snapshots/" + e.snapshot.S("id") + "/form?issueId=" + iid) + initialForm := f.call("GET", formPath, nil, "", 200) + liveFormPath := f.path("/collaboration/forms/" + e.workflow + "?issueId=" + iid) + if live := f.call("GET", liveFormPath, nil, "", 200); !reflect.DeepEqual(live["fields"], initialForm["fields"]) { + t.Fatalf("the imported live copy and initial published form disagree: live=%v snapshot=%v", live, initialForm) + } + // Distinguish the selected snapshot's form from the mutable live form. This is the same + // snapshot-form endpoint the @ composer reads when the user selects a published version. + live := f.call("GET", f.path("/workflows/"+e.workflow), nil, "", 200) + f.call("PUT", f.path("/workflows/"+e.workflow), core.Object{ + "version": live.N("version"), "graph": startPromptGraph([]any{invocationVariable("replacement")}, "Draft-only prompt"), + }, "", 200) + changed := formFields(t, f.call("GET", liveFormPath, nil, "", 200)) + if changed["replacement"] == nil || changed["releaseName"] != nil { + t.Fatalf("test did not give the live draft a different form: %v", changed) + } + project := func(user core.Claims) core.Object { + t.Helper() + // This is the public directory used by useCollaborationTargets in the Issue @ picker. + directory := callAs(t, f, user, "GET", f.path("/collaboration/targets"), "", nil, 200) + var target core.Object + for _, item := range issueItems(directory) { + if item.S("type") == "workflow" && item.S("id") == e.workflow { + target = item + } + } + if target == nil || target.S("displayName") != "Imported Release Evidence" { + t.Fatalf("imported copy is absent from the @ directory: %v", directory) + } + interaction := target.O("interactionDescriptor") + if interaction.S("mode") != "form" || interaction.B("requiresTask") || interaction.S("formRef") != e.workflow { + t.Fatalf("imported target does not advertise its workflow form: %v", target) + } + form := callAs(t, f, user, "GET", formPath, "", nil, 200) + fields := formFields(t, form) + if form.S("snapshotId") != e.snapshot.S("id") || form.S("formRef") != interaction.S("formRef") || + fields["releaseName"].S("label") != "Imported release" || fields["releaseName"].S("defaultValue") != "package-default" || + !fields["releaseName"].B("required") || fields["replacement"] != nil || + fields["prompt"].S("defaultValue") != "Ship the imported release" || fields["release.mode"].S("defaultValue") != "safe" { + t.Fatalf("selected form was not projected from the imported snapshot: %v", form) + } + if !reflect.DeepEqual(form, initialForm) { + t.Fatalf("editing the live copy changed its published form: initial=%v selected=%v", initialForm, form) + } + workflow := callAs(t, f, user, "GET", f.path("/workflows/"+e.workflow), "", nil, 200) + return core.Object{"target": target, "form": form, "workflow": workflow} + } + start := func(user core.Claims, key string) core.Object { + t.Helper() + run := callAs(t, f, user, "POST", f.path("/workflows/"+e.workflow+"/runs"), key, core.Object{ + "snapshotId": e.snapshot.S("id"), "input": importedInvocationInput(), + }, 200).O("resource") + return e.assertRun(t, user, run.S("id"), e.snapshot) + } + baseline := project(trigger) + baselineRun := start(trigger, "asset-author-trigger-run") + if otherProjection, otherRun := project(other), start(other, "asset-author-other-run"); !reflect.DeepEqual(otherProjection, baseline) || !reflect.DeepEqual(otherRun, baselineRun) { + t.Fatalf("changing administrator changed imported projection/run: projection=%v run=%v", otherProjection, otherRun) + } + // Global account disable has no public admin endpoint; change the actual authorization row, + // then prove the old identity is refused over HTTP before comparing the surviving admin. + _, err := f.store.Pool.Exec("UPDATE users SET status='disabled' WHERE id=$1", f.uid) + must(t, err) + callAs(t, f, trigger, "GET", f.path("/collaboration/targets"), "", nil, 403) + if afterProjection, afterRun := project(other), start(other, "asset-author-after-disable-run"); !reflect.DeepEqual(afterProjection, baseline) || !reflect.DeepEqual(afterRun, baselineRun) { + t.Fatalf("deactivating the import trigger changed the retained copy: projection=%v run=%v", afterProjection, afterRun) + } + if n := f.scalar("SELECT count(*) FROM workflow_runs WHERE workflow_id=$1 AND snapshot_id=$2", e.workflow, e.snapshot.S("id")); n != 3 { + t.Fatalf("expected the same snapshot for all three administrators/account states, got %d", n) + } +} + +func TestImportedWorkflowSnapshotBindsConfirmAndDirectRuns(t *testing.T) { + e := importWorkflowForInvocation(t) + f, wid, snapshot := e.f, e.workflow, e.snapshot + iid := f.call("POST", f.path("/issues"), core.Object{"title": "Use imported snapshot"}, "asset-inv-issue", 200).O("resource").S("id") + interaction := workflowComment(t, f, wid, iid, "asset-inv-comment", "@ imported release") + values := importedInvocationInput() + values["repository"], values["branch"], values["snapshotId"] = "https://example.invalid/repo.git", "main", snapshot.S("id") + issueRun := f.call("POST", confirmPath(f, iid, interaction.S("id")), core.Object{"values": values}, "asset-inv-confirm", 200).O("resource") + ref := issueRun.S("workflowInvocationRef") + if ref == "" || issueRun.O("input").S("workflowSnapshotId") != snapshot.S("id") { + t.Fatalf("Confirm did not link the imported snapshot: %v", issueRun) + } + e.assertRun(t, f.user, ref, snapshot) + if n := f.scalar(`SELECT count(*) FROM issue_interactions ix + JOIN issue_runs ir ON ir.id=ix.run_id + JOIN workflow_runs wr ON wr.id=ir.workflow_invocation_ref AND wr.tenant_id=ir.tenant_id + WHERE ix.id=$1 AND ir.id=$2 AND wr.id=$3 AND wr.snapshot_id=$4`, interaction.S("id"), issueRun.S("id"), ref, snapshot.S("id")); n != 1 { + t.Fatalf("imported Confirm did not persist the interaction/run pair: %d", n) + } + create := func(key string) string { + t.Helper() + return f.call("POST", f.path("/workflows/"+wid+"/runs"), core.Object{ + "snapshotId": snapshot.S("id"), "input": importedInvocationInput(), + }, key, 200).O("resource").S("id") + } + first := create("asset-inv-direct") + e.assertRun(t, f.user, first, snapshot) + var frozen string + must(t, f.store.Pool.QueryRow("SELECT graph::text FROM workflow_snapshots WHERE id=$1", snapshot.S("id")).Scan(&frozen)) + live := f.call("GET", f.path("/workflows/"+wid), nil, "", 200) + draft := startPromptGraph([]any{invocationVariable("replacement")}, "Changed draft prompt") + f.call("PUT", f.path("/workflows/"+wid), core.Object{"graph": draft, "version": live.N("version")}, "", 200) + // Direct admission judges the selected snapshot even when the draft has a different schema. + // Confirm's separate live-form compatibility gate is deliberately not bypassed here. + second := create("asset-inv-after-edit") + if first == second || second == ref || first == ref { + t.Fatal("distinct starts must create distinct WorkflowRun records") + } + e.assertRun(t, f.user, second, snapshot) + e.assertRun(t, f.user, first, snapshot) + e.assertRun(t, f.user, ref, snapshot) + var after string + must(t, f.store.Pool.QueryRow("SELECT graph::text FROM workflow_snapshots WHERE id=$1", snapshot.S("id")).Scan(&after)) + if after != frozen { + t.Fatal("editing the live draft rewrote the imported snapshot") + } + if n := f.scalar("SELECT count(*) FROM workflow_runs WHERE workflow_id=$1 AND snapshot_id=$2", wid, snapshot.S("id")); n != 3 { + t.Fatalf("expected Confirm and two direct starts bound to the imported snapshot, got %d", n) + } +} diff --git a/integration/workflow_asset_retained_test.go b/integration/workflow_asset_retained_test.go new file mode 100644 index 000000000..cedafd331 --- /dev/null +++ b/integration/workflow_asset_retained_test.go @@ -0,0 +1,58 @@ +package integration + +import ( + "testing" + + "github.com/wanglongan587/cloud/internal/core" +) + +// TestImportedWorkflowRemainsPublishableAndRunnableAfterUninstall proves an imported document is +// retained user data: removing its plugin neither removes its history nor prevents a new invocation. +func TestImportedWorkflowRemainsPublishableAndRunnableAfterUninstall(t *testing.T) { + e := importWorkflowForInvocation(t) + f, wid, old := e.f, e.workflow, e.snapshot + readOldSnapshot := func() (string, string) { + t.Helper() + var graph, row string + must(t, f.store.Pool.QueryRow(`SELECT graph::text, row_to_json(s)::text FROM workflow_snapshots s + WHERE id=$1 AND tenant_id=$2 AND workflow_id=$3`, old.S("id"), f.tid, wid).Scan(&graph, &row)) + return graph, row + } + frozenGraph, frozenRow := readOldSnapshot() + plugin := f.spacePlugin(e.spaceID, "official/invocation-kit") + if plugin == nil { + t.Fatal("imported workflow has no installed plugin selection to remove") + } + f.call("DELETE", f.pluginSpacePath(e.spaceID)+"/plugins", core.Object{ + "identifier": "official/invocation-kit", "version": plugin.N("version"), + }, "asset-retained-uninstall", 200) + if removed := f.spacePlugin(e.spaceID, "official/invocation-kit"); removed == nil || removed.S("desiredState") != "removed" { + t.Fatalf("plugin uninstall did not persist its removed intent: %v", removed) + } + if retained := f.call("GET", f.path("/workflows/"+wid), nil, "", 200); retained.S("id") != wid { + t.Fatalf("uninstall did not retain the imported workflow: %v", retained) + } + if graph, row := readOldSnapshot(); graph != frozenGraph || row != frozenRow { + t.Fatal("uninstall changed the imported snapshot row or graph") + } + + newSnapshot := publishWorkflow(t, f, wid, "asset-retained-publish") + if newSnapshot.S("id") == "" || newSnapshot.S("id") == old.S("id") || newSnapshot.N("version") != old.N("version")+1 { + t.Fatalf("retained workflow did not publish its next distinct snapshot: old=%v new=%v", old, newSnapshot) + } + run := f.call("POST", f.path("/workflows/"+wid+"/runs"), core.Object{ + "snapshotId": newSnapshot.S("id"), "input": importedInvocationInput(), + }, "asset-retained-run", 200).O("resource") + e.assertRun(t, f.user, run.S("id"), newSnapshot) + if graph, row := readOldSnapshot(); graph != frozenGraph || row != frozenRow { + t.Fatal("publishing or running the retained workflow changed its old snapshot") + } + snapshots := issueItems(f.call("GET", f.path("/workflows/"+wid+"/snapshots"), nil, "", 200)) + seen := map[string]int64{} + for _, snapshot := range snapshots { + seen[snapshot.S("id")] = snapshot.N("version") + } + if len(snapshots) != 2 || seen[old.S("id")] != old.N("version") || seen[newSnapshot.S("id")] != newSnapshot.N("version") { + t.Fatalf("retained workflow lost its old or new snapshot: %v", snapshots) + } +} From af0ee5612e36fc76de54e99a46da4ab1239f5a89 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:22:16 -0700 Subject: [PATCH 08/19] style(test): format the imported snapshot invocation test Co-Authored-By: Claude Opus 5.5 --- integration/workflow_asset_invocation_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/integration/workflow_asset_invocation_test.go b/integration/workflow_asset_invocation_test.go index c0a1886e7..356a791ef 100644 --- a/integration/workflow_asset_invocation_test.go +++ b/integration/workflow_asset_invocation_test.go @@ -80,8 +80,10 @@ func (e *importedWorkflowEvidence) assertRun(t *testing.T, user core.Claims, id if n := e.f.scalar("SELECT count(*) FROM workflow_runs WHERE id=$1 AND tenant_id=$2 AND workflow_id=$3 AND snapshot_id=$4", id, e.f.tid, e.workflow, snapshot.S("id")); n != 1 { t.Fatalf("stored WorkflowRun binding = %d rows, want 1", n) } - return core.Object{"workflowId": run.S("workflowId"), "snapshotId": run.S("snapshotId"), - "status": run.S("status"), "input": run.O("input"), "definitionSnapshot": run.O("definitionSnapshot")} + return core.Object{ + "workflowId": run.S("workflowId"), "snapshotId": run.S("snapshotId"), + "status": run.S("status"), "input": run.O("input"), "definitionSnapshot": run.O("definitionSnapshot"), + } } func TestImportedWorkflowDirectoryAndRunsIgnoreImportTriggerUser(t *testing.T) { From 52314da27b6b093e96a0213b0cfff9795da237fa Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:52:22 -0700 Subject: [PATCH 09/19] =?UTF-8?q?docs(workflow):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E8=A1=A8=E5=8D=95=E6=9D=A5=E6=BA=90=E4=B8=8E?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=B7=AE=E5=BC=82=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 协作文档仍把确认前的表单描述为始终取自当前文档,与前端选择已发布版本后重取快照表单的行为不符。 同步修正中英文说明,区分初次打开与选择快照后的表单来源,明确现有活稿字段差异 409 检查仍然生效,并在流程图显示关联创建的两类运行。 验证:对照 WorkflowInteractionComposer、useWorkflowSnapshotForm 和 confirmInteraction 的现有实现;两份文档的相对文件链接均有效;git diff --check 通过。 仅修改文档,不改变 API、字段差异门禁、迁移或调度行为。 --- docs/collaboration/interactions.en.md | 14 +++++++++----- docs/collaboration/interactions.md | 8 +++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/collaboration/interactions.en.md b/docs/collaboration/interactions.en.md index f351fb4ba..969a37087 100644 --- a/docs/collaboration/interactions.en.md +++ b/docs/collaboration/interactions.en.md @@ -69,7 +69,7 @@ Cloud's own background loops, see [Agent IssueRuns](../agent-runs/issue-runs.en. A workflow has exactly one execution boundary: an explicit **confirm**. ```text -select workflow ──▶ read form descriptor ──▶ fill in (optional AI assist) ──▶ confirm ──▶ IssueRun +select workflow ──▶ read form descriptor ──▶ fill in (optional AI assist) ──▶ confirm ──▶ WorkflowRun + IssueRun no execution no execution no execution, no run the only execution boundary ``` @@ -77,6 +77,9 @@ select workflow ──▶ read form descriptor ──▶ fill in (optional AI as Issue-facing rendering description: `formRef`, title, description and a list of fields. It is not the workflow's schema and binds to no schema technology. Fields come in three bands: platform fields, the variables declared by the workflow's Start node, and the graph's global variables. + Opening the form initially reads the current document. Selecting a published version in the platform + version selector reloads the chosen snapshot's form through + `GET /tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form?issueId=…`. - **Platform fields** are injected only when the form is opened for an Issue: repository URL and branch (defaulting to the repository and default branch of the Issue's Project; required), a prompt, the published version to run `snapshotId`, and additional context references `context_refs`. When the @@ -90,10 +93,11 @@ select workflow ──▶ read form descriptor ──▶ fill in (optional AI as - Frontend validation is only for user experience. On confirm Cloud resolves the target **again** and validates the submitted values against the form of **the published version being confirmed**; a workflow with no published version cannot be confirmed, because an unpublished draft cannot run. - - The form the user saw comes from the current document; if its fields no longer match the confirmed - version's, the confirm is refused with the changed fields listed - (`409 workflow_version_fields_changed`), rather than validating the user's values against a schema - they never saw. + - Cloud still compares the live draft's fields with the selected published version's fields. A + difference rejects confirm and lists the changed fields (`409 workflow_version_fields_changed`), + even when the frontend has rendered the selected snapshot's form. The frontend asks the user to + publish the current draft or choose another version; reading the snapshot's form does not remove + this check. - The submitted values are kept as the interaction's input and the executed version is frozen into the run input, so the run still knows which version it executed after the workflow is edited or published again. diff --git a/docs/collaboration/interactions.md b/docs/collaboration/interactions.md index 6b548a1f5..717c41cfb 100644 --- a/docs/collaboration/interactions.md +++ b/docs/collaboration/interactions.md @@ -54,12 +54,14 @@ Agent 名册(`space_agents`,由已安装的 Agent 插件形成,见 [插件 工作流的执行边界只有一个:显式的**确认**。 ```text -选中工作流 ──▶ 读取表单描述 ──▶ 填写(可选 AI 辅助)──▶ 确认 ──▶ IssueRun +选中工作流 ──▶ 读取表单描述 ──▶ 填写(可选 AI 辅助)──▶ 确认 ──▶ WorkflowRun + IssueRun 不执行 不执行 不执行、不建运行 唯一的执行边界 ``` - **表单描述**(`GET /tenants/:tid/collaboration/forms/:formRef?issueId=…`)是面向 Issue 的渲染描述:`formRef`、标题、说明和 字段列表。它不是工作流的 schema,也不绑定任何 schema 技术。字段分三段:平台字段、工作流 Start 节点声明的变量、图的全局变量。 + 初次打开时读取当前文档;在平台版本选择器中选择已发布版本后,前端通过 + `GET /tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form?issueId=…` 重新读取所选快照的表单。 - **平台字段**只在带着 Issue 打开表单时注入:仓库地址和分支(默认取 Issue 关联 Project 的仓库与默认分支,必填)、提示词、要执行 的已发布版本 `snapshotId`、附加的上下文引用 `context_refs`。工作流自己声明了同名变量时,以工作流的声明为准。 - **AI 辅助**(`POST .../issues/:iid/collaboration/assist`)只返回字段级的建议补丁和建议的上下文引用:从不创建运行,也不写入 @@ -67,8 +69,8 @@ Agent 名册(`space_agents`,由已安装的 Agent 插件形成,见 [插件 - **确认**(`POST .../issues/:iid/interactions/:ixid/confirm`)是唯一把工作流交互变成执行意图的转换: - 前端校验只是体验,Cloud 在确认时**重新**解析目标,并按**被确认的那个已发布版本**的表单校验提交的值;没有任何已发布版本的 工作流不能确认,因为不能执行未发布的草稿。 - - 用户看到的表单来自当前文档;如果它与被确认版本的字段已经不一致,确认被拒绝并列出变化的字段 - (`409 workflow_version_fields_changed`),而不是用用户没见过的 schema 校验他填的值。 + - Cloud 仍比较当前活稿与所选发布版本的字段;即使前端已展示所选快照的表单,两者不一致时仍拒绝确认并列出变化的字段 + (`409 workflow_version_fields_changed`)。前端提示先发布当前活稿或选择其他版本;按快照读取表单并未取消这条检查。 - 提交的值作为交互的输入保存,被执行的版本冻结进运行输入;运行之后工作流再被编辑或发布,它仍然知道自己执行的是哪个版本。 - 同一事务里创建一个 WorkflowRun 和一个 IssueRun,IssueRun 的 `workflowInvocationRef` 指向该 WorkflowRun(与租户一起的复合外键,且唯一);直接运行、manual 工作流 IssueRun 与确认共用同一个创建函数。已关联的 IssueRun 不交给 `ExecutionDispatcher`。 - 用 `run_id IS NULL` 的比较并交换认领交互,重复确认返回 `409 interaction_already_confirmed`;对非表单交互确认返回 From 713b19168519607b3ab7bea8914692c1eac2bda4 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:26:24 -0700 Subject: [PATCH 10/19] feat(core): project workflow run states onto linked issue runs Project only successful workflow status writes in the same transaction. Propagate settle CAS outcomes before session dispatch or cleanup, and truncate existing output/error summaries at UTF-8 boundaries within 20000 bytes. Add PostgreSQL coverage for Confirm lifecycle, repeat invocation after completion, deterministic failure, concurrent passes, lost CAS, summary limits, and atomic rollback. Validate with task format, go run ./cmd/checkformat, task lint, targeted DB tests, and the requested core/integration suite. --- integration/workflow_invocation_test.go | 152 +++++++++++++++++ internal/core/workflow_issue_projection.go | 32 +++- internal/core/workflow_run.go | 4 +- internal/core/workflow_run_session.go | 12 +- internal/core/workflow_scheduler.go | 29 ++-- internal/core/workflow_scheduler_db_test.go | 170 ++++++++++++++++++++ 6 files changed, 381 insertions(+), 18 deletions(-) diff --git a/integration/workflow_invocation_test.go b/integration/workflow_invocation_test.go index 96460d9c2..cb83e357f 100644 --- a/integration/workflow_invocation_test.go +++ b/integration/workflow_invocation_test.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "sort" + "strings" "sync" "testing" @@ -256,3 +257,154 @@ func TestManualWorkflowInvocationCreatesLinkedRun(t *testing.T) { t.Fatalf("manual start created %d valid linked pairs, want 1", n) } } + +// confirmInvocationScene crosses the public comment/form boundary. Unlike the fixture's direct +// workflow-run POST, Confirm leaves execution pending for the real scheduler used by these tests. +func confirmInvocationScene(t *testing.T, f *fixture, graph core.Object, key string) (wid, iid string, confirmed core.Object) { + t.Helper() + wid = f.call("POST", f.path("/workflows"), core.Object{ + "name": "Scheduled invocation", "graph": graph, + }, key+"-workflow", 200).O("resource").S("id") + publishWorkflow(t, f, wid, key+"-publish") + iid = launchIssue(t, f, runProject(t, f, key+"-project"), "Scheduled invocation", key+"-issue").S("id") + interaction := workflowComment(t, f, wid, iid, key+"-comment", "run this workflow") + confirmed = f.call("POST", confirmPath(f, iid, interaction.S("id")), core.Object{"values": core.Object{ + "repository": "ora-space/cloud", "branch": "main", + }}, key+"-confirm", 200).O("resource") + if confirmed.S("status") != "queued" || confirmed.S("workflowInvocationRef") == "" { + t.Fatalf("Confirm must enqueue a linked invocation before scheduling: %v", confirmed) + } + return wid, iid, confirmed +} + +// assertInvocationProjection reads both public run resources and the issue timeline, so a scheduler +// state change without its IssueRun projection or lifecycle activity cannot satisfy the test. +func assertInvocationProjection(t *testing.T, f *fixture, wid, iid string, confirmed core.Object, workflowStatus, issueStatus string, actions []string) (core.Object, core.Object) { + t.Helper() + workflow := f.call("GET", f.path("/workflows/"+wid+"/runs/"+confirmed.S("workflowInvocationRef")), nil, "", 200) + var issueRun core.Object + for _, candidate := range issueItems(f.call("GET", f.path("/issues/"+iid+"/runs"), nil, "", 200)) { + if candidate.S("id") == confirmed.S("id") { + issueRun = candidate + break + } + } + if workflow.S("status") != workflowStatus || issueRun.S("status") != issueStatus { + t.Fatalf("scheduler projection disagrees: workflow=%v issue=%v; want %s/%s", workflow, issueRun, workflowStatus, issueStatus) + } + if issueRun.S("workflowInvocationRef") != workflow.S("id") || issueRun.S("executorId") != wid || issueRun.S("executorType") != "workflow" { + t.Fatalf("projected IssueRun lost its execution identity: %v", issueRun) + } + if workflow.S("startedAt") == "" || issueRun.S("startedAt") != workflow.S("startedAt") || issueRun.S("completedAt") != workflow.S("finishedAt") { + t.Fatalf("projected lifecycle timestamps disagree: workflow=%v issue=%v", workflow, issueRun) + } + if issueRun.S("failureReason") != workflow.S("error") || issueRun.S("error") != workflow.S("error") { + t.Fatalf("projected failure reason disagrees: workflow=%v issue=%v", workflow, issueRun) + } + var got []string + for _, item := range issueItems(f.call("GET", f.path("/issues/"+iid+"/timeline"), nil, "", 200)) { + details := item.O("details") + if item.S("kind") != "activity" || details.S("runId") != confirmed.S("id") { + continue + } + got = append(got, item.S("action")) + if details.S("executorType") != "workflow" || details.S("executorId") != wid { + t.Fatalf("activity lost its executor identity: %v", item) + } + if item.S("action") != "run.enqueued" && details.S("workflowInvocationRef") != workflow.S("id") { + t.Fatalf("scheduler activity lost its workflow invocation identity: %v", item) + } + if item.S("action") == "run.failed" && details.S("failureReason") != workflow.S("error") { + t.Fatalf("failure activity lost the authoritative reason: %v", item) + } + } + if !reflect.DeepEqual(got, actions) { + t.Fatalf("invocation lifecycle activities = %v, want %v", got, actions) + } + return workflow, issueRun +} + +func TestWorkflowConfirmSchedulerProjectsRunningOnce(t *testing.T) { + f := setup(t) + seedPersonalModel(t, f) + wid, iid, confirmed := confirmInvocationScene(t, f, runGraph(), "inv-scheduler-running") + // The Project is still provisioning: session startup waits without Node or cluster execution. + // A second advance exercises the running-state retry, which must not emit another run.started. + for range 2 { + must(t, f.store.AdvanceWorkflowRunsOnce(t.Context())) + } + workflow, _ := assertInvocationProjection(t, f, wid, iid, confirmed, "running", "running", []string{"run.enqueued", "run.started"}) + if len(workflow.O("nodeStates")) != 0 || workflow.S("finishedAt") != "" { + t.Fatalf("waiting invocation fabricated committed nodes or terminal evidence: %v", workflow) + } +} + +func TestWorkflowConfirmSchedulerCompletesAndAllowsNextInvocation(t *testing.T) { + f := setup(t) + graph := startInputGraph(nil) + graph["nodes"] = append(graph["nodes"].([]any), core.Object{ + "id": "output-1", "type": "workflow", "position": core.Object{"x": 300, "y": 0}, + "data": core.Object{"kind": "output", "title": "Done"}, + }) + graph["edges"] = []any{core.Object{"id": "output-edge", "source": "start-1", "target": "output-1"}} + wid, iid, confirmed := confirmInvocationScene(t, f, graph, "inv-scheduler-completed") + for range 2 { + must(t, f.store.AdvanceWorkflowRunsOnce(t.Context())) + } + workflow, _ := assertInvocationProjection(t, f, wid, iid, confirmed, "succeeded", "completed", []string{"run.enqueued", "run.started", "run.completed"}) + for _, node := range []string{"start-1", "output-1"} { + if workflow.O("nodeStates").O(node).S("status") != "succeeded" { + t.Fatalf("completed invocation did not execute node %s: %v", node, workflow) + } + } + if workflow.S("finishedAt") == "" { + t.Fatal("completed invocation has no terminal timestamp") + } + + // workflowComment returns the first form, so find the fresh unclaimed form explicitly on this + // same Issue. Terminal projection must release the pending-executor dedup for this Confirm. + f.call("POST", f.path("/issues/"+iid+"/comments"), core.Object{ + "body": "run this workflow again", "targets": []any{core.Object{"type": "workflow", "id": wid}}, + }, "inv-scheduler-completed-next-comment", 200) + var nextInteraction core.Object + for _, interaction := range issueItems(f.call("GET", f.path("/issues/"+iid+"/interactions"), nil, "", 200)) { + if interaction.S("mode") == "form" && interaction.S("runId") == "" { + nextInteraction = interaction + break + } + } + if nextInteraction == nil { + t.Fatal("second workflow comment did not create an unclaimed form") + } + next := f.call("POST", confirmPath(f, iid, nextInteraction.S("id")), core.Object{"values": core.Object{ + "repository": "ora-space/cloud", "branch": "main", + }}, "inv-scheduler-completed-next-confirm", 200).O("resource") + if next.S("status") != "queued" || next.S("id") == confirmed.S("id") || next.S("workflowInvocationRef") == "" || next.S("workflowInvocationRef") == confirmed.S("workflowInvocationRef") { + t.Fatalf("second Confirm did not create a new linked queued invocation: first=%v next=%v", confirmed, next) + } +} + +func TestWorkflowConfirmSchedulerProjectsConditionFailure(t *testing.T) { + f := setup(t) + graph := startInputGraph(nil) + // input is a declared Start root and is always a string. Publishing admits the selector, but + // traversing a nested object path on that string deterministically fails during real execution. + graph["nodes"] = append(graph["nodes"].([]any), core.Object{ + "id": "condition-1", "type": "workflow", "position": core.Object{"x": 300, "y": 0}, + "data": core.Object{"kind": "condition", "title": "Invalid nested input", "cases": []any{core.Object{ + "id": "matched", "logic": "and", "conditions": []any{core.Object{ + "variableSelector": []any{"start-1", "input", "missing"}, "operator": "equals", "value": "value", + }}, + }}}, + }) + graph["edges"] = []any{core.Object{"id": "condition-edge", "source": "start-1", "target": "condition-1"}} + wid, iid, confirmed := confirmInvocationScene(t, f, graph, "inv-scheduler-failed") + for range 2 { + must(t, f.store.AdvanceWorkflowRunsOnce(t.Context())) + } + workflow, _ := assertInvocationProjection(t, f, wid, iid, confirmed, "failed", "failed", []string{"run.enqueued", "run.started", "run.message", "run.failed"}) + failed := workflow.O("nodeStates").O("condition-1") + if failed.S("status") != "failed" || !strings.Contains(workflow.S("error"), "start-1.input.missing") || failed.S("error") != workflow.S("error") || workflow.S("finishedAt") == "" { + t.Fatalf("condition failure did not preserve its node reason and terminal evidence: %v", workflow) + } +} diff --git a/internal/core/workflow_issue_projection.go b/internal/core/workflow_issue_projection.go index c42d2696a..9931b4c5d 100644 --- a/internal/core/workflow_issue_projection.go +++ b/internal/core/workflow_issue_projection.go @@ -1,9 +1,37 @@ package core -import "strings" +import ( + "strings" + "unicode/utf8" +) + +// workflowIssueOutputSummary selects existing output-node results or the run error. Callers bound +// the UTF-8 message before projection so execution settlement cannot fail on activity size alone. +func workflowIssueOutputSummary(graph, states Object, runErr string) string { + message := strings.TrimSpace(runErr) + if message == "" { + outputs := Object{} + for _, node := range readWorkflowGraph(graph).Nodes { + if state := states.O(node.ID); node.Kind == "output" && state.S("status") == "succeeded" && len(state.O("output")) > 0 { + outputs[node.ID] = state.O("output") + } + } + if len(outputs) > 0 { + message = jsonText(outputs) + } + } + if len(message) > 20000 { + end := 20000 + for !utf8.RuneStart(message[end]) { + end-- + } + message = message[:end] + } + return message +} // projectWorkflowRunToIssueRun mirrors the committed-in-this-transaction workflow state onto its -// optional IssueRun. The scheduler must call it after its state compare-and-set, in the same +// optional IssueRun. Execution callers invoke it only after a successful state write, in the same // Store.transact boundary, so a projection or activity failure rolls back the workflow transition. // Direct workflow runs have no IssueRun and are a no-op. The caller supplies the execution's output // summary because per-node output has no canonical summary field in workflow_runs. awaiting_input diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index 001dca868..37afd0c44 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -126,7 +126,9 @@ func createWorkflowRun(t *transaction, r *PublicRequest, uid string) Object { id := created.S("id") 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) + if t.execRows("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) > 0 { + projectWorkflowRunToIssueRun(t, r.TenantID, id, workflowIssueOutputSummary(s.O("graph"), nodeStates, "")) + } } 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 diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index b35aae95d..751f8ea19 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -124,8 +124,7 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no return states, false default: // The node succeeded; downstream waves continue in this same pass. - settleWorkflowRunStates(t, runID, version, "running", "", states) - return states, true + return states, settleWorkflowRunStates(t, runID, version, "running", "", states) } } @@ -135,8 +134,9 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no // 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) + if settleWorkflowRunStates(t, runID, version, status, runErr, states) { + finishWorkflowRunCleanup(t, runID) + } } // finishWorkflowRunCleanup deletes the run's unregistered execution work and re-declares the @@ -265,7 +265,9 @@ func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView return } states[node.ID] = workflowNodeState{Status: "running", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339()} - settleWorkflowRunStates(t, runID, version, "running", "", states) + if !settleWorkflowRunStates(t, runID, version, "running", "", states) { + return + } enqueueExecutionWork(t, runID, "agent_session", spec, target, node.ID, time.Time{}) } diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index b41a035f8..6a91ea4a1 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -479,6 +479,7 @@ func advanceWorkflowRun(t *transaction, runID string) { WHERE id=$1 AND status='pending' AND deleted_at IS NULL`, runID) == 0 { return } + projectWorkflowRunToIssueRun(t, run.S("tenantId"), runID, "") run = t.one(` SELECT r.*, s.graph FROM workflow_runs r JOIN workflow_snapshots s ON s.id = r.snapshot_id @@ -591,25 +592,33 @@ func workflowInactiveStates(graph workflowGraphView, states map[string]workflowN // 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) { +func settleWorkflowRunStates(t *transaction, runID string, version int64, status, runErr string, states map[string]workflowNodeState) bool { // 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" + var applied int64 + encoded := Object{} if states == nil { - t.exec(`UPDATE workflow_runs SET status=$2, error=$3, + applied = t.execRows(`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 + } else { + for id, state := range states { + encoded[id] = state.object() + } + applied = t.execRows(`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) } - encoded := Object{} - for id, state := range states { - encoded[id] = state.object() + if applied == 0 { + return false } - 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) + run := t.one(`SELECT r.tenant_id,s.graph FROM workflow_runs r + JOIN workflow_snapshots s ON s.id=r.snapshot_id WHERE r.id=$1`, runID) + projectWorkflowRunToIssueRun(t, run.S("tenantId"), runID, workflowIssueOutputSummary(run.O("graph"), encoded, runErr)) + return true } // workflowRunVersion re-reads the row's version after a mid-pass settle bumped it: one pass commits diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 8f1c85e12..b6d65ecb5 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -9,8 +9,11 @@ package core import ( "context" "encoding/json" + "reflect" "strings" + "sync" "testing" + "unicode/utf8" "github.com/google/uuid" ) @@ -93,6 +96,173 @@ func TestWorkflowAdvanceSucceedsLinearRun(t *testing.T) { if start := states["s"].(map[string]any)["output"].(map[string]any); start["input"] == nil { t.Fatal("start node output does not carry the kickoff prompt root") } + var activities, linked int + if err := store.Pool.QueryRow(`SELECT (SELECT count(*) FROM issue_activities), + (SELECT count(*) FROM issue_runs WHERE workflow_invocation_ref=$1)`, runID).Scan(&activities, &linked); err != nil || activities != 0 || linked != 0 { + t.Fatalf("direct run created collaboration records: activities=%d linked=%d err=%v", activities, linked, err) + } +} + +func TestWorkflowAdvanceProjectsConcurrentPassesOnce(t *testing.T) { + store := dispatcherDB(t) + s := seedWorkflowProjection(t, store) + graph := wfGraph([]Object{wfNode("s", "start", ""), wfNode("o", "output", "")}, [][3]string{{"s", "o", ""}}) + if _, err := store.Pool.Exec(`UPDATE workflow_snapshots SET graph=$2::jsonb + WHERE id=(SELECT snapshot_id FROM workflow_runs WHERE id=$1)`, s.workflowRun, jsonText(graph)); err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make([]error, 2) + for i := range errs { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs[i] = store.AdvanceWorkflowRunsOnce(context.Background()) + }() + } + close(start) + wg.Wait() + for _, err := range errs { + if err != nil { + t.Fatal(err) + } + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + var status string + var version int + if err := store.Pool.QueryRow("SELECT status,version FROM issue_runs WHERE id=$1", s.issueRun).Scan(&status, &version); err != nil || status != "completed" || version != 3 { + t.Fatalf("linked run: status=%s version=%d err=%v", status, version, err) + } + activities := workflowProjectionActivities(t, store, s) + actions := make([]string, len(activities)) + for i, activity := range activities { + actions[i] = activity.S("action") + } + if !reflect.DeepEqual(actions, []string{"run.enqueued", "run.started", "run.completed"}) { + t.Fatalf("concurrent/repeated passes emitted %v", actions) + } +} + +func TestWorkflowSettleLostCASDoesNotProject(t *testing.T) { + store := dispatcherDB(t) + for _, withStates := range []bool{false, true} { + name := "without-states" + if withStates { + name = "with-states" + } + t.Run(name, func(t *testing.T) { + s := seedWorkflowProjection(t, store) + // A concurrent writer won, but its IssueRun is deliberately still queued: calling + // projection after our lost CAS would now emit run.started from that winner's row. + if _, err := store.Pool.Exec("UPDATE workflow_runs SET status='running',started_at=now(),version=version+1 WHERE id=$1", s.workflowRun); err != nil { + t.Fatal(err) + } + var states map[string]workflowNodeState + if withStates { + states = map[string]workflowNodeState{"s": {Status: "succeeded"}} + } + _, err := store.transact(context.Background(), func(tx *transaction) Object { + if settleWorkflowRunStates(tx, s.workflowRun, 1, "failed", "stale failure", states) { + t.Fatal("stale version unexpectedly applied") + } + return Object{} + }) + if err != nil { + t.Fatal(err) + } + status, runErr, committed, version := readWorkflowRun(t, store, s.workflowRun) + if status != "running" || runErr != "" || len(committed) != 0 || version != 2 { + t.Fatalf("lost CAS changed winner: %s %q %v version=%d", status, runErr, committed, version) + } + var issueStatus string + if err := store.Pool.QueryRow("SELECT status FROM issue_runs WHERE id=$1", s.issueRun).Scan(&issueStatus); err != nil || issueStatus != "queued" { + t.Fatalf("lost CAS projected: status=%s err=%v", issueStatus, err) + } + if activities := workflowProjectionActivities(t, store, s); len(activities) != 1 { + t.Fatalf("lost CAS emitted activities: %v", activities) + } + }) + } +} + +func TestWorkflowSettleTruncatesIssueSummary(t *testing.T) { + store := dispatcherDB(t) + for _, source := range []string{"error", "output"} { + t.Run(source, func(t *testing.T) { + s := seedWorkflowProjection(t, store) + text := strings.Repeat("界", 7000) + status, runErr := "failed", text + var states map[string]workflowNodeState + if source == "output" { + graph := wfGraph([]Object{wfNode("s", "start", ""), wfNode("o", "output", "")}, [][3]string{{"s", "o", ""}}) + if _, err := store.Pool.Exec(`UPDATE workflow_snapshots SET graph=$2::jsonb + WHERE id=(SELECT snapshot_id FROM workflow_runs WHERE id=$1)`, s.workflowRun, jsonText(graph)); err != nil { + t.Fatal(err) + } + status, runErr = "succeeded", "" + states = map[string]workflowNodeState{"o": {Status: "succeeded", Output: Object{"text": text}}} + } + _, err := store.transact(context.Background(), func(tx *transaction) Object { + if !settleWorkflowRunStates(tx, s.workflowRun, 1, status, runErr, states) { + t.Fatal("settle did not apply") + } + return Object{} + }) + if err != nil { + t.Fatalf("oversized summary rolled back settlement: %v", err) + } + activities := workflowProjectionActivities(t, store, s) + if len(activities) != 3 || activities[1].S("action") != "run.message" { + t.Fatalf("terminal summary activities: %v", activities) + } + message := activities[1].O("details").S("message") + if len(message) < 19997 || len(message) > 20000 || !utf8.ValidString(message) { + t.Fatalf("summary bytes=%d valid UTF-8=%v", len(message), utf8.ValidString(message)) + } + actual, actualErr, _, version := readWorkflowRun(t, store, s.workflowRun) + if actual != status || actualErr != runErr || version != 2 { + t.Fatalf("settle did not preserve execution result: status=%s error bytes=%d version=%d", actual, len(actualErr), version) + } + var issueStatus string + wantIssueStatus := "failed" + if status == "succeeded" { + wantIssueStatus = "completed" + } + if err := store.Pool.QueryRow("SELECT status FROM issue_runs WHERE id=$1", s.issueRun).Scan(&issueStatus); err != nil || issueStatus != wantIssueStatus { + t.Fatalf("terminal IssueRun: status=%s err=%v", issueStatus, err) + } + }) + } +} + +func TestWorkflowAdvanceProjectionFailureRollsBackClaim(t *testing.T) { + store := dispatcherDB(t) + s := seedWorkflowProjection(t, store) + if _, err := store.Pool.Exec("ALTER TABLE issue_activities ADD CONSTRAINT reject_scheduler_start CHECK(action <> 'run.started')"); err != nil { + t.Fatal(err) + } + _, err := store.transact(context.Background(), func(tx *transaction) Object { + advanceWorkflowRun(tx, s.workflowRun) + return Object{} + }) + if err == nil { + t.Fatal("failed projection must abort the scheduler transaction") + } + status, _, _, version := readWorkflowRun(t, store, s.workflowRun) + if status != "pending" || version != 1 { + t.Fatalf("projection failure left claimed workflow: %s version=%d", status, version) + } + var issueStatus string + if err := store.Pool.QueryRow("SELECT status FROM issue_runs WHERE id=$1", s.issueRun).Scan(&issueStatus); err != nil || issueStatus != "queued" { + t.Fatalf("projection failure changed IssueRun: status=%s err=%v", issueStatus, err) + } + if activities := workflowProjectionActivities(t, store, s); len(activities) != 1 { + t.Fatalf("rolled-back claim emitted activities: %v", activities) + } } // A ready agent node on a run with no project settles the run failed with an explicit reason: the From fb34d8248de73b569e9d932e9dbc8bb0d19b42e3 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:26:25 -0700 Subject: [PATCH 11/19] docs(workflows): describe linked issue run status projection Keep Chinese and English workflow, collaboration, and core documentation aligned with same-transaction projection, affected-row guards, bounded summaries, and direct-run/awaiting-input no-ops. --- docs/collaboration/interactions.en.md | 6 ++++++ docs/collaboration/interactions.md | 4 ++++ docs/workflows/workflows.en.md | 7 ++++++- docs/workflows/workflows.md | 5 ++++- internal/core/README.en.md | 8 +++++--- internal/core/README.md | 5 +++-- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/collaboration/interactions.en.md b/docs/collaboration/interactions.en.md index 969a37087..066fe12c4 100644 --- a/docs/collaboration/interactions.en.md +++ b/docs/collaboration/interactions.en.md @@ -126,6 +126,12 @@ Agent runs do not go through `ExecutionDispatcher`; Cloud's own scheduling carri [Agent IssueRuns](../agent-runs/issue-runs.en.md)). Runs started directly from a workflow are a different aggregate, WorkflowRun; see [Workflows](../workflows/workflows.en.md). +For a linked workflow IssueRun, actual WorkflowRun status updates advance it in the same transaction: +updates affecting no rows do not project, `awaiting_input` leaves it unchanged, and contract conflicts +roll back the transaction. Output summaries are `system` `run.message` activities bounded to 20000 +bytes of valid UTF-8, never workflow-authored comments. Direct workflow runs without a linked IssueRun +create none of these collaboration records. + **Development fixtures.** `collaboration.development_fixtures: true` (environment variable `CLOUD_COLLABORATION_DEVELOPMENT_FIXTURES`) installs the in-process doubles from `internal/collab` on the Store: Agent and team targets with fixed IDs, a deterministic context builder (at most the 10 latest diff --git a/docs/collaboration/interactions.md b/docs/collaboration/interactions.md index 717c41cfb..db7a668cf 100644 --- a/docs/collaboration/interactions.md +++ b/docs/collaboration/interactions.md @@ -91,6 +91,10 @@ Issue 协作通过一组端口向外部能力请求"做一件事",而不定义 Agent 运行不走 `ExecutionDispatcher`,由 Cloud 自己的调度推进(见 [Agent IssueRun](../agent-runs/issue-runs.md))。直接从工作流 发起的运行是另一个聚合 WorkflowRun,见 [工作流](../workflows/workflows.md)。 +关联工作流的 IssueRun 由 WorkflowRun 的实际状态更新在同一事务内推进:未更新到行时不投影,`awaiting_input` 不改变 IssueRun, +契约冲突使事务回滚。输出摘要写成 `system` 的 `run.message` 活动,最多 20000 字节且保持合法 UTF-8,不创建工作流作者评论。 +没有关联 IssueRun 的直接工作流运行不产生这些协作记录。 + **开发夹具。** `collaboration.development_fixtures: true`(环境变量 `CLOUD_COLLABORATION_DEVELOPMENT_FIXTURES`)在 Store 上装入 `internal/collab` 的进程内替身:固定 ID 的 Agent 和团队目标、确定性的上下文构建器(最近至多 10 条评论,不用 AI)、给出固定输出的 派发器和 AI 辅助。工作流目标仍然来自数据库。这个开关只用于开发和演示,生产默认关闭;它与认证完全独立:开启 GitHub 登录不会打开 diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index d7878d128..44ca7bfe0 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -114,7 +114,12 @@ seconds): 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. -A workflow IssueRun is linked through `workflow_invocation_ref` to the WorkflowRun created in the same transaction, and the scheduling loop above advances that run. Projecting WorkflowRun status back onto the IssueRun is not yet called from the scheduler, so until then the IssueRun stays `queued` (see +A workflow IssueRun is linked through `workflow_invocation_ref` to the WorkflowRun created in the same transaction, +and the scheduling loop above advances that run. After a WorkflowRun status update affects a row, its state, +timestamps and errors are projected onto the linked IssueRun in that same transaction. An update affecting no rows +does not project; contract conflicts roll back the transaction. `awaiting_input` leaves the IssueRun unchanged, +and direct runs without a linked IssueRun create no collaboration records. Output summaries are `system` +`run.message` activities, bounded to 20000 bytes of valid UTF-8, never workflow-authored comments (see [Issue collaboration](../collaboration/interactions.en.md#ports-issues-own-only-the-consuming-side-contract)). ## Plugin asset import diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index bbc975671..0cdf5fc29 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -81,7 +81,10 @@ WorkflowRun 由 Cloud server 里的调度循环推进(`AdvanceWorkflowRunsOnce 这次运行的 Workspace。 开发夹具(`collaboration.development_fixtures`)会装入一个确定性的运行模拟器,在创建运行时就填好执行轨迹,并在输出里说明没有真实执行;生产环境 -不装它。工作流 IssueRun 通过 `workflow_invocation_ref` 关联同一事务里创建的 WorkflowRun,执行由上面的调度循环推进;把 WorkflowRun 状态投影回 IssueRun 的调用点还没有接入调度器,在此之前 IssueRun 保持 `queued`(见 [Issue 协作](../collaboration/interactions.md#端口issue-只拥有消费侧的契约))。 +不装它。工作流 IssueRun 通过 `workflow_invocation_ref` 关联同一事务里创建的 WorkflowRun,执行由上面的调度循环推进。 +WorkflowRun 的实际状态更新成功后,在同一事务里将状态、时间和错误投影回关联的 IssueRun;未更新到行时不投影,契约冲突使事务回滚。 +`awaiting_input` 不改变 IssueRun,未关联 IssueRun 的直接运行也不产生协作记录。输出摘要作为 `system` 的 `run.message` 活动写入, +最多 20000 字节且保持合法 UTF-8,不创建工作流作者评论(见 [Issue 协作](../collaboration/interactions.md#端口issue-只拥有消费侧的契约))。 ## 插件资产导入 diff --git a/internal/core/README.en.md b/internal/core/README.en.md index bda913b2a..b393b49cb 100644 --- a/internal/core/README.en.md +++ b/internal/core/README.en.md @@ -76,9 +76,11 @@ whose `input.workflowSnapshotId` matches the linked execution. Linked IssueRuns Dispatcher, including development doubles. `projectWorkflowRunToIssueRun` projects authoritative state, timestamps and errors inside the caller's -transaction. Output summaries are `system` `run.message` activities, never workflow-authored comments. -PostgreSQL tests call this function directly. Scheduler calls are not yet wired, so the linked IssueRun -still stays `queued`. Freezing execution identity and slot bindings does not establish real Node +transaction. Every actual status-update path calls it only after affecting a row; projection contract +conflicts roll back the whole transaction. `awaiting_input` and direct runs without a linked IssueRun +leave collaboration records unchanged. Output summaries are `system` `run.message` activities bounded +to 20000 bytes of valid UTF-8, never workflow-authored comments. +Freezing execution identity and slot bindings does not establish real Node acceptance. ## Session Dashboard reads diff --git a/internal/core/README.md b/internal/core/README.md index 4b18483c6..e837637d1 100644 --- a/internal/core/README.md +++ b/internal/core/README.md @@ -72,8 +72,9 @@ Start 与全局变量;平台字段保留在 IssueRun,`input.workflowSnapshot 有引用的 IssueRun 不进入 legacy Dispatcher;开发 mock 的完成状态不能替代 WorkflowRun 状态。 `projectWorkflowRunToIssueRun` 在调用方事务内从 WorkflowRun 投影状态、时间及错误, -输出摘要写为 `system` 的 `run.message` 活动,不创建 workflow 作者评论。 -该函数已由 PostgreSQL 测试直接调用;调用点尚未接入调度器,因此关联的 IssueRun 仍保持 `queued`。 +各实际状态更新路径仅在更新到行后调用它;投影契约冲突使整个事务回滚。 +`awaiting_input` 和没有关联 IssueRun 的直接运行不改变协作记录。 +输出摘要写为 `system` 的 `run.message` 活动,最多 20000 字节且保持合法 UTF-8,不创建 workflow 作者评论。 创建身份和槽位绑定的接入不代表实际 Node 联合验收已经完成。 ## 会话 Dashboard 读取 From 6699540325723d410aa8e98489f41bed18acf4d2 Mon Sep 17 00:00:00 2001 From: creegon2 <315603738+creegon2@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:34:34 -0700 Subject: [PATCH 12/19] docs(migrations): count 0052 and name its tenant-unique prerequisite The migration directory now holds 54 SQL files (main's 53 plus 0052), so both READMEs say so. The 0052 entry also names `workflow_runs_id_tenant_uniq`, the unique (id, tenant_id) constraint that the composite invocation foreign key requires. Co-Authored-By: Claude Opus 5.5 --- internal/core/migrations/README.en.md | 4 ++-- internal/core/migrations/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index 6d31d32a1..3b069125d 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -6,7 +6,7 @@ SQL is embedded in the binary through `embed.FS` in `core/store.go`. `Store.Migr ## File index -There are currently 53 SQL files. +There are currently 54 SQL files. | SQL file | Schema change | |---|---| @@ -78,7 +78,7 @@ There are currently 53 SQL files. - 0046–0048 backfill no old messages, Threads, history positions or pause state; `visible_text` is stored separately from the raw `record`, and old tasks are not paused automatically. Fresh, upgrade and repeated application are covered by `TestMigration0046TaskConversationFreshAndUpgrade` and the integration migration tests. - 0049 lets `thread_commands` accept a WorkflowRun owner and requires a graph node on workflow commands; each node holds at most one `end_session`, so the scheduler can replay it safely. Every old row carries `run_id` and is not backfilled. Semantics are in [workflows](../../../docs/workflows/workflows.en.md); coverage is in the `TestWorkflowSessionEndsAfterSuccessfulTurn` DB tests. - 0051 adds the cancellation-request instant to `workflow_runs` and backfills no old row; the request records intent only and is settled by the scheduler on later passes. Semantics are in [workflows](../../../docs/workflows/workflows.en.md#scheduling); coverage is in the `TestWorkflowRunCancelBeforeStartNeverStarts` DB tests. -- 0052 first settles every unlinked non-terminal historical workflow IssueRun (including running and deferred) as failed with `workflow_execution_unavailable_before_invocation_linkage`, preserving its input and outcome without inventing an execution. It then adds the `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` composite foreign key, a unique reference, and a CHECK that non-terminal workflow rows carry a reference while other executors carry none, all unconditionally validated. Historical terminal workflow rows may keep a NULL reference. Fresh, seeded upgrade, repeated application and constraint validation coverage is in `integration/workflow_invocation_migration_test.go`. +- 0052 first settles every unlinked non-terminal historical workflow IssueRun (including running and deferred) as failed with `workflow_execution_unavailable_before_invocation_linkage`, preserving its input and outcome without inventing an execution. It then adds `workflow_runs_id_tenant_uniq` (unique `(id, tenant_id)` on `workflow_runs`, which the composite foreign key requires), the `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` composite foreign key, a unique reference, and a CHECK that non-terminal workflow rows carry a reference while other executors carry none, all unconditionally validated. Historical terminal workflow rows may keep a NULL reference. Fresh, seeded upgrade, repeated application and constraint validation coverage is in `integration/workflow_invocation_migration_test.go`. - New migrations must verify fresh databases, upgrades from the preceding schema, repeated application and key constraints. See [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) and domain upgrade tests in integration. See [Persistence](../../../docs/core/persistence.en.md) for ownership/transactions, and [Workflows](../../../docs/workflows/workflows.en.md) / [External Git line](../../../docs/repositories/external-git.en.md) for runtime semantics. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 7256a402c..a2d7993cc 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -6,7 +6,7 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 ## 文件索引 -当前共 53 个 SQL 文件。 +当前共 54 个 SQL 文件。 | SQL 文件 | schema 变化 | |---|---| @@ -78,7 +78,7 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 - 0046–0048 不回填旧消息、旧 Thread、历史位置或暂停状态;`visible_text` 与原始 `record` 分开保存,旧任务不自动暂停。空库、升级与重复应用覆盖见 `TestMigration0046TaskConversationFreshAndUpgrade` 及 integration 的迁移测试。 - 0049 让 `thread_commands` 接受 WorkflowRun 归属,并要求工作流命令带图节点;每个节点至多一条 `end_session`,调度器可安全重放。旧行全部带 `run_id`,不回填。语义见 [工作流](../../../docs/workflows/workflows.md),覆盖见 `TestWorkflowSessionEndsAfterSuccessfulTurn` 等 DB 测试。 - 0051 给 `workflow_runs` 增加取消请求时刻,不回填旧行;请求只记录意图,由调度器在后续轮次结算。语义见[工作流](../../../docs/workflows/workflows.md#调度),覆盖见 `TestWorkflowRunCancelBeforeStartNeverStarts` 等 DB 测试。 -- 0052 先把没有引用的历史非终态 workflow IssueRun(含 running、deferred)结算为 failed(`workflow_execution_unavailable_before_invocation_linkage`),保留其输入和结果、不虚构执行;再加 `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` 复合外键、唯一引用,以及「非终态 workflow 行必须有引用、其他执行器不得有引用」的 CHECK,无条件 VALIDATE。历史终态 workflow 行可保留 NULL 引用。空库、带历史数据升级、重复应用与约束验证状态见 `integration/workflow_invocation_migration_test.go`。 +- 0052 先把没有引用的历史非终态 workflow IssueRun(含 running、deferred)结算为 failed(`workflow_execution_unavailable_before_invocation_linkage`),保留其输入和结果、不虚构执行;再给 `workflow_runs` 加复合外键所需的 `workflow_runs_id_tenant_uniq`(`(id, tenant_id)` 唯一),并加 `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` 复合外键、唯一引用,以及「非终态 workflow 行必须有引用、其他执行器不得有引用」的 CHECK,无条件 VALIDATE。历史终态 workflow 行可保留 NULL 引用。空库、带历史数据升级、重复应用与约束验证状态见 `integration/workflow_invocation_migration_test.go`。 - 新增迁移须验证空库、前一 schema 升级、重复应用与关键约束。覆盖见 [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) 和 integration 的领域升级测试。 数据归属与事务规则见 [持久化](../../../docs/core/persistence.md),运行语义见 [工作流](../../../docs/workflows/workflows.md) 和 [外部 Git 线](../../../docs/repositories/external-git.md)。 From 7e1feb94b8ff1a1ac2ce84a9d39df990efdd00ef Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Tue, 20 Oct 2026 22:07:31 +0800 Subject: [PATCH 13/19] fix(core): reference the tenant identity 0044 already enforces 0052 added its own UNIQUE (id, tenant_id) on workflow_runs, which duplicates workflow_run_tenant_identity from 0044. The composite invocation foreign key resolves against that existing constraint, so 0052 only adds the reference constraints now; the migration README says where the target comes from instead of describing a second unique key. Co-Authored-By: Claude Code --- integration/workflow_invocation_migration_test.go | 1 - .../core/migrations/0052_workflow_invocation_reference.sql | 7 +++---- internal/core/migrations/README.en.md | 2 +- internal/core/migrations/README.md | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/integration/workflow_invocation_migration_test.go b/integration/workflow_invocation_migration_test.go index f5e93a49b..2ba076c65 100644 --- a/integration/workflow_invocation_migration_test.go +++ b/integration/workflow_invocation_migration_test.go @@ -40,7 +40,6 @@ func assertInvocationConstraintValidation(t *testing.T, pool *sql.DB) { {"issue_runs", "issue_runs_workflow_invocation_shape", true}, {"issue_runs", "issue_runs_workflow_invocation_fk", true}, {"issue_runs", "issue_runs_workflow_invocation_uniq", true}, - {"workflow_runs", "workflow_runs_id_tenant_uniq", true}, } { var validated bool must(t, pool.QueryRow(`SELECT convalidated FROM pg_constraint WHERE conrelid=$1::regclass AND conname=$2`, constraint.table, constraint.name).Scan(&validated)) diff --git a/internal/core/migrations/0052_workflow_invocation_reference.sql b/internal/core/migrations/0052_workflow_invocation_reference.sql index 7229efe3f..9130fae62 100644 --- a/internal/core/migrations/0052_workflow_invocation_reference.sql +++ b/internal/core/migrations/0052_workflow_invocation_reference.sql @@ -7,12 +7,11 @@ SET status = 'failed', WHERE executor_type = 'workflow' AND workflow_invocation_ref IS NULL AND status NOT IN ('completed', 'failed', 'cancelled'); -ALTER TABLE workflow_runs - ADD CONSTRAINT workflow_runs_id_tenant_uniq UNIQUE (id, tenant_id); - ALTER TABLE issue_runs ADD CONSTRAINT issue_runs_workflow_invocation_fk - -- workflow_invocation_ref had no writer before this migration, so dangling references are not expected and the FK fails closed. + -- The composite reference targets workflow_run_tenant_identity, the tenant-scoped UNIQUE(id, + -- tenant_id) that 0044 already added; no new constraint is needed. workflow_invocation_ref had + -- no writer before this migration, so dangling references are not expected and the FK fails closed. FOREIGN KEY (workflow_invocation_ref, tenant_id) REFERENCES workflow_runs(id, tenant_id), ADD CONSTRAINT issue_runs_workflow_invocation_uniq UNIQUE (workflow_invocation_ref), ADD CONSTRAINT issue_runs_workflow_invocation_shape diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index 3b069125d..2f07f706f 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -78,7 +78,7 @@ There are currently 54 SQL files. - 0046–0048 backfill no old messages, Threads, history positions or pause state; `visible_text` is stored separately from the raw `record`, and old tasks are not paused automatically. Fresh, upgrade and repeated application are covered by `TestMigration0046TaskConversationFreshAndUpgrade` and the integration migration tests. - 0049 lets `thread_commands` accept a WorkflowRun owner and requires a graph node on workflow commands; each node holds at most one `end_session`, so the scheduler can replay it safely. Every old row carries `run_id` and is not backfilled. Semantics are in [workflows](../../../docs/workflows/workflows.en.md); coverage is in the `TestWorkflowSessionEndsAfterSuccessfulTurn` DB tests. - 0051 adds the cancellation-request instant to `workflow_runs` and backfills no old row; the request records intent only and is settled by the scheduler on later passes. Semantics are in [workflows](../../../docs/workflows/workflows.en.md#scheduling); coverage is in the `TestWorkflowRunCancelBeforeStartNeverStarts` DB tests. -- 0052 first settles every unlinked non-terminal historical workflow IssueRun (including running and deferred) as failed with `workflow_execution_unavailable_before_invocation_linkage`, preserving its input and outcome without inventing an execution. It then adds `workflow_runs_id_tenant_uniq` (unique `(id, tenant_id)` on `workflow_runs`, which the composite foreign key requires), the `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` composite foreign key, a unique reference, and a CHECK that non-terminal workflow rows carry a reference while other executors carry none, all unconditionally validated. Historical terminal workflow rows may keep a NULL reference. Fresh, seeded upgrade, repeated application and constraint validation coverage is in `integration/workflow_invocation_migration_test.go`. +- 0052 first settles every unlinked non-terminal historical workflow IssueRun (including running and deferred) as failed with `workflow_execution_unavailable_before_invocation_linkage`, preserving its input and outcome without inventing an execution. It then adds the `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` composite foreign key (targeting the `workflow_run_tenant_identity` unique constraint 0044 already added, without duplicating it), a unique reference, and a CHECK that non-terminal workflow rows carry a reference while other executors carry none, all unconditionally validated. Historical terminal workflow rows may keep a NULL reference. Fresh, seeded upgrade, repeated application and constraint validation coverage is in `integration/workflow_invocation_migration_test.go`. - New migrations must verify fresh databases, upgrades from the preceding schema, repeated application and key constraints. See [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) and domain upgrade tests in integration. See [Persistence](../../../docs/core/persistence.en.md) for ownership/transactions, and [Workflows](../../../docs/workflows/workflows.en.md) / [External Git line](../../../docs/repositories/external-git.en.md) for runtime semantics. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index a2d7993cc..29f46776e 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -78,7 +78,7 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 - 0046–0048 不回填旧消息、旧 Thread、历史位置或暂停状态;`visible_text` 与原始 `record` 分开保存,旧任务不自动暂停。空库、升级与重复应用覆盖见 `TestMigration0046TaskConversationFreshAndUpgrade` 及 integration 的迁移测试。 - 0049 让 `thread_commands` 接受 WorkflowRun 归属,并要求工作流命令带图节点;每个节点至多一条 `end_session`,调度器可安全重放。旧行全部带 `run_id`,不回填。语义见 [工作流](../../../docs/workflows/workflows.md),覆盖见 `TestWorkflowSessionEndsAfterSuccessfulTurn` 等 DB 测试。 - 0051 给 `workflow_runs` 增加取消请求时刻,不回填旧行;请求只记录意图,由调度器在后续轮次结算。语义见[工作流](../../../docs/workflows/workflows.md#调度),覆盖见 `TestWorkflowRunCancelBeforeStartNeverStarts` 等 DB 测试。 -- 0052 先把没有引用的历史非终态 workflow IssueRun(含 running、deferred)结算为 failed(`workflow_execution_unavailable_before_invocation_linkage`),保留其输入和结果、不虚构执行;再给 `workflow_runs` 加复合外键所需的 `workflow_runs_id_tenant_uniq`(`(id, tenant_id)` 唯一),并加 `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` 复合外键、唯一引用,以及「非终态 workflow 行必须有引用、其他执行器不得有引用」的 CHECK,无条件 VALIDATE。历史终态 workflow 行可保留 NULL 引用。空库、带历史数据升级、重复应用与约束验证状态见 `integration/workflow_invocation_migration_test.go`。 +- 0052 先把没有引用的历史非终态 workflow IssueRun(含 running、deferred)结算为 failed(`workflow_execution_unavailable_before_invocation_linkage`),保留其输入和结果、不虚构执行;再加 `(workflow_invocation_ref, tenant_id)` → `workflow_runs(id, tenant_id)` 复合外键(引用 0044 已建的 `workflow_run_tenant_identity` 唯一约束,不重复建)、唯一引用,以及「非终态 workflow 行必须有引用、其他执行器不得有引用」的 CHECK,无条件 VALIDATE。历史终态 workflow 行可保留 NULL 引用。空库、带历史数据升级、重复应用与约束验证状态见 `integration/workflow_invocation_migration_test.go`。 - 新增迁移须验证空库、前一 schema 升级、重复应用与关键约束。覆盖见 [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) 和 integration 的领域升级测试。 数据归属与事务规则见 [持久化](../../../docs/core/persistence.md),运行语义见 [工作流](../../../docs/workflows/workflows.md) 和 [外部 Git 线](../../../docs/repositories/external-git.md)。 From bc471490d730d15d5e7ca2030fd99a43d44e9bd7 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Tue, 20 Oct 2026 22:08:13 +0800 Subject: [PATCH 14/19] fix(core): validate a workflow confirm against the chosen version alone Confirm used to compare the selected snapshot's fields with the live draft and refuse with 409 workflow_version_fields_changed. That made selecting an older version useless exactly when the draft had moved on, which is the ordinary state between an edit and its publish, and the rule had no specs basis. Confirm now validates the submission against the chosen snapshot's own descriptor, the same rule the manual run entry follows. The divergence helpers lose their only caller and are removed; the frontend drops the 409 hint, and the collaboration docs and the composer READMEs describe the version's own schema as the authority. Co-Authored-By: Claude Code --- docs/collaboration/interactions.en.md | 9 +- docs/collaboration/interactions.md | 4 +- .../features/issues/components/README.en.md | 2 +- .../src/features/issues/components/README.md | 2 +- .../workflow-interaction-composer.tsx | 10 +- .../components/workflow-interaction.test.tsx | 21 +-- integration/workflow_interaction_test.go | 132 ++++++------------ internal/core/form_descriptor.go | 9 +- internal/core/issue_interactions.go | 32 ++--- internal/core/workflow_collaboration.go | 83 +---------- internal/core/workflow_collaboration_test.go | 73 ---------- 11 files changed, 78 insertions(+), 299 deletions(-) diff --git a/docs/collaboration/interactions.en.md b/docs/collaboration/interactions.en.md index 066fe12c4..c695e3b8f 100644 --- a/docs/collaboration/interactions.en.md +++ b/docs/collaboration/interactions.en.md @@ -93,11 +93,10 @@ select workflow ──▶ read form descriptor ──▶ fill in (optional AI as - Frontend validation is only for user experience. On confirm Cloud resolves the target **again** and validates the submitted values against the form of **the published version being confirmed**; a workflow with no published version cannot be confirmed, because an unpublished draft cannot run. - - Cloud still compares the live draft's fields with the selected published version's fields. A - difference rejects confirm and lists the changed fields (`409 workflow_version_fields_changed`), - even when the frontend has rendered the selected snapshot's form. The frontend asks the user to - publish the current draft or choose another version; reading the snapshot's form does not remove - this check. + - Cloud validates against the selected published version's own fields alone, no longer comparing + them with the live draft. Choosing an older version runs that snapshot: a submission that matches + its form is accepted even when its fields differ from the current draft, and a missing required + field or a field that version never declared is refused by that version's schema. - The submitted values are kept as the interaction's input and the executed version is frozen into the run input, so the run still knows which version it executed after the workflow is edited or published again. diff --git a/docs/collaboration/interactions.md b/docs/collaboration/interactions.md index db7a668cf..35896f5e8 100644 --- a/docs/collaboration/interactions.md +++ b/docs/collaboration/interactions.md @@ -69,8 +69,8 @@ Agent 名册(`space_agents`,由已安装的 Agent 插件形成,见 [插件 - **确认**(`POST .../issues/:iid/interactions/:ixid/confirm`)是唯一把工作流交互变成执行意图的转换: - 前端校验只是体验,Cloud 在确认时**重新**解析目标,并按**被确认的那个已发布版本**的表单校验提交的值;没有任何已发布版本的 工作流不能确认,因为不能执行未发布的草稿。 - - Cloud 仍比较当前活稿与所选发布版本的字段;即使前端已展示所选快照的表单,两者不一致时仍拒绝确认并列出变化的字段 - (`409 workflow_version_fields_changed`)。前端提示先发布当前活稿或选择其他版本;按快照读取表单并未取消这条检查。 + - Cloud 只按所选发布版本自身的字段校验,不再与当前活稿比较。选择旧版本就是按那份快照执行:即使它的字段与活稿不同, + 提交符合该版本表单即可通过;缺少该版本必填字段或提交它未声明的字段时,按该版本的 schema 拒绝。 - 提交的值作为交互的输入保存,被执行的版本冻结进运行输入;运行之后工作流再被编辑或发布,它仍然知道自己执行的是哪个版本。 - 同一事务里创建一个 WorkflowRun 和一个 IssueRun,IssueRun 的 `workflowInvocationRef` 指向该 WorkflowRun(与租户一起的复合外键,且唯一);直接运行、manual 工作流 IssueRun 与确认共用同一个创建函数。已关联的 IssueRun 不交给 `ExecutionDispatcher`。 - 用 `run_id IS NULL` 的比较并交换认领交互,重复确认返回 `409 interaction_already_confirmed`;对非表单交互确认返回 diff --git a/frontend/src/features/issues/components/README.en.md b/frontend/src/features/issues/components/README.en.md index d80c008a7..e09060ced 100644 --- a/frontend/src/features/issues/components/README.en.md +++ b/frontend/src/features/issues/components/README.en.md @@ -33,7 +33,7 @@ Workflow confirmation locks duplicate submissions, editing and removal from inte The initial form reads the live descriptor by `formRef + issueId`. A `snapshotId` select is recognized as the platform version selector only when every option belongs to this workflow's published snapshots; the form waits for that list and allows retry after a failed read. An author's same-named text field or select with custom options remains ordinary input. -Changing the platform version selector loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. Reading a form is not run admission; Confirm's `workflow_version_fields_changed` gate is unchanged. That refusal explains the selected version differs from the current draft and directs the user to publish the draft or pick another version. +Changing the platform version selector loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. Reading a form is not run admission, but Confirm validates against the selected version's own fields alone and no longer compares them with the live draft: an older version runs on its own form even when its fields differ from the current draft, and the server refuses by that version when a required field is missing or a field it never declared is submitted. Historical runs without a WorkflowRun reference retain their IssueRun status, make no WorkflowRun request and show no detail link. Linked runs always use the WorkflowRun's authoritative status. diff --git a/frontend/src/features/issues/components/README.md b/frontend/src/features/issues/components/README.md index 9ed788ee9..428265dc2 100644 --- a/frontend/src/features/issues/components/README.md +++ b/frontend/src/features/issues/components/README.md @@ -33,7 +33,7 @@ Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与 初始表单按 `formRef + issueId` 读取活稿。只有 `snapshotId` select 的全部选项都属于该 Workflow 的已发布快照时,才识别为平台版本选择器;列表读取完成前不开放表单,失败可重试。作者自定义的同名文本字段或含自定义选项的 select 仍是普通输入。 -用户切换平台版本选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试。读取表单不代表服务端准入通过,Confirm 的 `workflow_version_fields_changed` 门禁保持不变;收到该拒绝时,界面提示所选版本字段与当前草稿不一致,并引导先发布草稿或选择其他版本。 +用户切换平台版本选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试。读取表单不代表服务端准入通过,但 Confirm 只按所选版本自身的字段校验,不再与活稿比较:选中的旧版本哪怕字段和当前草稿不同,也按它自己的表单执行,所选版本缺必填字段或收到它未声明的字段时由服务端按该版本拒绝。 历史运行没有 WorkflowRun 引用时,确认行仍显示 IssueRun 状态,不请求 WorkflowRun,也不显示运行详情链接;有关联的运行始终以 WorkflowRun 状态为准。 diff --git a/frontend/src/features/issues/components/workflow-interaction-composer.tsx b/frontend/src/features/issues/components/workflow-interaction-composer.tsx index a173ecb38..cca0637d6 100644 --- a/frontend/src/features/issues/components/workflow-interaction-composer.tsx +++ b/frontend/src/features/issues/components/workflow-interaction-composer.tsx @@ -23,7 +23,6 @@ import type { FormField, FormValues, } from '@/features/issues/types' -import { faultCode } from '@/lib/api-client' /** Seeds the editable values from the descriptor's declared defaults. */ function seedValues(descriptor: FormDescriptor, snapshotId?: string): FormValues { @@ -47,13 +46,6 @@ function isSnapshotSelector(field: FormField, publishedIds: string[]): boolean { ) } -function confirmFailure(error: unknown): string { - if (faultCode(error) === 'workflow_version_fields_changed') { - return '所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。' - } - return '确认失败,请重试。' -} - /** Required fields the user has not filled yet — the only client-side gate on Review (Issue collaboration D9). */ function missingRequired(descriptor: FormDescriptor, values: FormValues): FormField[] { return descriptor.fields.filter((field) => { @@ -385,7 +377,7 @@ function useWorkflowSubmission({ return { submit, isPending: submission.isPending || submission.isSuccess, - errorMessage: submission.isError ? confirmFailure(submission.error) : undefined, + errorMessage: submission.isError ? '确认失败,请重试。' : undefined, } } diff --git a/frontend/src/features/issues/components/workflow-interaction.test.tsx b/frontend/src/features/issues/components/workflow-interaction.test.tsx index b75fddfd6..e31dc70ab 100644 --- a/frontend/src/features/issues/components/workflow-interaction.test.tsx +++ b/frontend/src/features/issues/components/workflow-interaction.test.tsx @@ -241,11 +241,11 @@ async function reviewAndConfirm(user: ReturnType) { await user.click(await screen.findByRole('button', { name: '确认执行' })) } -/** Renders the composer and confirms once against a server that answers the field-divergence 409. */ -async function confirmIntoFieldsChanged() { +/** Renders the composer and confirms once against a server that refuses the submission. */ +async function confirmIntoRejection(code: string, status: number) { server.use( http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', () => - HttpResponse.json({ code: 'workflow_version_fields_changed' }, { status: 409 }), + HttpResponse.json({ code, params: { field: 'prompt' } }, { status }), ), ) const user = userEvent.setup() @@ -666,22 +666,11 @@ describe('WorkflowInteractionComposer', () => { expect(formRead).not.toHaveBeenCalled() }) - it('explains how to resolve workflow version field divergence after confirm', async () => { - serveComposer({ descriptor: launchDescriptor }) - const { onConfirmed } = await confirmIntoFieldsChanged() - expect(await screen.findByRole('alert')).toHaveTextContent( - '所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。', - ) - expect(screen.queryByText('workflow_version_fields_changed')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: '返回修改' })).toBeEnabled() - expect(onConfirmed).not.toHaveBeenCalled() - }) - it('keeps the same interaction when a rejected confirm is retried after switching snapshot', async () => { const { comment, confirm } = serveComposer({ descriptor: launchDescriptor }) serveOldSnapshot() - const { user, onConfirmed } = await confirmIntoFieldsChanged() - await screen.findByText('所选版本的字段与当前草稿不一致,请先发布当前草稿或选择其他版本。') + const { user, onConfirmed } = await confirmIntoRejection('required_field_missing', 400) + await screen.findByText('确认失败,请重试。') await user.click(screen.getByRole('button', { name: '返回修改' })) await user.click(selectTrigger('运行版本')) await user.click(await screen.findByRole('option', { name: 'v2 · 发布 2' })) diff --git a/integration/workflow_interaction_test.go b/integration/workflow_interaction_test.go index ff3db3dc6..e5dc5063a 100644 --- a/integration/workflow_interaction_test.go +++ b/integration/workflow_interaction_test.go @@ -176,28 +176,6 @@ func confirmPath(f *fixture, iid, ixid string) string { return f.path("/issues/" + iid + "/interactions/" + ixid + "/confirm") } -// refusedFields asserts a confirm was refused because the version it names no longer describes the form -// the client rendered, and returns the fields that refusal names. The fault is the point of the -// assertion: the same submission checked against the version's schema alone would be refused too, by a -// fault about the values — saying a field the user was shown is invalid, or that one they were never -// shown is missing. -func refusedFields(t *testing.T, refusal core.Object) []string { - t.Helper() - if got := refusal.S("code"); got != "workflow_version_fields_changed" { - t.Fatalf("code = %q, want workflow_version_fields_changed: %v", got, refusal) - } - raw, _ := refusal.O("params")["fields"].([]any) - out := []string{} - for _, item := range raw { - text, ok := item.(string) - if !ok { - t.Fatalf("the refusal named a value that is not a field: %v", item) - } - out = append(out, text) - } - return out -} - // assistPath targets the stateless pre-confirm assist route: the form is a draft until the user // confirms it, so assist must work without a persisted interaction. func assistPath(f *fixture, iid string) string { @@ -940,17 +918,14 @@ func TestWorkflowAssistReadsThePreRenameVersionKey(t *testing.T) { } } -// TestWorkflowConfirmRefusesAVersionTheLiveFormHasLeft covers the two documents a confirm reconciles. -// The form the client rendered comes from the workflow's *live* document — the frontend resolves a -// descriptor once per formRef and keeps it while the user fills the form in — while the submission -// executes one frozen version. An author who edits between publishing a version and confirming against -// it, which is the ordinary state of a workflow between an edit and its publish, leaves that version -// describing a different form: validating the user's answers against it can only refuse a value they -// were legitimately shown, or demand a field they were never shown. Both are answers about the wrong -// document, so the confirm refuses the mismatch itself, names the fields, executes nothing, and leaves -// the interaction unclaimed — the user then does what the fault says, and publishes or picks a version -// whose fields the form still matches. -func TestWorkflowConfirmRefusesAVersionTheLiveFormHasLeft(t *testing.T) { +// TestWorkflowConfirmValidatesAgainstTheChosenVersion covers the two documents a confirm used to +// reconcile. It reconciles none of them now: the form offers the *selected* version's fields, so the +// confirm judges the submission by that version alone. A live draft that moved on after the last +// publish — the ordinary state of a workflow between an edit and its publish — neither invalidates the +// older version nor changes what it accepts, which is what makes "select an old snapshot and run it on +// its own schema" true. The version's own required fields are still enforced, and a key it does not +// declare is still refused by it. +func TestWorkflowConfirmValidatesAgainstTheChosenVersion(t *testing.T) { f := setup(t) authorField := func(name string) core.Object { return core.Object{"name": name, "displayName": name, "fieldType": "text-input", "valueType": "string", "required": true} @@ -971,10 +946,6 @@ func TestWorkflowConfirmRefusesAVersionTheLiveFormHasLeft(t *testing.T) { t.Helper() return f.call("POST", f.path("/issues"), core.Object{"title": "Diverging form", "projectRef": pid}, key, 200).O("resource").S("id") } - rendered := func(iid string) []string { - t.Helper() - return fieldKeys(orderedFormFields(t, f.call("GET", f.path("/collaboration/forms/"+wid+"?issueId="+iid), nil, "", 200))) - } withVersion := func(base core.Object, id string) core.Object { values := core.Object{"snapshotId": id} for key, value := range base { @@ -982,67 +953,48 @@ func TestWorkflowConfirmRefusesAVersionTheLiveFormHasLeft(t *testing.T) { } return values } - // What the form invites once the draft declares both fields: every key it renders, filled in. - submitted := core.Object{"repository": "ora-space/cloud", "branch": "main", "topic": "auth", "scope": "full-repo"} + // What version 1's own form asks for: the platform's launch fields plus the one field it declared. + version1 := core.Object{"repository": "ora-space/cloud", "branch": "main", "topic": "auth"} - // The draft gains a required field after version 1 was published, so the rendered form asks for - // `scope` and the version the user's answers would be checked against has never heard of it. + // The draft gains a required field after version 1 was published. The live form therefore asks for + // `scope`, a field version 1 has never heard of — and selecting version 1 still runs version 1. edit([]any{authorField("topic"), authorField("scope")}) ahead := newIssue("wf-diverge-issue-1") - if fields := rendered(ahead); !slices.Contains(fields, "scope") { - t.Fatalf("the rendered form is not the draft's: %v", fields) + if fields := fieldKeys(orderedFormFields(t, f.call("GET", f.path("/collaboration/forms/"+wid+"?issueId="+ahead), nil, "", 200))); !slices.Contains(fields, "scope") { + t.Fatalf("the live form is not the draft's: %v", fields) } it := workflowComment(t, f, wid, ahead, "wf-diverge-1", "configure") - refusal := f.call("POST", confirmPath(f, ahead, it.S("id")), core.Object{"values": submitted}, "wf-diverge-c1", 409) - if got := refusedFields(t, refusal); !slices.Equal(got, []string{"scope"}) { - t.Fatalf("the refusal named %v, want the field version 1 does not declare", got) + run := f.call("POST", confirmPath(f, ahead, it.S("id")), core.Object{"values": withVersion(version1, v1.S("id"))}, "wf-diverge-c1", 200).O("resource") + if got := f.call("GET", f.path("/issues/"+ahead+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v1.S("id") { + t.Fatalf("the run executed %q, want the selected version %q", got, v1.S("id")) } - // A refused confirm claims nothing: no run was created, and the same interaction is still there to - // confirm once the form and the version agree again. - if n := f.scalar("SELECT count(*) FROM issue_runs WHERE issue_id=$1", ahead); n != 0 { + + // The version's own required field is still enforced: version 1 requires `topic`, and leaving it out + // is refused by version 1's schema rather than accepted because the live draft could supply one. + missingIssue := newIssue("wf-diverge-issue-2") + missingIt := workflowComment(t, f, wid, missingIssue, "wf-diverge-2", "configure") + thin := core.Object{"repository": "ora-space/cloud", "branch": "main"} + missing := f.call("POST", confirmPath(f, missingIssue, missingIt.S("id")), core.Object{"values": withVersion(thin, v1.S("id"))}, "wf-diverge-c2", 400) + if code, field := missing.S("code"), missing.O("params").S("field"); code != "required_field_missing" || field != "topic" { + t.Fatalf("a version-1 confirm without topic = %q on %q; want required_field_missing on topic: %v", code, field, missing) + } + if n := f.scalar("SELECT count(*) FROM issue_runs WHERE issue_id=$1", missingIssue); n != 0 { t.Fatalf("a refused confirm created %d runs", n) } - // Publishing the draft is the first remedy the fault names, and the same submission then goes through. - v2 := publishWorkflow(t, f, wid, "wf-diverge-pub-2") - run := f.call("POST", confirmPath(f, ahead, it.S("id")), core.Object{"values": submitted}, "wf-diverge-c2", 200).O("resource") - if got := f.call("GET", f.path("/issues/"+ahead+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v2.S("id") { - t.Fatalf("the run executed %q, want the version published since the refusal %q", got, v2.S("id")) - } - - // The other remedy: the live document is now version 2, and the same mismatch read from the version - // side is a user who picks the older version 1 — which does not declare `scope` either. Selecting a - // version has to be a real way out, so the fix and the mismatch are checked on one interaction. - picked := newIssue("wf-diverge-issue-2") - older := workflowComment(t, f, wid, picked, "wf-diverge-2", "configure") - chose := f.call("POST", confirmPath(f, picked, older.S("id")), core.Object{"values": withVersion(submitted, v1.S("id"))}, "wf-diverge-c3", 409) - if got := refusedFields(t, chose); !slices.Equal(got, []string{"scope"}) { - t.Fatalf("selecting version 1 named %v, want the field version 1 does not declare", got) - } - run = f.call("POST", confirmPath(f, picked, older.S("id")), core.Object{"values": withVersion(submitted, v2.S("id"))}, "wf-diverge-c4", 200).O("resource") - if got := f.call("GET", f.path("/issues/"+picked+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v2.S("id") { - t.Fatalf("the run executed %q, want the version that was selected %q", got, v2.S("id")) - } - - // And the direction where the values are complete: `scope` leaves the draft, so the form no longer - // offers it while version 2 still requires it. Checked against version 2 alone this submission is - // `required_field_missing` for a field the user was never shown — which is why the version is not - // asked, and the mismatch is named instead. - edit([]any{authorField("topic")}) - behind := newIssue("wf-diverge-issue-3") - if fields := rendered(behind); slices.Contains(fields, "scope") { - t.Fatalf("the rendered form still asks for a field the draft dropped: %v", fields) - } - back := workflowComment(t, f, wid, behind, "wf-diverge-3", "configure") - // No `scope` in the submission, because no form asked for one. - thin := core.Object{"repository": "ora-space/cloud", "branch": "main", "topic": "auth"} - missing := f.call("POST", confirmPath(f, behind, back.S("id")), core.Object{"values": thin}, "wf-diverge-c5", 409) - if got := refusedFields(t, missing); !slices.Equal(got, []string{"scope"}) { - t.Fatalf("the refusal named %v, want the field the form no longer asks for", got) - } - // Here the remedy is the older version, because version 1's fields are still this form's. - run = f.call("POST", confirmPath(f, behind, back.S("id")), core.Object{"values": withVersion(thin, v1.S("id"))}, "wf-diverge-c6", 200).O("resource") - if got := f.call("GET", f.path("/issues/"+behind+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v1.S("id") { - t.Fatalf("the run executed %q, want the version whose fields the form still matches %q", got, v1.S("id")) + + // And a key version 1 does not declare is refused by version 1: `scope` belongs to the live draft, + // not to the version the user selected, so it is not silently dropped or passed through. + extraIssue := newIssue("wf-diverge-issue-3") + extraIt := workflowComment(t, f, wid, extraIssue, "wf-diverge-3", "configure") + wide := core.Object{"repository": "ora-space/cloud", "branch": "main", "topic": "auth", "scope": "full-repo"} + extra := f.call("POST", confirmPath(f, extraIssue, extraIt.S("id")), core.Object{"values": withVersion(wide, v1.S("id"))}, "wf-diverge-c3", 400) + if code, field := extra.S("code"), extra.O("params").S("field"); code != "invalid_field_value" || field != "scope" { + t.Fatalf("a version-1 confirm carrying the draft's scope = %q on %q; want invalid_field_value on scope: %v", code, field, extra) + } + // The same interaction confirms once the submission answers version 1's own form. + run = f.call("POST", confirmPath(f, extraIssue, extraIt.S("id")), core.Object{"values": withVersion(version1, v1.S("id"))}, "wf-diverge-c4", 200).O("resource") + if got := f.call("GET", f.path("/issues/"+extraIssue+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v1.S("id") { + t.Fatalf("the run executed %q, want the selected version %q", got, v1.S("id")) } } diff --git a/internal/core/form_descriptor.go b/internal/core/form_descriptor.go index ac5f4304e..5e6c7cab3 100644 --- a/internal/core/form_descriptor.go +++ b/internal/core/form_descriptor.go @@ -319,11 +319,10 @@ func appliedContextRefs(body Object) []Object { // // This resolves the workflow's *live* document, which is right for both of them — they render a form // the user is still filling in, and the version picker has not been used yet. A confirm does not come -// through here: it executes the version that was chosen, so it projects that snapshot -// (snapshotFormDescriptor) and an edit made since the last publish cannot change what an already-offered -// version accepts. It projects the live document as well, but only to refuse a version that no longer -// describes the form the user was handed (workflowFieldDivergence) — and it projects it itself rather -// than through this provider, so that a descriptor outage cannot block a confirm. +// through here: it executes the version that was chosen and validates against that snapshot alone +// (snapshotFormDescriptor), so an edit made since the last publish cannot change what an already-offered +// version accepts. It projects the version itself rather than through this provider, so that a +// descriptor outage cannot block a confirm. func workflowFormDescriptor(t *transaction, tid, targetType, targetID string, issue *IssueFormContext) FormDescriptor { require(targetType == "workflow", 409, "interaction_not_confirmable") require(t.directory != nil, 503, "form_descriptor_unavailable") diff --git a/internal/core/issue_interactions.go b/internal/core/issue_interactions.go index e7c8b4747..d93240331 100644 --- a/internal/core/issue_interactions.go +++ b/internal/core/issue_interactions.go @@ -113,25 +113,23 @@ func applyCommentTargets(t *transaction, i Object, r *PublicRequest, uid, commen // interaction with a compare-and-set on `run_id IS NULL`. WorkflowRun owns execution; this path never // hands the linked IssueRun to the legacy dispatcher or performs an external effect (Issue collaboration D9). // -// The values are validated against the schema of the *version being confirmed* rather than the live -// document the form was rendered from, and against the same admission gate the other two entries use. -// The version is the form's own `snapshotId` field, whose option values are snapshot ids; leaving it -// unset means the newest published version, and a version that is not this workflow's is a 404 rather -// than a silent fallback. A workflow with nothing published cannot be confirmed at all: there is no -// frozen document to execute, and running the live draft is what the draft/published split forbids. -// The resolved version is frozen onto the run's input, so the run still says what it executed after -// the workflow has been edited or published again. +// The values are validated against the schema of the *version being confirmed* alone, never against +// the live document, and against the same admission gate the other two entries use. Selecting a +// version has to be a real way to run it: a draft that moved on after the last publish must not turn +// an already-published version into an unconfirmable one, or the selection would be refused exactly +// when it matters. This mirrors the manual run entry, which also validates against the version it +// runs. The version is the form's own `snapshotId` field, whose option values are snapshot ids; +// leaving it unset means the newest published version, and a version that is not this workflow's is a +// 404 rather than a silent fallback. A workflow with nothing published cannot be confirmed at all: +// there is no frozen document to execute, and running the live draft is what the draft/published +// split forbids. The resolved version is frozen onto the run's input, so the run still says what it +// executed after the workflow has been edited or published again. // // `snapshotId` is the platform's key only while the author's own Start node leaves it alone; a workflow // that declares a variable under that name owns the key, and the value submitted under it is that // variable's rather than a version selection — see workflowVersionCursor. A submission still carrying // the pre-rename `version` key is read under it, so the version the user picked is the one that runs, // and the key is folded away before the values are validated — see foldLegacyVersionAnswer. -// -// The version's schema is the schema of this submission only while it still describes the form the -// client rendered, which is the live document's. When the two have diverged the confirm refuses with -// `workflow_version_fields_changed` and names the fields, rather than validating values the user was -// shown against a schema they were never shown — see workflowFieldDivergence. func confirmInteraction(t *transaction, r *PublicRequest, uid string) Object { it := interaction(t, r.TenantID, r.IssueID, r.InteractionID) require(it.S("mode") == "form", 409, "interaction_not_confirmable") @@ -145,11 +143,9 @@ func confirmInteraction(t *transaction, r *PublicRequest, uid string) Object { foldLegacyVersionAnswer(w.O("graph"), values) s := admitWorkflowRun(t, r.TenantID, targetID, cursor, uid) issueContext := formContextForIssue(t, r.TenantID, i) - versionDescriptor := snapshotFormDescriptor(t, w, s, &issueContext) - if diverged := workflowFieldDivergence(liveFormDescriptor(t, w, &issueContext), versionDescriptor); len(diverged) > 0 { - rejectWith(409, "workflow_version_fields_changed", Object{"fields": stringList(diverged)}) - } - validateFormValues(versionDescriptor, values, true) + // The chosen version's own schema decides what the submission may contain: the form offers the + // selected version's fields, so a live draft that has moved on neither adds nor removes a question. + validateFormValues(snapshotFormDescriptor(t, w, s, &issueContext), values, true) input := buildRunContext(t, i, "", targetType, targetID, values, appliedContextRefs(r.Body)) runObj := enqueueWorkflowInvocation(t, r.TenantID, i.S("id"), w, s, values, input, "interaction", it.S("id"), uid) diff --git a/internal/core/workflow_collaboration.go b/internal/core/workflow_collaboration.go index 381ff3e67..b9fe87d7a 100644 --- a/internal/core/workflow_collaboration.go +++ b/internal/core/workflow_collaboration.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "errors" - "slices" "strconv" "strings" "unicode/utf8" @@ -225,26 +224,16 @@ func workflowDescriptorSource(t *transaction, w, graph Object) *workflowFormSour } } -// liveFormDescriptor projects a workflow's live document — the document the client rendered the form -// from, since the read routes resolve it through WorkflowFormDescriptors. A confirm projects it here -// rather than through that provider because the provider may not be wired at all: what a confirm needs -// must not depend on a service being available, which is the same reason it resolves the version itself. -func liveFormDescriptor(t *transaction, w Object, issue *IssueFormContext) FormDescriptor { - return formDescriptorFromGraph(workflowDescriptorSource(t, w, w.O("graph")), issue) -} - // snapshotFormDescriptor projects one *frozen* snapshot onto the form descriptor a confirm validates -// against, rather than the live document the form was rendered from. +// against, rather than the live document. // // The distinction is the whole point of confirming against a version: an edit made after the last // publish must not retroactively change what an already-offered version accepts, and a variable an // older version declared must still be submittable when that version is the one chosen. The // platform's own launch fields are still injected — they describe the invocation, not the version — -// so the descriptor carries the same field set the form showed for this version. -// -// That last clause is a promise this projection cannot keep on its own: the form was rendered from the -// live document, so the two describe the same fields only while the draft has not moved on from this -// version. A confirm checks that before it validates — see workflowFieldDivergence. +// so the descriptor carries the same field set the form showed for this version. A confirm validates +// against this descriptor alone, so selecting an older version whose fields differ from the live +// draft runs that version on its own schema. func snapshotFormDescriptor(t *transaction, w, s Object, issue *IssueFormContext) FormDescriptor { descriptor := formDescriptorFromGraph(workflowDescriptorSource(t, w, s.O("graph")), issue) // Fail closed, exactly as the provider path does: a descriptor that cannot be rendered is a 500 @@ -253,70 +242,6 @@ func snapshotFormDescriptor(t *transaction, w, s Object, issue *IssueFormContext return descriptor } -// judgedField is what a descriptor says about one key that decides whether a submitted value is -// accepted: which control receives it, whether the run must be given one, and which values it offers. -// A label, a description, a placeholder and a default judge none of that — they change what the form -// looks like, not what it takes — so they are deliberately left out and a draft that changed only one -// of them is not a divergence. -type judgedField struct { - Type string - Required bool - Options []string -} - -// sameAs compares two judged controls. The offered values are compared as a set: reordering a select's -// options changes the order the form draws them in, not which submissions it accepts. -func (f judgedField) sameAs(other judgedField) bool { - return f.Type == other.Type && f.Required == other.Required && slices.Equal(f.Options, other.Options) -} - -// judgedFields reduces a descriptor to the keys that judge a submission, for comparison. -func judgedFields(d FormDescriptor) map[string]judgedField { - out := make(map[string]judgedField, len(d.Fields)) - for i := range d.Fields { - field := &d.Fields[i] - options := make([]string, 0, len(field.Options)) - for _, option := range field.Options { - options = append(options, option.Value) - } - slices.Sort(options) - out[field.Key] = judgedField{Type: field.Type, Required: field.Required, Options: options} - } - return out -} - -// workflowFieldDivergence names the fields on which the form the client rendered and the version being -// confirmed disagree, sorted, or nothing when they agree about every field. -// -// A confirm validates the submitted values against the version it will execute, and that is only a -// meaningful thing to do while the version still describes the form the user filled in. The form is -// rendered from the live document — a draft is a legitimate document — so an author editing between -// publishing a version and confirming against it hands the client a form that version does not accept: -// the user is shown a field the version rejects as unknown, or is never shown one the version insists -// on, and either way the submission can only be refused by a fault that blames a value the user was -// legitimately given. Naming the divergence says the true thing instead: this version no longer matches -// the form, so publish it or select another. -// -// The comparison is of the whole descriptor rather than of the submission in hand. A divergence means -// the values are answers to a different question, and accepting them because they happen to fit the -// version too would be luck rather than agreement. -func workflowFieldDivergence(rendered, version FormDescriptor) []string { - live, frozen := judgedFields(rendered), judgedFields(version) - diverged := []string{} - for key, want := range frozen { - if got, ok := live[key]; !ok || !got.sameAs(want) { - diverged = append(diverged, key) - } - } - for key := range live { - if _, ok := frozen[key]; !ok { - diverged = append(diverged, key) - } - } - slices.Sort(diverged) - return diverged -} - // Reserved keys of the fields the platform injects into every workflow form (Issue collaboration D8). They // are reserved in the sense that a workflow declaring a variable under one of these names keeps its own // declaration — see injectPlatformFields. `context_refs` is reserved in a second sense: the Issues diff --git a/internal/core/workflow_collaboration_test.go b/internal/core/workflow_collaboration_test.go index 18edc5cd7..a64308f05 100644 --- a/internal/core/workflow_collaboration_test.go +++ b/internal/core/workflow_collaboration_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "reflect" - "slices" "strings" "testing" "unicode/utf8" @@ -845,78 +844,6 @@ func TestFoldLegacyVersionAnswer(t *testing.T) { } } -// TestWorkflowFieldDivergence covers what a confirm compares before it validates anything at all. The -// form the client rendered comes from the workflow's live document and the version it is about to -// execute comes from a snapshot, so the two have to agree about every field that decides whether a -// submission is acceptable — otherwise the version's schema refuses a value the user was legitimately -// shown, or insists on a field the form never showed them, and says either thing about the wrong -// document. What they do *not* have to agree about is how a control is drawn: a label, a description, a -// placeholder or a default changes no answer, and an edit that only renames a field must not strand a -// form the user has already filled in. -func TestWorkflowFieldDivergence(t *testing.T) { - text := func(name string) Object { - return Object{"name": name, "displayName": name, "fieldType": "text-input", "valueType": "string"} - } - required := func(name string) Object { - return Object{"name": name, "displayName": name, "fieldType": "text-input", "valueType": "string", "required": true} - } - optioned := func(name string, values ...any) Object { - return Object{"name": name, "displayName": name, "fieldType": "select", "valueType": "string", "options": values} - } - descriptor := func(graph Object) FormDescriptor { - return projectGraph(t, graph, snapshotRows(2, 1), issueContext()) - } - // The documents a confirm holds: one Start-only graph per way an edit can move a field. - plain := startGraph(nil, "") - scoped := startGraph([]any{text("scope")}, "") - scopeRequired := startGraph([]any{required("scope")}, "") - scopeSelected := startGraph([]any{optioned("scope", "a", "b")}, "") - scopeWidened := startGraph([]any{optioned("scope", "a", "b", "c")}, "") - scopeReordered := startGraph([]any{optioned("scope", "b", "a")}, "") - scopeRenamed := startGraph([]any{Object{ - "name": "scope", "displayName": "评审范围", "fieldType": "text-input", "valueType": "string", - }}, "") - scopeDefaulted := startGraph([]any{Object{ - "name": "scope", "displayName": "Scope", "fieldType": "text-input", "valueType": "string", "value": "everything", - }}, "") - scopedPrompted := startGraph([]any{text("scope")}, "review the diff") - // A draft that withdrew a platform field, and one that handed a platform key to the author: the two - // ways an edit reaches a field the author did not write themselves in the Start node. - withdrawn := launchGraph([]any{Object{"key": fieldKeyRepository, "enabled": false}}) - owned := startGraph([]any{Object{ - "name": fieldKeyRepository, "displayName": "Repository", "fieldType": "text-input", "valueType": "string", - }}, "") - left, right := startGraph([]any{text("left")}, ""), startGraph([]any{text("right")}, "") - - // The failure is a wire list, so the order it is reported in is part of the contract. - cases := []struct { - name string - rendered, version Object - want []string - }{ - {"the same document on both sides", scoped, scoped, nil}, - {"a field the version does not declare", scoped, plain, []string{"scope"}}, - {"a field the version declares and the form does not", plain, scoped, []string{"scope"}}, - {"a field the version requires and the form does not", scoped, scopeRequired, []string{"scope"}}, - {"a field the form requires and the version does not", scopeRequired, scoped, []string{"scope"}}, - {"the same key as another control", scopeSelected, scoped, []string{"scope"}}, - {"the same control offering other values", scopeSelected, scopeWidened, []string{"scope"}}, - {"a field only one side has, each way", left, right, []string{"left", "right"}}, - {"a platform field the draft withdrew", withdrawn, plain, []string{fieldKeyRepository}}, - {"a platform key the draft handed to the author", owned, plain, []string{fieldKeyRepository}}, - // The same field, drawn differently. - {"the same options in another order", scopeSelected, scopeReordered, nil}, - {"another label", scoped, scopeRenamed, nil}, - {"another default", scoped, scopeDefaulted, nil}, - {"another Start prompt", scopedPrompted, scoped, nil}, - } - for _, tc := range cases { - if got := workflowFieldDivergence(descriptor(tc.rendered), descriptor(tc.version)); !slices.Equal(got, tc.want) { - t.Fatalf("%s: diverged = %v, want %v", tc.name, got, tc.want) - } - } -} - // TestPlatformFieldsAreAbsentWithoutAnIssue covers the back-compatibility rule: a descriptor resolved // with no issue context is the projection callers got before the platform fields existed — even when // the graph would have supplied a prompt to prefill. From 2464f759d84ec490a98bfd33f175eeb484648428 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Tue, 20 Oct 2026 22:08:21 +0800 Subject: [PATCH 15/19] test(core): project cancellation and an unreachable Node onto the linked IssueRun The scheduler-level projection tests covered the workflow row only. Two end to end cases now assert what the issue shows: a Confirm-created IssueRun cancelled before its first claim projects cancelled with no start and no run.started activity, and one whose Node goes unreachable projects failed with both timestamps and the same reason the run carries. Co-Authored-By: Claude Code --- integration/workflow_invocation_test.go | 46 +++++++++++++++++++ internal/core/workflow_run_session_db_test.go | 44 ++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/integration/workflow_invocation_test.go b/integration/workflow_invocation_test.go index cb83e357f..33cc1976e 100644 --- a/integration/workflow_invocation_test.go +++ b/integration/workflow_invocation_test.go @@ -408,3 +408,49 @@ func TestWorkflowConfirmSchedulerProjectsConditionFailure(t *testing.T) { t.Fatalf("condition failure did not preserve its node reason and terminal evidence: %v", workflow) } } + +// TestWorkflowConfirmSchedulerProjectsCancelBeforeStart links a Confirm-created IssueRun and cancels +// it before the scheduler claims the run: the run never starts, so the terminal projection must +// record cancelled without fabricating a start timestamp or a run.started activity. +func TestWorkflowConfirmSchedulerProjectsCancelBeforeStart(t *testing.T) { + f := setup(t) + seedPersonalModel(t, f) + wid, iid, confirmed := confirmInvocationScene(t, f, runGraph(), "inv-scheduler-cancelled") + runID := confirmed.S("workflowInvocationRef") + + // Cancel while the run is still pending: it must settle cancelled on the next scheduler pass + // without ever claiming, so neither the run nor its IssueRun carries a start. + out := f.call("POST", f.path("/workflows/"+wid+"/runs/"+runID+"/cancel"), core.Object{}, "inv-scheduler-cancelled-cancel", 202) + if out.S("cancelRequestedAt") == "" { + t.Fatalf("cancel request reported no recorded instant: %v", out) + } + must(t, f.store.AdvanceWorkflowRunsOnce(t.Context())) + + workflow := f.call("GET", f.path("/workflows/"+wid+"/runs/"+runID), nil, "", 200) + if workflow.S("status") != "cancelled" || workflow.S("startedAt") != "" || workflow.S("finishedAt") == "" { + t.Fatalf("cancel-before-start settled %q started=%q finished=%q; want cancelled with no start and a terminal finish", + workflow.S("status"), workflow.S("startedAt"), workflow.S("finishedAt")) + } + var issueRun core.Object + for _, candidate := range issueItems(f.call("GET", f.path("/issues/"+iid+"/runs"), nil, "", 200)) { + if candidate.S("id") == confirmed.S("id") { + issueRun = candidate + break + } + } + if issueRun.S("status") != "cancelled" || issueRun.S("startedAt") != "" || issueRun.S("completedAt") != workflow.S("finishedAt") { + t.Fatalf("projected IssueRun status=%q started=%q completed=%q; want cancelled with no start and the workflow finish", + issueRun.S("status"), issueRun.S("startedAt"), issueRun.S("completedAt")) + } + var got []string + for _, item := range issueItems(f.call("GET", f.path("/issues/"+iid+"/timeline"), nil, "", 200)) { + details := item.O("details") + if item.S("kind") != "activity" || details.S("runId") != confirmed.S("id") { + continue + } + got = append(got, item.S("action")) + } + if !reflect.DeepEqual(got, []string{"run.enqueued", "run.cancelled"}) { + t.Fatalf("cancel-before-start lifecycle activities = %v, want [run.enqueued run.cancelled]", got) + } +} diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index 16bce9df6..eb519ac5b 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -894,6 +894,50 @@ func TestWorkflowSessionNodeGoneSettlesTheNodeFailed(t *testing.T) { } } +// A linked IssueRun mirrors the Node-unreachable settlement: when Cloud diagnoses the silent Node +// and settles the run failed, the invocation's IssueRun projects failed with the same evidence. +// Direct runs have no IssueRun, so this closes the gap between the projection's unit coverage and +// the real settlement path a viewer reads through the issue. +func TestWorkflowSessionNodeUnreachableProjectsLinkedIssueRun(t *testing.T) { + store := dispatcherDB(t) + store.DeliveryUnreachableAfter = 30 * time.Minute + runID, tid, uid, _, _ := seedWorkflowSessionScene(t, store) + wid := workflowRunWorkflowID(t, store, runID) + issueID, issueRunID := uuid.NewString(), uuid.NewString() + if _, err := store.Pool.Exec(`INSERT INTO issues(id,tenant_id,creator_user_id,title,number) VALUES($1,$2,$3,'linked invocation',1)`, issueID, tid, uid); err != nil { + t.Fatalf("seed linked issue: %v", err) + } + if _, err := store.Pool.Exec(`INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,status,workflow_invocation_ref) VALUES($1,$2,$3,'workflow',$4,'queued',$5)`, issueRunID, tid, issueID, wid, runID); err != nil { + t.Fatalf("seed linked IssueRun: %v", err) + } + 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") + dispatchSession(t, store, runID, "a", nodeService, spec) + if _, err := store.Pool.Exec(`UPDATE node_instances SET last_seen_at=clock_timestamp()-interval '2 hours' WHERE workspace_id=$1`, wsID); err != nil { + t.Fatalf("age the Node heartbeat: %v", err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatalf("advance after the Node is gone: %v", err) + } + status, runErr, _, _ := readWorkflowRun(t, store, runID) + if status != "failed" || runErr != workflowSessionNodeUnavailable { + t.Fatalf("run settled %q error %q; want failed/%s", status, runErr, workflowSessionNodeUnavailable) + } + var irStatus string + var started, completed sql.NullTime + var failureReason sql.NullString + if err := store.Pool.QueryRow(`SELECT status, started_at, completed_at, failure_reason FROM issue_runs WHERE id=$1`, issueRunID). + Scan(&irStatus, &started, &completed, &failureReason); err != nil { + t.Fatalf("read linked IssueRun: %v", err) + } + if irStatus != "failed" || !started.Valid || !completed.Valid || !failureReason.Valid || failureReason.String != workflowSessionNodeUnavailable { + t.Fatalf("linked IssueRun status=%q started=%v completed=%v reason=%v; want failed with both timestamps and %s", irStatus, started, completed, failureReason, workflowSessionNodeUnavailable) + } +} + // The window is the boundary, not the silence itself. A session may legitimately run for hours, so a // Node that has not been heard from for a few minutes is a Node whose session is still running and // the pass holds it — settling on any silence would fail runs whose Nodes are merely quiet. The same From cedfbfea45dd9f80553f8cff20568a0df61ecd51 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Tue, 20 Oct 2026 22:10:15 +0800 Subject: [PATCH 16/19] docs: drop the last references to the removed divergence gate The component READMEs still listed the field-divergence test that the chosen-version validation replaced, and the snapshot-form comment still said the @ form relies on the confirm's divergence refusal. Co-Authored-By: Claude Code --- frontend/src/features/issues/components/README.en.md | 2 +- frontend/src/features/issues/components/README.md | 2 +- internal/core/form_descriptor.go | 9 ++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/issues/components/README.en.md b/frontend/src/features/issues/components/README.en.md index e09060ced..c0aa41a13 100644 --- a/frontend/src/features/issues/components/README.en.md +++ b/frontend/src/features/issues/components/README.en.md @@ -41,4 +41,4 @@ Historical runs without a WorkflowRun reference retain their IssueRun status, ma Component tests use MSW; properties and Task Mode→Thread are covered by `issue-detail-page.test.tsx`. Loading, empty, refused and retry states are distinct; creation, ordinary-comment and target failures retain drafts, while scope changes clear them. -Workflow tests cover double-click exclusion, rejected confirms reusing an interaction across version switches, snapshot fields/defaults/echoes, an author's same-named select making no snapshot-form request, actionable field-divergence feedback, pending and failed reads, and WorkflowRun status and detail links. +Workflow tests cover double-click exclusion, rejected confirms reusing an interaction across version switches, snapshot fields/defaults/echoes, an author's same-named select making no snapshot-form request, acceptance and refusal by the selected version's own fields, pending and failed reads, and WorkflowRun status and detail links. diff --git a/frontend/src/features/issues/components/README.md b/frontend/src/features/issues/components/README.md index 428265dc2..5f0c056b4 100644 --- a/frontend/src/features/issues/components/README.md +++ b/frontend/src/features/issues/components/README.md @@ -41,4 +41,4 @@ Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与 组件测试用 MSW;属性栏和 Task Mode→Thread 经 `issue-detail-page.test.tsx` 覆盖。测试区分加载、空列表、拒绝、重试;创建、普通评论和目标提交失败保留草稿,作用域切换则清空草稿。 -Workflow 测试覆盖双击互斥、确认失败后复用 interaction(含切换版本)、版本字段/default/回显切换、作者同名 select 不发快照表单请求、字段差异拒绝的操作提示、读取等待与失败,以及 WorkflowRun 状态和详情链接。 +Workflow 测试覆盖双击互斥、确认失败后复用 interaction(含切换版本)、版本字段/default/回显切换、作者同名 select 不发快照表单请求、按所选版本自身的字段通过与被拒、读取等待与失败,以及 WorkflowRun 状态和详情链接。 diff --git a/internal/core/form_descriptor.go b/internal/core/form_descriptor.go index 5e6c7cab3..8622bf49d 100644 --- a/internal/core/form_descriptor.go +++ b/internal/core/form_descriptor.go @@ -372,11 +372,10 @@ func formDescriptorByRef(t *transaction, r *PublicRequest) Object { // // It exists because a caller can choose the version *before* rendering — the workflow editor's manual // run entry does — and such a caller has to be shown the fields of the version it picked. The `@` form -// is rendered from the live document and relies on the confirm's divergence refusal instead, which -// works there because the user never chose a version; a manual run has chosen one, so projecting the -// live document would show a form the chosen version does not accept and then refuse every answer. -// Confirm already projects this way when it validates a submission, so both read the one projection: -// this route adds a caller, not a second rule. +// reads the live descriptor until the user selects a published version and then reloads through this +// route; a manual run has chosen one up front, so either way the chosen version's own fields are what +// the submission is judged by. Confirm already projects this way when it validates a submission, so +// both read the one projection: this route adds a caller, not a second rule. // // It is a projection and not an admission decision. Reading a descriptor says nothing about whether a // run may start — publish state, archiving, input dependencies and per-slot resolution stay the start From c9a6a48c3e82517d5faa04df05ed550f593a3a58 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Wed, 21 Oct 2026 10:25:18 +0800 Subject: [PATCH 17/19] =?UTF-8?q?feat(core):=20@=20=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E8=A7=86=E5=9B=BE=E4=B8=8E=20Confirm=20?= =?UTF-8?q?=E5=90=8C=E6=BA=90=E8=AF=BB=E5=8F=96=E6=89=80=E9=80=89=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端先读活稿只为定位平台版本选择器及其默认版本,确定默认发布版本后即按该版本读取 `GET /workflows/{wfid}/snapshots/{sid}/form` 渲染,切换版本同样重新读取,使显示字段与 Confirm 校验的 schema 始终来自同一版本;确认失败按 `params.field` 给出字段级提示。 - Assist 按同一版本描述判断提交值形状,与 Confirm、表单读取三方一致。 - 抽出 `workflowLatestSnapshot`,让「最新已发布版本」只有一个定义。 - 迁移改为 `0053_workflow_invocation_reference.sql`,避让上游已合入的 0052;0052 的升级测试 拆成「仅 0052」与「整条序列」两段断言,后者覆盖 0053 结算剩余未链接的非终态行。 Co-Authored-By: Claude Code --- docs/collaboration/interactions.en.md | 14 +- docs/collaboration/interactions.md | 8 +- .../features/issues/components/README.en.md | 6 +- .../src/features/issues/components/README.md | 6 +- .../workflow-interaction-composer.tsx | 152 ++++++++++++++---- .../components/workflow-interaction.test.tsx | 71 +++++++- frontend/src/lib/README.en.md | 4 +- frontend/src/lib/README.md | 4 +- frontend/src/lib/api-client.test.ts | 28 +++- frontend/src/lib/api-client.ts | 11 ++ integration/migration_upgrade_path_test.go | 41 ++++- integration/workflow_interaction_test.go | 15 +- .../workflow_invocation_migration_test.go | 2 +- internal/core/form_descriptor.go | 9 +- internal/core/issue_interactions.go | 20 ++- internal/core/workflow_admission.go | 9 +- internal/core/workflow_collaboration.go | 4 +- 17 files changed, 339 insertions(+), 65 deletions(-) diff --git a/docs/collaboration/interactions.en.md b/docs/collaboration/interactions.en.md index c695e3b8f..8eafba82b 100644 --- a/docs/collaboration/interactions.en.md +++ b/docs/collaboration/interactions.en.md @@ -77,17 +77,21 @@ select workflow ──▶ read form descriptor ──▶ fill in (optional AI as Issue-facing rendering description: `formRef`, title, description and a list of fields. It is not the workflow's schema and binds to no schema technology. Fields come in three bands: platform fields, the variables declared by the workflow's Start node, and the graph's global variables. - Opening the form initially reads the current document. Selecting a published version in the platform - version selector reloads the chosen snapshot's form through - `GET /tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form?issueId=…`. + The frontend first reads the current document only to locate the platform version selector and its + default version. Once the default (the newest published version) is known, the initial form is read + from that version through `GET /tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form?issueId=…`, + and switching the selector reloads the new version the same way. The form shown and the version Confirm + validates are therefore always the same, and fields the live draft has not published never appear in + the default view. - **Platform fields** are injected only when the form is opened for an Issue: repository URL and branch (defaulting to the repository and default branch of the Issue's Project; required), a prompt, the published version to run `snapshotId`, and additional context references `context_refs`. When the workflow declares a variable of the same name, the workflow's declaration wins. - **AI assist** (`POST .../issues/:iid/collaboration/assist`) only returns a field-level suggestion patch and suggested context references: it never creates a run and never writes persistent context - references. Applying a suggestion is the user's action. Production has no assist provider wired in and - returns `503 assist_unavailable`. + references. Applying a suggestion is the user's action. It judges the shape of the submission against + the same version descriptor Confirm uses, so once anything is published it accepts only the fields + that version declares. Production has no assist provider wired in and returns `503 assist_unavailable`. - **Confirm** (`POST .../issues/:iid/interactions/:ixid/confirm`) is the only transition that turns a workflow interaction into execution intent: - Frontend validation is only for user experience. On confirm Cloud resolves the target **again** and diff --git a/docs/collaboration/interactions.md b/docs/collaboration/interactions.md index 35896f5e8..08c8c27e1 100644 --- a/docs/collaboration/interactions.md +++ b/docs/collaboration/interactions.md @@ -60,12 +60,14 @@ Agent 名册(`space_agents`,由已安装的 Agent 插件形成,见 [插件 - **表单描述**(`GET /tenants/:tid/collaboration/forms/:formRef?issueId=…`)是面向 Issue 的渲染描述:`formRef`、标题、说明和 字段列表。它不是工作流的 schema,也不绑定任何 schema 技术。字段分三段:平台字段、工作流 Start 节点声明的变量、图的全局变量。 - 初次打开时读取当前文档;在平台版本选择器中选择已发布版本后,前端通过 - `GET /tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form?issueId=…` 重新读取所选快照的表单。 + 前端先读取当前文档,只为定位平台版本选择器及其默认版本;确定默认版本(最新已发布版本)后,初次打开就按该版本通过 + `GET /tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form?issueId=…` 读取表单,切换版本时按新版本重新读取。 + 因此显示的表单与 Confirm 校验的版本始终相同,活稿里未发布的字段不出现在默认视图。 - **平台字段**只在带着 Issue 打开表单时注入:仓库地址和分支(默认取 Issue 关联 Project 的仓库与默认分支,必填)、提示词、要执行 的已发布版本 `snapshotId`、附加的上下文引用 `context_refs`。工作流自己声明了同名变量时,以工作流的声明为准。 - **AI 辅助**(`POST .../issues/:iid/collaboration/assist`)只返回字段级的建议补丁和建议的上下文引用:从不创建运行,也不写入 - 持久的上下文引用。应用建议是用户的动作。生产环境没有接入辅助提供方,返回 `503 assist_unavailable`。 + 持久的上下文引用。应用建议是用户的动作。它按与 Confirm 相同的版本描述判断提交值的形状,所以有已发布版本时只接受该版本声明的字段。 + 生产环境没有接入辅助提供方,返回 `503 assist_unavailable`。 - **确认**(`POST .../issues/:iid/interactions/:ixid/confirm`)是唯一把工作流交互变成执行意图的转换: - 前端校验只是体验,Cloud 在确认时**重新**解析目标,并按**被确认的那个已发布版本**的表单校验提交的值;没有任何已发布版本的 工作流不能确认,因为不能执行未发布的草稿。 diff --git a/frontend/src/features/issues/components/README.en.md b/frontend/src/features/issues/components/README.en.md index c0aa41a13..4b17574f1 100644 --- a/frontend/src/features/issues/components/README.en.md +++ b/frontend/src/features/issues/components/README.en.md @@ -31,9 +31,9 @@ Creation drafts remount by tenant/space; activity drafts by tenant/space/issue, Workflow confirmation locks duplicate submissions, editing and removal from interaction creation onward; a rejected confirm reuses that interaction on retry, including after a version switch. Confirmed rows use `features/workflows/api` to read the WorkflowRun referenced by `IssueRun.workflowInvocationRef`; detail links combine `executorId`, that reference and the route's workspace slug. -The initial form reads the live descriptor by `formRef + issueId`. A `snapshotId` select is recognized as the platform version selector only when every option belongs to this workflow's published snapshots; the form waits for that list and allows retry after a failed read. An author's same-named text field or select with custom options remains ordinary input. +The initial read takes the live descriptor by `formRef + issueId`, but only to locate the platform version selector and its default version. A `snapshotId` select is recognized as the platform version selector only when every option belongs to this workflow's published snapshots; the form waits for that list and allows retry after a failed read. Once the default version is known, the form itself renders that version's snapshot form even while the user stays on the default, so fields the live draft has not published never appear. An author's same-named text field or select with custom options remains ordinary input and renders from the live descriptor. -Changing the platform version selector loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. Reading a form is not run admission, but Confirm validates against the selected version's own fields alone and no longer compares them with the live draft: an older version runs on its own form even when its fields differ from the current draft, and the server refuses by that version when a required field is missing or a field it never declared is submitted. +Changing the platform version selector loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. Reading a form is not run admission, but Confirm validates against the selected version's own fields alone and no longer compares them with the live draft: an older version runs on its own form even when its fields differ from the current draft, and the server refuses by that version when a required field is missing or a field it never declared is submitted. A refusal that names a field is shown with that field's label; failures without a field parameter keep the generic message. Historical runs without a WorkflowRun reference retain their IssueRun status, make no WorkflowRun request and show no detail link. Linked runs always use the WorkflowRun's authoritative status. @@ -41,4 +41,4 @@ Historical runs without a WorkflowRun reference retain their IssueRun status, ma Component tests use MSW; properties and Task Mode→Thread are covered by `issue-detail-page.test.tsx`. Loading, empty, refused and retry states are distinct; creation, ordinary-comment and target failures retain drafts, while scope changes clear them. -Workflow tests cover double-click exclusion, rejected confirms reusing an interaction across version switches, snapshot fields/defaults/echoes, an author's same-named select making no snapshot-form request, acceptance and refusal by the selected version's own fields, pending and failed reads, and WorkflowRun status and detail links. +Workflow tests cover double-click exclusion, rejected confirms reusing an interaction across version switches, both the default and a switched version rendering from that version's snapshot, snapshot fields/defaults/echoes, named-field hints on validation refusals, an author's same-named select making no snapshot-form request, acceptance and refusal by the selected version's own fields, pending and failed reads, and WorkflowRun status and detail links. diff --git a/frontend/src/features/issues/components/README.md b/frontend/src/features/issues/components/README.md index 5f0c056b4..2dca15bef 100644 --- a/frontend/src/features/issues/components/README.md +++ b/frontend/src/features/issues/components/README.md @@ -31,9 +31,9 @@ Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与取消;确认失败后仍用同一 interaction 重试,包括切换版本后的重试。确认行通过 `features/workflows/api` 读取 `IssueRun.workflowInvocationRef` 对应的 WorkflowRun 状态,详情链接使用 `executorId`、该引用和当前路由的空间 slug。 -初始表单按 `formRef + issueId` 读取活稿。只有 `snapshotId` select 的全部选项都属于该 Workflow 的已发布快照时,才识别为平台版本选择器;列表读取完成前不开放表单,失败可重试。作者自定义的同名文本字段或含自定义选项的 select 仍是普通输入。 +初始读取按 `formRef + issueId` 取活稿,但只用来定位平台版本选择器及其默认版本。只有 `snapshotId` select 的全部选项都属于该 Workflow 的已发布快照时,才识别为平台版本选择器;列表读取完成前不开放表单,失败可重试。识别出默认版本后,表单本身按该版本的 snapshot form 渲染,用户不切换版本也一样,所以活稿里未发布的字段不会出现在表单里。作者自定义的同名文本字段或含自定义选项的 select 仍是普通输入,直接从活稿渲染。 -用户切换平台版本选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试。读取表单不代表服务端准入通过,但 Confirm 只按所选版本自身的字段校验,不再与活稿比较:选中的旧版本哪怕字段和当前草稿不同,也按它自己的表单执行,所选版本缺必填字段或收到它未声明的字段时由服务端按该版本拒绝。 +用户切换平台版本选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试。读取表单不代表服务端准入通过,但 Confirm 只按所选版本自身的字段校验,不再与活稿比较:选中的旧版本哪怕字段和当前草稿不同,也按它自己的表单执行,所选版本缺必填字段或收到它未声明的字段时由服务端按该版本拒绝。拒绝会带上字段参数,表单据此提示具体字段;没有字段参数的其他失败仍显示通用提示。 历史运行没有 WorkflowRun 引用时,确认行仍显示 IssueRun 状态,不请求 WorkflowRun,也不显示运行详情链接;有关联的运行始终以 WorkflowRun 状态为准。 @@ -41,4 +41,4 @@ Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与 组件测试用 MSW;属性栏和 Task Mode→Thread 经 `issue-detail-page.test.tsx` 覆盖。测试区分加载、空列表、拒绝、重试;创建、普通评论和目标提交失败保留草稿,作用域切换则清空草稿。 -Workflow 测试覆盖双击互斥、确认失败后复用 interaction(含切换版本)、版本字段/default/回显切换、作者同名 select 不发快照表单请求、按所选版本自身的字段通过与被拒、读取等待与失败,以及 WorkflowRun 状态和详情链接。 +Workflow 测试覆盖双击互斥、确认失败后复用 interaction(含切换版本)、默认版本与切换后的版本都按该版本快照渲染、版本字段/default/回显切换、字段校验拒绝时提示具体字段、作者同名 select 不发快照表单请求、按所选版本自身的字段通过与被拒、读取等待与失败,以及 WorkflowRun 状态和详情链接。 diff --git a/frontend/src/features/issues/components/workflow-interaction-composer.tsx b/frontend/src/features/issues/components/workflow-interaction-composer.tsx index cca0637d6..925ff4d96 100644 --- a/frontend/src/features/issues/components/workflow-interaction-composer.tsx +++ b/frontend/src/features/issues/components/workflow-interaction-composer.tsx @@ -15,6 +15,7 @@ import { import { AssistSuggestions } from '@/features/issues/components/assist-suggestions' import { ConfirmReview } from '@/features/issues/components/confirm-review' import { DynamicFormRenderer } from '@/features/issues/components/dynamic-form-renderer' +import { faultCode, faultParam } from '@/lib/api-client' import type { AssistSuggestion, CollaborationTargetSummary, @@ -377,7 +378,112 @@ function useWorkflowSubmission({ return { submit, isPending: submission.isPending || submission.isSuccess, - errorMessage: submission.isError ? '确认失败,请重试。' : undefined, + error: submission.error, + } +} + +/** + * Turns a refused confirm into a hint the user can act on. A field rejection names the failing key, so + * it is shown with the descriptor's label; any other failure stays generic, because its code has no + * counterpart the form can point at and retrying is the only remedy the surface can offer. + */ +function confirmErrorMessage(error: unknown, descriptor: FormDescriptor): string { + const code = faultCode(error) + const key = faultParam(error, 'field') + if (!code || !key) return '确认失败,请重试。' + const label = descriptor.fields.find((field) => field.key === key)?.label ?? key + if (code === 'required_field_missing') return `请填写必填项:${label}` + if (code === 'invalid_field_value') return `字段「${label}」的取值不合法,请修改后重试。` + return '确认失败,请重试。' +} + +/** The live field that may be the platform version selector: the platform's key and control type. */ +function isSnapshotIdSelect(field: FormField): boolean { + return field.key === 'snapshotId' && field.type === 'select' +} + +/** + * The live descriptor's platform version field, confirmed against every published snapshot id, so an + * author's same-named control is never mistaken for the platform's. + */ +function platformVersionField( + descriptor: FormDescriptor | undefined, + publishedIds: string[] | undefined, +): FormField | undefined { + if (!descriptor || !publishedIds) return undefined + return descriptor.fields.find((field) => isSnapshotSelector(field, publishedIds)) +} + +/** The version a platform selector opens on: the server fills it with the newest published snapshot. */ +function defaultSnapshotId(field: FormField | undefined): string | undefined { + const value = field?.defaultValue + return typeof value === 'string' && value !== '' ? value : undefined +} + +/** Why the form cannot be drawn yet: the reads are still in flight, or one of them failed. */ +type FormBlocker = { kind: 'loading' } | { kind: 'failed'; retry: () => void } + +/** What the composer renders: a blocker, or the form itself. */ +type ChosenVersionForm = + | FormBlocker + | { + kind: 'ready' + descriptor: FormDescriptor + versionField: FormField | undefined + snapshotId: string | undefined + loading: boolean + chooseVersion: (id: string) => void + } + +/** The slice of a query a form read needs to decide whether it can contribute a descriptor. */ +interface FormRead { + isPending: boolean + isError: boolean + refetch: () => void +} + +/** + * Reports the first read that blocks the form: a failure outranks a pending read because retrying it is + * the only way forward, and a read that has not started cannot be failed. + */ +function formBlocker(reads: FormRead[]): FormBlocker | undefined { + const failed = reads.find((read) => read.isError) + if (failed) return { kind: 'failed', retry: () => failed.refetch() } + return reads.some((read) => read.isPending) ? { kind: 'loading' } : undefined +} + +/** + * Resolves the form the composer shows. The live descriptor only locates the platform version field and + * the version it opens on; the rendered form itself comes from that version's snapshot, so the fields + * the user sees are exactly the ones a confirm validates (Issue collaboration D9). Choosing another + * version reloads the same way, which is why there is one code path for the default and a switch. + */ +function useChosenVersionForm( + slug: string, + workflowId: string, + issueId: string, + formRef: string | undefined, +): ChosenVersionForm { + const live = useFormDescriptor(slug, formRef, issueId) + const candidate = live.data?.fields.find(isSnapshotIdSelect) + const published = useWorkflowPublishedSnapshotIds(slug, candidate ? workflowId : undefined) + const [chosenSnapshotId, setChosenSnapshotId] = useState() + const selected = + chosenSnapshotId ?? defaultSnapshotId(platformVersionField(live.data, published.data)) + const snapshot = useWorkflowSnapshotForm(slug, workflowId, selected, issueId) + + const reads = [live, ...(candidate ? [published] : []), ...(selected ? [snapshot] : [])] + const blocked = formBlocker(reads) + if (blocked) return blocked + const descriptor = selected ? snapshot.data : live.data + if (!descriptor) return { kind: 'loading' } + return { + kind: 'ready', + descriptor, + versionField: platformVersionField(descriptor, published.data), + snapshotId: snapshot.data?.snapshotId, + loading: (selected ? snapshot : live).isFetching, + chooseVersion: setChosenSnapshotId, } } @@ -399,32 +505,22 @@ export function WorkflowInteractionComposer({ onConfirmed: () => void onRemove: () => void }) { - const formRef = target.interactionDescriptor.formRef ?? undefined - const [snapshotId, setSnapshotId] = useState() - const live = useFormDescriptor(slug, formRef, issueId) - const snapshot = useWorkflowSnapshotForm(slug, target.id, snapshotId, issueId) - const descriptorQuery = snapshotId ? snapshot : live - const confirm = useWorkflowSubmission({ slug, issueId, target, onConfirmed }) - const descriptor = descriptorQuery.data - const candidate = descriptor?.fields.find( - (field) => field.key === 'snapshotId' && field.type === 'select', + const form = useChosenVersionForm( + slug, + target.id, + issueId, + target.interactionDescriptor.formRef ?? undefined, ) - const published = useWorkflowPublishedSnapshotIds(slug, candidate ? target.id : undefined) + const confirm = useWorkflowSubmission({ slug, issueId, target, onConfirmed }) - if (descriptorQuery.isPending || (candidate && published.isPending)) { - return - } - if (descriptorQuery.isError || !descriptor || (candidate && published.isError)) { + if (form.kind === 'loading') return + if (form.kind === 'failed') { return (

当前部署无法加载该 Workflow 的表单(form descriptor 不可用)。

-
@@ -433,18 +529,16 @@ export function WorkflowInteractionComposer({ return ( - isSnapshotSelector(field, published.data ?? []), - )} - snapshotId={snapshot.data?.snapshotId} - loading={descriptorQuery.isFetching} + descriptor={form.descriptor} + versionField={form.versionField} + snapshotId={form.snapshotId} + loading={form.loading} confirm={confirm} - onSnapshotChange={setSnapshotId} + onSnapshotChange={form.chooseVersion} onRemove={onRemove} /> ) @@ -508,7 +602,7 @@ function WorkflowInteractionForm(props: { assistPending={assist.isPending} confirmPending={confirm.isPending} assistFailed={assist.isError} - confirmError={confirm.errorMessage} + confirmError={confirm.error ? confirmErrorMessage(confirm.error, descriptor) : undefined} onAssist={() => assist.mutate( { targetId: target.id, values: form.values }, diff --git a/frontend/src/features/issues/components/workflow-interaction.test.tsx b/frontend/src/features/issues/components/workflow-interaction.test.tsx index e31dc70ab..451e6ca97 100644 --- a/frontend/src/features/issues/components/workflow-interaction.test.tsx +++ b/frontend/src/features/issues/components/workflow-interaction.test.tsx @@ -183,6 +183,11 @@ function serveComposer(overrides: { descriptor?: unknown; suggestion?: AssistSug http.get('/api/v1/tenants/t1/workflows/wf1/snapshots', () => HttpResponse.json({ items: [{ id: 'snap-3' }, { id: 'snap-2' }], nextCursor: '' }), ), + // The composer opens on the platform's default version, so a form that selects one is read from + // that version's snapshot rather than from the live descriptor. + http.get('/api/v1/tenants/t1/workflows/wf1/snapshots/snap-3/form', () => + HttpResponse.json({ ...launchDescriptor, snapshotId: 'snap-3' }), + ), http.post('/api/v1/tenants/t1/issues/i1/collaboration/assist', async ({ request }) => { assist(await request.json()) return HttpResponse.json(overrides.suggestion ?? suggestion) @@ -403,6 +408,70 @@ describe('WorkflowInteractionComposer', () => { await waitFor(() => expect(onConfirmed).toHaveBeenCalled()) }) + it('opens on the default published version by reading that version, so rendering and validation match', async () => { + const { confirm } = serveComposer({ descriptor: launchDescriptor }) + const read = vi.fn<(issueId: string | null) => void>() + server.use( + http.get('/api/v1/tenants/t1/workflows/wf1/snapshots/snap-3/form', ({ request }) => { + read(new URL(request.url).searchParams.get('issueId')) + return HttpResponse.json({ + ...launchDescriptor, + snapshotId: 'snap-3', + fields: [ + ...launchDescriptor.fields, + { + key: 'v3Only', + label: 'V3 only', + type: 'text', + required: true, + defaultValue: 'frozen v3', + }, + ], + }) + }), + ) + const user = userEvent.setup() + const { onConfirmed } = renderComposer() + + // The live descriptor has no such field: seeing it proves the form is the snapshot's own, and the + // confirm submits exactly the fields this version asked for. + expect(await screen.findByLabelText(/V3 only/)).toHaveValue('frozen v3') + expect(selectTrigger('运行版本')).toHaveTextContent('v3 · 发布 3') + expect(read).toHaveBeenCalledWith('i1') + + await reviewAndConfirm(user) + await waitFor(() => + expect(confirm).toHaveBeenCalledWith({ + values: { + repository: 'https://github.com/ora/cloud', + branch: 'main', + snapshotId: 'snap-3', + prompt: 'Review the diff', + v3Only: 'frozen v3', + }, + }), + ) + await waitFor(() => expect(onConfirmed).toHaveBeenCalled()) + }) + + it('names the rejected field when a confirm fails validation', async () => { + serveComposer({ descriptor: launchDescriptor }) + server.use( + http.post('/api/v1/tenants/t1/issues/i1/interactions/ix1/confirm', () => + HttpResponse.json( + { code: 'invalid_field_value', params: { field: 'branch' } }, + { status: 400 }, + ), + ), + ) + const user = userEvent.setup() + renderComposer() + await screen.findByLabelText(/分支/) + await reviewAndConfirm(user) + + expect(await screen.findByText('字段「分支」的取值不合法,请修改后重试。')).toBeInTheDocument() + }) + it('prefills the branch and the newest published version, and gates Review on the branch too', async () => { const { confirm } = serveComposer({ descriptor: launchDescriptor }) const user = userEvent.setup() @@ -670,7 +739,7 @@ describe('WorkflowInteractionComposer', () => { const { comment, confirm } = serveComposer({ descriptor: launchDescriptor }) serveOldSnapshot() const { user, onConfirmed } = await confirmIntoRejection('required_field_missing', 400) - await screen.findByText('确认失败,请重试。') + await screen.findByText('请填写必填项:提示词') await user.click(screen.getByRole('button', { name: '返回修改' })) await user.click(selectTrigger('运行版本')) await user.click(await screen.findByRole('option', { name: 'v2 · 发布 2' })) diff --git a/frontend/src/lib/README.en.md b/frontend/src/lib/README.en.md index 5be9955d7..dd9316d02 100644 --- a/frontend/src/lib/README.en.md +++ b/frontend/src/lib/README.en.md @@ -11,8 +11,8 @@ Utilities shared by generated code and components that contain neither React nor | File | Description | | --- | --- | | `event-stream.ts` / `event-stream.test.ts` | Bounded SSE framing, reader cleanup and cancellable reconnect waits; no business payload interpretation. | -| `api-client.ts` | Shared axios instance `AXIOS_INSTANCE` and the orval mutator `customInstance`. The single home for cross-cutting HTTP policy; also reconciles the two cancellation sources, react-query's `AbortSignal` and orval's `cancel()`. The request interceptor supplies `Idempotency-Key` for POST/DELETE; the response interceptor broadcasts every 401 to `onUnauthorized` subscribers (the session owner ends the session on it). Authentication is not a header: the browser holds only the gateway's HttpOnly session cookie, same-origin requests carry it automatically, and the frontend never touches a token; `faultCode` extracts the backend `Fault.code` (e.g. `not_found`, `capability_unavailable`) from a rejected request so feature modules can map errors to UX. | -| `api-client.test.ts` | Verifies body unwrapping, both cancellation paths, the idempotency-key policy, that no credential header is attached, that 401 listeners fire and can be removed, and `faultCode` error-code extraction. | +| `api-client.ts` | Shared axios instance `AXIOS_INSTANCE` and the orval mutator `customInstance`. The single home for cross-cutting HTTP policy; also reconciles the two cancellation sources, react-query's `AbortSignal` and orval's `cancel()`. The request interceptor supplies `Idempotency-Key` for POST/DELETE; the response interceptor broadcasts every 401 to `onUnauthorized` subscribers (the session owner ends the session on it). Authentication is not a header: the browser holds only the gateway's HttpOnly session cookie, same-origin requests carry it automatically, and the frontend never touches a token; `faultCode` extracts the backend `Fault.code` (e.g. `not_found`, `capability_unavailable`) from a rejected request so feature modules can map errors to UX; `faultParam` reads one string parameter the backend marked safe (the `field` a validation rejection names), never transport or server detail. | +| `api-client.test.ts` | Verifies body unwrapping, both cancellation paths, the idempotency-key policy, that no credential header is attached, that 401 listeners fire and can be removed, and `faultCode` / `faultParam` error-code and safe-parameter extraction. | | `navigation.ts` | The only contact with other origins: `navigateExternal` (the login redirect to the provider) and `openExternalTab` (a provider page in a new `noopener` tab, this tab stays); `replaceExternalNavigation` / `replaceExternalTabOpener` let the test scaffolding swap them, since jsdom does not allow spying on `location.assign` and has no `window.open`. | | `navigation.test.ts` | Verifies the replace-and-restore semantics. | | `pagination.ts` / `pagination.test.ts` | Walks every cursor page so spaces, members, and applications are not silently truncated; tests cross-page ordering. | diff --git a/frontend/src/lib/README.md b/frontend/src/lib/README.md index 0e2037afe..32ac6ec7c 100644 --- a/frontend/src/lib/README.md +++ b/frontend/src/lib/README.md @@ -11,8 +11,8 @@ | 文件 | 说明 | | --- | --- | | `event-stream.ts` / `event-stream.test.ts` | 有界 SSE 帧读取、释放 reader 和可取消重连等待;不解释业务载荷。 | -| `api-client.ts` | 共享 axios 实例 `AXIOS_INSTANCE` 与 orval mutator `customInstance`。跨切面 HTTP 策略唯一的落点;同时处理 react-query 的 `AbortSignal` 与 orval 的 `cancel()` 两种取消来源。请求拦截器为 POST/DELETE 补发 `Idempotency-Key`;响应拦截器把任何 401 广播给 `onUnauthorized` 的订阅者(会话拥有者据此结束会话)。认证不是 header:浏览器只持有 Gateway 的 HttpOnly 会话 Cookie,同源请求自动携带,前端从不接触 token;`faultCode` 从被拒绝的请求中取出后端 `Fault.code`(如 `not_found`、`capability_unavailable`),供各 feature 模块把错误映射到 UX。 | -| `api-client.test.ts` | 验证响应体解包、两条取消路径、幂等键策略、不附加任何凭证头、401 监听器的触发与移除,以及 `faultCode` 的错误码提取。 | +| `api-client.ts` | 共享 axios 实例 `AXIOS_INSTANCE` 与 orval mutator `customInstance`。跨切面 HTTP 策略唯一的落点;同时处理 react-query 的 `AbortSignal` 与 orval 的 `cancel()` 两种取消来源。请求拦截器为 POST/DELETE 补发 `Idempotency-Key`;响应拦截器把任何 401 广播给 `onUnauthorized` 的订阅者(会话拥有者据此结束会话)。认证不是 header:浏览器只持有 Gateway 的 HttpOnly 会话 Cookie,同源请求自动携带,前端从不接触 token;`faultCode` 从被拒绝的请求中取出后端 `Fault.code`(如 `not_found`、`capability_unavailable`),供各 feature 模块把错误映射到 UX;`faultParam` 取出后端标记为安全的字符串参数(如校验拒绝指向的 `field`),同样不暴露传输或服务端细节。 | +| `api-client.test.ts` | 验证响应体解包、两条取消路径、幂等键策略、不附加任何凭证头、401 监听器的触发与移除,以及 `faultCode` / `faultParam` 的错误码与安全参数提取。 | | `navigation.ts` | 与其它 origin 接触的唯一出口:`navigateExternal`(登录跳转到 provider)与 `openExternalTab`(在新的 `noopener` 标签页打开 provider 页面,当前页保留);`replaceExternalNavigation` / `replaceExternalTabOpener` 供测试脚手架替换,因为 jsdom 不允许 spy `location.assign` 也没有 `window.open`。 | | `navigation.test.ts` | 验证替换与还原语义。 | | `pagination.ts` / `pagination.test.ts` | 顺序读取所有游标页,确保空间、成员和申请列表不会只显示首页;测试跨页顺序。 | diff --git a/frontend/src/lib/api-client.test.ts b/frontend/src/lib/api-client.test.ts index 553fd119a..7a924d0ab 100644 --- a/frontend/src/lib/api-client.test.ts +++ b/frontend/src/lib/api-client.test.ts @@ -1,7 +1,13 @@ import { CanceledError } from 'axios' import { describe, expect, it, onTestFinished } from 'vitest' import { installFakeHttp } from '@/test/http' -import { customInstance, faultCode, isUnauthorizedError, onUnauthorized } from './api-client' +import { + customInstance, + faultCode, + faultParam, + isUnauthorizedError, + onUnauthorized, +} from './api-client' describe('customInstance', () => { it('unwraps the response body', async () => { @@ -117,3 +123,23 @@ describe('faultCode', () => { expect(faultCode(undefined)).toBeUndefined() }) }) + +describe('faultParam', () => { + it('extracts a string parameter the backend marked safe for the client', () => { + const error = { + isAxiosError: true, + response: { data: { code: 'required_field_missing', params: { field: 'prompt' } } }, + } + expect(faultParam(error, 'field')).toBe('prompt') + }) + + it('returns undefined for missing, non-string or non-axios parameters', () => { + expect(faultParam({ isAxiosError: true }, 'field')).toBeUndefined() + expect(faultParam({ isAxiosError: true, response: {} }, 'field')).toBeUndefined() + expect( + faultParam({ isAxiosError: true, response: { data: { params: { field: 3 } } } }, 'field'), + ).toBeUndefined() + expect(faultParam(new Error('network down'), 'field')).toBeUndefined() + expect(faultParam(undefined, 'field')).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 66b15a15e..f4c5abf0d 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -64,6 +64,17 @@ export function faultCode(error: unknown): string | undefined { return typeof code === 'string' ? code : undefined } +/** + * Reads one safe public Fault parameter — the identifier the backend already lets the caller act on, + * such as the `field` a validation rejection names. Anything but a string is ignored, so a caller + * never renders transport or server detail. + */ +export function faultParam(error: unknown, key: string): string | undefined { + if (!isAxiosError<{ params?: Record }>(error)) return undefined + const value = error.response?.data?.params?.[key] + return typeof value === 'string' ? value : undefined +} + AXIOS_INSTANCE.interceptors.response.use(undefined, (error: unknown) => { if (isUnauthorizedError(error)) { for (const listener of unauthorizedListeners) listener() diff --git a/integration/migration_upgrade_path_test.go b/integration/migration_upgrade_path_test.go index c25cbb54e..111da7704 100644 --- a/integration/migration_upgrade_path_test.go +++ b/integration/migration_upgrade_path_test.go @@ -1433,6 +1433,39 @@ func TestMigration0052LegacyWorkflowIssueRunSettlementAppliesFreshAndUpgrades(t } } + // assertChained pins the state the whole sequence reaches. 0053 retires every remaining unlinked + // non-terminal workflow row — `running`, and the soft-deleted `queued` row — because a row with no + // WorkflowRun can never be finished by an executor, and its validated CHECK requires a reference on + // every non-terminal workflow row, soft-deleted ones included. + assertChained := func(t *testing.T, pool *sql.DB, ids map[string]string, stage string) { + t.Helper() + for _, id := range []string{ids["queued"], ids["dispatched"]} { + got := read(t, pool, id) + if got.status != "failed" || got.reason != "workflow_execution_unavailable" || !got.completedAt.Valid { + t.Fatalf("%s: run %s = %+v; want 0052's settlement to stand", stage, id, got) + } + } + for _, id := range []string{ids["running"], ids["deleted"]} { + got := read(t, pool, id) + if got.status != "failed" || got.reason != "workflow_execution_unavailable_before_invocation_linkage" || + !got.completedAt.Valid || got.version != 2 { + t.Fatalf("%s: unlinked run %s = %+v; want 0053 to settle it with the invocation-linkage reason", stage, id, got) + } + } + for id, want := range map[string]runShape{ + ids["completed"]: {status: "completed", version: 1}, + ids["agent"]: {status: "queued", version: 1}, + } { + got := read(t, pool, id) + if got.status != want.status || got.version != want.version || got.reason != "" || got.completedAt.Valid { + t.Fatalf("%s: untouched run %s = %+v; want %s with no correction", stage, id, got, want.status) + } + } + if deleted := read(t, pool, ids["deleted"]); !deleted.deletedAt.Valid { + t.Fatalf("%s: settled soft-deleted run = %+v; want deleted_at retained", stage, deleted) + } + } + freshPool, _ := testSchema(t, "test_wf52f_") migratedTwice(t, newStoreOnSchema(t, freshPool)) var corrected int @@ -1444,10 +1477,14 @@ func TestMigration0052LegacyWorkflowIssueRunSettlementAppliesFreshAndUpgrades(t pool, _ := testSchema(t, "test_wf52_") applyMigrationsUpTo(t, pool, migrationsBefore(t, "0052_legacy_workflow_issue_run_settlement.sql")) ids := seedRuns(t, pool) + // Apply 0052 alone so the assertions below show what this file itself changes; 0053 runs later and + // settles the rows 0052 deliberately left alone. + applyMigrationsUpTo(t, pool, []string{"0052_legacy_workflow_issue_run_settlement.sql"}) + assertUpgraded(t, pool, ids, "0052") migratedTwice(t, newStoreOnSchema(t, pool)) - assertUpgraded(t, pool, ids, "upgrade") + assertChained(t, pool, ids, "upgrade") // Repeated application is a no-op: the correction already committed, and Migrate skipped the // applied file by its checksum, so nothing can bump a version a second time. migratedTwice(t, newStoreOnSchema(t, pool)) - assertUpgraded(t, pool, ids, "replay") + assertChained(t, pool, ids, "replay") } diff --git a/integration/workflow_interaction_test.go b/integration/workflow_interaction_test.go index e5dc5063a..66289eac1 100644 --- a/integration/workflow_interaction_test.go +++ b/integration/workflow_interaction_test.go @@ -888,10 +888,12 @@ func TestWorkflowAssistReadsThePreRenameVersionKey(t *testing.T) { owned := createWorkflow(t, f, "Owned Workflow", "wf-assist-rename-owned", []any{ core.Object{"name": "snapshotId", "displayName": "Release version", "fieldType": "text-input", "valueType": "string", "required": true}, }) - publishWorkflow(t, f, owned, "wf-assist-rename-owned-pub") + ownedSnap := publishWorkflow(t, f, owned, "wf-assist-rename-owned-pub") ownedIss := f.call("POST", f.path("/issues"), core.Object{"title": "Fill the old form again"}, "wf-assist-rename-issue-2", 200).O("resource") + // The old key still carries the version selection here, so it has to name a real published version: + // assist resolves the version it answers for, exactly as the confirm does. f.call("POST", assistPath(f, ownedIss.S("id")), workflowAssistBody(owned, core.Object{ - "repository": "ora-space/cloud", "version": "1.2.3", + "repository": "ora-space/cloud", "version": ownedSnap.S("id"), }), "wf-assist-rename-owned-a1", 200) // A graph whose author declared `version` for a variable of their own. That key is the author's, so @@ -996,6 +998,15 @@ func TestWorkflowConfirmValidatesAgainstTheChosenVersion(t *testing.T) { if got := f.call("GET", f.path("/issues/"+extraIssue+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v1.S("id") { t.Fatalf("the run executed %q, want the selected version %q", got, v1.S("id")) } + + // Assist answers for that version too, because it validates the submission the confirm will judge. + // The live draft declares `scope`, so reading it would accept this submission; version 1 never did. + assistIssue := newIssue("wf-diverge-issue-4") + assistExtra := f.call("POST", assistPath(f, assistIssue), workflowAssistBody(wid, withVersion(wide, v1.S("id"))), "wf-diverge-a1", 400) + if code, field := assistExtra.S("code"), assistExtra.O("params").S("field"); code != "invalid_field_value" || field != "scope" { + t.Fatalf("assist accepted the draft's scope under version 1: %v", assistExtra) + } + f.call("POST", assistPath(f, assistIssue), workflowAssistBody(wid, withVersion(version1, v1.S("id"))), "wf-diverge-a2", 200) } // TestFormDescriptorUnavailableAndInvalid covers the provider failure modes (Issue collaboration D7) and where they diff --git a/integration/workflow_invocation_migration_test.go b/integration/workflow_invocation_migration_test.go index 2ba076c65..81e4a19cf 100644 --- a/integration/workflow_invocation_migration_test.go +++ b/integration/workflow_invocation_migration_test.go @@ -7,7 +7,7 @@ import ( "github.com/google/uuid" ) -const workflowInvocationMigration = "0052_workflow_invocation_reference.sql" +const workflowInvocationMigration = "0053_workflow_invocation_reference.sql" // seedInvocationWorkflowRun creates real rows for the composite reference under test. The graph is // deliberately minimal: these tests exercise database integrity rather than graph admission. diff --git a/internal/core/form_descriptor.go b/internal/core/form_descriptor.go index 8622bf49d..ec7eb3fd4 100644 --- a/internal/core/form_descriptor.go +++ b/internal/core/form_descriptor.go @@ -371,11 +371,10 @@ func formDescriptorByRef(t *transaction, r *PublicRequest) Object { // than from the live document. // // It exists because a caller can choose the version *before* rendering — the workflow editor's manual -// run entry does — and such a caller has to be shown the fields of the version it picked. The `@` form -// reads the live descriptor until the user selects a published version and then reloads through this -// route; a manual run has chosen one up front, so either way the chosen version's own fields are what -// the submission is judged by. Confirm already projects this way when it validates a submission, so -// both read the one projection: this route adds a caller, not a second rule. +// run entry does, and so does the `@` form, which opens on the platform's default published version +// and reloads through this route whenever the user picks another. Either way the chosen version's own +// fields are what the submission is judged by, because confirm validates against the one projection +// this route serves: it adds a caller, not a second rule. // // It is a projection and not an admission decision. Reading a descriptor says nothing about whether a // run may start — publish state, archiving, input dependencies and per-slot resolution stay the start diff --git a/internal/core/issue_interactions.go b/internal/core/issue_interactions.go index d93240331..53b7a7b9e 100644 --- a/internal/core/issue_interactions.go +++ b/internal/core/issue_interactions.go @@ -164,18 +164,32 @@ func confirmInteraction(t *transaction, r *PublicRequest, uid string) Object { // accept the same keys: a form rendered before the rename answers under the old `version` key, and the // live descriptor no longer declares it. The fold is the confirm's own, taken from the one place the // rule is defined (see foldLegacyVersionAnswer), rather than a second compatibility rule that could -// drift from it. Assist reads no version cursor: it resolves no version, and the graph is read only to -// decide what the old key means for this workflow. +// drift from it. +// +// The descriptor is the version's, not the live document's, whenever the submission names a published +// version or the workflow has one: the client renders the selected version's form even on its default, +// so assist has to answer for the fields that form shows. A workflow with nothing published has no +// frozen descriptor, and the live one is both what the form shows and what a confirm would refuse to +// run. func assistWorkflow(t *transaction, r *PublicRequest) Object { i := issue(t, r.TenantID, r.IssueID) targetID := r.Body.S("targetId") require(validID(targetID), 400, "invalid_target") issueContext := formContextForIssue(t, r.TenantID, i) + w := workflow(t, r.TenantID, targetID) descriptor := workflowFormDescriptor(t, r.TenantID, "workflow", targetID, &issueContext) // Assist is requested while the form is still being filled, so shape is validated but requiredness // is not — an incomplete form is exactly when a suggestion is useful. current := interactionValues(r.Body) - foldLegacyVersionAnswer(workflow(t, r.TenantID, targetID).O("graph"), current) + cursor := workflowVersionCursor(w.O("graph"), current) + foldLegacyVersionAnswer(w.O("graph"), current) + s := workflowLatestSnapshot(t, r.TenantID, targetID) + if cursor != "" { + s = workflowSnapshot(t, r.TenantID, targetID, cursor) + } + if s != nil { + descriptor = snapshotFormDescriptor(t, w, s, &issueContext) + } validateFormValues(descriptor, current, false) in := AssistInput{ diff --git a/internal/core/workflow_admission.go b/internal/core/workflow_admission.go index 918011c28..33a750d46 100644 --- a/internal/core/workflow_admission.go +++ b/internal/core/workflow_admission.go @@ -41,11 +41,18 @@ func workflowRunSnapshot(t *transaction, tid, wid, sid string) Object { if sid != "" { return workflowSnapshot(t, tid, wid, sid) } - s := t.one("SELECT * FROM workflow_snapshots WHERE tenant_id=$1 AND workflow_id=$2 ORDER BY version DESC LIMIT 1", tid, wid) + s := workflowLatestSnapshot(t, tid, wid) require(s != nil, 400, "workflow_no_published_snapshot") return s } +// workflowLatestSnapshot returns the workflow's newest published version, or nil while nothing is +// published. Callers that gate on a run require one; callers that only project a form can fall back to +// the live document, which is the only form such a workflow has. +func workflowLatestSnapshot(t *transaction, tid, wid string) Object { + return t.one("SELECT * FROM workflow_snapshots WHERE tenant_id=$1 AND workflow_id=$2 ORDER BY version DESC LIMIT 1", tid, wid) +} + // requireWorkflowRunInput refuses a run whose input does not answer the variables the frozen graph // declares: a required Start variable with no value, or a value of the wrong type for its control. // diff --git a/internal/core/workflow_collaboration.go b/internal/core/workflow_collaboration.go index b9fe87d7a..580710916 100644 --- a/internal/core/workflow_collaboration.go +++ b/internal/core/workflow_collaboration.go @@ -211,8 +211,8 @@ func workflowSnapshotOptions(t *transaction, tid, wid string) []Object { } // workflowDescriptorSource is the projection input for one graph of a workflow: its live document, or -// one of its frozen versions. Both descriptors a confirm compares are built from here, so the graph is -// the only thing that can differ between them — a source assembled any other way could make them +// one of its frozen versions. Both are built from here, so for the same graph the live form and a +// version's form differ only in the graph — a source assembled any other way could make a version // differ for a reason that has nothing to do with what the user was shown. func workflowDescriptorSource(t *transaction, w, graph Object) *workflowFormSource { return &workflowFormSource{ From 3f09884314298c93eee8f7597eed2d313247cfac Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Wed, 21 Oct 2026 10:54:49 +0800 Subject: [PATCH 18/19] =?UTF-8?q?test(core):=20=E4=B8=A4=E6=9D=A1=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=8B=92=E7=BB=9D=E8=B7=AF=E5=BE=84=E9=83=BD=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E4=B8=8D=E5=BB=BA=E8=BF=90=E8=A1=8C=E5=B9=B6=E5=8F=AF?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺必填字段与提交未声明字段的拒绝原本只断言错误码;补上 issue_runs 计数不变, 以及同一 interaction 随后按所选版本重新确认成功,与 specs 用例的证据说明对齐。 Co-Authored-By: Claude Code --- integration/workflow_interaction_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/integration/workflow_interaction_test.go b/integration/workflow_interaction_test.go index 66289eac1..d34ab9562 100644 --- a/integration/workflow_interaction_test.go +++ b/integration/workflow_interaction_test.go @@ -983,6 +983,11 @@ func TestWorkflowConfirmValidatesAgainstTheChosenVersion(t *testing.T) { if n := f.scalar("SELECT count(*) FROM issue_runs WHERE issue_id=$1", missingIssue); n != 0 { t.Fatalf("a refused confirm created %d runs", n) } + // The refusal left the interaction usable: answering version 1's own form confirms it. + run = f.call("POST", confirmPath(f, missingIssue, missingIt.S("id")), core.Object{"values": withVersion(version1, v1.S("id"))}, "wf-diverge-c2b", 200).O("resource") + if got := f.call("GET", f.path("/issues/"+missingIssue+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v1.S("id") { + t.Fatalf("the run executed %q, want the selected version %q", got, v1.S("id")) + } // And a key version 1 does not declare is refused by version 1: `scope` belongs to the live draft, // not to the version the user selected, so it is not silently dropped or passed through. @@ -993,6 +998,9 @@ func TestWorkflowConfirmValidatesAgainstTheChosenVersion(t *testing.T) { if code, field := extra.S("code"), extra.O("params").S("field"); code != "invalid_field_value" || field != "scope" { t.Fatalf("a version-1 confirm carrying the draft's scope = %q on %q; want invalid_field_value on scope: %v", code, field, extra) } + if n := f.scalar("SELECT count(*) FROM issue_runs WHERE issue_id=$1", extraIssue); n != 0 { + t.Fatalf("a refused confirm created %d runs", n) + } // The same interaction confirms once the submission answers version 1's own form. run = f.call("POST", confirmPath(f, extraIssue, extraIt.S("id")), core.Object{"values": withVersion(version1, v1.S("id"))}, "wf-diverge-c4", 200).O("resource") if got := f.call("GET", f.path("/issues/"+extraIssue+"/runs/"+run.S("id")), nil, "", 200).O("input").S("workflowSnapshotId"); got != v1.S("id") { From 994fcbae0e88b95c7a223057d8f6abb6836b2222 Mon Sep 17 00:00:00 2001 From: Zpc001 <1903085231@qq.com> Date: Wed, 21 Oct 2026 10:54:49 +0800 Subject: [PATCH 19/19] =?UTF-8?q?docs:=20=E5=86=99=E6=98=8E=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E5=B9=B3=E5=8F=B0=E7=89=88=E6=9C=AC=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=E6=97=B6=E7=9A=84=E6=98=BE=E7=A4=BA=E4=B8=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 活稿自行声明 snapshotId(或用启动字段移除它)时没有选择器,表单按活稿渲染而 Confirm 执行最新已发布版本;切到的版本自己声明该键时该版本表单同样没有平台字段, 作者值会被当成版本游标。两份协作文档与组件 README 按实际行为写明这是作者占用 保留键的边界,是否禁止或提示这类切换留给 circus#405。 Co-Authored-By: Claude Code --- docs/collaboration/interactions.en.md | 10 ++++++++++ docs/collaboration/interactions.md | 6 ++++++ frontend/src/features/issues/components/README.en.md | 2 ++ frontend/src/features/issues/components/README.md | 2 ++ 4 files changed, 20 insertions(+) diff --git a/docs/collaboration/interactions.en.md b/docs/collaboration/interactions.en.md index 8eafba82b..96999021e 100644 --- a/docs/collaboration/interactions.en.md +++ b/docs/collaboration/interactions.en.md @@ -83,6 +83,16 @@ select workflow ──▶ read form descriptor ──▶ fill in (optional AI as and switching the selector reloads the new version the same way. The form shown and the version Confirm validates are therefore always the same, and fields the live draft has not published never appear in the default view. + That promise assumes the platform can name the version, and the selector itself comes from the live + draft. When the live draft declares the reserved key `snapshotId` (or removes it through a launch + field) there is no version to choose: the @ form renders the live draft while Confirm still applies + the live draft's version rule and runs the newest published version. When the selector does exist but + the chosen version's own graph declares `snapshotId` (or its select offers options that are not all + published ids), that version's snapshot form has no platform field while Confirm still treats the key + as the platform's: the author's value is read as a version cursor, so an empty value runs the newest + published version and a non-id value is refused. Both are the boundary of an author occupying a + reserved key, not a supported configuration: authors must not name their own variables `snapshotId`. + Whether switching to such a version should be refused or warned about is a later decision (circus#405). - **Platform fields** are injected only when the form is opened for an Issue: repository URL and branch (defaulting to the repository and default branch of the Issue's Project; required), a prompt, the published version to run `snapshotId`, and additional context references `context_refs`. When the diff --git a/docs/collaboration/interactions.md b/docs/collaboration/interactions.md index 08c8c27e1..3fc9bc4d5 100644 --- a/docs/collaboration/interactions.md +++ b/docs/collaboration/interactions.md @@ -63,6 +63,12 @@ Agent 名册(`space_agents`,由已安装的 Agent 插件形成,见 [插件 前端先读取当前文档,只为定位平台版本选择器及其默认版本;确定默认版本(最新已发布版本)后,初次打开就按该版本通过 `GET /tenants/:tid/workflows/:wfid/snapshots/:snapshotId/form?issueId=…` 读取表单,切换版本时按新版本重新读取。 因此显示的表单与 Confirm 校验的版本始终相同,活稿里未发布的字段不出现在默认视图。 + 这条同源承诺的前提是平台能指定版本,而选择器由活稿决定。活稿自己声明了保留键 `snapshotId`(或用启动字段移除它)时没有 + 可选的版本:`@` 表单按活稿渲染,Confirm 仍按活稿的版本规则执行最新已发布版本,显示的字段可能与执行的版本不同。 + 选择器存在、但用户切到的那个版本自己的图声明了 `snapshotId`(或它的 select 选项不全是已发布 id)时,该版本的快照表单里 + 没有平台字段,Confirm 仍按活稿判定这个键归平台,提交里的作者值会被当成版本游标:空值落到最新已发布版本,非 id 值被拒。 + 两种情况都是作者占用保留键时的边界,不是可依赖的配置:作者不应把 `snapshotId` 用作自己的变量名。是否在切换时禁止或提示 + 这类版本,由后续决策另行确定(circus#405)。 - **平台字段**只在带着 Issue 打开表单时注入:仓库地址和分支(默认取 Issue 关联 Project 的仓库与默认分支,必填)、提示词、要执行 的已发布版本 `snapshotId`、附加的上下文引用 `context_refs`。工作流自己声明了同名变量时,以工作流的声明为准。 - **AI 辅助**(`POST .../issues/:iid/collaboration/assist`)只返回字段级的建议补丁和建议的上下文引用:从不创建运行,也不写入 diff --git a/frontend/src/features/issues/components/README.en.md b/frontend/src/features/issues/components/README.en.md index 4b17574f1..af4243d7f 100644 --- a/frontend/src/features/issues/components/README.en.md +++ b/frontend/src/features/issues/components/README.en.md @@ -33,6 +33,8 @@ Workflow confirmation locks duplicate submissions, editing and removal from inte The initial read takes the live descriptor by `formRef + issueId`, but only to locate the platform version selector and its default version. A `snapshotId` select is recognized as the platform version selector only when every option belongs to this workflow's published snapshots; the form waits for that list and allows retry after a failed read. Once the default version is known, the form itself renders that version's snapshot form even while the user stays on the default, so fields the live draft has not published never appear. An author's same-named text field or select with custom options remains ordinary input and renders from the live descriptor. +The selector comes from the live draft, and without one the component cannot tell the server which version to run. When the live draft declares `snapshotId` itself (or a launch field removes it) the form renders the live draft while Confirm applies the live draft's version rule and runs the newest published version, so the fields shown may belong to another version. When the selector does exist but the version switched to declares the key in its own graph, that version's form has no platform field and the component does not add one back; Confirm still treats the key as the platform's and reads the author's value as a version cursor. Both are the boundary of an author occupying a reserved key, not a supported configuration; the tests pin that a same-named variable stays ordinary input and is never disguised as the selector. Refusing or warning on such a switch is left to a later decision (circus#405). + Changing the platform version selector loads the existing snapshot-form route with a tenant/workflow/snapshot/issue-scoped key, clears the old field draft and initializes the selected version's defaults. The selector retains the echoed snapshotId rather than its newest-version default. Loading or failed reads cannot review stale fields; failed reads can retry. Reading a form is not run admission, but Confirm validates against the selected version's own fields alone and no longer compares them with the live draft: an older version runs on its own form even when its fields differ from the current draft, and the server refuses by that version when a required field is missing or a field it never declared is submitted. A refusal that names a field is shown with that field's label; failures without a field parameter keep the generic message. Historical runs without a WorkflowRun reference retain their IssueRun status, make no WorkflowRun request and show no detail link. Linked runs always use the WorkflowRun's authoritative status. diff --git a/frontend/src/features/issues/components/README.md b/frontend/src/features/issues/components/README.md index 2dca15bef..f69964f98 100644 --- a/frontend/src/features/issues/components/README.md +++ b/frontend/src/features/issues/components/README.md @@ -33,6 +33,8 @@ Workflow 确认从创建 interaction 开始锁住重复提交、返回修改与 初始读取按 `formRef + issueId` 取活稿,但只用来定位平台版本选择器及其默认版本。只有 `snapshotId` select 的全部选项都属于该 Workflow 的已发布快照时,才识别为平台版本选择器;列表读取完成前不开放表单,失败可重试。识别出默认版本后,表单本身按该版本的 snapshot form 渲染,用户不切换版本也一样,所以活稿里未发布的字段不会出现在表单里。作者自定义的同名文本字段或含自定义选项的 select 仍是普通输入,直接从活稿渲染。 +选择器由活稿决定,没有它时组件无法告诉服务端执行哪个版本。活稿自己声明了 `snapshotId`(或用启动字段移除它)时表单按活稿渲染,Confirm 按活稿的版本规则执行最新已发布版本,显示的字段可能属于另一个版本。选择器存在、但切到的版本自己的图声明了这个键时,该版本的表单里没有平台字段,组件也不加回选择器;Confirm 仍按活稿判定该键归平台,作者值会被当成版本游标。两种情况都是作者占用保留键的边界,不是可依赖的配置;测试固定了同名变量仍是普通输入,不会伪装成选择器。是否在切换时禁止或提示这类版本留给后续决策(circus#405)。 + 用户切换平台版本选择器时,按 tenant/workflow/snapshot/issue 读取既有 snapshot form 接口,清空旧字段草稿并初始化新版本默认值;选择器保留响应回显的 snapshotId,不采用它仍指向最新版本的默认值。加载或失败期间不能审阅旧字段,失败可重试。读取表单不代表服务端准入通过,但 Confirm 只按所选版本自身的字段校验,不再与活稿比较:选中的旧版本哪怕字段和当前草稿不同,也按它自己的表单执行,所选版本缺必填字段或收到它未声明的字段时由服务端按该版本拒绝。拒绝会带上字段参数,表单据此提示具体字段;没有字段参数的其他失败仍显示通用提示。 历史运行没有 WorkflowRun 引用时,确认行仍显示 IssueRun 状态,不请求 WorkflowRun,也不显示运行详情链接;有关联的运行始终以 WorkflowRun 状态为准。