roborean-spec 0.1.2__tar.gz
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.
- roborean_spec-0.1.2/.gitignore +27 -0
- roborean_spec-0.1.2/CHANGELOG.md +19 -0
- roborean_spec-0.1.2/PKG-INFO +15 -0
- roborean_spec-0.1.2/pyproject.toml +25 -0
- roborean_spec-0.1.2/src/roborean_spec/__init__.py +107 -0
- roborean_spec-0.1.2/src/roborean_spec/migrations/__init__.py +5 -0
- roborean_spec-0.1.2/src/roborean_spec/migrations/registry.py +24 -0
- roborean_spec-0.1.2/src/roborean_spec/models/__init__.py +489 -0
- roborean_spec-0.1.2/src/roborean_spec/schema_loader.py +55 -0
- roborean_spec-0.1.2/src/roborean_spec/version.py +3 -0
- roborean_spec-0.1.2/tests/conftest.py +17 -0
- roborean_spec-0.1.2/tests/models_test.py +42 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
venv/
|
|
2
|
+
venv-qt5/
|
|
3
|
+
venv-qt6/
|
|
4
|
+
.venv/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.coverage
|
|
10
|
+
htmlcov/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
*.tsbuildinfo
|
|
15
|
+
node_modules/
|
|
16
|
+
.pnpm-store/
|
|
17
|
+
.DS_Store
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/*.local
|
|
20
|
+
playground/
|
|
21
|
+
.e2e-ai/
|
|
22
|
+
**/test-results/
|
|
23
|
+
**/playwright-report/
|
|
24
|
+
research/
|
|
25
|
+
.roborean/
|
|
26
|
+
.roborean-sql-artifacts/
|
|
27
|
+
*.db
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Schema-backed Pydantic models and project migration support for Roborean
|
|
8
|
+
projects.
|
|
9
|
+
- Durable-run models: `RunRequest`, `RunRecord`, `RunDiff`, and related
|
|
10
|
+
enums for Phase 2 persistence.
|
|
11
|
+
- Document models for Phase 3: extended `DocumentDefinition`,
|
|
12
|
+
`TemplateManifest`, `DocumentOperation`, `ArtifactRecord`, and project
|
|
13
|
+
format `1.1.0`.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Export `Redacted` workspace value from the public package API.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: roborean-spec
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Schema-backed Roborean project models
|
|
5
|
+
Project-URL: Homepage, https://github.com/TNick/roborean
|
|
6
|
+
Project-URL: Repository, https://github.com/TNick/roborean
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: jsonschema>=4.23
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: flake8>=7.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: isort>=5.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "roborean-spec"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Schema-backed Roborean project models"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"jsonschema>=4.23",
|
|
13
|
+
"pydantic>=2.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8.0", "black>=24.0", "isort>=5.0", "flake8>=7.0"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/TNick/roborean"
|
|
22
|
+
Repository = "https://github.com/TNick/roborean"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/roborean_spec"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Schema-backed models and migration helpers for Roborean."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .migrations import migrate_project
|
|
6
|
+
from .models import (
|
|
7
|
+
ArtifactRecord,
|
|
8
|
+
Bit,
|
|
9
|
+
BitResult,
|
|
10
|
+
BitTypeManifest,
|
|
11
|
+
CompiledProject,
|
|
12
|
+
DocumentDefinition,
|
|
13
|
+
DocumentDriverManifest,
|
|
14
|
+
DocumentOperation,
|
|
15
|
+
DocumentPreview,
|
|
16
|
+
DocumentPreviewSettings,
|
|
17
|
+
EffectClass,
|
|
18
|
+
Exposure,
|
|
19
|
+
OnError,
|
|
20
|
+
Project,
|
|
21
|
+
PublicLiteral,
|
|
22
|
+
Redacted,
|
|
23
|
+
RejectOp,
|
|
24
|
+
RuleAst,
|
|
25
|
+
RunDiff,
|
|
26
|
+
RunError,
|
|
27
|
+
RunRecord,
|
|
28
|
+
RunRequest,
|
|
29
|
+
RunResults,
|
|
30
|
+
RunStatus,
|
|
31
|
+
RunTrigger,
|
|
32
|
+
SecretRefAccess,
|
|
33
|
+
SecretRefValue,
|
|
34
|
+
SetOp,
|
|
35
|
+
TemplateManifest,
|
|
36
|
+
TemplateSlot,
|
|
37
|
+
UnsetOp,
|
|
38
|
+
Variable,
|
|
39
|
+
WorkspaceChange,
|
|
40
|
+
WorkspacePatch,
|
|
41
|
+
WorkspaceValue,
|
|
42
|
+
)
|
|
43
|
+
from .schema_loader import (
|
|
44
|
+
find_repo_root,
|
|
45
|
+
load_schema,
|
|
46
|
+
schema_dir,
|
|
47
|
+
validate_instance,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__version__ = "0.3.0"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def project_from_dict(data: dict[str, Any]) -> Project:
|
|
54
|
+
"""Validate and construct a project model from JSON-compatible data."""
|
|
55
|
+
validate_instance("project", data)
|
|
56
|
+
return Project.model_validate(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def project_to_dict(project: Project) -> dict[str, Any]:
|
|
60
|
+
"""Return a JSON-compatible, schema-shaped project dictionary."""
|
|
61
|
+
return project.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"ArtifactRecord",
|
|
66
|
+
"Bit",
|
|
67
|
+
"BitResult",
|
|
68
|
+
"BitTypeManifest",
|
|
69
|
+
"CompiledProject",
|
|
70
|
+
"DocumentDefinition",
|
|
71
|
+
"DocumentDriverManifest",
|
|
72
|
+
"DocumentOperation",
|
|
73
|
+
"DocumentPreview",
|
|
74
|
+
"DocumentPreviewSettings",
|
|
75
|
+
"EffectClass",
|
|
76
|
+
"Exposure",
|
|
77
|
+
"OnError",
|
|
78
|
+
"Project",
|
|
79
|
+
"PublicLiteral",
|
|
80
|
+
"Redacted",
|
|
81
|
+
"RejectOp",
|
|
82
|
+
"RuleAst",
|
|
83
|
+
"RunDiff",
|
|
84
|
+
"RunError",
|
|
85
|
+
"RunRecord",
|
|
86
|
+
"RunRequest",
|
|
87
|
+
"RunResults",
|
|
88
|
+
"RunStatus",
|
|
89
|
+
"RunTrigger",
|
|
90
|
+
"SecretRefAccess",
|
|
91
|
+
"SecretRefValue",
|
|
92
|
+
"SetOp",
|
|
93
|
+
"TemplateManifest",
|
|
94
|
+
"TemplateSlot",
|
|
95
|
+
"UnsetOp",
|
|
96
|
+
"Variable",
|
|
97
|
+
"WorkspaceChange",
|
|
98
|
+
"WorkspacePatch",
|
|
99
|
+
"WorkspaceValue",
|
|
100
|
+
"find_repo_root",
|
|
101
|
+
"load_schema",
|
|
102
|
+
"migrate_project",
|
|
103
|
+
"project_from_dict",
|
|
104
|
+
"project_to_dict",
|
|
105
|
+
"schema_dir",
|
|
106
|
+
"validate_instance",
|
|
107
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Registry for explicit Roborean project format migrations."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def migrate_project(
|
|
8
|
+
data: dict[str, Any],
|
|
9
|
+
target: str = "1.1.0",
|
|
10
|
+
) -> dict[str, Any]:
|
|
11
|
+
"""Migrate a project dictionary to a supported target version."""
|
|
12
|
+
result = deepcopy(data)
|
|
13
|
+
source = result.get("schemaVersion")
|
|
14
|
+
if source == target:
|
|
15
|
+
return result
|
|
16
|
+
if source == "1.0.0" and target == "1.1.0":
|
|
17
|
+
# Document fields are additive; bump the version marker only.
|
|
18
|
+
result["schemaVersion"] = "1.1.0"
|
|
19
|
+
return result
|
|
20
|
+
if source == "1.0.0" and target == "1.0.0":
|
|
21
|
+
return result
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Unsupported project migration from {source!r} to {target!r}"
|
|
24
|
+
)
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Pydantic models for the canonical Roborean project format."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Annotated, Any, Literal, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Model(BaseModel):
|
|
10
|
+
"""Base model that mirrors schemas without extra properties."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(extra="forbid")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PublicLiteral(Model):
|
|
16
|
+
"""A public literal workspace value."""
|
|
17
|
+
|
|
18
|
+
kind: Literal["public_literal"]
|
|
19
|
+
data_type: Literal["string", "number", "boolean", "date"] = Field(
|
|
20
|
+
alias="dataType"
|
|
21
|
+
)
|
|
22
|
+
value: Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecretRefValue(Model):
|
|
26
|
+
"""A reference to a secret held outside the portable project."""
|
|
27
|
+
|
|
28
|
+
kind: Literal["secret_ref"]
|
|
29
|
+
ref: str
|
|
30
|
+
display_hint: str | None = Field(default=None, alias="displayHint")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EqToken(Model):
|
|
34
|
+
"""An equality-preserving opaque token."""
|
|
35
|
+
|
|
36
|
+
kind: Literal["eq_token"]
|
|
37
|
+
token: str
|
|
38
|
+
domain: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ShapeToken(Model):
|
|
42
|
+
"""A value represented by its structural shape."""
|
|
43
|
+
|
|
44
|
+
kind: Literal["shape_token"]
|
|
45
|
+
shape: Literal["email", "phone", "iban", "uuid", "code"]
|
|
46
|
+
length: int | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Bucket(Model):
|
|
50
|
+
"""A bucketed value with optional numeric bounds."""
|
|
51
|
+
|
|
52
|
+
kind: Literal["bucket"]
|
|
53
|
+
bucket: str
|
|
54
|
+
bounds: tuple[float, float] | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Redacted(Model):
|
|
58
|
+
"""A value withheld by secret or policy rules."""
|
|
59
|
+
|
|
60
|
+
kind: Literal["redacted"]
|
|
61
|
+
reason: Literal["secret", "policy", "consent", "unknown"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
WorkspaceValue = Annotated[
|
|
65
|
+
Union[
|
|
66
|
+
PublicLiteral,
|
|
67
|
+
SecretRefValue,
|
|
68
|
+
EqToken,
|
|
69
|
+
ShapeToken,
|
|
70
|
+
Bucket,
|
|
71
|
+
Redacted,
|
|
72
|
+
],
|
|
73
|
+
Field(discriminator="kind"),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Exposure(str, Enum):
|
|
78
|
+
"""Controls whether a workspace value can reach clients."""
|
|
79
|
+
|
|
80
|
+
BACKEND_ONLY = "backendOnly"
|
|
81
|
+
REDACTED_TO_CLIENT = "redactedToClient"
|
|
82
|
+
CLIENT_VISIBLE = "clientVisible"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Variable(Model):
|
|
86
|
+
"""A declared workspace variable."""
|
|
87
|
+
|
|
88
|
+
key: str
|
|
89
|
+
schema_: dict[str, Any] = Field(alias="schema")
|
|
90
|
+
default_value: WorkspaceValue = Field(alias="defaultValue")
|
|
91
|
+
const: bool = False
|
|
92
|
+
exposure: Exposure
|
|
93
|
+
description: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RuleAst(Model):
|
|
97
|
+
"""A recursive CEL-profile rule expression."""
|
|
98
|
+
|
|
99
|
+
op: Literal[
|
|
100
|
+
"and",
|
|
101
|
+
"or",
|
|
102
|
+
"not",
|
|
103
|
+
"eq",
|
|
104
|
+
"ne",
|
|
105
|
+
"lt",
|
|
106
|
+
"le",
|
|
107
|
+
"gt",
|
|
108
|
+
"ge",
|
|
109
|
+
"has",
|
|
110
|
+
"const",
|
|
111
|
+
"var",
|
|
112
|
+
]
|
|
113
|
+
args: list[Any]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class EffectClass(str, Enum):
|
|
117
|
+
"""Classifies a bit's execution effects."""
|
|
118
|
+
|
|
119
|
+
PURE = "pure"
|
|
120
|
+
WORKSPACE = "workspace"
|
|
121
|
+
DOCUMENT = "document"
|
|
122
|
+
FILESYSTEM = "filesystem"
|
|
123
|
+
NETWORK = "network"
|
|
124
|
+
EXTERNAL_PROCESS = "external-process"
|
|
125
|
+
TRANSACTIONAL_EXTERNAL = "transactional-external"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class OnError(str, Enum):
|
|
129
|
+
"""Controls a run after a failed bit."""
|
|
130
|
+
|
|
131
|
+
ABORT = "abort"
|
|
132
|
+
SKIP = "skip"
|
|
133
|
+
CONTINUE = "continue"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Bit(Model):
|
|
137
|
+
"""An ordered conditional unit of project work."""
|
|
138
|
+
|
|
139
|
+
id: str
|
|
140
|
+
type: str
|
|
141
|
+
label: str | None = None
|
|
142
|
+
when: Literal[True] | RuleAst
|
|
143
|
+
config: dict[str, Any]
|
|
144
|
+
reads: list[str]
|
|
145
|
+
writes: list[str]
|
|
146
|
+
emits: list[str]
|
|
147
|
+
effect_class: EffectClass = Field(alias="effectClass")
|
|
148
|
+
on_error: OnError = Field(alias="onError")
|
|
149
|
+
capabilities: list[str]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class DocumentPreviewSettings(Model):
|
|
153
|
+
"""Preview configuration for a document definition."""
|
|
154
|
+
|
|
155
|
+
mode: Literal["none", "text", "html", "drawing-json"]
|
|
156
|
+
enabled: bool
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class DocumentDefinition(Model):
|
|
160
|
+
"""A deferred document output definition."""
|
|
161
|
+
|
|
162
|
+
id: str
|
|
163
|
+
type: Literal["text", "markdown", "xlsx", "docx", "image", "dxf"]
|
|
164
|
+
template_ref: str = Field(alias="templateRef")
|
|
165
|
+
template_manifest_ref: str | None = Field(
|
|
166
|
+
default=None, alias="templateManifestRef"
|
|
167
|
+
)
|
|
168
|
+
driver: str
|
|
169
|
+
output_target: str | None = Field(default=None, alias="outputTarget")
|
|
170
|
+
ir_family: Literal["flow", "sheet", "drawing", "raster", "plain"] | None = (
|
|
171
|
+
Field(default=None, alias="irFamily")
|
|
172
|
+
)
|
|
173
|
+
settings: dict[str, Any] = Field(default_factory=dict)
|
|
174
|
+
preview: DocumentPreviewSettings | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TemplateSlot(Model):
|
|
178
|
+
"""A declared template slot."""
|
|
179
|
+
|
|
180
|
+
kind: Literal[
|
|
181
|
+
"text",
|
|
182
|
+
"richtext",
|
|
183
|
+
"repeating_table",
|
|
184
|
+
"image",
|
|
185
|
+
"cell",
|
|
186
|
+
"named_range",
|
|
187
|
+
"block",
|
|
188
|
+
"layer",
|
|
189
|
+
]
|
|
190
|
+
required: bool = False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TemplateManifest(Model):
|
|
194
|
+
"""Sidecar metadata for a document template."""
|
|
195
|
+
|
|
196
|
+
template_id: str = Field(alias="templateId")
|
|
197
|
+
template_version: str = Field(alias="templateVersion")
|
|
198
|
+
document_type: Literal[
|
|
199
|
+
"text", "markdown", "xlsx", "docx", "image", "dxf"
|
|
200
|
+
] = Field(alias="documentType")
|
|
201
|
+
driver: str
|
|
202
|
+
required_inputs: list[str] = Field(alias="requiredInputs")
|
|
203
|
+
capabilities: list[str]
|
|
204
|
+
declared_slots: dict[str, TemplateSlot] = Field(alias="declaredSlots")
|
|
205
|
+
content_hash: str | None = Field(default=None, alias="contentHash")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class DocumentDriverManifest(Model):
|
|
209
|
+
"""Capability advertisement for an installed document driver."""
|
|
210
|
+
|
|
211
|
+
driver_id: str = Field(alias="driverId")
|
|
212
|
+
version: str
|
|
213
|
+
ir_family: Literal["flow", "sheet", "drawing", "raster", "plain"] = Field(
|
|
214
|
+
alias="irFamily"
|
|
215
|
+
)
|
|
216
|
+
capabilities: list[str]
|
|
217
|
+
supports_preview: bool = Field(alias="supportsPreview")
|
|
218
|
+
supports_browser_execution: bool = Field(alias="supportsBrowserExecution")
|
|
219
|
+
supports_diff: bool = Field(alias="supportsDiff")
|
|
220
|
+
requires_backend: bool = Field(alias="requiresBackend")
|
|
221
|
+
template_media_types: list[str] = Field(alias="templateMediaTypes")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class DocumentPreview(Model):
|
|
225
|
+
"""Renderer-owned preview payload."""
|
|
226
|
+
|
|
227
|
+
document_id: str = Field(alias="documentId")
|
|
228
|
+
mode: Literal["text", "html", "drawing-json"]
|
|
229
|
+
body: Any
|
|
230
|
+
warnings: list[str]
|
|
231
|
+
generated_at: str = Field(alias="generatedAt")
|
|
232
|
+
renderer: dict[str, str]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ArtifactRecord(Model):
|
|
236
|
+
"""One generated document artifact referenced from run results."""
|
|
237
|
+
|
|
238
|
+
document_id: str = Field(alias="documentId")
|
|
239
|
+
path: str
|
|
240
|
+
media_type: str = Field(alias="mediaType")
|
|
241
|
+
digest_sha256: str = Field(alias="digestSha256")
|
|
242
|
+
byte_length: int = Field(alias="byteLength")
|
|
243
|
+
template_id: str = Field(alias="templateId")
|
|
244
|
+
template_version: str = Field(alias="templateVersion")
|
|
245
|
+
driver_id: str = Field(alias="driverId")
|
|
246
|
+
driver_version: str = Field(alias="driverVersion")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class DocumentOperation(Model):
|
|
250
|
+
"""A typed document operation emitted by a bit."""
|
|
251
|
+
|
|
252
|
+
model_config = ConfigDict(extra="allow")
|
|
253
|
+
|
|
254
|
+
document_id: str = Field(alias="documentId")
|
|
255
|
+
op: str
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class SetOp(Model):
|
|
259
|
+
"""Set one workspace key."""
|
|
260
|
+
|
|
261
|
+
op: Literal["set"]
|
|
262
|
+
key: str
|
|
263
|
+
value: WorkspaceValue
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class UnsetOp(Model):
|
|
267
|
+
"""Remove one workspace key."""
|
|
268
|
+
|
|
269
|
+
op: Literal["unset"]
|
|
270
|
+
key: str
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class RejectOp(Model):
|
|
274
|
+
"""Record a rejected workspace mutation."""
|
|
275
|
+
|
|
276
|
+
op: Literal["reject"]
|
|
277
|
+
key: str
|
|
278
|
+
reason: str
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
PatchOp = Annotated[
|
|
282
|
+
Union[SetOp, UnsetOp, RejectOp],
|
|
283
|
+
Field(discriminator="op"),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class WorkspacePatch(Model):
|
|
288
|
+
"""An ordered, auditable list of workspace mutations."""
|
|
289
|
+
|
|
290
|
+
ops: list[PatchOp]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class BitTypeManifest(Model):
|
|
294
|
+
"""Describes an installed bit implementation."""
|
|
295
|
+
|
|
296
|
+
type_id: str = Field(alias="typeId")
|
|
297
|
+
version: str
|
|
298
|
+
config_schema: dict[str, Any] = Field(alias="configSchema")
|
|
299
|
+
effect_class: EffectClass = Field(alias="effectClass")
|
|
300
|
+
capabilities: list[str]
|
|
301
|
+
reads_from_config: bool = Field(default=False, alias="readsFromConfig")
|
|
302
|
+
browser_safe: bool = Field(alias="browserSafe")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class Project(Model):
|
|
306
|
+
"""A portable, versioned Roborean project."""
|
|
307
|
+
|
|
308
|
+
schema_version: Literal["1.0.0", "1.1.0"] = Field(alias="schemaVersion")
|
|
309
|
+
id: str
|
|
310
|
+
name: str
|
|
311
|
+
description: str | None = None
|
|
312
|
+
plugin_requirements: list[dict[str, str]] = Field(
|
|
313
|
+
alias="pluginRequirements"
|
|
314
|
+
)
|
|
315
|
+
workspace: dict[str, list[Variable]]
|
|
316
|
+
bits: list[Bit]
|
|
317
|
+
documents: list[DocumentDefinition]
|
|
318
|
+
templates: list[dict[str, str]]
|
|
319
|
+
metadata: dict[str, Any]
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def variables(self) -> list[Variable]:
|
|
323
|
+
"""Return declared workspace variables."""
|
|
324
|
+
return self.workspace["variables"]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class BitResult(Model):
|
|
328
|
+
"""The recorded result of one bit execution."""
|
|
329
|
+
|
|
330
|
+
bit_id: str = Field(alias="bitId")
|
|
331
|
+
type: str
|
|
332
|
+
active: bool
|
|
333
|
+
activation_reason: bool | Literal["always"] = Field(
|
|
334
|
+
alias="activationReason"
|
|
335
|
+
)
|
|
336
|
+
status: Literal["success", "skipped", "failed", "inactive"]
|
|
337
|
+
duration_ms: float = Field(alias="durationMs")
|
|
338
|
+
workspace_patch: WorkspacePatch = Field(alias="workspacePatch")
|
|
339
|
+
document_ops: list[Any] = Field(alias="documentOps")
|
|
340
|
+
diagnostics: list[dict[str, Any]]
|
|
341
|
+
plugin_version: str = Field(alias="pluginVersion")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class CompiledProject(Model):
|
|
345
|
+
"""A resolved project ready for deterministic execution."""
|
|
346
|
+
|
|
347
|
+
schema_version: Literal["1.0.0", "1.1.0"] = Field(alias="schemaVersion")
|
|
348
|
+
project_id: str = Field(alias="projectId")
|
|
349
|
+
project_name: str = Field(alias="projectName")
|
|
350
|
+
compiled_at: str = Field(alias="compiledAt")
|
|
351
|
+
engine_version: str = Field(alias="engineVersion")
|
|
352
|
+
rule_profile_version: str = Field(alias="ruleProfileVersion")
|
|
353
|
+
digest: str
|
|
354
|
+
variables: list[Variable]
|
|
355
|
+
bits: list[dict[str, Any]]
|
|
356
|
+
activation_expressions: dict[str, RuleAst] = Field(
|
|
357
|
+
alias="activationExpressions"
|
|
358
|
+
)
|
|
359
|
+
dependency_map: dict[str, dict[str, list[str]]] = Field(
|
|
360
|
+
alias="dependencyMap"
|
|
361
|
+
)
|
|
362
|
+
documents: list[DocumentDefinition]
|
|
363
|
+
templates: list[dict[str, str]]
|
|
364
|
+
plugin_versions: dict[str, str] = Field(alias="pluginVersions")
|
|
365
|
+
diagnostics: list[dict[str, Any]]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class RunResults(Model):
|
|
369
|
+
"""The portable output record for a Phase 1 execution."""
|
|
370
|
+
|
|
371
|
+
run_id: str = Field(alias="runId")
|
|
372
|
+
project_id: str = Field(alias="projectId")
|
|
373
|
+
project_digest: str = Field(alias="projectDigest")
|
|
374
|
+
started_at: str = Field(alias="startedAt")
|
|
375
|
+
finished_at: str = Field(alias="finishedAt")
|
|
376
|
+
status: Literal["success", "failed", "aborted"]
|
|
377
|
+
input_workspace_hash: str = Field(alias="inputWorkspaceHash")
|
|
378
|
+
final_workspace_hash: str = Field(alias="finalWorkspaceHash")
|
|
379
|
+
bit_results: list[BitResult] = Field(alias="bitResults")
|
|
380
|
+
artifacts: list[Any]
|
|
381
|
+
engine_version: str = Field(alias="engineVersion")
|
|
382
|
+
rule_profile_version: str = Field(alias="ruleProfileVersion")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class RunTrigger(str, Enum):
|
|
386
|
+
"""How a durable run was requested."""
|
|
387
|
+
|
|
388
|
+
CLI = "cli"
|
|
389
|
+
API = "api"
|
|
390
|
+
RETRY = "retry"
|
|
391
|
+
TEST = "test"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class RunRequest(Model):
|
|
395
|
+
"""Idempotent request to execute a project."""
|
|
396
|
+
|
|
397
|
+
project_id: str = Field(alias="projectId")
|
|
398
|
+
project_revision: str | None = Field(default=None, alias="projectRevision")
|
|
399
|
+
idempotency_key: str = Field(alias="idempotencyKey")
|
|
400
|
+
trigger: RunTrigger
|
|
401
|
+
workspace_overrides: dict[str, WorkspaceValue] = Field(
|
|
402
|
+
default_factory=dict, alias="workspaceOverrides"
|
|
403
|
+
)
|
|
404
|
+
strict_workspace_access: bool = Field(
|
|
405
|
+
default=True, alias="strictWorkspaceAccess"
|
|
406
|
+
)
|
|
407
|
+
retry_of_run_id: str | None = Field(default=None, alias="retryOfRunId")
|
|
408
|
+
requested_at: str | None = Field(default=None, alias="requestedAt")
|
|
409
|
+
|
|
410
|
+
@field_validator("idempotency_key")
|
|
411
|
+
@classmethod
|
|
412
|
+
def _validate_idempotency_key(cls, value: str) -> str:
|
|
413
|
+
"""Reject empty or oversized keys early."""
|
|
414
|
+
if not value or len(value) > 128:
|
|
415
|
+
raise ValueError("idempotencyKey must be 1..128 characters")
|
|
416
|
+
return value
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class WorkspaceChange(Model):
|
|
420
|
+
"""One workspace key delta for a run diff."""
|
|
421
|
+
|
|
422
|
+
key: str
|
|
423
|
+
before: WorkspaceValue | None
|
|
424
|
+
after: WorkspaceValue | None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class SecretRefAccess(Model):
|
|
428
|
+
"""Metadata that a secret reference was touched without revealing it."""
|
|
429
|
+
|
|
430
|
+
bit_id: str = Field(alias="bitId")
|
|
431
|
+
provider: str
|
|
432
|
+
name: str
|
|
433
|
+
version: str | None = None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class RunDiff(Model):
|
|
437
|
+
"""Redacted provenance summary for a completed run."""
|
|
438
|
+
|
|
439
|
+
workspace_changes: list[WorkspaceChange] = Field(alias="workspaceChanges")
|
|
440
|
+
bits_activated: list[str] = Field(alias="bitsActivated")
|
|
441
|
+
bits_skipped_inactive: list[str] = Field(alias="bitsSkippedInactive")
|
|
442
|
+
bits_failed: list[str] = Field(alias="bitsFailed")
|
|
443
|
+
secret_refs_accessed: list[SecretRefAccess] = Field(
|
|
444
|
+
alias="secretRefsAccessed"
|
|
445
|
+
)
|
|
446
|
+
document_ops_count: dict[str, int] = Field(alias="documentOpsCount")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class RunError(Model):
|
|
450
|
+
"""Structured failure information for a durable run."""
|
|
451
|
+
|
|
452
|
+
code: str
|
|
453
|
+
message: str
|
|
454
|
+
bit_id: str | None = Field(default=None, alias="bitId")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class RunStatus(str, Enum):
|
|
458
|
+
"""Lifecycle status for a durable run record."""
|
|
459
|
+
|
|
460
|
+
QUEUED = "queued"
|
|
461
|
+
RUNNING = "running"
|
|
462
|
+
SUCCEEDED = "succeeded"
|
|
463
|
+
FAILED = "failed"
|
|
464
|
+
CANCELLED = "cancelled"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class RunRecord(Model):
|
|
468
|
+
"""Durable envelope around compile/run artifacts."""
|
|
469
|
+
|
|
470
|
+
run_id: str = Field(alias="runId")
|
|
471
|
+
idempotency_key: str = Field(alias="idempotencyKey")
|
|
472
|
+
project_id: str = Field(alias="projectId")
|
|
473
|
+
project_revision: str = Field(alias="projectRevision")
|
|
474
|
+
compiled_digest: str = Field(alias="compiledDigest")
|
|
475
|
+
status: RunStatus
|
|
476
|
+
request: RunRequest
|
|
477
|
+
results: RunResults | None = None
|
|
478
|
+
diff: RunDiff | None = None
|
|
479
|
+
attempt: int = 1
|
|
480
|
+
retry_policy_snapshot: dict[str, Any] = Field(
|
|
481
|
+
default_factory=dict, alias="retryPolicySnapshot"
|
|
482
|
+
)
|
|
483
|
+
engine_version: str = Field(alias="engineVersion")
|
|
484
|
+
plugin_versions: dict[str, str] = Field(alias="pluginVersions")
|
|
485
|
+
error: RunError | None = None
|
|
486
|
+
created_at: str = Field(alias="createdAt")
|
|
487
|
+
started_at: str | None = Field(default=None, alias="startedAt")
|
|
488
|
+
finished_at: str | None = Field(default=None, alias="finishedAt")
|
|
489
|
+
request_digest: str = Field(default="", alias="requestDigest")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Load and validate canonical Roborean JSON Schemas."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from jsonschema import Draft202012Validator, FormatChecker, RefResolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_repo_root() -> Path:
|
|
11
|
+
"""Find the repository root containing the project schema."""
|
|
12
|
+
# Walk from this installed source location toward a repository checkout.
|
|
13
|
+
for parent in Path(__file__).resolve().parents:
|
|
14
|
+
if (parent / "schemas" / "project.schema.json").is_file():
|
|
15
|
+
return parent
|
|
16
|
+
|
|
17
|
+
raise FileNotFoundError("Could not find schemas/project.schema.json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def schema_dir() -> Path:
|
|
21
|
+
"""Return the canonical schema directory."""
|
|
22
|
+
return find_repo_root() / "schemas"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_schema(name: str) -> dict[str, Any]:
|
|
26
|
+
"""Load one canonical schema by logical name."""
|
|
27
|
+
# Preserve meta paths while adding the standard schema suffix elsewhere.
|
|
28
|
+
relative = Path(name)
|
|
29
|
+
filename = relative.name
|
|
30
|
+
if not filename.endswith(".json"):
|
|
31
|
+
filename = f"{filename}.schema.json"
|
|
32
|
+
return json.loads((schema_dir() / relative.parent / filename).read_text())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_instance(schema_name: str, data: dict[str, Any]) -> None:
|
|
36
|
+
"""Validate data using its schema and every local schema as a ref store."""
|
|
37
|
+
# Build a resolver store so relative schema references work consistently.
|
|
38
|
+
directory = schema_dir()
|
|
39
|
+
store: dict[str, Any] = {}
|
|
40
|
+
for path in directory.rglob("*.json"):
|
|
41
|
+
schema = json.loads(path.read_text())
|
|
42
|
+
schema_id = schema.get("$id")
|
|
43
|
+
if schema_id:
|
|
44
|
+
store[schema_id] = schema
|
|
45
|
+
|
|
46
|
+
schema = load_schema(schema_name)
|
|
47
|
+
resolver = RefResolver.from_schema(schema, store=store)
|
|
48
|
+
validator = Draft202012Validator(
|
|
49
|
+
schema,
|
|
50
|
+
resolver=resolver,
|
|
51
|
+
format_checker=FormatChecker(),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Raise jsonschema's standard, information-rich validation exception.
|
|
55
|
+
validator.validate(data)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shared fixtures for Roborean spec tests."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(scope="session")
|
|
9
|
+
def repo_root() -> Path:
|
|
10
|
+
"""Return the repository root."""
|
|
11
|
+
return Path(__file__).resolve().parents[4]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="session")
|
|
15
|
+
def conformance_dir(repo_root: Path) -> Path:
|
|
16
|
+
"""Return the shared conformance corpus."""
|
|
17
|
+
return repo_root / "conformance"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Tests for schema-backed Pydantic models."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
from roborean_spec import project_from_dict, project_to_dict
|
|
8
|
+
from roborean_spec.models import PublicLiteral, Variable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestModels:
|
|
12
|
+
"""Verify model parsing and JSON round trips."""
|
|
13
|
+
|
|
14
|
+
def test_project_round_trip(self, conformance_dir) -> None:
|
|
15
|
+
"""Preserve the canonical project representation."""
|
|
16
|
+
data = json.loads(
|
|
17
|
+
(conformance_dir / "projects" / "01_minimal.json").read_text()
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
assert project_to_dict(project_from_dict(data)) == data
|
|
21
|
+
|
|
22
|
+
def test_unknown_workspace_kind_fails(self) -> None:
|
|
23
|
+
"""Reject a workspace value outside the discriminated union."""
|
|
24
|
+
with pytest.raises(ValidationError):
|
|
25
|
+
Variable.model_validate(
|
|
26
|
+
{
|
|
27
|
+
"key": "value",
|
|
28
|
+
"schema": {},
|
|
29
|
+
"defaultValue": {"kind": "unknown"},
|
|
30
|
+
"exposure": "clientVisible",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def test_public_literal(self) -> None:
|
|
35
|
+
"""Parse a public literal through its explicit model."""
|
|
36
|
+
value = PublicLiteral(
|
|
37
|
+
kind="public_literal",
|
|
38
|
+
dataType="string",
|
|
39
|
+
value="Hello",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
assert value.value == "Hello"
|