flowspec2 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
flowspec2/ir.py
ADDED
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
"""Canonical normalization and a deterministic intermediate representation.
|
|
2
|
+
|
|
3
|
+
Flow documents remain the only authoring surface. This module turns a valid
|
|
4
|
+
source document into an explicit, immutable compiler input: schema defaults and
|
|
5
|
+
node identifiers are materialized, references and state access are indexed, and
|
|
6
|
+
stable digests make cache/provenance decisions independent of JSON key order.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from typing import Any, Final, Literal, cast
|
|
18
|
+
|
|
19
|
+
from jsonschema import Draft202012Validator
|
|
20
|
+
|
|
21
|
+
from .profiles import FlowProfile, capability_is_requested, reference_profile
|
|
22
|
+
from .schema import schema, validate_flow
|
|
23
|
+
from .subflows import SubflowDefinition
|
|
24
|
+
|
|
25
|
+
FLOW_IR_FORMAT: Final[str] = "flowspec2/ir@1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class FlowReference:
|
|
30
|
+
"""A statically discoverable reference from one source location."""
|
|
31
|
+
|
|
32
|
+
source: str
|
|
33
|
+
namespace: Literal[
|
|
34
|
+
"domain",
|
|
35
|
+
"slot",
|
|
36
|
+
"step",
|
|
37
|
+
"subflow",
|
|
38
|
+
"tool",
|
|
39
|
+
"config",
|
|
40
|
+
"data",
|
|
41
|
+
"internal",
|
|
42
|
+
"payload",
|
|
43
|
+
"address",
|
|
44
|
+
"token",
|
|
45
|
+
]
|
|
46
|
+
target: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class FlowSlot:
|
|
51
|
+
"""The normalized persistence and validation contract for one slot."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
domain: str
|
|
55
|
+
partition: Literal["data", "internal"]
|
|
56
|
+
required: bool
|
|
57
|
+
nullable: bool
|
|
58
|
+
requires: tuple[str, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class FlowNode:
|
|
63
|
+
"""A source-level node with explicit state access."""
|
|
64
|
+
|
|
65
|
+
identifier: str
|
|
66
|
+
kind: Literal[
|
|
67
|
+
"init",
|
|
68
|
+
"collect",
|
|
69
|
+
"confirm",
|
|
70
|
+
"derive",
|
|
71
|
+
"terminal",
|
|
72
|
+
"subflow",
|
|
73
|
+
"await_external",
|
|
74
|
+
]
|
|
75
|
+
source: str
|
|
76
|
+
reads: tuple[str, ...]
|
|
77
|
+
writes: tuple[str, ...]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class FlowTransition:
|
|
82
|
+
"""A deterministic source-level transition in the normalized rail."""
|
|
83
|
+
|
|
84
|
+
source: str
|
|
85
|
+
target: str
|
|
86
|
+
condition: Literal[
|
|
87
|
+
"next",
|
|
88
|
+
"confirm",
|
|
89
|
+
"reject",
|
|
90
|
+
"correction",
|
|
91
|
+
"resume",
|
|
92
|
+
"abort",
|
|
93
|
+
"resend",
|
|
94
|
+
"switch",
|
|
95
|
+
"timeout",
|
|
96
|
+
"pause",
|
|
97
|
+
"end",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class FlowIR:
|
|
103
|
+
"""Immutable, serializable representation consumed after source checking."""
|
|
104
|
+
|
|
105
|
+
ir_format: str
|
|
106
|
+
format: str
|
|
107
|
+
flow: str
|
|
108
|
+
version: str
|
|
109
|
+
profile: str
|
|
110
|
+
profile_digest: str
|
|
111
|
+
dependency_digest: str
|
|
112
|
+
source_digest: str
|
|
113
|
+
digest: str
|
|
114
|
+
canonical_json: str
|
|
115
|
+
state_schema_json: str
|
|
116
|
+
slots: tuple[FlowSlot, ...]
|
|
117
|
+
nodes: tuple[FlowNode, ...]
|
|
118
|
+
transitions: tuple[FlowTransition, ...]
|
|
119
|
+
references: tuple[FlowReference, ...]
|
|
120
|
+
required_capabilities: tuple[str, ...]
|
|
121
|
+
|
|
122
|
+
def to_document(self) -> dict[str, Any]:
|
|
123
|
+
"""Return a fresh mutable document for a compiler invocation."""
|
|
124
|
+
|
|
125
|
+
return cast(dict[str, Any], json.loads(self.canonical_json))
|
|
126
|
+
|
|
127
|
+
def state_schema(self) -> dict[str, Any]:
|
|
128
|
+
"""Return the generated JSON Schema for the three state partitions."""
|
|
129
|
+
|
|
130
|
+
return cast(dict[str, Any], json.loads(self.state_schema_json))
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict[str, Any]:
|
|
133
|
+
"""Return the complete deterministic JSON-compatible IR projection."""
|
|
134
|
+
|
|
135
|
+
projection = {
|
|
136
|
+
"ir_format": self.ir_format,
|
|
137
|
+
"format": self.format,
|
|
138
|
+
"flow": self.flow,
|
|
139
|
+
"version": self.version,
|
|
140
|
+
"profile": self.profile,
|
|
141
|
+
"profile_digest": self.profile_digest,
|
|
142
|
+
"dependency_digest": self.dependency_digest,
|
|
143
|
+
"source_digest": self.source_digest,
|
|
144
|
+
"digest": self.digest,
|
|
145
|
+
"document": self.to_document(),
|
|
146
|
+
"state_schema": self.state_schema(),
|
|
147
|
+
"slots": [asdict(slot) for slot in self.slots],
|
|
148
|
+
"nodes": [asdict(node) for node in self.nodes],
|
|
149
|
+
"transitions": [asdict(transition) for transition in self.transitions],
|
|
150
|
+
"references": [asdict(reference) for reference in self.references],
|
|
151
|
+
"required_capabilities": list(self.required_capabilities),
|
|
152
|
+
}
|
|
153
|
+
return cast(dict[str, Any], json.loads(_canonical_json(projection)))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _canonical_json(document: Any) -> str:
|
|
157
|
+
return json.dumps(
|
|
158
|
+
document,
|
|
159
|
+
ensure_ascii=False,
|
|
160
|
+
allow_nan=False,
|
|
161
|
+
separators=(",", ":"),
|
|
162
|
+
sort_keys=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _mutable_json(value: Any) -> Any:
|
|
167
|
+
if isinstance(value, Mapping):
|
|
168
|
+
return {key: _mutable_json(nested_value) for key, nested_value in value.items()}
|
|
169
|
+
if isinstance(value, tuple):
|
|
170
|
+
return [_mutable_json(nested_value) for nested_value in value]
|
|
171
|
+
if isinstance(value, list):
|
|
172
|
+
return [_mutable_json(nested_value) for nested_value in value]
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _digest(document: Any) -> str:
|
|
177
|
+
return hashlib.sha256(_canonical_json(document).encode()).hexdigest()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolved_dependency_contract(
|
|
181
|
+
document: dict[str, Any],
|
|
182
|
+
profile: FlowProfile | str,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
referenced_tools: set[str] = set()
|
|
185
|
+
if (entry := document.get("entry")) is not None:
|
|
186
|
+
referenced_tools.add(cast(str, entry["tool"]))
|
|
187
|
+
if (terminal := document.get("terminal")) is not None:
|
|
188
|
+
referenced_tools.add(cast(str, terminal["tool"]))
|
|
189
|
+
await_definition = cast(
|
|
190
|
+
dict[str, Any],
|
|
191
|
+
cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
|
|
192
|
+
)
|
|
193
|
+
enrichment = cast(dict[str, Any], await_definition.get("on_resume", {})).get("enrich")
|
|
194
|
+
if isinstance(enrichment, str):
|
|
195
|
+
referenced_tools.add(enrichment)
|
|
196
|
+
elif isinstance(enrichment, dict):
|
|
197
|
+
referenced_tools.add(cast(str, enrichment["tool"]))
|
|
198
|
+
|
|
199
|
+
referenced_subflows = {
|
|
200
|
+
cast(str, use_definition["ref"]) for use_definition in document.get("uses", [])
|
|
201
|
+
}
|
|
202
|
+
requested_capabilities = {
|
|
203
|
+
capability_name
|
|
204
|
+
for capability_name, capability_value in document.get("capabilities", {}).items()
|
|
205
|
+
if capability_is_requested(capability_value)
|
|
206
|
+
}
|
|
207
|
+
if document.get("auto_flow") is not None:
|
|
208
|
+
requested_capabilities.add("auto_flow")
|
|
209
|
+
used_domain_types = {
|
|
210
|
+
cast(str, domain_definition.get("type", "categorical"))
|
|
211
|
+
for domain_definition in cast(
|
|
212
|
+
dict[str, dict[str, Any]], document.get("domains", {})
|
|
213
|
+
).values()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if isinstance(profile, str):
|
|
217
|
+
return {
|
|
218
|
+
"identifier": profile,
|
|
219
|
+
"resolved": False,
|
|
220
|
+
"tools": sorted(referenced_tools),
|
|
221
|
+
"subflows": sorted(referenced_subflows),
|
|
222
|
+
"capabilities": sorted(requested_capabilities),
|
|
223
|
+
"domain_types": sorted(used_domain_types),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for subflow_reference in referenced_subflows:
|
|
227
|
+
if profile.subflows.has(subflow_reference):
|
|
228
|
+
subflow_definition = profile.subflows.definition(subflow_reference)
|
|
229
|
+
requested_capabilities.update(subflow_definition.capabilities)
|
|
230
|
+
referenced_tools.update(subflow_definition.required_tools)
|
|
231
|
+
return {
|
|
232
|
+
"identifier": profile.identifier,
|
|
233
|
+
"allow_legacy_contracts": profile.allow_legacy_contracts,
|
|
234
|
+
"tools": {
|
|
235
|
+
tool_name: (
|
|
236
|
+
profile.tools.definition(tool_name).as_dict()
|
|
237
|
+
if profile.tools.has(tool_name)
|
|
238
|
+
else None
|
|
239
|
+
)
|
|
240
|
+
for tool_name in sorted(referenced_tools)
|
|
241
|
+
},
|
|
242
|
+
"subflows": {
|
|
243
|
+
subflow_reference: (
|
|
244
|
+
profile.subflows.definition(subflow_reference).as_dict()
|
|
245
|
+
if profile.subflows.has(subflow_reference)
|
|
246
|
+
else None
|
|
247
|
+
)
|
|
248
|
+
for subflow_reference in sorted(referenced_subflows)
|
|
249
|
+
},
|
|
250
|
+
"capabilities": {
|
|
251
|
+
capability_name: capability_name in profile.capabilities
|
|
252
|
+
for capability_name in sorted(requested_capabilities)
|
|
253
|
+
},
|
|
254
|
+
"domain_types": {
|
|
255
|
+
domain_type: domain_type in profile.domain_types
|
|
256
|
+
for domain_type in sorted(used_domain_types)
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _resolve_local_reference(root_schema: dict[str, Any], reference: str) -> dict[str, Any]:
|
|
262
|
+
if not reference.startswith("#/"):
|
|
263
|
+
raise ValueError(f"only local schema references are supported, got {reference!r}")
|
|
264
|
+
resolved: Any = root_schema
|
|
265
|
+
for encoded_segment in reference[2:].split("/"):
|
|
266
|
+
segment = encoded_segment.replace("~1", "/").replace("~0", "~")
|
|
267
|
+
resolved = resolved[segment]
|
|
268
|
+
return cast(dict[str, Any], resolved)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _matching_branch(
|
|
272
|
+
instance: Any,
|
|
273
|
+
branches: list[dict[str, Any]],
|
|
274
|
+
validator: Draft202012Validator,
|
|
275
|
+
) -> dict[str, Any] | None:
|
|
276
|
+
return next(
|
|
277
|
+
(branch for branch in branches if validator.evolve(schema=branch).is_valid(instance)),
|
|
278
|
+
None,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _materialize_existing_defaults(
|
|
283
|
+
instance: Any,
|
|
284
|
+
schema_node: dict[str, Any],
|
|
285
|
+
*,
|
|
286
|
+
root_schema: dict[str, Any],
|
|
287
|
+
validator: Draft202012Validator,
|
|
288
|
+
) -> Any:
|
|
289
|
+
"""Copy an instance while applying defaults only inside existing containers."""
|
|
290
|
+
|
|
291
|
+
normalized = copy.deepcopy(instance)
|
|
292
|
+
reference = schema_node.get("$ref")
|
|
293
|
+
if isinstance(reference, str):
|
|
294
|
+
normalized = _materialize_existing_defaults(
|
|
295
|
+
normalized,
|
|
296
|
+
_resolve_local_reference(root_schema, reference),
|
|
297
|
+
root_schema=root_schema,
|
|
298
|
+
validator=validator,
|
|
299
|
+
)
|
|
300
|
+
schema_node = {key: value for key, value in schema_node.items() if key != "$ref"}
|
|
301
|
+
|
|
302
|
+
all_of = schema_node.get("allOf")
|
|
303
|
+
if isinstance(all_of, list):
|
|
304
|
+
for branch in all_of:
|
|
305
|
+
normalized = _materialize_existing_defaults(
|
|
306
|
+
normalized,
|
|
307
|
+
cast(dict[str, Any], branch),
|
|
308
|
+
root_schema=root_schema,
|
|
309
|
+
validator=validator,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
for union_keyword in ("oneOf", "anyOf"):
|
|
313
|
+
raw_branches = schema_node.get(union_keyword)
|
|
314
|
+
if isinstance(raw_branches, list):
|
|
315
|
+
branches = [cast(dict[str, Any], branch) for branch in raw_branches]
|
|
316
|
+
if (branch := _matching_branch(normalized, branches, validator)) is not None:
|
|
317
|
+
normalized = _materialize_existing_defaults(
|
|
318
|
+
normalized,
|
|
319
|
+
branch,
|
|
320
|
+
root_schema=root_schema,
|
|
321
|
+
validator=validator,
|
|
322
|
+
)
|
|
323
|
+
break
|
|
324
|
+
|
|
325
|
+
conditional = schema_node.get("if")
|
|
326
|
+
if isinstance(conditional, dict):
|
|
327
|
+
branch_key = "then" if validator.evolve(schema=conditional).is_valid(normalized) else "else"
|
|
328
|
+
if isinstance(branch := schema_node.get(branch_key), dict):
|
|
329
|
+
normalized = _materialize_existing_defaults(
|
|
330
|
+
normalized,
|
|
331
|
+
branch,
|
|
332
|
+
root_schema=root_schema,
|
|
333
|
+
validator=validator,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if isinstance(normalized, dict):
|
|
337
|
+
properties = cast(dict[str, dict[str, Any]], schema_node.get("properties", {}))
|
|
338
|
+
for property_name, property_schema in properties.items():
|
|
339
|
+
if property_name in normalized:
|
|
340
|
+
normalized[property_name] = _materialize_existing_defaults(
|
|
341
|
+
normalized[property_name],
|
|
342
|
+
property_schema,
|
|
343
|
+
root_schema=root_schema,
|
|
344
|
+
validator=validator,
|
|
345
|
+
)
|
|
346
|
+
elif "default" in property_schema:
|
|
347
|
+
normalized[property_name] = copy.deepcopy(property_schema["default"])
|
|
348
|
+
|
|
349
|
+
additional_schema = schema_node.get("additionalProperties")
|
|
350
|
+
if isinstance(additional_schema, dict):
|
|
351
|
+
for property_name in normalized.keys() - properties.keys():
|
|
352
|
+
normalized[property_name] = _materialize_existing_defaults(
|
|
353
|
+
normalized[property_name],
|
|
354
|
+
additional_schema,
|
|
355
|
+
root_schema=root_schema,
|
|
356
|
+
validator=validator,
|
|
357
|
+
)
|
|
358
|
+
elif isinstance(normalized, list) and isinstance(schema_node.get("items"), dict):
|
|
359
|
+
item_schema = cast(dict[str, Any], schema_node["items"])
|
|
360
|
+
normalized = [
|
|
361
|
+
_materialize_existing_defaults(
|
|
362
|
+
item,
|
|
363
|
+
item_schema,
|
|
364
|
+
root_schema=root_schema,
|
|
365
|
+
validator=validator,
|
|
366
|
+
)
|
|
367
|
+
for item in normalized
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
return normalized
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _materialize_container(
|
|
374
|
+
container: dict[str, Any],
|
|
375
|
+
key: str,
|
|
376
|
+
schema_node: dict[str, Any],
|
|
377
|
+
*,
|
|
378
|
+
root_schema: dict[str, Any],
|
|
379
|
+
validator: Draft202012Validator,
|
|
380
|
+
) -> None:
|
|
381
|
+
current = container.setdefault(key, {})
|
|
382
|
+
container[key] = _materialize_existing_defaults(
|
|
383
|
+
current,
|
|
384
|
+
schema_node,
|
|
385
|
+
root_schema=root_schema,
|
|
386
|
+
validator=validator,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _materialize_effective_containers(
|
|
391
|
+
document: dict[str, Any],
|
|
392
|
+
root_schema: dict[str, Any],
|
|
393
|
+
validator: Draft202012Validator,
|
|
394
|
+
) -> dict[str, Any]:
|
|
395
|
+
top_properties = cast(dict[str, dict[str, Any]], root_schema["properties"])
|
|
396
|
+
effective_containers = ["config"]
|
|
397
|
+
if "service" in document:
|
|
398
|
+
effective_containers.append("service")
|
|
399
|
+
for key in effective_containers:
|
|
400
|
+
_materialize_container(
|
|
401
|
+
document,
|
|
402
|
+
key,
|
|
403
|
+
top_properties[key],
|
|
404
|
+
root_schema=root_schema,
|
|
405
|
+
validator=validator,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
domain_schema = cast(dict[str, Any], top_properties["domains"]["additionalProperties"])
|
|
409
|
+
domain_branches = cast(list[dict[str, Any]], domain_schema["oneOf"])
|
|
410
|
+
for domain_definition in document.get("domains", {}).values():
|
|
411
|
+
domain_branch = _matching_branch(domain_definition, domain_branches, validator)
|
|
412
|
+
if domain_branch is None:
|
|
413
|
+
raise ValueError("normalized domain does not match a closed domain-kind contract")
|
|
414
|
+
domain_properties = cast(dict[str, dict[str, Any]], domain_branch["properties"])
|
|
415
|
+
normalize_schema = domain_properties.get("normalize")
|
|
416
|
+
if normalize_schema is None:
|
|
417
|
+
continue
|
|
418
|
+
_materialize_container(
|
|
419
|
+
domain_definition,
|
|
420
|
+
"normalize",
|
|
421
|
+
normalize_schema,
|
|
422
|
+
root_schema=root_schema,
|
|
423
|
+
validator=validator,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if (terminal := document.get("terminal")) is not None:
|
|
427
|
+
terminal_schema = _resolve_local_reference(root_schema, "#/$defs/terminalDef")
|
|
428
|
+
terminal_properties = cast(dict[str, dict[str, Any]], terminal_schema["properties"])
|
|
429
|
+
_materialize_container(
|
|
430
|
+
terminal,
|
|
431
|
+
"outcomes",
|
|
432
|
+
terminal_properties["outcomes"],
|
|
433
|
+
root_schema=root_schema,
|
|
434
|
+
validator=validator,
|
|
435
|
+
)
|
|
436
|
+
outcomes = cast(dict[str, Any], terminal["outcomes"])
|
|
437
|
+
outcome_properties = cast(
|
|
438
|
+
dict[str, dict[str, Any]], terminal_properties["outcomes"]["properties"]
|
|
439
|
+
)
|
|
440
|
+
for outcome in ("success", "retryable", "fatal"):
|
|
441
|
+
_materialize_container(
|
|
442
|
+
outcomes,
|
|
443
|
+
outcome,
|
|
444
|
+
outcome_properties[outcome],
|
|
445
|
+
root_schema=root_schema,
|
|
446
|
+
validator=validator,
|
|
447
|
+
)
|
|
448
|
+
_materialize_container(
|
|
449
|
+
terminal,
|
|
450
|
+
"empty_payload",
|
|
451
|
+
terminal_properties["empty_payload"],
|
|
452
|
+
root_schema=root_schema,
|
|
453
|
+
validator=validator,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return document
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _materialize_node_identifiers(document: dict[str, Any]) -> None:
|
|
460
|
+
await_definition = cast(
|
|
461
|
+
dict[str, Any] | None,
|
|
462
|
+
cast(dict[str, Any], document.get("capabilities", {})).get("await_external"),
|
|
463
|
+
)
|
|
464
|
+
for path_step in document["path"]:
|
|
465
|
+
if "slot" in path_step:
|
|
466
|
+
path_step.setdefault("step", path_step["slot"])
|
|
467
|
+
elif "confirm" in path_step:
|
|
468
|
+
path_step.setdefault("step", path_step["confirm"])
|
|
469
|
+
elif "derive" in path_step:
|
|
470
|
+
path_step.setdefault("step", f"derive_{path_step['derive']}")
|
|
471
|
+
elif "await_external" in path_step:
|
|
472
|
+
resolved_identifier = path_step.get("step")
|
|
473
|
+
if resolved_identifier is None and await_definition is not None:
|
|
474
|
+
resolved_identifier = await_definition.get("step")
|
|
475
|
+
resolved_identifier = resolved_identifier or "await_external"
|
|
476
|
+
path_step["step"] = resolved_identifier
|
|
477
|
+
if await_definition is not None:
|
|
478
|
+
await_definition.setdefault("step", resolved_identifier)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def normalize_flow(document: dict[str, Any], *, validate: bool = True) -> dict[str, Any]:
|
|
482
|
+
"""Return a schema-valid canonical copy with effective defaults and node ids."""
|
|
483
|
+
|
|
484
|
+
if validate:
|
|
485
|
+
validate_flow(document)
|
|
486
|
+
root_schema = schema()
|
|
487
|
+
validator = Draft202012Validator(root_schema)
|
|
488
|
+
normalized = cast(
|
|
489
|
+
dict[str, Any],
|
|
490
|
+
_materialize_existing_defaults(
|
|
491
|
+
document,
|
|
492
|
+
root_schema,
|
|
493
|
+
root_schema=root_schema,
|
|
494
|
+
validator=validator,
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
normalized = _materialize_effective_containers(normalized, root_schema, validator)
|
|
498
|
+
_materialize_node_identifiers(normalized)
|
|
499
|
+
if validate:
|
|
500
|
+
validate_flow(normalized)
|
|
501
|
+
return cast(dict[str, Any], json.loads(_canonical_json(normalized)))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _escape_pointer_segment(segment: str) -> str:
|
|
505
|
+
return segment.replace("~", "~0").replace("/", "~1")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _predicate_references(predicate: Any) -> tuple[str, ...]:
|
|
509
|
+
if not isinstance(predicate, dict) or len(predicate) != 1:
|
|
510
|
+
return ()
|
|
511
|
+
operator, operand = next(iter(predicate.items()))
|
|
512
|
+
candidates: list[Any]
|
|
513
|
+
if operator in {"eq", "ne"} and isinstance(operand, list):
|
|
514
|
+
candidates = operand
|
|
515
|
+
elif operator == "in" and isinstance(operand, list):
|
|
516
|
+
candidates = operand[:1]
|
|
517
|
+
elif operator == "is_present":
|
|
518
|
+
candidates = [operand]
|
|
519
|
+
elif operator in {"and", "or"} and isinstance(operand, list):
|
|
520
|
+
return tuple(
|
|
521
|
+
dict.fromkeys(
|
|
522
|
+
reference
|
|
523
|
+
for nested_predicate in operand
|
|
524
|
+
for reference in _predicate_references(nested_predicate)
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
elif operator == "not":
|
|
528
|
+
return _predicate_references(operand)
|
|
529
|
+
else:
|
|
530
|
+
candidates = []
|
|
531
|
+
return tuple(
|
|
532
|
+
candidate
|
|
533
|
+
for candidate in candidates
|
|
534
|
+
if isinstance(candidate, str)
|
|
535
|
+
and candidate.split(".", 1)[0] in {"slots", "internal", "payload", "config", "address"}
|
|
536
|
+
and "." in candidate
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _state_reference(slot_name: str, slots: Mapping[str, Any]) -> str:
|
|
541
|
+
slot_definition = cast(dict[str, Any], slots.get(slot_name, {}))
|
|
542
|
+
partition = slot_definition.get("persist", "data")
|
|
543
|
+
return f"{partition}.{slot_name}"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _canonical_state_reference(reference: str, slots: dict[str, Any]) -> str:
|
|
547
|
+
namespace, state_key = reference.split(".", 1)
|
|
548
|
+
if namespace == "slots":
|
|
549
|
+
return _state_reference(state_key, slots)
|
|
550
|
+
if namespace == "address":
|
|
551
|
+
return f"data.address.{state_key}"
|
|
552
|
+
return reference
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _step_kind(path_step: dict[str, Any]) -> str:
|
|
556
|
+
return next(
|
|
557
|
+
kind
|
|
558
|
+
for kind in ("slot", "confirm", "derive", "terminal", "use", "await_external")
|
|
559
|
+
if kind in path_step
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _path_predicate_reads(
|
|
564
|
+
path_step: dict[str, Any],
|
|
565
|
+
document: dict[str, Any],
|
|
566
|
+
) -> tuple[str, ...]:
|
|
567
|
+
gates = cast(
|
|
568
|
+
dict[str, Any], cast(dict[str, Any], document.get("overrides", {})).get("gates", {})
|
|
569
|
+
)
|
|
570
|
+
step_identifier = cast(str, path_step.get("step", ""))
|
|
571
|
+
interactive = cast(dict[str, Any], path_step.get("interactive", {}))
|
|
572
|
+
option_predicates = tuple(
|
|
573
|
+
conditional_option.get("gate") for conditional_option in interactive.get("options_when", [])
|
|
574
|
+
)
|
|
575
|
+
confirmation = cast(dict[str, Any], document.get("confirm", {}))
|
|
576
|
+
confirmation_interactive = (
|
|
577
|
+
cast(dict[str, Any], confirmation.get("interactive", {}))
|
|
578
|
+
if path_step.get("correctable") is True
|
|
579
|
+
else {}
|
|
580
|
+
)
|
|
581
|
+
confirmation_option_predicates = tuple(
|
|
582
|
+
conditional_option.get("gate")
|
|
583
|
+
for conditional_option in confirmation_interactive.get("options_when", [])
|
|
584
|
+
)
|
|
585
|
+
predicates = (
|
|
586
|
+
path_step.get("ask_when"),
|
|
587
|
+
path_step.get("skip_when"),
|
|
588
|
+
gates.get(step_identifier),
|
|
589
|
+
*option_predicates,
|
|
590
|
+
*confirmation_option_predicates,
|
|
591
|
+
)
|
|
592
|
+
slots = cast(dict[str, Any], document.get("slots", {}))
|
|
593
|
+
return tuple(
|
|
594
|
+
dict.fromkeys(
|
|
595
|
+
_canonical_state_reference(reference, slots)
|
|
596
|
+
for predicate in predicates
|
|
597
|
+
for reference in _predicate_references(predicate)
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _state_writes_for_mapping(
|
|
603
|
+
mapping: Mapping[str, Any],
|
|
604
|
+
slots: Mapping[str, Any],
|
|
605
|
+
) -> tuple[str, ...]:
|
|
606
|
+
return tuple(_state_reference(state_key, slots) for state_key in mapping)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _await_state_access(document: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
610
|
+
await_definition = cast(
|
|
611
|
+
dict[str, Any],
|
|
612
|
+
cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
|
|
613
|
+
)
|
|
614
|
+
if not await_definition:
|
|
615
|
+
return (), ()
|
|
616
|
+
slots = cast(dict[str, Any], document.get("slots", {}))
|
|
617
|
+
resume_on = cast(str, await_definition.get("resume_on", ""))
|
|
618
|
+
reads = tuple(
|
|
619
|
+
reference
|
|
620
|
+
for reference in (f"payload.{resume_on}" if resume_on else "", "payload._external_event")
|
|
621
|
+
if reference
|
|
622
|
+
)
|
|
623
|
+
on_resume = cast(dict[str, Any], await_definition.get("on_resume", {}))
|
|
624
|
+
writes = list(_state_writes_for_mapping(cast(dict[str, Any], on_resume.get("set", {})), slots))
|
|
625
|
+
enrichment = on_resume.get("enrich")
|
|
626
|
+
if isinstance(enrichment, dict):
|
|
627
|
+
writes.extend(
|
|
628
|
+
_state_writes_for_mapping(
|
|
629
|
+
cast(dict[str, Any], enrichment.get("set", {})),
|
|
630
|
+
slots,
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
for transition in (
|
|
634
|
+
await_definition.get("timeout"),
|
|
635
|
+
*cast(dict[str, Any], await_definition.get("recovery", {})).values(),
|
|
636
|
+
):
|
|
637
|
+
if isinstance(transition, dict):
|
|
638
|
+
writes.extend(
|
|
639
|
+
_state_writes_for_mapping(
|
|
640
|
+
cast(dict[str, Any], transition.get("set", {})),
|
|
641
|
+
slots,
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
if await_definition.get("max_resends") is not None:
|
|
645
|
+
node_id = cast(str, await_definition["step"])
|
|
646
|
+
resend_count_reference = f"internal._await_external_resend_count:{node_id}"
|
|
647
|
+
reads = (*reads, resend_count_reference)
|
|
648
|
+
writes.append(resend_count_reference)
|
|
649
|
+
return tuple(dict.fromkeys(reads)), tuple(dict.fromkeys(writes))
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _node_for_step(
|
|
653
|
+
path_step: dict[str, Any],
|
|
654
|
+
path_index: int,
|
|
655
|
+
document: dict[str, Any],
|
|
656
|
+
subflow_definitions: Mapping[str, SubflowDefinition],
|
|
657
|
+
) -> FlowNode:
|
|
658
|
+
step_kind = _step_kind(path_step)
|
|
659
|
+
source = f"/path/{path_index}"
|
|
660
|
+
slots = cast(dict[str, Any], document.get("slots", {}))
|
|
661
|
+
predicate_reads = _path_predicate_reads(path_step, document)
|
|
662
|
+
if step_kind == "slot":
|
|
663
|
+
slot_name = cast(str, path_step["slot"])
|
|
664
|
+
slot_definition = cast(dict[str, Any], slots.get(slot_name, {}))
|
|
665
|
+
dependency_reads = tuple(
|
|
666
|
+
_state_reference(required_slot, slots)
|
|
667
|
+
for required_slot in slot_definition.get("requires", [])
|
|
668
|
+
)
|
|
669
|
+
return FlowNode(
|
|
670
|
+
cast(str, path_step["step"]),
|
|
671
|
+
"collect",
|
|
672
|
+
source,
|
|
673
|
+
tuple(dict.fromkeys((*predicate_reads, *dependency_reads))),
|
|
674
|
+
(_state_reference(slot_name, slots),),
|
|
675
|
+
)
|
|
676
|
+
if step_kind == "confirm":
|
|
677
|
+
slot_name = cast(str, path_step["confirm"])
|
|
678
|
+
return FlowNode(
|
|
679
|
+
cast(str, path_step["step"]),
|
|
680
|
+
"confirm",
|
|
681
|
+
source,
|
|
682
|
+
predicate_reads,
|
|
683
|
+
(_state_reference(slot_name, slots),),
|
|
684
|
+
)
|
|
685
|
+
if step_kind == "derive":
|
|
686
|
+
writes = cast(str, path_step["derive"])
|
|
687
|
+
derive_definition = next(
|
|
688
|
+
(
|
|
689
|
+
cast(dict[str, Any], candidate)
|
|
690
|
+
for candidate in document.get("derive", [])
|
|
691
|
+
if candidate.get("writes") == writes
|
|
692
|
+
),
|
|
693
|
+
{},
|
|
694
|
+
)
|
|
695
|
+
return FlowNode(
|
|
696
|
+
cast(str, path_step["step"]),
|
|
697
|
+
"derive",
|
|
698
|
+
source,
|
|
699
|
+
tuple(
|
|
700
|
+
_state_reference(source_slot, slots)
|
|
701
|
+
for source_slot in derive_definition.get("from", [])
|
|
702
|
+
),
|
|
703
|
+
(_state_reference(writes, slots),),
|
|
704
|
+
)
|
|
705
|
+
if step_kind == "terminal":
|
|
706
|
+
terminal = cast(dict[str, Any], document.get("terminal", {}))
|
|
707
|
+
success_writes = cast(
|
|
708
|
+
dict[str, Any],
|
|
709
|
+
cast(dict[str, Any], terminal.get("outcomes", {})).get("success", {}).get("set", {}),
|
|
710
|
+
)
|
|
711
|
+
return FlowNode(
|
|
712
|
+
cast(str, terminal.get("step", "terminal")),
|
|
713
|
+
"terminal",
|
|
714
|
+
source,
|
|
715
|
+
tuple(
|
|
716
|
+
_state_reference(binding["slot"], slots) for binding in terminal.get("input", [])
|
|
717
|
+
),
|
|
718
|
+
tuple(
|
|
719
|
+
dict.fromkeys(
|
|
720
|
+
_state_reference(state_key, slots)
|
|
721
|
+
for state_key in (*terminal.get("outputs", {}), *success_writes)
|
|
722
|
+
)
|
|
723
|
+
),
|
|
724
|
+
)
|
|
725
|
+
if step_kind == "use":
|
|
726
|
+
reference = cast(str, path_step["use"])
|
|
727
|
+
definition = subflow_definitions.get(reference)
|
|
728
|
+
owned_state_access = (
|
|
729
|
+
tuple(
|
|
730
|
+
f"{state_contract.partition}.{state_key}"
|
|
731
|
+
for state_key, state_contract in sorted(definition.owned_state_keys.items())
|
|
732
|
+
)
|
|
733
|
+
if definition is not None
|
|
734
|
+
else ()
|
|
735
|
+
)
|
|
736
|
+
await_reads: tuple[str, ...] = ()
|
|
737
|
+
await_writes: tuple[str, ...] = ()
|
|
738
|
+
await_definition = cast(
|
|
739
|
+
dict[str, Any],
|
|
740
|
+
cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
|
|
741
|
+
)
|
|
742
|
+
if definition is not None and await_definition.get("step") in definition.node_identifiers:
|
|
743
|
+
await_reads, await_writes = _await_state_access(document)
|
|
744
|
+
return FlowNode(
|
|
745
|
+
f"use:{path_index}:{reference}",
|
|
746
|
+
"subflow",
|
|
747
|
+
source,
|
|
748
|
+
tuple(dict.fromkeys((*owned_state_access, *await_reads))),
|
|
749
|
+
tuple(dict.fromkeys((*owned_state_access, *await_writes))),
|
|
750
|
+
)
|
|
751
|
+
await_reads, await_writes = _await_state_access(document)
|
|
752
|
+
return FlowNode(
|
|
753
|
+
cast(str, path_step["step"]),
|
|
754
|
+
"await_external",
|
|
755
|
+
source,
|
|
756
|
+
await_reads,
|
|
757
|
+
await_writes,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _unplaced_derive_nodes(document: dict[str, Any]) -> tuple[FlowNode, ...]:
|
|
762
|
+
placed = {path_step["derive"] for path_step in document["path"] if "derive" in path_step}
|
|
763
|
+
slots = cast(dict[str, Any], document.get("slots", {}))
|
|
764
|
+
return tuple(
|
|
765
|
+
FlowNode(
|
|
766
|
+
f"derive_{derive_definition['writes']}",
|
|
767
|
+
"derive",
|
|
768
|
+
f"/derive/{derive_index}",
|
|
769
|
+
tuple(_state_reference(source, slots) for source in derive_definition["from"]),
|
|
770
|
+
(_state_reference(derive_definition["writes"], slots),),
|
|
771
|
+
)
|
|
772
|
+
for derive_index, derive_definition in enumerate(document.get("derive", []))
|
|
773
|
+
if derive_definition["writes"] not in placed
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _ordered_nodes(
|
|
778
|
+
document: dict[str, Any],
|
|
779
|
+
subflow_definitions: Mapping[str, SubflowDefinition],
|
|
780
|
+
) -> tuple[FlowNode, ...]:
|
|
781
|
+
path_nodes = [
|
|
782
|
+
_node_for_step(path_step, path_index, document, subflow_definitions)
|
|
783
|
+
for path_index, path_step in enumerate(document["path"])
|
|
784
|
+
]
|
|
785
|
+
last_inserted_for_anchor: dict[str, str] = {}
|
|
786
|
+
for derive_node in _unplaced_derive_nodes(document):
|
|
787
|
+
derive_definition = document["derive"][int(derive_node.source.rsplit("/", 1)[1])]
|
|
788
|
+
anchor = derive_definition.get("after")
|
|
789
|
+
if anchor is None:
|
|
790
|
+
raise ValueError(
|
|
791
|
+
f"{derive_node.source} must be placed in path or declare an after anchor"
|
|
792
|
+
)
|
|
793
|
+
effective_anchor = last_inserted_for_anchor.get(anchor, anchor)
|
|
794
|
+
anchor_index = next(
|
|
795
|
+
(
|
|
796
|
+
node_index
|
|
797
|
+
for node_index, candidate in enumerate(path_nodes)
|
|
798
|
+
if candidate.identifier == effective_anchor
|
|
799
|
+
),
|
|
800
|
+
None,
|
|
801
|
+
)
|
|
802
|
+
if anchor_index is None:
|
|
803
|
+
raise ValueError(
|
|
804
|
+
f"{derive_node.source}/after does not resolve to a normalized node: {anchor!r}"
|
|
805
|
+
)
|
|
806
|
+
path_nodes.insert(anchor_index + 1, derive_node)
|
|
807
|
+
last_inserted_for_anchor[anchor] = derive_node.identifier
|
|
808
|
+
entry = cast(dict[str, Any], document.get("entry", {}))
|
|
809
|
+
init_writes = ["data.service"] if document.get("service") else []
|
|
810
|
+
if entry.get("writes"):
|
|
811
|
+
init_writes.append(f"data.{entry['writes']}")
|
|
812
|
+
init_writes.append("internal._entry_done")
|
|
813
|
+
return (
|
|
814
|
+
FlowNode("__init__", "init", "", (), tuple(dict.fromkeys(init_writes))),
|
|
815
|
+
*path_nodes,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _transitions(
|
|
820
|
+
nodes: tuple[FlowNode, ...], document: dict[str, Any]
|
|
821
|
+
) -> tuple[FlowTransition, ...]:
|
|
822
|
+
transitions: list[FlowTransition] = []
|
|
823
|
+
path_slot_nodes = {
|
|
824
|
+
path_step.get("slot") or path_step.get("confirm"): path_step.get("step")
|
|
825
|
+
for path_step in document["path"]
|
|
826
|
+
if "slot" in path_step or "confirm" in path_step
|
|
827
|
+
}
|
|
828
|
+
confirmation = cast(dict[str, Any], document.get("confirm", {}))
|
|
829
|
+
await_definition = cast(
|
|
830
|
+
dict[str, Any],
|
|
831
|
+
cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
|
|
832
|
+
)
|
|
833
|
+
for node_index, node in enumerate(nodes):
|
|
834
|
+
next_identifier = (
|
|
835
|
+
nodes[node_index + 1].identifier if node_index + 1 < len(nodes) else "$end"
|
|
836
|
+
)
|
|
837
|
+
if node.kind == "terminal":
|
|
838
|
+
transitions.append(FlowTransition(node.identifier, "$end", "end"))
|
|
839
|
+
continue
|
|
840
|
+
if node.kind == "await_external":
|
|
841
|
+
transitions.extend(
|
|
842
|
+
(
|
|
843
|
+
FlowTransition(node.identifier, next_identifier, "resume"),
|
|
844
|
+
FlowTransition(node.identifier, "$end", "pause"),
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
configured_transitions = {
|
|
848
|
+
"timeout": await_definition.get("timeout"),
|
|
849
|
+
**cast(dict[str, Any], await_definition.get("recovery", {})),
|
|
850
|
+
}
|
|
851
|
+
for event_name, transition in configured_transitions.items():
|
|
852
|
+
if not isinstance(transition, dict):
|
|
853
|
+
continue
|
|
854
|
+
transition_target = cast(str, transition["goto"])
|
|
855
|
+
transitions.append(
|
|
856
|
+
FlowTransition(
|
|
857
|
+
node.identifier,
|
|
858
|
+
"$end" if transition_target == "END" else transition_target,
|
|
859
|
+
cast(Any, event_name),
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
continue
|
|
863
|
+
if node.source and node.kind == "confirm":
|
|
864
|
+
path_index = int(node.source.rsplit("/", 1)[1])
|
|
865
|
+
path_step = cast(dict[str, Any], document["path"][path_index])
|
|
866
|
+
if path_step.get("correctable") is True and confirmation:
|
|
867
|
+
confirmation_target = cast(
|
|
868
|
+
str,
|
|
869
|
+
confirmation.get("on_confirm", next_identifier),
|
|
870
|
+
)
|
|
871
|
+
transitions.append(FlowTransition(node.identifier, confirmation_target, "confirm"))
|
|
872
|
+
for correctable_slot in confirmation.get("correctable", []):
|
|
873
|
+
target = path_slot_nodes.get(correctable_slot)
|
|
874
|
+
transitions.append(
|
|
875
|
+
FlowTransition(
|
|
876
|
+
node.identifier,
|
|
877
|
+
cast(str, target or f"$slot:{correctable_slot}"),
|
|
878
|
+
"correction",
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
transitions.append(FlowTransition(node.identifier, "$end", "pause"))
|
|
882
|
+
continue
|
|
883
|
+
transitions.append(FlowTransition(node.identifier, next_identifier, "confirm"))
|
|
884
|
+
transitions.append(FlowTransition(node.identifier, "$end", "pause"))
|
|
885
|
+
if "on_reject" in path_step:
|
|
886
|
+
transitions.append(FlowTransition(node.identifier, "$end", "reject"))
|
|
887
|
+
continue
|
|
888
|
+
transitions.append(FlowTransition(node.identifier, next_identifier, "next"))
|
|
889
|
+
if node.kind == "collect":
|
|
890
|
+
transitions.append(FlowTransition(node.identifier, "$end", "pause"))
|
|
891
|
+
return tuple(transitions)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _validate_reachability(
|
|
895
|
+
nodes: tuple[FlowNode, ...],
|
|
896
|
+
transitions: tuple[FlowTransition, ...],
|
|
897
|
+
) -> None:
|
|
898
|
+
if not nodes:
|
|
899
|
+
raise ValueError("normalized flow must contain an init node")
|
|
900
|
+
node_identifiers = {node.identifier for node in nodes}
|
|
901
|
+
outgoing: dict[str, set[str]] = {}
|
|
902
|
+
for transition in transitions:
|
|
903
|
+
if transition.target in node_identifiers:
|
|
904
|
+
outgoing.setdefault(transition.source, set()).add(transition.target)
|
|
905
|
+
reachable = {nodes[0].identifier}
|
|
906
|
+
pending = [nodes[0].identifier]
|
|
907
|
+
while pending:
|
|
908
|
+
source = pending.pop()
|
|
909
|
+
for target in outgoing.get(source, set()):
|
|
910
|
+
if target not in reachable:
|
|
911
|
+
reachable.add(target)
|
|
912
|
+
pending.append(target)
|
|
913
|
+
if unreachable := sorted(node_identifiers - reachable):
|
|
914
|
+
raise ValueError(f"normalized flow contains unreachable nodes: {', '.join(unreachable)}")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _references(document: dict[str, Any]) -> tuple[FlowReference, ...]:
|
|
918
|
+
references: list[FlowReference] = []
|
|
919
|
+
|
|
920
|
+
def append_predicate_references(source: str, predicate: Any) -> None:
|
|
921
|
+
for predicate_reference in _predicate_references(predicate):
|
|
922
|
+
predicate_namespace, predicate_target = predicate_reference.split(".", 1)
|
|
923
|
+
normalized_namespace = "data" if predicate_namespace == "slots" else predicate_namespace
|
|
924
|
+
references.append(
|
|
925
|
+
FlowReference(
|
|
926
|
+
source,
|
|
927
|
+
cast(Any, normalized_namespace),
|
|
928
|
+
predicate_target,
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
for slot_name, slot_definition in document.get("slots", {}).items():
|
|
933
|
+
escaped_slot = _escape_pointer_segment(slot_name)
|
|
934
|
+
references.append(
|
|
935
|
+
FlowReference(f"/slots/{escaped_slot}/domain", "domain", slot_definition["domain"])
|
|
936
|
+
)
|
|
937
|
+
references.extend(
|
|
938
|
+
FlowReference(
|
|
939
|
+
f"/slots/{escaped_slot}/requires/{requirement_index}",
|
|
940
|
+
"slot",
|
|
941
|
+
required_slot,
|
|
942
|
+
)
|
|
943
|
+
for requirement_index, required_slot in enumerate(slot_definition.get("requires", []))
|
|
944
|
+
)
|
|
945
|
+
for path_index, path_step in enumerate(document["path"]):
|
|
946
|
+
step_kind = _step_kind(path_step)
|
|
947
|
+
if step_kind in {"slot", "confirm"}:
|
|
948
|
+
references.append(
|
|
949
|
+
FlowReference(
|
|
950
|
+
f"/path/{path_index}/{step_kind}",
|
|
951
|
+
"slot",
|
|
952
|
+
cast(str, path_step[step_kind]),
|
|
953
|
+
)
|
|
954
|
+
)
|
|
955
|
+
elif step_kind == "derive":
|
|
956
|
+
references.append(
|
|
957
|
+
FlowReference(
|
|
958
|
+
f"/path/{path_index}/derive",
|
|
959
|
+
"slot",
|
|
960
|
+
cast(str, path_step["derive"]),
|
|
961
|
+
)
|
|
962
|
+
)
|
|
963
|
+
elif step_kind == "use":
|
|
964
|
+
references.append(
|
|
965
|
+
FlowReference(
|
|
966
|
+
f"/path/{path_index}/use",
|
|
967
|
+
"subflow",
|
|
968
|
+
cast(str, path_step["use"]),
|
|
969
|
+
)
|
|
970
|
+
)
|
|
971
|
+
interactive = cast(dict[str, Any], path_step.get("interactive", {}))
|
|
972
|
+
if (domain_name := interactive.get("from_domain")) is not None:
|
|
973
|
+
references.append(
|
|
974
|
+
FlowReference(
|
|
975
|
+
f"/path/{path_index}/interactive/from_domain",
|
|
976
|
+
"domain",
|
|
977
|
+
cast(str, domain_name),
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
for option_index, conditional_option in enumerate(interactive.get("options_when", [])):
|
|
981
|
+
append_predicate_references(
|
|
982
|
+
f"/path/{path_index}/interactive/options_when/{option_index}/gate",
|
|
983
|
+
conditional_option.get("gate"),
|
|
984
|
+
)
|
|
985
|
+
for predicate_key in ("ask_when", "skip_when"):
|
|
986
|
+
append_predicate_references(
|
|
987
|
+
f"/path/{path_index}/{predicate_key}",
|
|
988
|
+
path_step.get(predicate_key),
|
|
989
|
+
)
|
|
990
|
+
references.extend(
|
|
991
|
+
FlowReference(f"/uses/{use_index}/ref", "subflow", use_definition["ref"])
|
|
992
|
+
for use_index, use_definition in enumerate(document.get("uses", []))
|
|
993
|
+
)
|
|
994
|
+
for derive_index, derive_definition in enumerate(document.get("derive", [])):
|
|
995
|
+
references.extend(
|
|
996
|
+
FlowReference(
|
|
997
|
+
f"/derive/{derive_index}/from/{source_index}",
|
|
998
|
+
"slot",
|
|
999
|
+
source_slot,
|
|
1000
|
+
)
|
|
1001
|
+
for source_index, source_slot in enumerate(derive_definition["from"])
|
|
1002
|
+
)
|
|
1003
|
+
if (anchor := derive_definition.get("after")) is not None:
|
|
1004
|
+
references.append(FlowReference(f"/derive/{derive_index}/after", "step", anchor))
|
|
1005
|
+
if (entry := document.get("entry")) is not None:
|
|
1006
|
+
references.append(FlowReference("/entry/tool", "tool", entry["tool"]))
|
|
1007
|
+
if (terminal := document.get("terminal")) is not None:
|
|
1008
|
+
references.append(FlowReference("/terminal/tool", "tool", terminal["tool"]))
|
|
1009
|
+
references.extend(
|
|
1010
|
+
FlowReference(
|
|
1011
|
+
f"/terminal/input/{binding_index}/slot",
|
|
1012
|
+
"slot",
|
|
1013
|
+
binding["slot"],
|
|
1014
|
+
)
|
|
1015
|
+
for binding_index, binding in enumerate(terminal.get("input", []))
|
|
1016
|
+
)
|
|
1017
|
+
if (confirmation := document.get("confirm")) is not None:
|
|
1018
|
+
references.append(FlowReference("/confirm/slot", "slot", confirmation["slot"]))
|
|
1019
|
+
references.extend(
|
|
1020
|
+
FlowReference(
|
|
1021
|
+
f"/confirm/correctable/{correctable_index}",
|
|
1022
|
+
"slot",
|
|
1023
|
+
correctable_slot,
|
|
1024
|
+
)
|
|
1025
|
+
for correctable_index, correctable_slot in enumerate(
|
|
1026
|
+
confirmation.get("correctable", [])
|
|
1027
|
+
)
|
|
1028
|
+
)
|
|
1029
|
+
if (confirmation_target := confirmation.get("on_confirm")) is not None:
|
|
1030
|
+
references.append(FlowReference("/confirm/on_confirm", "step", confirmation_target))
|
|
1031
|
+
confirmation_interactive = cast(dict[str, Any], confirmation.get("interactive", {}))
|
|
1032
|
+
if (confirmation_domain := confirmation_interactive.get("from_domain")) is not None:
|
|
1033
|
+
references.append(
|
|
1034
|
+
FlowReference(
|
|
1035
|
+
"/confirm/interactive/from_domain",
|
|
1036
|
+
"domain",
|
|
1037
|
+
cast(str, confirmation_domain),
|
|
1038
|
+
)
|
|
1039
|
+
)
|
|
1040
|
+
for option_index, conditional_option in enumerate(
|
|
1041
|
+
confirmation_interactive.get("options_when", [])
|
|
1042
|
+
):
|
|
1043
|
+
append_predicate_references(
|
|
1044
|
+
f"/confirm/interactive/options_when/{option_index}/gate",
|
|
1045
|
+
conditional_option.get("gate"),
|
|
1046
|
+
)
|
|
1047
|
+
gates = cast(
|
|
1048
|
+
dict[str, Any], cast(dict[str, Any], document.get("overrides", {})).get("gates", {})
|
|
1049
|
+
)
|
|
1050
|
+
for gate_step, gate_predicate in gates.items():
|
|
1051
|
+
escaped_step = _escape_pointer_segment(gate_step)
|
|
1052
|
+
references.append(FlowReference(f"/overrides/gates/{escaped_step}", "step", gate_step))
|
|
1053
|
+
append_predicate_references(
|
|
1054
|
+
f"/overrides/gates/{escaped_step}",
|
|
1055
|
+
gate_predicate,
|
|
1056
|
+
)
|
|
1057
|
+
if (auto_flow := document.get("auto_flow")) is not None:
|
|
1058
|
+
if (resume_target := auto_flow.get("resume_at")) is not None:
|
|
1059
|
+
references.append(FlowReference("/auto_flow/resume_at", "step", resume_target))
|
|
1060
|
+
references.extend(
|
|
1061
|
+
FlowReference(
|
|
1062
|
+
f"/auto_flow/prefill_from/{prefill_index}",
|
|
1063
|
+
"slot",
|
|
1064
|
+
prefill_slot,
|
|
1065
|
+
)
|
|
1066
|
+
for prefill_index, prefill_slot in enumerate(auto_flow.get("prefill_from", []))
|
|
1067
|
+
)
|
|
1068
|
+
append_predicate_references("/auto_flow/send_when", auto_flow.get("send_when"))
|
|
1069
|
+
await_definition = cast(
|
|
1070
|
+
dict[str, Any],
|
|
1071
|
+
cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
|
|
1072
|
+
)
|
|
1073
|
+
if await_definition and (await_step := await_definition.get("step")) is not None:
|
|
1074
|
+
references.append(
|
|
1075
|
+
FlowReference(
|
|
1076
|
+
"/capabilities/await_external/step",
|
|
1077
|
+
"step",
|
|
1078
|
+
cast(str, await_step),
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
resume_contract = cast(dict[str, Any], await_definition.get("resume", {}))
|
|
1082
|
+
if correlation_reference := resume_contract.get("correlation"):
|
|
1083
|
+
references.append(
|
|
1084
|
+
FlowReference(
|
|
1085
|
+
"/capabilities/await_external/resume/correlation",
|
|
1086
|
+
"token",
|
|
1087
|
+
cast(str, correlation_reference),
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
on_resume = cast(dict[str, Any], await_definition.get("on_resume", {}))
|
|
1091
|
+
for state_key, token_binding in cast(dict[str, Any], on_resume.get("set", {})).items():
|
|
1092
|
+
if isinstance(token_binding, str) and token_binding.startswith("$token."):
|
|
1093
|
+
references.append(
|
|
1094
|
+
FlowReference(
|
|
1095
|
+
"/capabilities/await_external/on_resume/set/"
|
|
1096
|
+
+ _escape_pointer_segment(state_key),
|
|
1097
|
+
"token",
|
|
1098
|
+
token_binding,
|
|
1099
|
+
)
|
|
1100
|
+
)
|
|
1101
|
+
enrichment = on_resume.get("enrich")
|
|
1102
|
+
if isinstance(enrichment, str):
|
|
1103
|
+
references.append(
|
|
1104
|
+
FlowReference(
|
|
1105
|
+
"/capabilities/await_external/on_resume/enrich",
|
|
1106
|
+
"tool",
|
|
1107
|
+
enrichment,
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
elif isinstance(enrichment, dict):
|
|
1111
|
+
references.append(
|
|
1112
|
+
FlowReference(
|
|
1113
|
+
"/capabilities/await_external/on_resume/enrich/tool",
|
|
1114
|
+
"tool",
|
|
1115
|
+
enrichment["tool"],
|
|
1116
|
+
)
|
|
1117
|
+
)
|
|
1118
|
+
for parameter_name, token_binding in cast(
|
|
1119
|
+
dict[str, Any], enrichment.get("input", {})
|
|
1120
|
+
).items():
|
|
1121
|
+
if isinstance(token_binding, str) and token_binding.startswith("$token."):
|
|
1122
|
+
references.append(
|
|
1123
|
+
FlowReference(
|
|
1124
|
+
"/capabilities/await_external/on_resume/enrich/input/"
|
|
1125
|
+
+ _escape_pointer_segment(parameter_name),
|
|
1126
|
+
"token",
|
|
1127
|
+
token_binding,
|
|
1128
|
+
)
|
|
1129
|
+
)
|
|
1130
|
+
configured_transitions = {
|
|
1131
|
+
"timeout": await_definition.get("timeout"),
|
|
1132
|
+
**cast(dict[str, Any], await_definition.get("recovery", {})),
|
|
1133
|
+
}
|
|
1134
|
+
for event_name, transition in configured_transitions.items():
|
|
1135
|
+
if isinstance(transition, dict) and transition.get("goto") != "END":
|
|
1136
|
+
references.append(
|
|
1137
|
+
FlowReference(
|
|
1138
|
+
f"/capabilities/await_external/{event_name}/goto",
|
|
1139
|
+
"step",
|
|
1140
|
+
transition["goto"],
|
|
1141
|
+
)
|
|
1142
|
+
)
|
|
1143
|
+
return tuple(references)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _domain_state_schema(domain_definition: dict[str, Any], nullable: bool) -> dict[str, Any]:
|
|
1147
|
+
domain_type = domain_definition["type"]
|
|
1148
|
+
if domain_type == "categorical":
|
|
1149
|
+
allowed_values: list[Any] = [
|
|
1150
|
+
value for value in domain_definition.get("values", []) if value is not None or nullable
|
|
1151
|
+
]
|
|
1152
|
+
if nullable and None not in allowed_values:
|
|
1153
|
+
allowed_values.append(None)
|
|
1154
|
+
return {"enum": allowed_values}
|
|
1155
|
+
if domain_type == "bool":
|
|
1156
|
+
return {"type": ["boolean", "null"] if nullable else "boolean"}
|
|
1157
|
+
if domain_type in {"integer", "number"}:
|
|
1158
|
+
numeric_schema: dict[str, Any] = {
|
|
1159
|
+
"type": [domain_type, "null"] if nullable else domain_type
|
|
1160
|
+
}
|
|
1161
|
+
for constraint in ("minimum", "maximum"):
|
|
1162
|
+
if constraint in domain_definition:
|
|
1163
|
+
numeric_schema[constraint] = domain_definition[constraint]
|
|
1164
|
+
return numeric_schema
|
|
1165
|
+
state_type: str | list[str] = ["string", "null"] if nullable else "string"
|
|
1166
|
+
state_schema: dict[str, Any] = {"type": state_type}
|
|
1167
|
+
if domain_type == "brazilian_tax_id":
|
|
1168
|
+
state_schema["pattern"] = r"^[0-9]{11}$"
|
|
1169
|
+
elif domain_type == "email":
|
|
1170
|
+
state_schema["format"] = "email"
|
|
1171
|
+
state_schema["pattern"] = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
|
|
1172
|
+
elif domain_type == "name":
|
|
1173
|
+
state_schema["minLength"] = 2
|
|
1174
|
+
elif domain_type == "free_text" and not domain_definition.get("optional"):
|
|
1175
|
+
state_schema["minLength"] = 1
|
|
1176
|
+
return state_schema
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _embedded_subflow_state_schema(
|
|
1180
|
+
definition: SubflowDefinition,
|
|
1181
|
+
state_key: str,
|
|
1182
|
+
) -> dict[str, Any]:
|
|
1183
|
+
"""Embed a subflow state contract as its own JSON Schema resource."""
|
|
1184
|
+
|
|
1185
|
+
state_schema = cast(
|
|
1186
|
+
dict[str, Any],
|
|
1187
|
+
_mutable_json(definition.owned_state_keys[state_key].schema),
|
|
1188
|
+
)
|
|
1189
|
+
state_schema.setdefault(
|
|
1190
|
+
"$id",
|
|
1191
|
+
f"urn:flowspec2:subflow-state:{definition.ref}:{state_key}",
|
|
1192
|
+
)
|
|
1193
|
+
return state_schema
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _state_schema(
|
|
1197
|
+
document: dict[str, Any],
|
|
1198
|
+
subflow_definitions: Mapping[str, SubflowDefinition],
|
|
1199
|
+
) -> dict[str, Any]:
|
|
1200
|
+
partitions: dict[str, dict[str, Any]] = {
|
|
1201
|
+
partition: {"type": "object", "properties": {}, "additionalProperties": True}
|
|
1202
|
+
for partition in ("data", "internal", "payload")
|
|
1203
|
+
}
|
|
1204
|
+
domains = cast(dict[str, dict[str, Any]], document.get("domains", {}))
|
|
1205
|
+
for slot_name, slot_definition in document.get("slots", {}).items():
|
|
1206
|
+
partition = cast(str, slot_definition["persist"])
|
|
1207
|
+
domain_definition = domains.get(slot_definition["domain"])
|
|
1208
|
+
if domain_definition is None:
|
|
1209
|
+
continue
|
|
1210
|
+
partitions[partition]["properties"][slot_name] = _domain_state_schema(
|
|
1211
|
+
domain_definition,
|
|
1212
|
+
cast(bool, slot_definition["nullable"]),
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
data_properties = cast(dict[str, Any], partitions["data"]["properties"])
|
|
1216
|
+
internal_properties = cast(dict[str, Any], partitions["internal"]["properties"])
|
|
1217
|
+
for use_definition in document.get("uses", []):
|
|
1218
|
+
definition = subflow_definitions.get(use_definition["ref"])
|
|
1219
|
+
if definition is None:
|
|
1220
|
+
continue
|
|
1221
|
+
for state_key, state_contract in definition.owned_state_keys.items():
|
|
1222
|
+
partition_properties = cast(
|
|
1223
|
+
dict[str, Any], partitions[state_contract.partition]["properties"]
|
|
1224
|
+
)
|
|
1225
|
+
partition_properties.setdefault(
|
|
1226
|
+
state_key,
|
|
1227
|
+
_embedded_subflow_state_schema(definition, state_key),
|
|
1228
|
+
)
|
|
1229
|
+
if service_definition := document.get("service"):
|
|
1230
|
+
data_properties.setdefault("service", {"const": copy.deepcopy(service_definition)})
|
|
1231
|
+
if (entry := document.get("entry")) is not None:
|
|
1232
|
+
if entry.get("writes"):
|
|
1233
|
+
data_properties.setdefault(entry["writes"], {})
|
|
1234
|
+
internal_properties.setdefault("_entry_done", {"type": "boolean"})
|
|
1235
|
+
for derive_definition in document.get("derive", []):
|
|
1236
|
+
writes = cast(str, derive_definition["writes"])
|
|
1237
|
+
if writes in document.get("slots", {}):
|
|
1238
|
+
continue
|
|
1239
|
+
possible_values = list(derive_definition.get("lookup", {}).values())
|
|
1240
|
+
literal_default = derive_definition.get("default")
|
|
1241
|
+
source_default_schema: dict[str, Any] | None = None
|
|
1242
|
+
if isinstance(literal_default, str) and (
|
|
1243
|
+
default_match := re.fullmatch(r"\$from\[(\d+)\]", literal_default)
|
|
1244
|
+
):
|
|
1245
|
+
source_index = int(default_match.group(1))
|
|
1246
|
+
derive_sources = cast(list[str], derive_definition["from"])
|
|
1247
|
+
if source_index < len(derive_sources):
|
|
1248
|
+
source_name = derive_sources[source_index]
|
|
1249
|
+
source_slot = cast(
|
|
1250
|
+
dict[str, Any] | None,
|
|
1251
|
+
cast(dict[str, Any], document.get("slots", {})).get(source_name),
|
|
1252
|
+
)
|
|
1253
|
+
if source_slot is not None:
|
|
1254
|
+
source_partition = cast(str, source_slot["persist"])
|
|
1255
|
+
source_default_schema = copy.deepcopy(
|
|
1256
|
+
cast(dict[str, Any], partitions[source_partition]["properties"]).get(
|
|
1257
|
+
source_name,
|
|
1258
|
+
{},
|
|
1259
|
+
)
|
|
1260
|
+
)
|
|
1261
|
+
else:
|
|
1262
|
+
source_default_schema = copy.deepcopy(data_properties.get(source_name, {}))
|
|
1263
|
+
if literal_default is not None and not (
|
|
1264
|
+
isinstance(literal_default, str) and literal_default.startswith("$from[")
|
|
1265
|
+
):
|
|
1266
|
+
possible_values.append(literal_default)
|
|
1267
|
+
unique_values = list(dict.fromkeys(_canonical_json(value) for value in possible_values))
|
|
1268
|
+
enumerated_schema: dict[str, Any] | None = (
|
|
1269
|
+
{"enum": [json.loads(serialized_value) for serialized_value in unique_values]}
|
|
1270
|
+
if unique_values
|
|
1271
|
+
else None
|
|
1272
|
+
)
|
|
1273
|
+
if source_default_schema == {}:
|
|
1274
|
+
derived_state_schema: dict[str, Any] = {}
|
|
1275
|
+
elif source_default_schema is not None and enumerated_schema is not None:
|
|
1276
|
+
if set(source_default_schema) == {"enum"}:
|
|
1277
|
+
combined_values = [
|
|
1278
|
+
*cast(list[Any], enumerated_schema["enum"]),
|
|
1279
|
+
*cast(list[Any], source_default_schema["enum"]),
|
|
1280
|
+
]
|
|
1281
|
+
combined_serialized = dict.fromkeys(
|
|
1282
|
+
_canonical_json(value) for value in combined_values
|
|
1283
|
+
)
|
|
1284
|
+
derived_state_schema = {
|
|
1285
|
+
"enum": [json.loads(value) for value in combined_serialized]
|
|
1286
|
+
}
|
|
1287
|
+
else:
|
|
1288
|
+
derived_state_schema = {"anyOf": [enumerated_schema, source_default_schema]}
|
|
1289
|
+
elif source_default_schema is not None:
|
|
1290
|
+
derived_state_schema = source_default_schema
|
|
1291
|
+
else:
|
|
1292
|
+
derived_state_schema = enumerated_schema or {}
|
|
1293
|
+
data_properties.setdefault(
|
|
1294
|
+
writes,
|
|
1295
|
+
derived_state_schema,
|
|
1296
|
+
)
|
|
1297
|
+
if (terminal := document.get("terminal")) is not None:
|
|
1298
|
+
for output_key in terminal.get("outputs", {}):
|
|
1299
|
+
data_properties.setdefault(output_key, {})
|
|
1300
|
+
for state_key, state_value in (
|
|
1301
|
+
cast(dict[str, Any], terminal.get("outcomes", {})).get("success", {}).get("set", {})
|
|
1302
|
+
).items():
|
|
1303
|
+
data_properties.setdefault(state_key, {"const": state_value})
|
|
1304
|
+
await_definition = cast(
|
|
1305
|
+
dict[str, Any],
|
|
1306
|
+
cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
|
|
1307
|
+
)
|
|
1308
|
+
if await_definition:
|
|
1309
|
+
slots = cast(dict[str, Any], document.get("slots", {}))
|
|
1310
|
+
|
|
1311
|
+
if await_definition.get("max_resends") is not None:
|
|
1312
|
+
await_node_id = cast(str, await_definition["step"])
|
|
1313
|
+
internal_properties.setdefault(
|
|
1314
|
+
f"_await_external_resend_count:{await_node_id}",
|
|
1315
|
+
{"type": "integer", "minimum": 0},
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
def set_await_state_schema(state_key: str, state_value_schema: dict[str, Any]) -> None:
|
|
1319
|
+
slot_definition = cast(dict[str, Any] | None, slots.get(state_key))
|
|
1320
|
+
partition_name = (
|
|
1321
|
+
cast(str, slot_definition["persist"]) if slot_definition is not None else "data"
|
|
1322
|
+
)
|
|
1323
|
+
partition_properties = cast(dict[str, Any], partitions[partition_name]["properties"])
|
|
1324
|
+
partition_properties.setdefault(
|
|
1325
|
+
state_key,
|
|
1326
|
+
state_value_schema if slot_definition is not None else {},
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
on_resume = cast(dict[str, Any], await_definition.get("on_resume", {}))
|
|
1330
|
+
for state_key, binding in cast(dict[str, Any], on_resume.get("set", {})).items():
|
|
1331
|
+
set_await_state_schema(
|
|
1332
|
+
state_key,
|
|
1333
|
+
{} if isinstance(binding, str) and binding.startswith("$") else {"const": binding},
|
|
1334
|
+
)
|
|
1335
|
+
enrichment = on_resume.get("enrich")
|
|
1336
|
+
if isinstance(enrichment, dict):
|
|
1337
|
+
for state_key in cast(dict[str, Any], enrichment.get("set", {})):
|
|
1338
|
+
set_await_state_schema(state_key, {})
|
|
1339
|
+
for transition in (
|
|
1340
|
+
await_definition.get("timeout"),
|
|
1341
|
+
*cast(dict[str, Any], await_definition.get("recovery", {})).values(),
|
|
1342
|
+
):
|
|
1343
|
+
if not isinstance(transition, dict):
|
|
1344
|
+
continue
|
|
1345
|
+
for state_key, state_value in cast(dict[str, Any], transition.get("set", {})).items():
|
|
1346
|
+
set_await_state_schema(state_key, {"const": state_value})
|
|
1347
|
+
data_properties.setdefault("_reset_on_next_call", {"type": "boolean"})
|
|
1348
|
+
internal_properties.setdefault("_flowspec2_flow_finished", {"type": "boolean"})
|
|
1349
|
+
return {
|
|
1350
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
1351
|
+
"type": "object",
|
|
1352
|
+
"additionalProperties": False,
|
|
1353
|
+
"required": ["data", "internal", "payload"],
|
|
1354
|
+
"properties": partitions,
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def build_flow_ir(
|
|
1359
|
+
document: dict[str, Any],
|
|
1360
|
+
*,
|
|
1361
|
+
profile: FlowProfile | str | None = None,
|
|
1362
|
+
validate: bool = True,
|
|
1363
|
+
) -> FlowIR:
|
|
1364
|
+
"""Normalize a source document and index its deterministic contracts."""
|
|
1365
|
+
|
|
1366
|
+
effective_profile: FlowProfile | str = reference_profile() if profile is None else profile
|
|
1367
|
+
profile_identifier = (
|
|
1368
|
+
effective_profile.identifier
|
|
1369
|
+
if isinstance(effective_profile, FlowProfile)
|
|
1370
|
+
else effective_profile
|
|
1371
|
+
)
|
|
1372
|
+
if not profile_identifier.strip():
|
|
1373
|
+
raise ValueError("profile identifier must be non-empty")
|
|
1374
|
+
subflow_definitions: Mapping[str, SubflowDefinition] = (
|
|
1375
|
+
effective_profile.subflows.definitions if isinstance(effective_profile, FlowProfile) else {}
|
|
1376
|
+
)
|
|
1377
|
+
normalized = normalize_flow(document, validate=validate)
|
|
1378
|
+
profile_contract = (
|
|
1379
|
+
effective_profile.as_dict()
|
|
1380
|
+
if isinstance(effective_profile, FlowProfile)
|
|
1381
|
+
else {"identifier": profile_identifier, "resolved": False}
|
|
1382
|
+
)
|
|
1383
|
+
profile_digest = (
|
|
1384
|
+
effective_profile.digest
|
|
1385
|
+
if isinstance(effective_profile, FlowProfile)
|
|
1386
|
+
else _digest(profile_contract)
|
|
1387
|
+
)
|
|
1388
|
+
dependency_digest = _digest(_resolved_dependency_contract(normalized, effective_profile))
|
|
1389
|
+
slots = tuple(
|
|
1390
|
+
FlowSlot(
|
|
1391
|
+
name=slot_name,
|
|
1392
|
+
domain=slot_definition["domain"],
|
|
1393
|
+
partition=slot_definition["persist"],
|
|
1394
|
+
required=slot_definition["required"],
|
|
1395
|
+
nullable=slot_definition["nullable"],
|
|
1396
|
+
requires=tuple(slot_definition.get("requires", [])),
|
|
1397
|
+
)
|
|
1398
|
+
for slot_name, slot_definition in sorted(normalized.get("slots", {}).items())
|
|
1399
|
+
)
|
|
1400
|
+
capabilities = {
|
|
1401
|
+
*(
|
|
1402
|
+
f"capability:{name}"
|
|
1403
|
+
for name, capability_value in normalized.get("capabilities", {}).items()
|
|
1404
|
+
if capability_is_requested(capability_value)
|
|
1405
|
+
),
|
|
1406
|
+
*(f"subflow:{use['ref']}" for use in normalized.get("uses", [])),
|
|
1407
|
+
}
|
|
1408
|
+
if normalized.get("auto_flow") is not None:
|
|
1409
|
+
capabilities.add("capability:auto_flow")
|
|
1410
|
+
for use_definition in normalized.get("uses", []):
|
|
1411
|
+
subflow_definition = subflow_definitions.get(use_definition["ref"])
|
|
1412
|
+
if subflow_definition is not None:
|
|
1413
|
+
capabilities.update(
|
|
1414
|
+
f"capability:{capability}" for capability in subflow_definition.capabilities
|
|
1415
|
+
)
|
|
1416
|
+
capabilities.update(
|
|
1417
|
+
f"tool:{tool_name}" for tool_name in subflow_definition.required_tools
|
|
1418
|
+
)
|
|
1419
|
+
if (entry := normalized.get("entry")) is not None:
|
|
1420
|
+
capabilities.add(f"tool:{entry['tool']}")
|
|
1421
|
+
if (terminal := normalized.get("terminal")) is not None:
|
|
1422
|
+
capabilities.add(f"tool:{terminal['tool']}")
|
|
1423
|
+
await_definition = cast(
|
|
1424
|
+
dict[str, Any],
|
|
1425
|
+
cast(dict[str, Any], normalized.get("capabilities", {})).get("await_external", {}),
|
|
1426
|
+
)
|
|
1427
|
+
enrichment = cast(dict[str, Any], await_definition.get("on_resume", {})).get("enrich")
|
|
1428
|
+
if isinstance(enrichment, str):
|
|
1429
|
+
capabilities.add(f"tool:{enrichment}")
|
|
1430
|
+
elif isinstance(enrichment, dict):
|
|
1431
|
+
capabilities.add(f"tool:{enrichment['tool']}")
|
|
1432
|
+
canonical_json = _canonical_json(normalized)
|
|
1433
|
+
nodes = _ordered_nodes(normalized, subflow_definitions)
|
|
1434
|
+
transitions = _transitions(nodes, normalized)
|
|
1435
|
+
_validate_reachability(nodes, transitions)
|
|
1436
|
+
return FlowIR(
|
|
1437
|
+
ir_format=FLOW_IR_FORMAT,
|
|
1438
|
+
format=normalized["schema"],
|
|
1439
|
+
flow=normalized["flow"],
|
|
1440
|
+
version=normalized["version"],
|
|
1441
|
+
profile=profile_identifier,
|
|
1442
|
+
profile_digest=profile_digest,
|
|
1443
|
+
dependency_digest=dependency_digest,
|
|
1444
|
+
source_digest=_digest(document),
|
|
1445
|
+
digest=_digest(
|
|
1446
|
+
{
|
|
1447
|
+
"ir_format": FLOW_IR_FORMAT,
|
|
1448
|
+
"dependency_digest": dependency_digest,
|
|
1449
|
+
"document": normalized,
|
|
1450
|
+
}
|
|
1451
|
+
),
|
|
1452
|
+
canonical_json=canonical_json,
|
|
1453
|
+
state_schema_json=_canonical_json(_state_schema(normalized, subflow_definitions)),
|
|
1454
|
+
slots=slots,
|
|
1455
|
+
nodes=nodes,
|
|
1456
|
+
transitions=transitions,
|
|
1457
|
+
references=_references(normalized),
|
|
1458
|
+
required_capabilities=tuple(sorted(capabilities)),
|
|
1459
|
+
)
|