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,178 @@
|
|
|
1
|
+
"""Definition and value validation for the supported JSON schema subset.
|
|
2
|
+
|
|
3
|
+
This module is independent of configuration loading and artifact validation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SUPPORTED_SCHEMA_KEYWORDS = frozenset(
|
|
16
|
+
{"type", "required", "properties", "items", "enum", "pattern", "additionalProperties"}
|
|
17
|
+
)
|
|
18
|
+
SUPPORTED_JSON_TYPES = frozenset(
|
|
19
|
+
{"object", "array", "string", "number", "integer", "boolean", "null"}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SchemaDefinitionError(ValueError):
|
|
24
|
+
"""A schema uses syntax outside the deliberately small supported subset."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, *, location: str = "$") -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.location = location
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_json_schema(path: Path) -> dict[str, Any]:
|
|
32
|
+
"""Load and validate a project-owned subset JSON schema."""
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
value = json.loads(
|
|
36
|
+
path.read_text(encoding="utf-8"),
|
|
37
|
+
parse_constant=lambda value: (_ for _ in ()).throw(
|
|
38
|
+
ValueError(f"invalid JSON constant {value}")
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
42
|
+
raise SchemaDefinitionError(f"schema is not readable JSON: {exc}") from exc
|
|
43
|
+
validate_schema_definition(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_schema_definition(schema: Any, *, location: str = "$") -> None:
|
|
48
|
+
"""Reject unsupported or malformed schema definitions recursively."""
|
|
49
|
+
|
|
50
|
+
if not isinstance(schema, Mapping):
|
|
51
|
+
raise SchemaDefinitionError("schema must be an object", location=location)
|
|
52
|
+
unknown = sorted(set(schema) - SUPPORTED_SCHEMA_KEYWORDS)
|
|
53
|
+
if unknown:
|
|
54
|
+
raise SchemaDefinitionError(
|
|
55
|
+
f"unsupported schema keyword {unknown[0]!r}", location=location
|
|
56
|
+
)
|
|
57
|
+
schema_type = schema.get("type")
|
|
58
|
+
if schema_type is not None and schema_type not in SUPPORTED_JSON_TYPES:
|
|
59
|
+
raise SchemaDefinitionError(
|
|
60
|
+
f"unsupported schema type {schema_type!r}", location=f"{location}.type"
|
|
61
|
+
)
|
|
62
|
+
required = schema.get("required")
|
|
63
|
+
if required is not None and (
|
|
64
|
+
not isinstance(required, list)
|
|
65
|
+
or any(not isinstance(item, str) or not item for item in required)
|
|
66
|
+
or len(set(required)) != len(required)
|
|
67
|
+
):
|
|
68
|
+
raise SchemaDefinitionError(
|
|
69
|
+
"required must be a unique list of non-empty strings",
|
|
70
|
+
location=f"{location}.required",
|
|
71
|
+
)
|
|
72
|
+
properties = schema.get("properties")
|
|
73
|
+
if properties is not None:
|
|
74
|
+
if not isinstance(properties, Mapping) or any(
|
|
75
|
+
not isinstance(name, str) for name in properties
|
|
76
|
+
):
|
|
77
|
+
raise SchemaDefinitionError(
|
|
78
|
+
"properties must be an object", location=f"{location}.properties"
|
|
79
|
+
)
|
|
80
|
+
for name, child in properties.items():
|
|
81
|
+
validate_schema_definition(child, location=f"{location}.properties.{name}")
|
|
82
|
+
if "items" in schema:
|
|
83
|
+
validate_schema_definition(schema["items"], location=f"{location}.items")
|
|
84
|
+
enum = schema.get("enum")
|
|
85
|
+
if enum is not None:
|
|
86
|
+
if not isinstance(enum, list) or not enum:
|
|
87
|
+
raise SchemaDefinitionError(
|
|
88
|
+
"enum must be a non-empty array", location=f"{location}.enum"
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
json.dumps(enum, allow_nan=False)
|
|
92
|
+
except (TypeError, ValueError) as exc:
|
|
93
|
+
raise SchemaDefinitionError(
|
|
94
|
+
"enum values must be JSON values", location=f"{location}.enum"
|
|
95
|
+
) from exc
|
|
96
|
+
pattern = schema.get("pattern")
|
|
97
|
+
if pattern is not None:
|
|
98
|
+
if not isinstance(pattern, str):
|
|
99
|
+
raise SchemaDefinitionError(
|
|
100
|
+
"pattern must be a string", location=f"{location}.pattern"
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
re.compile(pattern)
|
|
104
|
+
except re.error as exc:
|
|
105
|
+
raise SchemaDefinitionError(
|
|
106
|
+
f"pattern is invalid: {exc}", location=f"{location}.pattern"
|
|
107
|
+
) from exc
|
|
108
|
+
additional = schema.get("additionalProperties")
|
|
109
|
+
if additional is not None and not isinstance(additional, bool):
|
|
110
|
+
if not isinstance(additional, Mapping):
|
|
111
|
+
raise SchemaDefinitionError(
|
|
112
|
+
"additionalProperties must be a boolean or schema",
|
|
113
|
+
location=f"{location}.additionalProperties",
|
|
114
|
+
)
|
|
115
|
+
validate_schema_definition(
|
|
116
|
+
additional, location=f"{location}.additionalProperties"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _validate_json_value(
|
|
121
|
+
value: Any,
|
|
122
|
+
schema: Mapping[str, Any],
|
|
123
|
+
location: str,
|
|
124
|
+
errors: list[dict[str, str]],
|
|
125
|
+
) -> None:
|
|
126
|
+
expected = schema.get("type")
|
|
127
|
+
if expected is not None and not _matches_type(value, expected):
|
|
128
|
+
errors.append({"path": location, "message": f"expected {expected}"})
|
|
129
|
+
return
|
|
130
|
+
if "enum" in schema and not any(
|
|
131
|
+
type(value) is type(item) and value == item for item in schema["enum"]
|
|
132
|
+
):
|
|
133
|
+
errors.append({"path": location, "message": "value is not in enum"})
|
|
134
|
+
if "pattern" in schema and isinstance(value, str) and re.search(schema["pattern"], value) is None:
|
|
135
|
+
errors.append({"path": location, "message": "string does not match pattern"})
|
|
136
|
+
if isinstance(value, dict):
|
|
137
|
+
properties = schema.get("properties", {})
|
|
138
|
+
for name in schema.get("required", []):
|
|
139
|
+
if name not in value:
|
|
140
|
+
errors.append(
|
|
141
|
+
{"path": location, "message": f"missing required property {name!r}"}
|
|
142
|
+
)
|
|
143
|
+
additional = schema.get("additionalProperties", True)
|
|
144
|
+
extra_names = sorted(set(value) - set(properties))
|
|
145
|
+
if additional is False:
|
|
146
|
+
for name in extra_names:
|
|
147
|
+
errors.append(
|
|
148
|
+
{"path": f"{location}.{name}", "message": "additional property is not allowed"}
|
|
149
|
+
)
|
|
150
|
+
elif isinstance(additional, Mapping):
|
|
151
|
+
for name in extra_names:
|
|
152
|
+
_validate_json_value(
|
|
153
|
+
value[name], additional, f"{location}.{name}", errors
|
|
154
|
+
)
|
|
155
|
+
for name, child_schema in properties.items():
|
|
156
|
+
if name in value:
|
|
157
|
+
_validate_json_value(value[name], child_schema, f"{location}.{name}", errors)
|
|
158
|
+
if isinstance(value, list) and "items" in schema:
|
|
159
|
+
for index, item in enumerate(value):
|
|
160
|
+
_validate_json_value(item, schema["items"], f"{location}[{index}]", errors)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _matches_type(value: Any, expected: str) -> bool:
|
|
164
|
+
if expected == "null":
|
|
165
|
+
return value is None
|
|
166
|
+
if expected == "boolean":
|
|
167
|
+
return isinstance(value, bool)
|
|
168
|
+
if expected == "integer":
|
|
169
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
170
|
+
if expected == "number":
|
|
171
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
172
|
+
if expected == "string":
|
|
173
|
+
return isinstance(value, str)
|
|
174
|
+
if expected == "array":
|
|
175
|
+
return isinstance(value, list)
|
|
176
|
+
if expected == "object":
|
|
177
|
+
return isinstance(value, dict)
|
|
178
|
+
return False
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://specromancy.dev/schemas/event.schema.json",
|
|
4
|
+
"title": "Specromancy audit event",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"sequence",
|
|
10
|
+
"timestamp",
|
|
11
|
+
"run_id",
|
|
12
|
+
"visit_number",
|
|
13
|
+
"type",
|
|
14
|
+
"payload",
|
|
15
|
+
"manifest_revision",
|
|
16
|
+
"manifest_hash"
|
|
17
|
+
],
|
|
18
|
+
"properties": {
|
|
19
|
+
"schema_version": {"const": 1},
|
|
20
|
+
"sequence": {"type": "integer", "minimum": 1},
|
|
21
|
+
"timestamp": {"type": "string", "pattern": "Z$"},
|
|
22
|
+
"run_id": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"pattern": "^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$"
|
|
25
|
+
},
|
|
26
|
+
"visit_number": {
|
|
27
|
+
"oneOf": [{"type": "null"}, {"type": "integer", "minimum": 1}]
|
|
28
|
+
},
|
|
29
|
+
"type": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
|
30
|
+
"payload": {"type": "object"},
|
|
31
|
+
"manifest_revision": {"type": "integer", "minimum": 1},
|
|
32
|
+
"manifest_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://specromancy.dev/schemas/pipeline.schema.json",
|
|
4
|
+
"title": "Specromancy pipeline",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"id",
|
|
10
|
+
"version",
|
|
11
|
+
"start",
|
|
12
|
+
"terminal_outcomes",
|
|
13
|
+
"artifact_pattern",
|
|
14
|
+
"phases"
|
|
15
|
+
],
|
|
16
|
+
"properties": {
|
|
17
|
+
"schema_version": {"const": 1},
|
|
18
|
+
"id": {"$ref": "#/$defs/identifier"},
|
|
19
|
+
"version": {"type": "integer", "minimum": 1},
|
|
20
|
+
"start": {"$ref": "#/$defs/identifier"},
|
|
21
|
+
"terminal_outcomes": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"minItems": 1,
|
|
24
|
+
"uniqueItems": true,
|
|
25
|
+
"items": {"$ref": "#/$defs/identifier"}
|
|
26
|
+
},
|
|
27
|
+
"artifact_pattern": {"type": "string", "minLength": 1},
|
|
28
|
+
"allow_non_git": {"type": "boolean"},
|
|
29
|
+
"phases": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"minItems": 1,
|
|
32
|
+
"items": {"$ref": "#/$defs/phase"}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"$defs": {
|
|
36
|
+
"identifier": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
39
|
+
},
|
|
40
|
+
"stringList": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"uniqueItems": true,
|
|
43
|
+
"items": {"type": "string", "minLength": 1}
|
|
44
|
+
},
|
|
45
|
+
"validator": {
|
|
46
|
+
"oneOf": [
|
|
47
|
+
{"enum": ["markdown", "json", "file"]},
|
|
48
|
+
{
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"required": ["type"],
|
|
52
|
+
"properties": {
|
|
53
|
+
"type": {"enum": ["markdown", "json", "file"]},
|
|
54
|
+
"required_headings": {"$ref": "#/$defs/stringList"},
|
|
55
|
+
"heading_occurrence": {"enum": ["exactly-once", "at-least-once"]},
|
|
56
|
+
"path": {"type": "string", "minLength": 1},
|
|
57
|
+
"schema": {"type": "string", "minLength": 1}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"command": {
|
|
63
|
+
"oneOf": [
|
|
64
|
+
{
|
|
65
|
+
"type": "array",
|
|
66
|
+
"minItems": 1,
|
|
67
|
+
"items": {"type": "string", "minLength": 1}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "object",
|
|
71
|
+
"additionalProperties": false,
|
|
72
|
+
"required": ["argv"],
|
|
73
|
+
"properties": {
|
|
74
|
+
"argv": {
|
|
75
|
+
"type": "array",
|
|
76
|
+
"minItems": 1,
|
|
77
|
+
"items": {"type": "string", "minLength": 1}
|
|
78
|
+
},
|
|
79
|
+
"timeout_seconds": {"type": "number", "exclusiveMinimum": 0},
|
|
80
|
+
"required": {"type": "boolean"}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
"transition": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"required": ["outcome"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"outcome": {"$ref": "#/$defs/identifier"},
|
|
91
|
+
"target": {"$ref": "#/$defs/identifier"},
|
|
92
|
+
"max_traversals": {"type": "integer", "minimum": 1},
|
|
93
|
+
"pause": {"type": "boolean"}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"phase": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"required": [
|
|
100
|
+
"id",
|
|
101
|
+
"skill",
|
|
102
|
+
"inputs",
|
|
103
|
+
"output_name",
|
|
104
|
+
"mutation",
|
|
105
|
+
"completion_criteria",
|
|
106
|
+
"validator",
|
|
107
|
+
"approval_conditions",
|
|
108
|
+
"stop_conditions",
|
|
109
|
+
"transitions"
|
|
110
|
+
],
|
|
111
|
+
"properties": {
|
|
112
|
+
"id": {"$ref": "#/$defs/identifier"},
|
|
113
|
+
"skill": {"$ref": "#/$defs/identifier"},
|
|
114
|
+
"inputs": {"$ref": "#/$defs/stringList"},
|
|
115
|
+
"output_name": {"type": "string", "minLength": 1},
|
|
116
|
+
"output_template": {"type": "string", "minLength": 1},
|
|
117
|
+
"mutation": {"enum": ["read-only", "repository-write", "allowlist"]},
|
|
118
|
+
"allowlist": {"$ref": "#/$defs/stringList"},
|
|
119
|
+
"mutation_allowlist": {"$ref": "#/$defs/stringList"},
|
|
120
|
+
"completion_criteria": {
|
|
121
|
+
"allOf": [{"$ref": "#/$defs/stringList"}, {"minItems": 1}]
|
|
122
|
+
},
|
|
123
|
+
"validator": {"$ref": "#/$defs/validator"},
|
|
124
|
+
"validator_path": {"type": "string", "minLength": 1},
|
|
125
|
+
"required_headings": {"$ref": "#/$defs/stringList"},
|
|
126
|
+
"heading_occurrence": {"enum": ["exactly-once", "at-least-once"]},
|
|
127
|
+
"commands": {
|
|
128
|
+
"type": "array",
|
|
129
|
+
"items": {"$ref": "#/$defs/command"}
|
|
130
|
+
},
|
|
131
|
+
"validation_commands": {
|
|
132
|
+
"type": "array",
|
|
133
|
+
"items": {"$ref": "#/$defs/command"}
|
|
134
|
+
},
|
|
135
|
+
"approval_conditions": {
|
|
136
|
+
"type": "array",
|
|
137
|
+
"uniqueItems": true,
|
|
138
|
+
"items": {"$ref": "#/$defs/identifier"}
|
|
139
|
+
},
|
|
140
|
+
"stop_conditions": {
|
|
141
|
+
"type": "array",
|
|
142
|
+
"uniqueItems": true,
|
|
143
|
+
"items": {"$ref": "#/$defs/identifier"}
|
|
144
|
+
},
|
|
145
|
+
"transitions": {
|
|
146
|
+
"type": "array",
|
|
147
|
+
"minItems": 1,
|
|
148
|
+
"items": {"$ref": "#/$defs/transition"}
|
|
149
|
+
},
|
|
150
|
+
"max_visits": {"type": "integer", "minimum": 1}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Specromancy pipeline registry",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": [
|
|
6
|
+
"schema_version",
|
|
7
|
+
"pipelines"
|
|
8
|
+
],
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"schema_version": {
|
|
12
|
+
"const": 1,
|
|
13
|
+
"type": "integer"
|
|
14
|
+
},
|
|
15
|
+
"pipelines": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"minItems": 1,
|
|
18
|
+
"items": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": [
|
|
21
|
+
"id",
|
|
22
|
+
"path"
|
|
23
|
+
],
|
|
24
|
+
"additionalProperties": false,
|
|
25
|
+
"properties": {
|
|
26
|
+
"id": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
29
|
+
},
|
|
30
|
+
"path": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"pattern": "^pipelines/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\\.toml$"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://specromancy.dev/schemas/run.schema.json",
|
|
4
|
+
"title": "Specromancy run manifest",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"revision",
|
|
10
|
+
"run_id",
|
|
11
|
+
"pipeline",
|
|
12
|
+
"status",
|
|
13
|
+
"current_visit",
|
|
14
|
+
"request",
|
|
15
|
+
"git",
|
|
16
|
+
"created_at",
|
|
17
|
+
"updated_at",
|
|
18
|
+
"visits",
|
|
19
|
+
"approvals",
|
|
20
|
+
"terminal_result",
|
|
21
|
+
"block_reason"
|
|
22
|
+
],
|
|
23
|
+
"properties": {
|
|
24
|
+
"schema_version": {"const": 1},
|
|
25
|
+
"revision": {"type": "integer", "minimum": 1},
|
|
26
|
+
"run_id": {"$ref": "#/$defs/runId"},
|
|
27
|
+
"pipeline": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"required": ["id", "version", "path", "sha256"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"id": {"type": "string", "minLength": 1},
|
|
33
|
+
"version": {"type": "integer", "minimum": 1},
|
|
34
|
+
"path": {"$ref": "#/$defs/path"},
|
|
35
|
+
"sha256": {"$ref": "#/$defs/hash"}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"status": {"$ref": "#/$defs/status"},
|
|
39
|
+
"current_visit": {
|
|
40
|
+
"oneOf": [{"type": "null"}, {"type": "integer", "minimum": 1}]
|
|
41
|
+
},
|
|
42
|
+
"request": {"$ref": "#/$defs/sealedArtifact"},
|
|
43
|
+
"git": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"additionalProperties": false,
|
|
46
|
+
"required": ["base", "head"],
|
|
47
|
+
"properties": {"base": {}, "head": {}}
|
|
48
|
+
},
|
|
49
|
+
"created_at": {"$ref": "#/$defs/timestamp"},
|
|
50
|
+
"updated_at": {"$ref": "#/$defs/timestamp"},
|
|
51
|
+
"visits": {"type": "array", "items": {"$ref": "#/$defs/visit"}},
|
|
52
|
+
"approvals": {"type": "array", "items": {"type": "object"}},
|
|
53
|
+
"terminal_result": {},
|
|
54
|
+
"block_reason": {}
|
|
55
|
+
},
|
|
56
|
+
"$defs": {
|
|
57
|
+
"runId": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"pattern": "^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$"
|
|
60
|
+
},
|
|
61
|
+
"hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
|
62
|
+
"path": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*\\\\).+$"},
|
|
63
|
+
"timestamp": {"type": "string", "pattern": "Z$"},
|
|
64
|
+
"status": {
|
|
65
|
+
"enum": ["active", "awaiting-agent", "awaiting-approval", "paused", "completed", "failed", "blocked"]
|
|
66
|
+
},
|
|
67
|
+
"visitStatus": {
|
|
68
|
+
"enum": ["pending", "active", "awaiting-approval", "completed", "failed", "blocked"]
|
|
69
|
+
},
|
|
70
|
+
"sealedArtifact": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"additionalProperties": false,
|
|
73
|
+
"required": ["path", "sha256"],
|
|
74
|
+
"properties": {
|
|
75
|
+
"path": {"$ref": "#/$defs/path"},
|
|
76
|
+
"sha256": {"$ref": "#/$defs/hash"}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"inputArtifact": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"additionalProperties": false,
|
|
82
|
+
"required": ["reference", "path", "sha256"],
|
|
83
|
+
"properties": {
|
|
84
|
+
"reference": {"type": "string", "minLength": 1},
|
|
85
|
+
"path": {"$ref": "#/$defs/path"},
|
|
86
|
+
"sha256": {"$ref": "#/$defs/hash"}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"outputArtifact": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"additionalProperties": false,
|
|
92
|
+
"required": ["path", "sha256"],
|
|
93
|
+
"properties": {
|
|
94
|
+
"path": {"$ref": "#/$defs/path"},
|
|
95
|
+
"sha256": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/hash"}]}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"provenance": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"additionalProperties": false,
|
|
101
|
+
"required": ["path", "sha256"],
|
|
102
|
+
"properties": {
|
|
103
|
+
"path": {"$ref": "#/$defs/path"},
|
|
104
|
+
"sha256": {"$ref": "#/$defs/hash"}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"visit": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"additionalProperties": false,
|
|
110
|
+
"required": [
|
|
111
|
+
"phase_id",
|
|
112
|
+
"ordinal",
|
|
113
|
+
"attempt",
|
|
114
|
+
"status",
|
|
115
|
+
"inputs",
|
|
116
|
+
"output",
|
|
117
|
+
"mutation_policy",
|
|
118
|
+
"mutation_baseline",
|
|
119
|
+
"mutation_result",
|
|
120
|
+
"validation_checks",
|
|
121
|
+
"command_results",
|
|
122
|
+
"chosen_outcome",
|
|
123
|
+
"transition_target",
|
|
124
|
+
"skill",
|
|
125
|
+
"template",
|
|
126
|
+
"started_at",
|
|
127
|
+
"completed_at",
|
|
128
|
+
"deviations"
|
|
129
|
+
],
|
|
130
|
+
"properties": {
|
|
131
|
+
"phase_id": {"type": "string", "minLength": 1},
|
|
132
|
+
"ordinal": {"type": "integer", "minimum": 1},
|
|
133
|
+
"attempt": {"type": "integer", "minimum": 1},
|
|
134
|
+
"status": {"$ref": "#/$defs/visitStatus"},
|
|
135
|
+
"inputs": {"type": "array", "items": {"$ref": "#/$defs/inputArtifact"}},
|
|
136
|
+
"output": {"$ref": "#/$defs/outputArtifact"},
|
|
137
|
+
"mutation_policy": {"enum": ["read-only", "repository-write", "allowlist"]},
|
|
138
|
+
"mutation_baseline": {},
|
|
139
|
+
"mutation_result": {},
|
|
140
|
+
"validation_checks": {"type": "array"},
|
|
141
|
+
"command_results": {"type": "array"},
|
|
142
|
+
"chosen_outcome": {"oneOf": [{"type": "null"}, {"type": "string"}]},
|
|
143
|
+
"transition_target": {"oneOf": [{"type": "null"}, {"type": "string"}]},
|
|
144
|
+
"skill": {"$ref": "#/$defs/provenance"},
|
|
145
|
+
"template": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/provenance"}]},
|
|
146
|
+
"started_at": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/timestamp"}]},
|
|
147
|
+
"completed_at": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/timestamp"}]},
|
|
148
|
+
"deviations": {"type": "array"}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Read-only status projection for persisted runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._config.config_models import PipelineConfig
|
|
8
|
+
from ._runs.run_records import RunRecord, VisitRecord, current_visit, latest_approval
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
STATUS_SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_status(
|
|
15
|
+
manifest: RunRecord,
|
|
16
|
+
pipeline: PipelineConfig | None,
|
|
17
|
+
*,
|
|
18
|
+
warnings: list[dict[str, Any]] | None = None,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
current = _current_visit(manifest)
|
|
21
|
+
completed = [
|
|
22
|
+
{
|
|
23
|
+
"visit_id": visit["ordinal"],
|
|
24
|
+
"phase": visit["phase_id"],
|
|
25
|
+
"outcome": visit["chosen_outcome"],
|
|
26
|
+
"output": visit["output"],
|
|
27
|
+
}
|
|
28
|
+
for visit in manifest["visits"]
|
|
29
|
+
if visit["status"] == "completed"
|
|
30
|
+
]
|
|
31
|
+
pending_approval = latest_approval(manifest, "pending")
|
|
32
|
+
return {
|
|
33
|
+
"schema_version": STATUS_SCHEMA_VERSION,
|
|
34
|
+
"run_id": manifest["run_id"],
|
|
35
|
+
"pipeline": {
|
|
36
|
+
**manifest["pipeline"],
|
|
37
|
+
"current_sha256": pipeline.config_hash if pipeline is not None else None,
|
|
38
|
+
},
|
|
39
|
+
"status": manifest["status"],
|
|
40
|
+
"current": (
|
|
41
|
+
{
|
|
42
|
+
"visit_id": current["ordinal"],
|
|
43
|
+
"phase": current["phase_id"],
|
|
44
|
+
"status": current["status"],
|
|
45
|
+
"output": current["output"],
|
|
46
|
+
}
|
|
47
|
+
if current is not None
|
|
48
|
+
else None
|
|
49
|
+
),
|
|
50
|
+
"completed_visits": completed,
|
|
51
|
+
"pending_approval": pending_approval,
|
|
52
|
+
"block": manifest["block_reason"],
|
|
53
|
+
"terminal_result": manifest["terminal_result"],
|
|
54
|
+
"next_command": next_command(manifest),
|
|
55
|
+
"warnings": list(warnings or []),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def next_command(manifest: RunRecord) -> list[str] | None:
|
|
60
|
+
run_id = manifest["run_id"]
|
|
61
|
+
current = _current_visit(manifest)
|
|
62
|
+
if manifest["status"] == "awaiting-agent" and current is not None:
|
|
63
|
+
return ["specromancy", "phase", run_id, current["phase_id"]]
|
|
64
|
+
if manifest["status"] == "active" and current is not None:
|
|
65
|
+
return ["specromancy", "validate", run_id, current["phase_id"]]
|
|
66
|
+
if manifest["status"] == "awaiting-approval" and current is not None:
|
|
67
|
+
pending = latest_approval(
|
|
68
|
+
manifest, "pending", visit_number=current["ordinal"]
|
|
69
|
+
)
|
|
70
|
+
if pending:
|
|
71
|
+
return ["specromancy", "approve", run_id, current["phase_id"]]
|
|
72
|
+
return ["specromancy", "request-approval", run_id]
|
|
73
|
+
if manifest["status"] == "paused":
|
|
74
|
+
return ["specromancy", "resume", run_id]
|
|
75
|
+
if manifest["status"] == "blocked":
|
|
76
|
+
return ["specromancy", "status", run_id]
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render_status(status: dict[str, Any]) -> str:
|
|
81
|
+
current = status["current"]
|
|
82
|
+
lines = [
|
|
83
|
+
f"Run: {status['run_id']}",
|
|
84
|
+
f"Pipeline: {status['pipeline']['id']} v{status['pipeline']['version']}",
|
|
85
|
+
f"Status: {status['status']}",
|
|
86
|
+
]
|
|
87
|
+
if current is not None:
|
|
88
|
+
lines.append(
|
|
89
|
+
f"Current: visit {current['visit_id']} / {current['phase']} / {current['status']}"
|
|
90
|
+
)
|
|
91
|
+
lines.append(f"Completed visits: {len(status['completed_visits'])}")
|
|
92
|
+
if status["pending_approval"] is not None:
|
|
93
|
+
lines.append(
|
|
94
|
+
f"Approval: {status['pending_approval'].get('reason', 'pending')}"
|
|
95
|
+
)
|
|
96
|
+
if status["block"] is not None:
|
|
97
|
+
lines.append(f"Block: {status['block']}")
|
|
98
|
+
if status["next_command"] is not None:
|
|
99
|
+
lines.append("Next: " + " ".join(status["next_command"]))
|
|
100
|
+
for warning in status["warnings"]:
|
|
101
|
+
lines.append(f"Warning: {warning['message']}")
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _current_visit(manifest: RunRecord) -> VisitRecord | None:
|
|
106
|
+
return current_visit(manifest)
|