From 782eadb615df1235cdeb7e44711079634350e50d Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Thu, 1 Oct 2026 17:02:29 +1300 Subject: [PATCH] feat: STATE_TX_FROM_STATES on state-tx nodes; workflow-script composite invariant; refresh legacy-mount NOTE Claude-Session: https://claude.ai/code/session_01N218bERyNeLhbLmDRDvbWF --- .../workflow/bug-ancestor-auto-advance@1.yaml | 27 ++++++----- .../workflow/frozen-fixture-emit@1.yaml | 1 + task-templates/workflow/test-validate@1.yaml | 1 + tests/invariants/test_state_tx_runtime_env.py | 45 ++++++++++++++++--- .../test_workflow_script_composite_layers.py | 34 ++++++++++++++ 5 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 tests/invariants/test_workflow_script_composite_layers.py diff --git a/task-templates/workflow/bug-ancestor-auto-advance@1.yaml b/task-templates/workflow/bug-ancestor-auto-advance@1.yaml index 4083328..271c692 100644 --- a/task-templates/workflow/bug-ancestor-auto-advance@1.yaml +++ b/task-templates/workflow/bug-ancestor-auto-advance@1.yaml @@ -57,15 +57,22 @@ nodes: runtime_env: CP_URL: "http://controlplane.agent-runtimes.svc.cluster.local:8100" STATE_TX_NEW_STATE: verify-pending -# NOTE (script-node credentials, M43): kind: script nodes are harness-exempt -# (WF-COMP-6) but now carry the AU-51 internal-service bearer token by -# default — see spec/typed-workflows.md WF-COMP-26. The compiler emits -# `secrets_required: [CP_SERVICE_TOKEN]` and -# `runtime_env.CP_SERVICE_TOKEN_FILE=/run/secrets/CP_SERVICE_TOKEN` on -# every script node; the dispatcher's `_run_script_task` legacy-key -# branch mounts the dispatcher-process-env CP_SERVICE_TOKEN value as a -# file. All three nodes above read it via -# `open(os.environ["CP_SERVICE_TOKEN_FILE"]).read().strip()`. Each script -# still resolves its own actor identity (OIDC_IDENTITY_EMAIL or + STATE_TX_FROM_STATES: "pending,open" +# NOTE (script-node credentials): kind: script nodes are harness-exempt +# (WF-COMP-6), but the flag-on script path composes the workflow-script/v1 +# composite (shared-variables/v1 + cp-service-token/v1; WF-COMP-31). The +# cp-service-token/v1 layer restages the AU-51 internal-service bearer token +# and exports CP_SERVICE_TOKEN_FILE=/run/agent/cp-service-token/token, which +# all three nodes above read via +# `open(os.environ["CP_SERVICE_TOKEN_FILE"]).read().strip()`. The legacy +# WF-COMP-26 mount (`secrets_required: [CP_SERVICE_TOKEN]` and +# `runtime_env.CP_SERVICE_TOKEN_FILE=/run/secrets/CP_SERVICE_TOKEN`, backed by +# the dispatcher's legacy-key branch) is removed (WF-COMP-33 step 10). Each +# script still resolves its own actor identity (OIDC_IDENTITY_EMAIL or # CP_Caller-Identity header — see scripts/workflow/tag-set and # parent-link-check) for the write envelope. +# +# STATE_TX_FROM_STATES on the state-tx node is the compare-and-set guard: the +# live flow_state must be one of the listed states (or already the target), +# else state-tx exits 2 (unexpected_current_state). pending,open mirror +# `input.state` + `escalation_input_states`. diff --git a/task-templates/workflow/frozen-fixture-emit@1.yaml b/task-templates/workflow/frozen-fixture-emit@1.yaml index 5f40d50..0800f6d 100644 --- a/task-templates/workflow/frozen-fixture-emit@1.yaml +++ b/task-templates/workflow/frozen-fixture-emit@1.yaml @@ -24,3 +24,4 @@ nodes: runtime_env: CP_URL: "http://controlplane.agent-runtimes.svc.cluster.local:8100" STATE_TX_NEW_STATE: test-skeleton-ready + STATE_TX_FROM_STATES: scoped-tagged diff --git a/task-templates/workflow/test-validate@1.yaml b/task-templates/workflow/test-validate@1.yaml index 33a16be..fe8af8e 100644 --- a/task-templates/workflow/test-validate@1.yaml +++ b/task-templates/workflow/test-validate@1.yaml @@ -32,3 +32,4 @@ nodes: runtime_env: CP_URL: "http://controlplane.agent-runtimes.svc.cluster.local:8100" STATE_TX_NEW_STATE: test-validated + STATE_TX_FROM_STATES: test-write-pending diff --git a/tests/invariants/test_state_tx_runtime_env.py b/tests/invariants/test_state_tx_runtime_env.py index 434cbef..176c468 100644 --- a/tests/invariants/test_state_tx_runtime_env.py +++ b/tests/invariants/test_state_tx_runtime_env.py @@ -1,9 +1,11 @@ -"""state-tx script nodes must declare CP_URL and STATE_TX_NEW_STATE. +"""state-tx script nodes must declare CP_URL, STATE_TX_NEW_STATE and STATE_TX_FROM_STATES. -scripts/workflow/state-tx fails with exit 2 when either is missing. +scripts/workflow/state-tx fails with exit 2 when any is missing +(from_states_missing for STATE_TX_FROM_STATES). CP_SERVICE_TOKEN_FILE is supplied by the workflow-script/v1 composite, not runtime_env, and must not be declared here. STATE_TX_NEW_STATE must equal the -template's own output.state. +template's own output.state. STATE_TX_FROM_STATES is a comma list of acceptable +current flow_states and must be non-empty. """ from __future__ import annotations @@ -14,10 +16,27 @@ import yaml _DIR = Path(__file__).resolve().parents[2] / "task-templates" / "workflow" - -@pytest.mark.parametrize( - "name", ["test-validate@1.yaml", "frozen-fixture-emit@1.yaml"] +# Every template with a state-tx node, discovered from disk so new @N variants +# are covered automatically. +_NAMES = sorted( + p.name + for p in _DIR.glob("*.yaml") + if any( + n.get("cmd") == "scripts/workflow/state-tx" + for n in (yaml.safe_load(p.read_text()).get("nodes") or []) + ) ) + + +def test_state_tx_templates_discovered(): + assert { + "test-validate@1.yaml", + "frozen-fixture-emit@1.yaml", + "bug-ancestor-auto-advance@1.yaml", + } <= set(_NAMES) + + +@pytest.mark.parametrize("name", _NAMES) def test_state_tx_node_declares_required_runtime_env(name): doc = yaml.safe_load((_DIR / name).read_text()) nodes = [n for n in doc["nodes"] if n.get("cmd") == "scripts/workflow/state-tx"] @@ -27,3 +46,17 @@ def test_state_tx_node_declares_required_runtime_env(name): assert env.get("STATE_TX_NEW_STATE") == doc["output"]["state"] assert "CP_SERVICE_TOKEN_FILE" not in env assert not any(k.startswith("OIDC_") for k in env) + + +@pytest.mark.parametrize("name", _NAMES) +def test_state_tx_node_declares_from_states(name): + doc = yaml.safe_load((_DIR / name).read_text()) + nodes = [n for n in doc["nodes"] if n.get("cmd") == "scripts/workflow/state-tx"] + env = nodes[0].get("runtime_env") or {} + raw = env.get("STATE_TX_FROM_STATES") + assert isinstance(raw, str) and raw.strip() + states = [s.strip() for s in raw.split(",")] + assert all(states), f"empty entry in STATE_TX_FROM_STATES {raw!r}" + assert doc["output"]["state"] not in states + # The template's own input state must be an acceptable from-state. + assert doc["input"]["state"] in states diff --git a/tests/invariants/test_workflow_script_composite_layers.py b/tests/invariants/test_workflow_script_composite_layers.py new file mode 100644 index 0000000..3d3824b --- /dev/null +++ b/tests/invariants/test_workflow_script_composite_layers.py @@ -0,0 +1,34 @@ +"""workflow-script/v1 composite: exact layer order and credential env export. + +Script nodes get their CP write credential only through this composite +(the legacy WF-COMP-26 mount is removed), so the layering is load-bearing. +""" +from __future__ import annotations + +from pathlib import Path + +import yaml + +_ROOT = Path(__file__).resolve().parents[2] / "harnesses" + + +def _load(kind_dir: str, name: str) -> dict: + return yaml.safe_load((_ROOT / kind_dir / name / "v1" / "harness.yaml").read_text()) + + +def test_workflow_script_layers_shared_variables_then_cp_service_token(): + doc = _load("composites", "workflow-script") + assert doc["layers"] == [ + {"context": "shared-variables/v1"}, + {"context": "cp-service-token/v1"}, + ] + + +def test_workflow_script_env_exports_cp_service_token_file(): + doc = _load("composites", "workflow-script") + env: dict = dict(doc.get("env") or {}) + for layer in doc["layers"]: + (kind, ref), = layer.items() + name = ref.split("/")[0] + env.update(_load(kind + "s", name).get("env") or {}) + assert env["CP_SERVICE_TOKEN_FILE"] == "/run/agent/cp-service-token/token"