Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5301,7 +5301,7 @@
},
"input": {
"additionalProperties": true,
"description": "The kickoff input passed to the run's start node.",
"description": "The run's input object: each form value keyed by variable name, plus the reserved `input` key carrying the kickoff text the run submitted. A run that submitted no kickoff evaluates against the Start node's authored instruction instead.",
"type": "object"
},
"modelBindings": {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/api/generated.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2373,7 +2373,7 @@ export interface WorkflowAssetImportList {
export type WorkflowRunDefinitionSnapshot = { [key: string]: unknown } | null;

/**
* The kickoff input passed to the run's start node.
* The run's input object: each form value keyed by variable name, plus the reserved `input` key carrying the kickoff text the run submitted. A run that submitted no kickoff evaluates against the Start node's authored instruction instead.
*/
export type WorkflowRunInput = { [key: string]: unknown };

Expand Down Expand Up @@ -2415,7 +2415,7 @@ export interface WorkflowRun {
/** @nullable */
finishedAt: string | null;
id: string;
/** The kickoff input passed to the run's start node. */
/** The run's input object: each form value keyed by variable name, plus the reserved `input` key carrying the kickoff text the run submitted. A run that submitted no kickoff evaluates against the Start node's authored instruction instead. */
input: WorkflowRunInput;
/**
* The frozen per-slot model bindings this run executes against, ordered by slot name. Present on a detail read. Each binding is frozen at run creation and used for the run's whole lifetime; only `revoked` can turn true (the owning connection's grant was revoked).
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/workflows/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export interface CreateWorkflowRunInput {
name?: string
/** The exact snapshot to execute, or omit to pin the latest published one. */
snapshotId?: string
/** Kickoff input delivered to the start node. */
/** The run's input object: form values keyed by variable name, plus the reserved `input` kickoff text. */
input?: Record<string, unknown>
}

Expand Down
2 changes: 1 addition & 1 deletion integration/workflow_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func agentRunGraph(slot string, legacy bool, slots []any) core.Object {
core.Object{
"id": "start", "type": "workflow", "deletable": false,
"position": core.Object{"x": 0, "y": 0},
"data": core.Object{"kind": "start", "title": "开始", "description": ""},
"data": core.Object{"kind": "start", "title": "开始", "description": "", "input": ""},
},
core.Object{
"id": "agent-1", "type": "workflow",
Expand Down
27 changes: 24 additions & 3 deletions integration/workflow_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,35 @@ func TestWorkflowRunLifecycle(t *testing.T) {
t.Fatalf("run node_states missing nodes: %v", states)
}
// The start node reports the run form the way desktop's variable pool does: each run-form variable
// is its own root, and `input` is the authored kickoff prompt (unset in this fixture).
// is its own root, and `input` is the kickoff prompt. This run submitted none, so it falls back to
// the authored (empty) instruction in the frozen document.
output := states.O("start").O("output")
if output.S("branch") != "main" {
t.Fatalf("start output did not expose the run input variable: %v", output)
}
if prompt, ok := output["input"].(string); !ok || prompt != "" {
t.Fatalf("start output input = %v; want the authored kickoff prompt as a string", output["input"])
}

// The run's own kickoff is persisted on the row and is what its Start output reports, even when
// the authored instruction differs. The rest of the run form keeps its roots either way.
override := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{
"name": "Override run", "input": core.Object{"branch": "dev", "input": "review the diff"}, "projectId": pid,
}, "run-override", 200).O("resource")
if override.O("input").S("input") != "review the diff" {
t.Fatalf("run row lost the kickoff: %v", override.O("input"))
}
overrideOutput := override.O("nodeStates").O("start").O("output")
if overrideOutput.S("input") != "review the diff" || overrideOutput.S("branch") != "dev" {
t.Fatalf("start output did not read the run's kickoff: %v", overrideOutput)
}
// The reserved selector accepts a string, the type desktop's kickoff_input carries, and nothing
// else: another JSON type would change what [start, input] means across the two sides.
if refusal := f.call("POST", f.path("/workflows/"+wfid+"/runs"), core.Object{
"input": core.Object{"input": 7}, "projectId": pid,
}, "run-bad-kickoff", 400); refusal.S("code") != "invalid_field_value" {
t.Fatalf("non-string kickoff refusal = %v; want invalid_field_value", refusal)
}
// definitionSnapshot is the frozen graph, not the live document: the run carries its own copy.
ds := run.O("definitionSnapshot")
if ds.O("viewport").N("zoom") != 1 {
Expand All @@ -130,8 +151,8 @@ func TestWorkflowRunLifecycle(t *testing.T) {
t.Fatalf("run read-back changed the trace: %v", one)
}
listed := issueItems(f.call("GET", f.path("/workflows/"+wfid+"/runs"), nil, "", 200))
if len(listed) != 2 {
t.Fatalf("run list has %d rows, want 2", len(listed))
if len(listed) != 3 {
t.Fatalf("run list has %d rows, want 3", len(listed))
}
// The generic list returns full rows; page it with limit to prove the envelope carries nextCursor.
page := f.call("GET", f.path("/workflows/"+wfid+"/runs")+"?limit=1", nil, "", 200)
Expand Down
9 changes: 6 additions & 3 deletions internal/collab/workflow-run-simulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,18 @@ func (MockWorkflowRunSimulator) SimulateWorkflowRun(graph, input core.Object) (n
}
if isStart {
// The one thing the sim can truthfully report: what it was kicked off with. Match the
// real Start output shape — `input` is the authored prompt and each run-form variable is
// a root — so a UI negative case reads the same pool the scheduler writes.
// real Start output shape — `input` is the run's kickoff (the submitted value, else the
// authored prompt) and each run-form variable is a root — so a UI negative case reads the
// same pool the scheduler writes.
output := core.Object{}
for key, value := range input {
if key != "input" {
output[key] = value
}
}
output["input"] = data.S("input")
if kickoff, ok := core.WorkflowStartKickoff(data, input); ok {
output["input"] = kickoff
}
state["output"] = output
} else {
state["output"] = core.Object{"message": "Simulated " + title + " result (simulated; no real work was performed)."}
Expand Down
2 changes: 1 addition & 1 deletion internal/contract/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func Document() map[string]any {
s["WorkflowRun"] = resource("id tenantId workflowId snapshotId name status input nodeStates rounds error startedAt finishedAt createdAt updatedAt workflowName", "startedAt finishedAt")
workflowRunProps := properties(s, "WorkflowRun")
workflowRunProps["status"] = enumeration("pending", "running", "awaiting_input", "succeeded", "failed", "cancelled")
workflowRunProps["input"] = obj{"type": "object", "additionalProperties": true, "description": "The kickoff input passed to the run's start node."}
workflowRunProps["input"] = obj{"type": "object", "additionalProperties": true, "description": "The run's input object: each form value keyed by variable name, plus the reserved `input` key carrying the kickoff text the run submitted. A run that submitted no kickoff evaluates against the Start node's authored instruction instead."}
workflowRunProps["nodeStates"] = obj{"type": "object", "additionalProperties": true, "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed."}
workflowRunProps["rounds"] = array(obj{"type": "object", "additionalProperties": true})
// definitionSnapshot lives only on a detail read (the history list trips without it);
Expand Down
37 changes: 31 additions & 6 deletions internal/core/workflow_admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,44 @@ func workflowRunSnapshot(t *transaction, tid, wid, sid string) Object {
// 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.
//
// Only the declared variables are checked, and keys the graph does not declare are left alone. A run's
// input is an execution input object, not a form submission: it carries the platform's own material
// (context references, the issue context, a frozen executor reference) alongside the workflow's
// parameters, so treating an extra key as an error would refuse input the platform itself writes. The
// @ form is the one place where the set of keys *is* the contract, and it rejects unknown keys on its
// own — see validateFormValues.
// The reserved `input` kickoff selector is checked before the declared variables: when present it must
// be a string, and a null is treated as absent, the way desktop's kickoff_input is. Then only the
// declared variables are checked, and keys the graph does not declare are left alone. A run's input is
// an execution input object, not a form submission: it carries the platform's own material (context
// references, the issue context, a frozen executor reference) alongside the workflow's parameters, so
// treating an extra key as an error would refuse input the platform itself writes. The @ form is the
// one place where the set of keys *is* the contract, and it rejects unknown keys on its own — see
// validateFormValues.
//
// A variable that declares the name `input` is shadowed by the reserved selector: the kickoff carries
// the value `[start, input]` resolves to (see workflowStartOutput), so the declared variable's type is
// never judged here — desktop likewise lets the kickoff overwrite a declared variable of the same name
// rather than refusing it.
//
// The check reads the snapshot's graph, never the live document, so a version published months ago
// still accepts exactly what it accepted then.
func requireWorkflowRunInput(graph, input Object) {
// `input` is the reserved kickoff selector, not a workflow variable. Desktop carries it on the
// creation request as a string (kickoff_input), so accepting another JSON type here would make
// `[start, input]` an object on Cloud and a string on desktop. A null is treated as absent, the
// same way the declared-variable check below treats it, and stays accepted for a run that named
// no override.
if v, ok := input["input"]; ok && v != nil {
if _, ok := v.(string); !ok {
rejectWith(400, "invalid_field_value", Object{"field": "input"})
}
}
fields := workflowInputFields(graph)
for i := range fields {
field := &fields[i]
// A declared variable named `input` is shadowed by the reserved selector, whose value the Start
// output resolves from WorkflowStartKickoff rather than from this field. Desktop lets the kickoff
// overwrite a declared variable of the same name, so judging the declared type here would make a
// legal graph unstartable: a string kickoff fails a non-string declaration, and a value of the
// declared type fails the reserved-string check above.
if field.Key == "input" {
continue
}
v, present := input[field.Key]
if !present || v == nil {
if field.Required {
Expand Down
42 changes: 42 additions & 0 deletions internal/core/workflow_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ func TestRequireWorkflowRunInput(t *testing.T) {
input: Object{"topic": "auth", "scope": "everything"},
want: &Fault{Code: "invalid_field_value", Status: 400, Params: Object{"field": "scope"}},
},
{
// `input` is the reserved kickoff selector rather than a declared variable. Desktop
// carries it as a string, and accepting another type here would make [start, input] an
// object on Cloud and a string on desktop.
name: "the reserved kickoff selector as a string",
input: Object{"topic": "auth", "input": "review the diff"},
},
{
// A null override is a run that named none: it is treated as absent and falls back to
// the authored Start instruction, exactly like a declared variable left null.
name: "the reserved kickoff selector explicitly null",
input: Object{"topic": "auth", "input": nil},
},
{
name: "the reserved kickoff selector as an object",
input: Object{"topic": "auth", "input": Object{"text": "review the diff"}},
want: &Fault{Code: "invalid_field_value", Status: 400, Params: Object{"field": "input"}},
},
}

for _, tc := range cases {
Expand All @@ -84,6 +102,30 @@ func TestRequireWorkflowRunInput(t *testing.T) {
}
}

// TestRequireWorkflowRunInputKickoffShadowsInputVariable pins the desktop shadow semantics for a Start
// variable literally named `input`: the reserved kickoff selector overwrites the declared variable, so
// the declared type is never judged while the reserved key stays string-only. Without the shadow a
// graph declaring `input` as a number would reject every run — a string kickoff fails the declared
// type, and a number fails the reserved-string check — a Cloud-only dead end desktop runs fine.
func TestRequireWorkflowRunInputKickoffShadowsInputVariable(t *testing.T) {
graph := inputGraph(t, []any{
Object{"name": "topic", "valueType": "string", "required": true},
Object{"name": "input", "valueType": "number"},
})

// A string `input` is the kickoff and is admitted even though the declared `input` variable is a
// number: the reserved selector shadows it, so the number's type is not judged.
noFault(t, func() { requireWorkflowRunInput(graph, Object{"topic": "auth", "input": "review the diff"}) })

// The reserved key is still string-only: a number fails the reserved check even though a declared
// `input` variable of that type would have been legal on its own.
fault := faultOf(t, func() { requireWorkflowRunInput(graph, Object{"topic": "auth", "input": 7}) })
want := &Fault{Code: "invalid_field_value", Status: 400, Params: Object{"field": "input"}}
if !reflect.DeepEqual(fault, want) {
t.Fatalf("fault = %+v, want %+v", fault, want)
}
}

// TestRequireWorkflowRunInputReadsTheGraphItIsGiven covers the reason the check takes a graph rather
// than reading the live document: the schema that governs a run is the one the version it executes
// was published with. A variable the live document added after that publish must not be demanded of
Expand Down
49 changes: 40 additions & 9 deletions internal/core/workflow_scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,9 +669,10 @@ func executeWorkflowNode(node workflowNodeView, input Object, states map[string]
// `[start, name]` resolved to nothing and `[start, input]` answered for the entire form — the same
// document meant different things on the two sides.
//
// The run form's values are keyed by variable name, so flattening them is exact. A key literally
// named `input` is dropped: desktop reserves that root, and letting a user variable shadow the
// kickoff selector would make the same selector mean a run value on Cloud and a prompt on desktop.
// The run form's values are keyed by variable name, so flattening them is exact. The `input` key is
// not flattened with the rest: it is the reserved kickoff selector, and letting a user variable
// shadow it would make the same selector mean a run value on Cloud and a prompt on desktop. Its value
// comes from WorkflowStartKickoff instead.
func workflowStartOutput(node workflowNodeView, input Object) Object {
output := make(Object, len(input)+1)
for key, value := range input {
Expand All @@ -680,15 +681,45 @@ func workflowStartOutput(node workflowNodeView, input Object) Object {
}
output[key] = value
}
// Cloud does not persist the per-run prompt override, so the authored prompt is the only kickoff
// text available to the variable pool. The launch field still shows that same default. The text is
// used verbatim, whitespace included, because desktop's start.input is the kickoff prompt as the
// author wrote it; trimming here would make the same selector compare differently on the two
// sides.
output["input"] = node.Data.S("input")
if kickoff, ok := WorkflowStartKickoff(node.Data, input); ok {
output["input"] = kickoff
}
return output
}

// WorkflowStartKickoff resolves the Start node's kickoff text and reports whether it exists. The
// run's own submitted `input` wins; the authored Start instruction is the fallback.
//
// The two sources exist because runs outlive the code that created them. A run created before Cloud
// persisted the per-run kickoff has no reserved `input` key in its own input object — the prompt it
// executed then lived in the frozen snapshot's Start node, not the run row — so falling back to that
// node's `data.input` keeps historical runs evaluating exactly as they did (circus#287). The fallback
// reads the snapshot's node, never the live workflow document. Cloud's canonical document stores the
// prompt under `data.input` only; the legacy `data.instruction` desktop also reads is migrated to
// `input` by the frontend when a document is read, so the server never has to fall back to it.
//
// The text is used verbatim, whitespace included: desktop's start.input is the kickoff prompt as the
// author or the run submitted it, and trimming here would make the same selector compare differently
// on the two sides. When neither source has a value the root is left out of the pool rather than set
// to "": desktop leaves {start}.input unset when the run supplied no kickoff and the graph has no
// instruction, and exists/not_exists must answer the same way on both sides.
//
// It is exported for the development simulator, which writes the Start output outside this package;
// sharing the resolver keeps the simulated trace from drifting from what the scheduler commits.
func WorkflowStartKickoff(data, input Object) (string, bool) {
if v, ok := input["input"]; ok && v != nil {
if text, ok := v.(string); ok {
return text, true
}
}
if v, ok := data["input"]; ok && v != nil {
if text, ok := v.(string); ok {
return text, true
}
}
return "", false
}

// workflowNodeDisplayName is the label the run viewer shows: the author's title, or the node kind
// when the title is empty — the same projection the simulator writes.
func workflowNodeDisplayName(node workflowNodeView) string {
Expand Down
Loading
Loading