specromancy-engine 0.1.3__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.
- specromancy_engine/__init__.py +32 -0
- specromancy_engine/__main__.py +8 -0
- specromancy_engine/_adapters/__init__.py +1 -0
- specromancy_engine/_adapters/adapter_contracts.py +81 -0
- specromancy_engine/_adapters/adapter_ownership.py +302 -0
- specromancy_engine/_adapters/adapter_rendering.py +218 -0
- specromancy_engine/_adapters/adapter_sources.py +140 -0
- specromancy_engine/_cli/__init__.py +1 -0
- specromancy_engine/_cli/cli_commands.py +136 -0
- specromancy_engine/_cli/pipeline_selection.py +28 -0
- specromancy_engine/_config/__init__.py +1 -0
- specromancy_engine/_config/config_errors.py +57 -0
- specromancy_engine/_config/config_fields.py +228 -0
- specromancy_engine/_config/config_loader.py +246 -0
- specromancy_engine/_config/config_models.py +97 -0
- specromancy_engine/_config/config_phase_parser.py +497 -0
- specromancy_engine/_config/config_serialization.py +76 -0
- specromancy_engine/_engine/__init__.py +1 -0
- specromancy_engine/_engine/approval_service.py +278 -0
- specromancy_engine/_engine/approvals.py +248 -0
- specromancy_engine/_engine/command_decisions.py +81 -0
- specromancy_engine/_engine/engine_errors.py +22 -0
- specromancy_engine/_engine/provenance.py +58 -0
- specromancy_engine/_engine/responses.py +129 -0
- specromancy_engine/_engine/validation_service.py +112 -0
- specromancy_engine/_runs/__init__.py +1 -0
- specromancy_engine/_runs/run_errors.py +51 -0
- specromancy_engine/_runs/run_identity.py +66 -0
- specromancy_engine/_runs/run_persistence.py +303 -0
- specromancy_engine/_runs/run_records.py +159 -0
- specromancy_engine/_runs/run_validation.py +241 -0
- specromancy_engine/_runs/visit_preparation.py +61 -0
- specromancy_engine/_runs/visit_transitions.py +311 -0
- specromancy_engine/_version.py +18 -0
- specromancy_engine/actions.py +118 -0
- specromancy_engine/adapters.py +97 -0
- specromancy_engine/artifacts.py +303 -0
- specromancy_engine/cli.py +338 -0
- specromancy_engine/commands.py +140 -0
- specromancy_engine/config.py +63 -0
- specromancy_engine/contracts.py +20 -0
- specromancy_engine/engine.py +423 -0
- specromancy_engine/errors.py +42 -0
- specromancy_engine/exit_codes.py +53 -0
- specromancy_engine/git.py +264 -0
- specromancy_engine/graph.py +321 -0
- specromancy_engine/hashing.py +95 -0
- specromancy_engine/locking.py +107 -0
- specromancy_engine/registry.py +134 -0
- specromancy_engine/run_store.py +643 -0
- specromancy_engine/schema_validation.py +178 -0
- specromancy_engine/schemas/event.schema.json +34 -0
- specromancy_engine/schemas/pipeline.schema.json +154 -0
- specromancy_engine/schemas/pipelines.schema.json +38 -0
- specromancy_engine/schemas/run.schema.json +152 -0
- specromancy_engine/status.py +106 -0
- specromancy_engine/validation.py +241 -0
- specromancy_engine-0.1.3.dist-info/METADATA +122 -0
- specromancy_engine-0.1.3.dist-info/RECORD +63 -0
- specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
- specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
- specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
- specromancy_engine-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Parse phases and their validation, command, transition, and artifact contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import string
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .config_fields import FieldContext, IDENTIFIER_PATTERN, _safe_declared_path
|
|
12
|
+
from .config_models import PhaseConfig, TransitionConfig, ValidationCommand, ValidatorConfig
|
|
13
|
+
from ..contracts import RESERVED_COMMANDS
|
|
14
|
+
from ..schema_validation import SchemaDefinitionError, load_json_schema
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MUTATION_POLICIES = frozenset({"read-only", "repository-write", "allowlist"})
|
|
18
|
+
VALIDATOR_TYPES = frozenset({"markdown", "json", "file"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_phase(context: FieldContext, value: Any) -> PhaseConfig:
|
|
22
|
+
raw = context.require_mapping(value, "phases")
|
|
23
|
+
phase_id = context.require_identifier(raw.get("id"), "id")
|
|
24
|
+
allowed = {
|
|
25
|
+
"id",
|
|
26
|
+
"skill",
|
|
27
|
+
"inputs",
|
|
28
|
+
"output_name",
|
|
29
|
+
"output_template",
|
|
30
|
+
"mutation",
|
|
31
|
+
"allowlist",
|
|
32
|
+
"mutation_allowlist",
|
|
33
|
+
"completion_criteria",
|
|
34
|
+
"validator",
|
|
35
|
+
"validator_path",
|
|
36
|
+
"required_headings",
|
|
37
|
+
"heading_occurrence",
|
|
38
|
+
"commands",
|
|
39
|
+
"validation_commands",
|
|
40
|
+
"approval_conditions",
|
|
41
|
+
"stop_conditions",
|
|
42
|
+
"transitions",
|
|
43
|
+
"max_visits",
|
|
44
|
+
}
|
|
45
|
+
context.check_unknown(raw, allowed, phase=phase_id)
|
|
46
|
+
if phase_id in RESERVED_COMMANDS:
|
|
47
|
+
context.fail(
|
|
48
|
+
"reserved-phase-id",
|
|
49
|
+
f"phase id {phase_id!r} shadows a reserved CLI command",
|
|
50
|
+
phase=phase_id,
|
|
51
|
+
field="id",
|
|
52
|
+
value=phase_id,
|
|
53
|
+
remediation="choose a phase id that is not a CLI command",
|
|
54
|
+
)
|
|
55
|
+
skill = context.require_identifier(raw.get("skill"), "skill", phase=phase_id)
|
|
56
|
+
skill_path = context.existing_path(
|
|
57
|
+
f".agents/skills/{skill}/SKILL.md",
|
|
58
|
+
"skill",
|
|
59
|
+
phase=phase_id,
|
|
60
|
+
base=context.repository_root,
|
|
61
|
+
)
|
|
62
|
+
inputs = context.string_list(raw.get("inputs"), "inputs", phase=phase_id)
|
|
63
|
+
_validate_inputs(inputs, phase_id, context.fail)
|
|
64
|
+
output_name = context.require_string(
|
|
65
|
+
raw.get("output_name"), "output_name", phase=phase_id
|
|
66
|
+
)
|
|
67
|
+
_validate_output_pattern(
|
|
68
|
+
output_name, "output_name", context.fail, phase=phase_id
|
|
69
|
+
)
|
|
70
|
+
output_template_raw = raw.get("output_template")
|
|
71
|
+
output_template: str | None = None
|
|
72
|
+
output_template_path: Path | None = None
|
|
73
|
+
if output_template_raw is not None:
|
|
74
|
+
output_template = context.require_string(
|
|
75
|
+
output_template_raw, "output_template", phase=phase_id
|
|
76
|
+
)
|
|
77
|
+
output_template_path = context.existing_path(
|
|
78
|
+
output_template, "output_template", phase=phase_id
|
|
79
|
+
)
|
|
80
|
+
mutation = context.require_string(raw.get("mutation"), "mutation", phase=phase_id)
|
|
81
|
+
if mutation not in MUTATION_POLICIES:
|
|
82
|
+
context.fail(
|
|
83
|
+
"invalid-mutation-policy",
|
|
84
|
+
f"phase {phase_id!r} uses unsupported mutation policy {mutation!r}",
|
|
85
|
+
phase=phase_id,
|
|
86
|
+
field="mutation",
|
|
87
|
+
value=mutation,
|
|
88
|
+
remediation=f"choose one of {', '.join(sorted(MUTATION_POLICIES))}",
|
|
89
|
+
)
|
|
90
|
+
if "allowlist" in raw and "mutation_allowlist" in raw:
|
|
91
|
+
context.fail(
|
|
92
|
+
"conflicting-fields",
|
|
93
|
+
"allowlist and mutation_allowlist cannot both be declared",
|
|
94
|
+
phase=phase_id,
|
|
95
|
+
field="allowlist",
|
|
96
|
+
value=raw["allowlist"],
|
|
97
|
+
remediation="declare only allowlist",
|
|
98
|
+
)
|
|
99
|
+
allowlist_value = raw.get("allowlist", raw.get("mutation_allowlist", []))
|
|
100
|
+
allowlist = context.string_list(
|
|
101
|
+
allowlist_value, "allowlist", phase=phase_id, allow_empty=mutation != "allowlist"
|
|
102
|
+
)
|
|
103
|
+
for pattern in allowlist:
|
|
104
|
+
if not _safe_declared_path(pattern, allow_glob=True):
|
|
105
|
+
context.fail(
|
|
106
|
+
"unsafe-path",
|
|
107
|
+
f"allowlist pattern escapes the repository: {pattern!r}",
|
|
108
|
+
phase=phase_id,
|
|
109
|
+
field="allowlist",
|
|
110
|
+
value=pattern,
|
|
111
|
+
remediation="use repository-relative glob patterns without parent segments",
|
|
112
|
+
)
|
|
113
|
+
if mutation != "allowlist" and allowlist:
|
|
114
|
+
context.fail(
|
|
115
|
+
"unexpected-allowlist",
|
|
116
|
+
f"mutation policy {mutation!r} cannot declare an allowlist",
|
|
117
|
+
phase=phase_id,
|
|
118
|
+
field="allowlist",
|
|
119
|
+
value=allowlist,
|
|
120
|
+
remediation="remove allowlist or use mutation = 'allowlist'",
|
|
121
|
+
)
|
|
122
|
+
completion_criteria = context.string_list(
|
|
123
|
+
raw.get("completion_criteria"),
|
|
124
|
+
"completion_criteria",
|
|
125
|
+
phase=phase_id,
|
|
126
|
+
allow_empty=False,
|
|
127
|
+
)
|
|
128
|
+
validator = parse_validator(context, raw, phase_id)
|
|
129
|
+
commands = parse_commands(context, raw, phase_id)
|
|
130
|
+
approval_conditions = context.string_list(
|
|
131
|
+
raw.get("approval_conditions"),
|
|
132
|
+
"approval_conditions",
|
|
133
|
+
phase=phase_id,
|
|
134
|
+
identifiers=True,
|
|
135
|
+
)
|
|
136
|
+
stop_conditions = context.string_list(
|
|
137
|
+
raw.get("stop_conditions"),
|
|
138
|
+
"stop_conditions",
|
|
139
|
+
phase=phase_id,
|
|
140
|
+
identifiers=True,
|
|
141
|
+
)
|
|
142
|
+
transitions_raw = raw.get("transitions")
|
|
143
|
+
if not isinstance(transitions_raw, list):
|
|
144
|
+
context.fail(
|
|
145
|
+
"invalid-field-type",
|
|
146
|
+
"transitions must be an array of tables",
|
|
147
|
+
phase=phase_id,
|
|
148
|
+
field="transitions",
|
|
149
|
+
value=transitions_raw,
|
|
150
|
+
remediation="declare one or more [[phases.transitions]] tables",
|
|
151
|
+
)
|
|
152
|
+
transitions = tuple(
|
|
153
|
+
parse_transition(context, item, phase_id) for item in transitions_raw
|
|
154
|
+
)
|
|
155
|
+
max_visits = None
|
|
156
|
+
if "max_visits" in raw:
|
|
157
|
+
max_visits = context.require_integer(
|
|
158
|
+
raw["max_visits"], "max_visits", phase=phase_id, minimum=1
|
|
159
|
+
)
|
|
160
|
+
return PhaseConfig(
|
|
161
|
+
id=phase_id,
|
|
162
|
+
skill=skill,
|
|
163
|
+
skill_path=skill_path,
|
|
164
|
+
inputs=inputs,
|
|
165
|
+
output_name=output_name,
|
|
166
|
+
output_template=output_template,
|
|
167
|
+
output_template_path=output_template_path,
|
|
168
|
+
mutation=mutation,
|
|
169
|
+
allowlist=allowlist,
|
|
170
|
+
completion_criteria=completion_criteria,
|
|
171
|
+
validator=validator,
|
|
172
|
+
commands=commands,
|
|
173
|
+
approval_conditions=approval_conditions,
|
|
174
|
+
stop_conditions=stop_conditions,
|
|
175
|
+
transitions=transitions,
|
|
176
|
+
max_visits=max_visits,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_validator(
|
|
181
|
+
context: FieldContext, phase_raw: Mapping[str, Any], phase_id: str
|
|
182
|
+
) -> ValidatorConfig:
|
|
183
|
+
raw = phase_raw.get("validator")
|
|
184
|
+
required_headings_raw: Any = phase_raw.get("required_headings", [])
|
|
185
|
+
heading_occurrence_raw: Any = phase_raw.get(
|
|
186
|
+
"heading_occurrence", "exactly-once"
|
|
187
|
+
)
|
|
188
|
+
declared_path_raw: Any = phase_raw.get("validator_path")
|
|
189
|
+
if isinstance(raw, str):
|
|
190
|
+
validator_type = context.require_string(raw, "validator", phase=phase_id)
|
|
191
|
+
elif isinstance(raw, Mapping):
|
|
192
|
+
context.check_unknown(
|
|
193
|
+
raw,
|
|
194
|
+
{
|
|
195
|
+
"type",
|
|
196
|
+
"required_headings",
|
|
197
|
+
"heading_occurrence",
|
|
198
|
+
"path",
|
|
199
|
+
"schema",
|
|
200
|
+
},
|
|
201
|
+
phase=phase_id,
|
|
202
|
+
)
|
|
203
|
+
validator_type = context.require_string(
|
|
204
|
+
raw.get("type"), "validator.type", phase=phase_id
|
|
205
|
+
)
|
|
206
|
+
if "required_headings" in raw:
|
|
207
|
+
if "required_headings" in phase_raw:
|
|
208
|
+
context.fail(
|
|
209
|
+
"conflicting-fields",
|
|
210
|
+
"required_headings is declared twice",
|
|
211
|
+
phase=phase_id,
|
|
212
|
+
field="required_headings",
|
|
213
|
+
value=required_headings_raw,
|
|
214
|
+
remediation="declare headings either beside or inside validator",
|
|
215
|
+
)
|
|
216
|
+
required_headings_raw = raw["required_headings"]
|
|
217
|
+
if "heading_occurrence" in raw:
|
|
218
|
+
if "heading_occurrence" in phase_raw:
|
|
219
|
+
context.fail(
|
|
220
|
+
"conflicting-fields",
|
|
221
|
+
"heading_occurrence is declared twice",
|
|
222
|
+
phase=phase_id,
|
|
223
|
+
field="heading_occurrence",
|
|
224
|
+
value=heading_occurrence_raw,
|
|
225
|
+
remediation="declare it either beside or inside validator",
|
|
226
|
+
)
|
|
227
|
+
heading_occurrence_raw = raw["heading_occurrence"]
|
|
228
|
+
if "path" in raw and "schema" in raw:
|
|
229
|
+
context.fail(
|
|
230
|
+
"conflicting-fields",
|
|
231
|
+
"validator path and schema cannot both be declared",
|
|
232
|
+
phase=phase_id,
|
|
233
|
+
field="validator.path",
|
|
234
|
+
value=raw["path"],
|
|
235
|
+
remediation="declare one validator file path",
|
|
236
|
+
)
|
|
237
|
+
nested_path = raw.get("path", raw.get("schema"))
|
|
238
|
+
if nested_path is not None:
|
|
239
|
+
if declared_path_raw is not None:
|
|
240
|
+
context.fail(
|
|
241
|
+
"conflicting-fields",
|
|
242
|
+
"validator path is declared twice",
|
|
243
|
+
phase=phase_id,
|
|
244
|
+
field="validator_path",
|
|
245
|
+
value=declared_path_raw,
|
|
246
|
+
remediation="declare the path either beside or inside validator",
|
|
247
|
+
)
|
|
248
|
+
declared_path_raw = nested_path
|
|
249
|
+
else:
|
|
250
|
+
context.fail(
|
|
251
|
+
"invalid-field-type",
|
|
252
|
+
"validator must be a type string or table",
|
|
253
|
+
phase=phase_id,
|
|
254
|
+
field="validator",
|
|
255
|
+
value=raw,
|
|
256
|
+
remediation="use markdown, json, or file",
|
|
257
|
+
)
|
|
258
|
+
if validator_type not in VALIDATOR_TYPES:
|
|
259
|
+
context.fail(
|
|
260
|
+
"invalid-validator",
|
|
261
|
+
f"phase {phase_id!r} uses unsupported validator {validator_type!r}",
|
|
262
|
+
phase=phase_id,
|
|
263
|
+
field="validator",
|
|
264
|
+
value=validator_type,
|
|
265
|
+
remediation=f"choose one of {', '.join(sorted(VALIDATOR_TYPES))}",
|
|
266
|
+
)
|
|
267
|
+
required_headings = context.string_list(
|
|
268
|
+
required_headings_raw,
|
|
269
|
+
"required_headings",
|
|
270
|
+
phase=phase_id,
|
|
271
|
+
)
|
|
272
|
+
if validator_type != "markdown" and required_headings:
|
|
273
|
+
context.fail(
|
|
274
|
+
"invalid-validator-rule",
|
|
275
|
+
"required_headings is supported only by the markdown validator",
|
|
276
|
+
phase=phase_id,
|
|
277
|
+
field="required_headings",
|
|
278
|
+
value=required_headings,
|
|
279
|
+
remediation="remove the headings or use validator = 'markdown'",
|
|
280
|
+
)
|
|
281
|
+
heading_occurrence = context.require_string(
|
|
282
|
+
heading_occurrence_raw, "heading_occurrence", phase=phase_id
|
|
283
|
+
)
|
|
284
|
+
if heading_occurrence not in {"exactly-once", "at-least-once"}:
|
|
285
|
+
context.fail(
|
|
286
|
+
"invalid-heading-occurrence",
|
|
287
|
+
"heading_occurrence must be exactly-once or at-least-once",
|
|
288
|
+
phase=phase_id,
|
|
289
|
+
field="heading_occurrence",
|
|
290
|
+
value=heading_occurrence,
|
|
291
|
+
remediation="choose exactly-once or at-least-once",
|
|
292
|
+
)
|
|
293
|
+
if validator_type != "markdown" and heading_occurrence_raw != "exactly-once":
|
|
294
|
+
context.fail(
|
|
295
|
+
"invalid-validator-rule",
|
|
296
|
+
"heading_occurrence is supported only by the markdown validator",
|
|
297
|
+
phase=phase_id,
|
|
298
|
+
field="heading_occurrence",
|
|
299
|
+
value=heading_occurrence,
|
|
300
|
+
remediation="remove the setting or use validator = 'markdown'",
|
|
301
|
+
)
|
|
302
|
+
declared_path: str | None = None
|
|
303
|
+
resolved_path: Path | None = None
|
|
304
|
+
json_schema: Mapping[str, Any] | None = None
|
|
305
|
+
if declared_path_raw is not None:
|
|
306
|
+
declared_path = context.require_string(
|
|
307
|
+
declared_path_raw, "validator_path", phase=phase_id
|
|
308
|
+
)
|
|
309
|
+
resolved_path = context.existing_path(
|
|
310
|
+
declared_path, "validator_path", phase=phase_id
|
|
311
|
+
)
|
|
312
|
+
if validator_type != "json":
|
|
313
|
+
context.fail(
|
|
314
|
+
"invalid-validator-rule",
|
|
315
|
+
"a validator schema path is supported only by the JSON validator",
|
|
316
|
+
phase=phase_id,
|
|
317
|
+
field="validator_path",
|
|
318
|
+
value=declared_path,
|
|
319
|
+
remediation="remove the path or use validator = 'json'",
|
|
320
|
+
)
|
|
321
|
+
try:
|
|
322
|
+
json_schema = load_json_schema(resolved_path)
|
|
323
|
+
except SchemaDefinitionError as exc:
|
|
324
|
+
context.fail(
|
|
325
|
+
"unsupported-json-schema",
|
|
326
|
+
f"invalid JSON validator schema at {exc.location}: {exc}",
|
|
327
|
+
phase=phase_id,
|
|
328
|
+
field="validator_path",
|
|
329
|
+
value=declared_path,
|
|
330
|
+
remediation="use only type, required, properties, items, enum, pattern, and additionalProperties",
|
|
331
|
+
)
|
|
332
|
+
return ValidatorConfig(
|
|
333
|
+
type=validator_type,
|
|
334
|
+
required_headings=required_headings,
|
|
335
|
+
heading_occurrence=heading_occurrence,
|
|
336
|
+
declared_path=declared_path,
|
|
337
|
+
resolved_path=resolved_path,
|
|
338
|
+
json_schema=json_schema,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def parse_commands(
|
|
343
|
+
context: FieldContext, raw: Mapping[str, Any], phase_id: str
|
|
344
|
+
) -> tuple[ValidationCommand, ...]:
|
|
345
|
+
if "commands" in raw and "validation_commands" in raw:
|
|
346
|
+
context.fail(
|
|
347
|
+
"conflicting-fields",
|
|
348
|
+
"commands and validation_commands cannot both be declared",
|
|
349
|
+
phase=phase_id,
|
|
350
|
+
field="commands",
|
|
351
|
+
value=raw["commands"],
|
|
352
|
+
remediation="declare only commands",
|
|
353
|
+
)
|
|
354
|
+
values = raw.get("commands", raw.get("validation_commands", []))
|
|
355
|
+
if not isinstance(values, list):
|
|
356
|
+
context.fail(
|
|
357
|
+
"invalid-field-type",
|
|
358
|
+
"commands must be an array of tables or argument arrays",
|
|
359
|
+
phase=phase_id,
|
|
360
|
+
field="commands",
|
|
361
|
+
value=values,
|
|
362
|
+
remediation="use command tables with an argv string array",
|
|
363
|
+
)
|
|
364
|
+
commands: list[ValidationCommand] = []
|
|
365
|
+
for value in values:
|
|
366
|
+
if isinstance(value, list):
|
|
367
|
+
command_raw: Mapping[str, Any] = {"argv": value}
|
|
368
|
+
else:
|
|
369
|
+
command_raw = context.require_mapping(value, "commands", phase=phase_id)
|
|
370
|
+
context.check_unknown(
|
|
371
|
+
command_raw, {"argv", "timeout_seconds", "required"}, phase=phase_id
|
|
372
|
+
)
|
|
373
|
+
argv = context.string_list(
|
|
374
|
+
command_raw.get("argv"), "commands.argv", phase=phase_id, allow_empty=False
|
|
375
|
+
)
|
|
376
|
+
timeout_raw = command_raw.get("timeout_seconds", 300)
|
|
377
|
+
if (
|
|
378
|
+
not isinstance(timeout_raw, (int, float))
|
|
379
|
+
or isinstance(timeout_raw, bool)
|
|
380
|
+
or not math.isfinite(timeout_raw)
|
|
381
|
+
or timeout_raw <= 0
|
|
382
|
+
):
|
|
383
|
+
context.fail(
|
|
384
|
+
"invalid-command-timeout",
|
|
385
|
+
"command timeout_seconds must be positive",
|
|
386
|
+
phase=phase_id,
|
|
387
|
+
field="commands.timeout_seconds",
|
|
388
|
+
value=timeout_raw,
|
|
389
|
+
remediation="set a positive timeout in seconds",
|
|
390
|
+
)
|
|
391
|
+
required = command_raw.get("required", True)
|
|
392
|
+
if not isinstance(required, bool):
|
|
393
|
+
context.fail(
|
|
394
|
+
"invalid-field-type",
|
|
395
|
+
"commands.required must be a boolean",
|
|
396
|
+
phase=phase_id,
|
|
397
|
+
field="commands.required",
|
|
398
|
+
value=required,
|
|
399
|
+
remediation="use true or false",
|
|
400
|
+
)
|
|
401
|
+
commands.append(
|
|
402
|
+
ValidationCommand(argv, float(timeout_raw), required)
|
|
403
|
+
)
|
|
404
|
+
return tuple(commands)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def parse_transition(context: FieldContext, value: Any, phase_id: str) -> TransitionConfig:
|
|
408
|
+
raw = context.require_mapping(value, "transitions", phase=phase_id)
|
|
409
|
+
context.check_unknown(
|
|
410
|
+
raw, {"outcome", "target", "max_traversals", "pause"}, phase=phase_id
|
|
411
|
+
)
|
|
412
|
+
outcome = context.require_identifier(
|
|
413
|
+
raw.get("outcome"), "transitions.outcome", phase=phase_id
|
|
414
|
+
)
|
|
415
|
+
target = None
|
|
416
|
+
if "target" in raw:
|
|
417
|
+
target = context.require_identifier(
|
|
418
|
+
raw["target"], "transitions.target", phase=phase_id
|
|
419
|
+
)
|
|
420
|
+
max_traversals = None
|
|
421
|
+
if "max_traversals" in raw:
|
|
422
|
+
max_traversals = context.require_integer(
|
|
423
|
+
raw["max_traversals"],
|
|
424
|
+
"transitions.max_traversals",
|
|
425
|
+
phase=phase_id,
|
|
426
|
+
minimum=1,
|
|
427
|
+
)
|
|
428
|
+
pause = raw.get("pause", False)
|
|
429
|
+
if not isinstance(pause, bool):
|
|
430
|
+
context.fail(
|
|
431
|
+
"invalid-field-type",
|
|
432
|
+
"transitions.pause must be a boolean",
|
|
433
|
+
phase=phase_id,
|
|
434
|
+
field="transitions.pause",
|
|
435
|
+
value=pause,
|
|
436
|
+
remediation="use true or false",
|
|
437
|
+
)
|
|
438
|
+
return TransitionConfig(outcome, target, max_traversals, pause)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _validate_output_pattern(
|
|
442
|
+
pattern: str,
|
|
443
|
+
field: str,
|
|
444
|
+
fail: Any,
|
|
445
|
+
*,
|
|
446
|
+
phase: str | None = None,
|
|
447
|
+
output_name: str = "output.md",
|
|
448
|
+
) -> None:
|
|
449
|
+
allowed_fields = {"visit", "phase", "output_name"}
|
|
450
|
+
try:
|
|
451
|
+
parsed = tuple(string.Formatter().parse(pattern))
|
|
452
|
+
for _, name, _, conversion in parsed:
|
|
453
|
+
if name is not None and name not in allowed_fields:
|
|
454
|
+
raise ValueError(f"unknown placeholder {name!r}")
|
|
455
|
+
if conversion:
|
|
456
|
+
raise ValueError("conversions are not supported")
|
|
457
|
+
rendered = pattern.format(visit=1, phase=phase or "phase", output_name=output_name)
|
|
458
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
459
|
+
fail(
|
|
460
|
+
"invalid-output-pattern",
|
|
461
|
+
f"{field} is not a valid output pattern: {exc}",
|
|
462
|
+
phase=phase,
|
|
463
|
+
field=field,
|
|
464
|
+
value=pattern,
|
|
465
|
+
remediation="use only {visit}, {phase}, and {output_name} placeholders",
|
|
466
|
+
)
|
|
467
|
+
if not _safe_declared_path(rendered):
|
|
468
|
+
fail(
|
|
469
|
+
"unsafe-output-path",
|
|
470
|
+
f"{field} can resolve outside a run directory",
|
|
471
|
+
phase=phase,
|
|
472
|
+
field=field,
|
|
473
|
+
value=pattern,
|
|
474
|
+
remediation="use a relative output path without parent segments",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _validate_inputs(inputs: tuple[str, ...], phase: str, fail: Any) -> None:
|
|
479
|
+
for reference in inputs:
|
|
480
|
+
if reference == "request":
|
|
481
|
+
continue
|
|
482
|
+
if reference.startswith("latest:"):
|
|
483
|
+
producer = reference.removeprefix("latest:")
|
|
484
|
+
if IDENTIFIER_PATTERN.fullmatch(producer):
|
|
485
|
+
continue
|
|
486
|
+
if reference.startswith("visit:"):
|
|
487
|
+
ordinal = reference.removeprefix("visit:")
|
|
488
|
+
if ordinal.isascii() and ordinal.isdigit() and int(ordinal) >= 1:
|
|
489
|
+
continue
|
|
490
|
+
fail(
|
|
491
|
+
"invalid-input-reference",
|
|
492
|
+
f"phase {phase!r} has invalid input reference {reference!r}",
|
|
493
|
+
phase=phase,
|
|
494
|
+
field="inputs",
|
|
495
|
+
value=reference,
|
|
496
|
+
remediation="use request, latest:<phase>, or visit:<positive-number>",
|
|
497
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Canonical pipeline documents and their compatibility-sensitive encoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config_models import PhaseConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _canonical_document(
|
|
12
|
+
schema_version: int,
|
|
13
|
+
pipeline_id: str,
|
|
14
|
+
version: int,
|
|
15
|
+
start: str,
|
|
16
|
+
terminal_outcomes: tuple[str, ...],
|
|
17
|
+
artifact_pattern: str,
|
|
18
|
+
allow_non_git: bool,
|
|
19
|
+
phases: tuple[PhaseConfig, ...],
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
return {
|
|
22
|
+
"allow_non_git": allow_non_git,
|
|
23
|
+
"artifact_pattern": artifact_pattern,
|
|
24
|
+
"id": pipeline_id,
|
|
25
|
+
"phases": [
|
|
26
|
+
{
|
|
27
|
+
"allowlist": list(phase.allowlist),
|
|
28
|
+
"approval_conditions": list(phase.approval_conditions),
|
|
29
|
+
"commands": [
|
|
30
|
+
{
|
|
31
|
+
"argv": list(command.argv),
|
|
32
|
+
"required": command.required,
|
|
33
|
+
"timeout_seconds": command.timeout_seconds,
|
|
34
|
+
}
|
|
35
|
+
for command in phase.commands
|
|
36
|
+
],
|
|
37
|
+
"completion_criteria": list(phase.completion_criteria),
|
|
38
|
+
"id": phase.id,
|
|
39
|
+
"inputs": list(phase.inputs),
|
|
40
|
+
"max_visits": phase.max_visits,
|
|
41
|
+
"mutation": phase.mutation,
|
|
42
|
+
"output_name": phase.output_name,
|
|
43
|
+
"output_template": phase.output_template,
|
|
44
|
+
"skill": phase.skill,
|
|
45
|
+
"stop_conditions": list(phase.stop_conditions),
|
|
46
|
+
"transitions": [
|
|
47
|
+
{
|
|
48
|
+
"max_traversals": transition.max_traversals,
|
|
49
|
+
"outcome": transition.outcome,
|
|
50
|
+
"target": transition.target,
|
|
51
|
+
**({"pause": True} if transition.pause else {}),
|
|
52
|
+
}
|
|
53
|
+
for transition in sorted(
|
|
54
|
+
phase.transitions, key=lambda item: item.outcome
|
|
55
|
+
)
|
|
56
|
+
],
|
|
57
|
+
"validator": {
|
|
58
|
+
"heading_occurrence": phase.validator.heading_occurrence,
|
|
59
|
+
"path": phase.validator.declared_path,
|
|
60
|
+
"required_headings": list(phase.validator.required_headings),
|
|
61
|
+
"type": phase.validator.type,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
for phase in phases
|
|
65
|
+
],
|
|
66
|
+
"schema_version": schema_version,
|
|
67
|
+
"start": start,
|
|
68
|
+
"terminal_outcomes": sorted(terminal_outcomes),
|
|
69
|
+
"version": version,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _encode_canonical_document(document: dict[str, Any]) -> str:
|
|
74
|
+
"""Preserve pipeline ASCII escaping, unlike generic run JSON hashing."""
|
|
75
|
+
|
|
76
|
+
return json.dumps(document, sort_keys=True, separators=(",", ":"))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private engine implementation components; use the public facades."""
|