runtime-labs-contracts 0.1.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.
- asset_contracts.py +196 -0
- contracts.py +150 -0
- deployment_contracts.py +102 -0
- evidence_contracts.py +256 -0
- messaging.py +341 -0
- repository_identity.py +63 -0
- runtime_labs_contracts-0.1.0.dist-info/METADATA +26 -0
- runtime_labs_contracts-0.1.0.dist-info/RECORD +9 -0
- runtime_labs_contracts-0.1.0.dist-info/WHEEL +4 -0
asset_contracts.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The scanner's discovery output: assets, their metadata, and direct asset edges.
|
|
2
|
+
|
|
3
|
+
This is the only graph the scanner publishes for downstream consumers. The data
|
|
4
|
+
processing pipelines build every view (UI, evals, risk, governance) from it and
|
|
5
|
+
never read source code themselves.
|
|
6
|
+
|
|
7
|
+
Rules the scanner guarantees:
|
|
8
|
+
|
|
9
|
+
- Every asset and edge is backed by source evidence the verifier re-proved at the
|
|
10
|
+
scanned commit. Nothing comes from names, runtime traces or model confidence.
|
|
11
|
+
- Edges are single direct relations. A route that reaches a model through two
|
|
12
|
+
helper functions is three edges, not one; consumers traverse when they need to.
|
|
13
|
+
- ``asset_id`` is stable across scans while the asset's identity is unchanged
|
|
14
|
+
(kind plus symbol, registration label, route or call ordinal), so history,
|
|
15
|
+
diffs and trace links survive unrelated edits. Line ranges live in ``anchor``.
|
|
16
|
+
- Traces are not assets. A span links to an asset through ``runtime_ids``, the
|
|
17
|
+
deployment-manifest node IDs the SDK stamps on spans.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from contracts import SourceLocation, StrictModel
|
|
23
|
+
from pydantic import Field, model_validator
|
|
24
|
+
|
|
25
|
+
AssetKind = Literal[
|
|
26
|
+
"Endpoint", # an HTTP route handler
|
|
27
|
+
"Workflow", # a compiled agent graph (identified by the factory that builds it)
|
|
28
|
+
"WorkflowNode", # one registered node of a workflow
|
|
29
|
+
"Agent", # an agent object outside a workflow framework
|
|
30
|
+
"Tool", # a callable exposed to a model
|
|
31
|
+
"ModelCall", # one model invocation site
|
|
32
|
+
"DataStore", # a persisted entity such as an ORM model
|
|
33
|
+
"Function", # plain code that directly links other assets
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
EdgeKind = Literal[
|
|
37
|
+
"contains", # Workflow -> WorkflowNode
|
|
38
|
+
"starts", # Workflow -> WorkflowNode it enters first (-> itself: may end at once)
|
|
39
|
+
"ends", # WorkflowNode -> Workflow it can finish
|
|
40
|
+
"transitions_to", # WorkflowNode -> WorkflowNode (control flow)
|
|
41
|
+
"delegates_to", # WorkflowNode -> Workflow it runs as a subgraph
|
|
42
|
+
"invokes", # direct call between code assets
|
|
43
|
+
"calls_model", # asset whose body contains the ModelCall
|
|
44
|
+
"offers_tool", # ModelCall -> Tool passed to it, or Agent -> Tool its model can run
|
|
45
|
+
"reads", # asset -> DataStore it queries
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SourceAnchor(StrictModel):
|
|
50
|
+
path: str
|
|
51
|
+
location: SourceLocation
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Metadata(StrictModel):
|
|
55
|
+
description: str | None = None
|
|
56
|
+
signature: str | None = None
|
|
57
|
+
return_annotation: str | None = None
|
|
58
|
+
asynchronous: bool | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EndpointMetadata(_Metadata):
|
|
62
|
+
method: str
|
|
63
|
+
route: str
|
|
64
|
+
handler: str
|
|
65
|
+
framework: str | None = None # the library spec that recognized the route
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WorkflowMetadata(_Metadata):
|
|
69
|
+
factory: str
|
|
70
|
+
state_type: str | None = None
|
|
71
|
+
framework: str | None = None # the library spec that recognized the graph
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WorkflowNodeMetadata(_Metadata):
|
|
75
|
+
label: str
|
|
76
|
+
callback: str
|
|
77
|
+
subgraph: bool = False
|
|
78
|
+
state_type: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AgentMetadata(_Metadata):
|
|
82
|
+
rule: str | None = None
|
|
83
|
+
# True for code that makes one model call and uses the answer (a summary or a
|
|
84
|
+
# classification), rather than looping on the model's output.
|
|
85
|
+
single_call: bool | None = None
|
|
86
|
+
# What the code asks the model, for summaries: its bounded source, enclosing
|
|
87
|
+
# docstrings and the text constants (prompts, questions) it uses.
|
|
88
|
+
context: dict[str, Any] | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ToolMetadata(_Metadata):
|
|
92
|
+
rule: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ModelCallMetadata(_Metadata):
|
|
96
|
+
"""AI configuration as source values: each says whether it is a literal, a
|
|
97
|
+
template with variables, or an unresolved expression. The description and
|
|
98
|
+
signature are the enclosing function's."""
|
|
99
|
+
|
|
100
|
+
enclosing_symbol: str
|
|
101
|
+
call_ordinal: int | None = None
|
|
102
|
+
provider: str | None = None
|
|
103
|
+
operation: str | None = None
|
|
104
|
+
# The function that makes the provider call, when this site reaches it through
|
|
105
|
+
# the application's own wrappers (``through``, outermost first).
|
|
106
|
+
boundary: str | None = None
|
|
107
|
+
through: list[str] = Field(default_factory=list)
|
|
108
|
+
model: dict[str, Any] | None = None
|
|
109
|
+
prompts: list[dict[str, Any]] = Field(default_factory=list)
|
|
110
|
+
tools: dict[str, Any] | None = None
|
|
111
|
+
settings: dict[str, Any] = Field(default_factory=dict)
|
|
112
|
+
configuration: dict[str, Any] | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DataStoreMetadata(_Metadata):
|
|
116
|
+
framework: str | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class FunctionMetadata(_Metadata):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
METADATA = {
|
|
124
|
+
"Endpoint": EndpointMetadata,
|
|
125
|
+
"Workflow": WorkflowMetadata,
|
|
126
|
+
"WorkflowNode": WorkflowNodeMetadata,
|
|
127
|
+
"Agent": AgentMetadata,
|
|
128
|
+
"Tool": ToolMetadata,
|
|
129
|
+
"ModelCall": ModelCallMetadata,
|
|
130
|
+
"DataStore": DataStoreMetadata,
|
|
131
|
+
"Function": FunctionMetadata,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Asset(StrictModel):
|
|
136
|
+
asset_id: str
|
|
137
|
+
kind: AssetKind
|
|
138
|
+
name: str
|
|
139
|
+
qualified_symbol: str
|
|
140
|
+
parent_id: str | None = None
|
|
141
|
+
anchor: SourceAnchor
|
|
142
|
+
evidence_ids: list[str] = Field(min_length=1)
|
|
143
|
+
runtime_ids: list[str] = Field(default_factory=list)
|
|
144
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
@model_validator(mode="after")
|
|
147
|
+
def typed_metadata(self):
|
|
148
|
+
self.metadata = METADATA[self.kind].model_validate(self.metadata).model_dump(
|
|
149
|
+
exclude_none=True
|
|
150
|
+
)
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class AssetEdge(StrictModel):
|
|
155
|
+
edge_id: str
|
|
156
|
+
kind: EdgeKind
|
|
157
|
+
from_asset_id: str
|
|
158
|
+
to_asset_id: str
|
|
159
|
+
evidence_ids: list[str] = Field(min_length=1)
|
|
160
|
+
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class DiscoveryGap(StrictModel):
|
|
164
|
+
"""Something the scanner saw but could not prove, kept so coverage is honest."""
|
|
165
|
+
|
|
166
|
+
code: str
|
|
167
|
+
message: str
|
|
168
|
+
anchor: SourceAnchor | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SourceRemote(StrictModel):
|
|
172
|
+
"""Where the scanned checkout lives on its Git host, so consumers can link to source."""
|
|
173
|
+
|
|
174
|
+
url: str # normalized remote, e.g. "github.com/acme/app"
|
|
175
|
+
subdirectory: str = "" # the scanned directory within that repository; "" at its root
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AssetGraph(StrictModel):
|
|
179
|
+
schema_version: Literal[1] = 1
|
|
180
|
+
repository_id: str
|
|
181
|
+
commit_sha: str
|
|
182
|
+
remote: SourceRemote | None = None
|
|
183
|
+
scanner_version: str
|
|
184
|
+
assets: list[Asset]
|
|
185
|
+
edges: list[AssetEdge]
|
|
186
|
+
gaps: list[DiscoveryGap] = Field(default_factory=list)
|
|
187
|
+
|
|
188
|
+
@model_validator(mode="after")
|
|
189
|
+
def closed_graph(self):
|
|
190
|
+
ids = {asset.asset_id for asset in self.assets}
|
|
191
|
+
if len(ids) != len(self.assets):
|
|
192
|
+
raise ValueError("Duplicate asset IDs")
|
|
193
|
+
for edge in self.edges:
|
|
194
|
+
if edge.from_asset_id not in ids or edge.to_asset_id not in ids:
|
|
195
|
+
raise ValueError(f"Edge {edge.edge_id} references an unknown asset")
|
|
196
|
+
return self
|
contracts.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Versioned wire contracts for source discovery and runtime attribution.
|
|
2
|
+
|
|
3
|
+
These models are shared across scanner output, instrumentation manifests, and
|
|
4
|
+
trace correlation. Extra fields are rejected so incompatible producers cannot
|
|
5
|
+
silently change the meaning of a stored graph or span identity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StrictModel(BaseModel):
|
|
14
|
+
"""Base contract that rejects fields outside the declared schema."""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(extra="forbid")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
NodeKind = Literal["Agent", "Tool", "Workflow", "ModelCallSite", "Model", "Provider", "Service"]
|
|
20
|
+
EdgeKind = Literal[
|
|
21
|
+
"DEFINED_IN",
|
|
22
|
+
"CONTAINS",
|
|
23
|
+
"REGISTERS_TOOL",
|
|
24
|
+
"INVOKES_AGENT",
|
|
25
|
+
"INVOKES_TOOL",
|
|
26
|
+
"CALLS_MODEL",
|
|
27
|
+
]
|
|
28
|
+
EvidenceKind = Literal["declared", "statically_inferred"]
|
|
29
|
+
SupportState = Literal["supported", "unresolved", "unsupported"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SourceLocation(StrictModel):
|
|
33
|
+
"""One-based source line range with zero-based columns."""
|
|
34
|
+
|
|
35
|
+
start_line: int = Field(ge=1)
|
|
36
|
+
start_col: int = Field(ge=0)
|
|
37
|
+
end_line: int = Field(ge=1)
|
|
38
|
+
end_col: int = Field(ge=0)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Evidence(StrictModel):
|
|
42
|
+
"""The scanner rule and source range supporting a discovered fact."""
|
|
43
|
+
|
|
44
|
+
rule: str
|
|
45
|
+
adapter: str
|
|
46
|
+
classification: EvidenceKind
|
|
47
|
+
path: str
|
|
48
|
+
location: SourceLocation
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SourceNode(StrictModel):
|
|
52
|
+
"""A source-level component or call site tied to its file and fingerprint."""
|
|
53
|
+
|
|
54
|
+
node_id: str
|
|
55
|
+
kind: NodeKind
|
|
56
|
+
qualified_symbol: str
|
|
57
|
+
path: str
|
|
58
|
+
location: SourceLocation
|
|
59
|
+
source_digest: str
|
|
60
|
+
semantic_fingerprint: str
|
|
61
|
+
operation: str | None = None
|
|
62
|
+
call_ordinal: int | None = Field(default=None, ge=1)
|
|
63
|
+
support: SupportState = "supported"
|
|
64
|
+
evidence: list[Evidence] = Field(min_length=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SourceEdge(StrictModel):
|
|
68
|
+
"""A directed relationship between two nodes in the same source graph."""
|
|
69
|
+
|
|
70
|
+
from_node_id: str
|
|
71
|
+
to_node_id: str
|
|
72
|
+
kind: EdgeKind
|
|
73
|
+
evidence: Evidence
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ScanDiagnostic(StrictModel):
|
|
77
|
+
"""A skipped, unsupported, or unresolved source pattern reported by a scan."""
|
|
78
|
+
|
|
79
|
+
code: str
|
|
80
|
+
path: str
|
|
81
|
+
message: str
|
|
82
|
+
location: SourceLocation | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SourceGraph(StrictModel):
|
|
86
|
+
"""The scanner's versioned graph for one repository snapshot."""
|
|
87
|
+
|
|
88
|
+
schema_version: Literal[1] = 1
|
|
89
|
+
project_id: str
|
|
90
|
+
repository_id: str
|
|
91
|
+
graph_version_id: str
|
|
92
|
+
commit_sha: str | None
|
|
93
|
+
dirty: bool
|
|
94
|
+
scanner_version: str
|
|
95
|
+
nodes: list[SourceNode]
|
|
96
|
+
edges: list[SourceEdge]
|
|
97
|
+
diagnostics: list[ScanDiagnostic] = Field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
@model_validator(mode="after")
|
|
100
|
+
def check_references(self) -> "SourceGraph":
|
|
101
|
+
"""Reject duplicate nodes and edges that point outside this graph."""
|
|
102
|
+
|
|
103
|
+
ids = [node.node_id for node in self.nodes]
|
|
104
|
+
if len(ids) != len(set(ids)):
|
|
105
|
+
raise ValueError("Duplicate source node IDs")
|
|
106
|
+
known = set(ids)
|
|
107
|
+
for edge in self.edges:
|
|
108
|
+
if edge.from_node_id not in known or edge.to_node_id not in known:
|
|
109
|
+
raise ValueError("Source edge references an absent node")
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ManifestTarget(StrictModel):
|
|
114
|
+
"""A source node and trusted SDK hook selected for instrumentation."""
|
|
115
|
+
|
|
116
|
+
node_id: str
|
|
117
|
+
kind: NodeKind
|
|
118
|
+
strategy: Literal["provider_hook", "framework_hook", "function_hook"]
|
|
119
|
+
hook_id: str
|
|
120
|
+
qualified_symbol: str
|
|
121
|
+
source_digest: str
|
|
122
|
+
expected_span_kind: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class InstrumentationManifest(StrictModel):
|
|
126
|
+
"""A revision-bound set of hooks for a supported customer deployment."""
|
|
127
|
+
|
|
128
|
+
schema_version: Literal[1] = 1
|
|
129
|
+
project_id: str
|
|
130
|
+
repository_id: str
|
|
131
|
+
graph_version_id: str
|
|
132
|
+
commit_sha: str
|
|
133
|
+
manifest_id: str
|
|
134
|
+
min_sdk_version: str
|
|
135
|
+
targets: list[ManifestTarget]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SpanIdentity(StrictModel):
|
|
139
|
+
"""Identifiers needed to link an observed span to its source revision."""
|
|
140
|
+
|
|
141
|
+
schema_version: Literal[1] = 1
|
|
142
|
+
project_id: str
|
|
143
|
+
repository_id: str | None = None
|
|
144
|
+
commit_sha: str | None = None
|
|
145
|
+
graph_version_id: str | None = None
|
|
146
|
+
manifest_id: str | None = None
|
|
147
|
+
node_id: str | None = None
|
|
148
|
+
trace_id: str
|
|
149
|
+
span_id: str
|
|
150
|
+
parent_span_id: str | None = None
|
deployment_contracts.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Metadata-only source identities for future runtime telemetry attribution."""
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from contracts import SourceLocation, StrictModel
|
|
7
|
+
from pydantic import Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
RuntimeEntityKind = Literal["Workflow", "Agent", "Tool", "ModelCallSite", "APIEndpoint"]
|
|
10
|
+
RuntimeRelationshipKind = Literal["CONTAINS", "REGISTERS_TOOL", "CALLS_MODEL", "ROUTES_TO"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ManifestEvidence(StrictModel):
|
|
14
|
+
"""Source proof reference; its revision is the enclosing manifest's revision."""
|
|
15
|
+
|
|
16
|
+
evidence_id: str
|
|
17
|
+
path: str
|
|
18
|
+
location: SourceLocation
|
|
19
|
+
source_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
20
|
+
adapter: str
|
|
21
|
+
adapter_version: str
|
|
22
|
+
verifier_version: str
|
|
23
|
+
|
|
24
|
+
@field_validator("path")
|
|
25
|
+
@classmethod
|
|
26
|
+
def repository_relative_path(cls, value):
|
|
27
|
+
path = PurePosixPath(value)
|
|
28
|
+
if path.is_absolute() or ".." in path.parts or "\\" in value or not path.parts:
|
|
29
|
+
raise ValueError("Manifest source paths must be repository relative")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def ordered_range(self):
|
|
34
|
+
loc = self.location
|
|
35
|
+
if (loc.end_line, loc.end_col) <= (loc.start_line, loc.start_col):
|
|
36
|
+
raise ValueError("Manifest source ranges must be nonempty and ordered")
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ManifestEntity(StrictModel):
|
|
41
|
+
node_id: str
|
|
42
|
+
kind: RuntimeEntityKind
|
|
43
|
+
qualified_symbol: str
|
|
44
|
+
source: ManifestEvidence
|
|
45
|
+
evidence_ids: list[str] = Field(min_length=1)
|
|
46
|
+
verification_status: Literal["verified_source"] = "verified_source"
|
|
47
|
+
framework: Literal["langgraph", "fastapi"] | None = None
|
|
48
|
+
provider: Literal["google"] | None = None
|
|
49
|
+
operation: str | None = None
|
|
50
|
+
model: str | None = None
|
|
51
|
+
|
|
52
|
+
@model_validator(mode="after")
|
|
53
|
+
def call_site_metadata(self):
|
|
54
|
+
if self.kind == "ModelCallSite":
|
|
55
|
+
if not self.provider:
|
|
56
|
+
raise ValueError("Model call sites require a verified provider")
|
|
57
|
+
elif any(value is not None for value in (self.model, self.operation)):
|
|
58
|
+
raise ValueError("Only model call sites may carry call-site metadata")
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ManifestRelationship(StrictModel):
|
|
63
|
+
relationship_id: str
|
|
64
|
+
kind: RuntimeRelationshipKind
|
|
65
|
+
from_node_id: str
|
|
66
|
+
to_node_id: str
|
|
67
|
+
evidence_ids: list[str] = Field(min_length=1)
|
|
68
|
+
verification_status: Literal["verified_source"] = "verified_source"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DeploymentManifest(StrictModel):
|
|
72
|
+
"""V1 deployment metadata compiled solely from a published EvidenceGraph."""
|
|
73
|
+
|
|
74
|
+
schema_version: Literal[1] = 1
|
|
75
|
+
repository_id: str
|
|
76
|
+
commit_sha: str
|
|
77
|
+
scanner_version: str
|
|
78
|
+
entities: list[ManifestEntity]
|
|
79
|
+
relationships: list[ManifestRelationship]
|
|
80
|
+
evidence: list[ManifestEvidence]
|
|
81
|
+
|
|
82
|
+
@model_validator(mode="after")
|
|
83
|
+
def check_references(self):
|
|
84
|
+
nodes = {entity.node_id: entity for entity in self.entities}
|
|
85
|
+
proof = {item.evidence_id: item for item in self.evidence}
|
|
86
|
+
if len(nodes) != len(self.entities) or len(proof) != len(self.evidence):
|
|
87
|
+
raise ValueError("Duplicate manifest node or evidence IDs")
|
|
88
|
+
if len({r.relationship_id for r in self.relationships}) != len(self.relationships):
|
|
89
|
+
raise ValueError("Duplicate manifest relationship IDs")
|
|
90
|
+
for item in [*self.entities, *self.relationships]:
|
|
91
|
+
if not set(item.evidence_ids) <= proof.keys():
|
|
92
|
+
raise ValueError("Unknown manifest evidence reference")
|
|
93
|
+
for entity in self.entities:
|
|
94
|
+
if (
|
|
95
|
+
proof.get(entity.source.evidence_id) != entity.source
|
|
96
|
+
or entity.source.evidence_id not in entity.evidence_ids
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("Entity source must reference its manifest evidence")
|
|
99
|
+
for relationship in self.relationships:
|
|
100
|
+
if relationship.from_node_id not in nodes or relationship.to_node_id not in nodes:
|
|
101
|
+
raise ValueError("Manifest relationship references an absent node")
|
|
102
|
+
return self
|
evidence_contracts.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""V2 evidence contracts; v1 source graphs and runtime span contracts stay unchanged."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from contracts import SourceLocation, StrictModel
|
|
6
|
+
from pydantic import Field, model_validator
|
|
7
|
+
|
|
8
|
+
ClaimStatus = Literal["proposed", "verified_source", "partial", "unresolved", "contradicted"]
|
|
9
|
+
Transform = Literal[
|
|
10
|
+
"field_copy",
|
|
11
|
+
"argument_binding",
|
|
12
|
+
"string_format",
|
|
13
|
+
"lookup",
|
|
14
|
+
"serialization",
|
|
15
|
+
"derived_output",
|
|
16
|
+
"return_binding",
|
|
17
|
+
"state_update",
|
|
18
|
+
"state_read",
|
|
19
|
+
"transformation",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EvidenceRef(StrictModel):
|
|
24
|
+
evidence_id: str
|
|
25
|
+
evidence_type: Literal["source", "runtime", "synthetic_test"] = "source"
|
|
26
|
+
repository_id: str
|
|
27
|
+
commit_sha: str
|
|
28
|
+
path: str
|
|
29
|
+
file_digest: str
|
|
30
|
+
location: SourceLocation
|
|
31
|
+
adapter: str
|
|
32
|
+
adapter_version: str = "2"
|
|
33
|
+
verifier_version: str = "2"
|
|
34
|
+
task_id: str = "index"
|
|
35
|
+
deployment_id: str | None = None
|
|
36
|
+
span_id: str | None = None
|
|
37
|
+
test_id: str | None = None
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="after")
|
|
40
|
+
def check_evidence_type(self):
|
|
41
|
+
if self.evidence_type == "runtime" and not (self.deployment_id and self.span_id):
|
|
42
|
+
raise ValueError("Runtime evidence requires deployment and span IDs")
|
|
43
|
+
if self.evidence_type == "synthetic_test" and not self.test_id:
|
|
44
|
+
raise ValueError("Synthetic evidence requires a test ID")
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Port(StrictModel):
|
|
49
|
+
port_id: str
|
|
50
|
+
symbol_id: str
|
|
51
|
+
name: str
|
|
52
|
+
kind: Literal["parameter", "field", "value", "argument", "return", "state", "model_output"]
|
|
53
|
+
evidence: EvidenceRef
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class IndexSymbol(StrictModel):
|
|
57
|
+
symbol_id: str
|
|
58
|
+
qualified_name: str
|
|
59
|
+
kind: str
|
|
60
|
+
parameters: list[str] = Field(default_factory=list)
|
|
61
|
+
parameter_types: dict[str, str] = Field(default_factory=dict)
|
|
62
|
+
return_type: str | None = None
|
|
63
|
+
bases: list[str] = Field(default_factory=list)
|
|
64
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
65
|
+
evidence: EvidenceRef
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class IndexFact(StrictModel):
|
|
69
|
+
"""Language-neutral syntax/semantic facts; only adapters may produce these."""
|
|
70
|
+
|
|
71
|
+
fact_id: str
|
|
72
|
+
kind: Literal[
|
|
73
|
+
"import",
|
|
74
|
+
"reference",
|
|
75
|
+
"call",
|
|
76
|
+
"assignment",
|
|
77
|
+
"argument_binding",
|
|
78
|
+
"return",
|
|
79
|
+
"flow",
|
|
80
|
+
"asset",
|
|
81
|
+
"relationship",
|
|
82
|
+
"registration",
|
|
83
|
+
"gap",
|
|
84
|
+
]
|
|
85
|
+
symbol_id: str
|
|
86
|
+
source: str = ""
|
|
87
|
+
target: str = ""
|
|
88
|
+
transform: Transform | None = None
|
|
89
|
+
guard: str | None = None
|
|
90
|
+
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
91
|
+
evidence: list[EvidenceRef] = Field(min_length=1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class IndexBundle(StrictModel):
|
|
95
|
+
schema_version: Literal[2] = 2
|
|
96
|
+
repository_id: str
|
|
97
|
+
commit_sha: str
|
|
98
|
+
files: dict[str, str]
|
|
99
|
+
symbols: list[IndexSymbol]
|
|
100
|
+
ports: list[Port]
|
|
101
|
+
facts: list[IndexFact]
|
|
102
|
+
diagnostics: list[str] = Field(default_factory=list)
|
|
103
|
+
cache_hits: int = 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Gap(StrictModel):
|
|
107
|
+
code: str
|
|
108
|
+
message: str
|
|
109
|
+
evidence: list[EvidenceRef] = Field(default_factory=list)
|
|
110
|
+
after_port: str | None = None
|
|
111
|
+
before_port: str | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class VerificationResult(StrictModel):
|
|
115
|
+
status: ClaimStatus
|
|
116
|
+
verifier_version: str = "2"
|
|
117
|
+
evidence: list[EvidenceRef] = Field(default_factory=list)
|
|
118
|
+
gaps: list[Gap] = Field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
@model_validator(mode="after")
|
|
121
|
+
def source_proof_requires_evidence(self):
|
|
122
|
+
if self.status == "verified_source" and (
|
|
123
|
+
not self.evidence
|
|
124
|
+
or self.gaps
|
|
125
|
+
or any(ev.evidence_type != "source" for ev in self.evidence)
|
|
126
|
+
):
|
|
127
|
+
raise ValueError("Source verification requires source evidence and no gaps")
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CandidateClaim(StrictModel):
|
|
132
|
+
claim_id: str
|
|
133
|
+
task_id: str
|
|
134
|
+
kind: Literal["asset", "relationship", "flow"]
|
|
135
|
+
fact_id: str
|
|
136
|
+
source: str
|
|
137
|
+
target: str
|
|
138
|
+
transform: Transform | None = None
|
|
139
|
+
guard: str | None = None
|
|
140
|
+
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
141
|
+
evidence: list[EvidenceRef] = Field(min_length=1)
|
|
142
|
+
status: Literal["proposed"] = "proposed"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class FlowHop(StrictModel):
|
|
146
|
+
hop_id: str
|
|
147
|
+
origin_port: str
|
|
148
|
+
target_port: str
|
|
149
|
+
transform: Transform
|
|
150
|
+
guard: str | None = None
|
|
151
|
+
verification: VerificationResult
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class FlowPath(StrictModel):
|
|
155
|
+
flow_path_id: str
|
|
156
|
+
origin_port: str
|
|
157
|
+
sink_port: str
|
|
158
|
+
labels: list[str] = Field(default_factory=list)
|
|
159
|
+
hops: list[FlowHop]
|
|
160
|
+
status: ClaimStatus
|
|
161
|
+
gaps: list[Gap] = Field(default_factory=list)
|
|
162
|
+
|
|
163
|
+
@model_validator(mode="after")
|
|
164
|
+
def check_complete_path(self):
|
|
165
|
+
continuous = bool(self.hops) and self.hops[0].origin_port == self.origin_port
|
|
166
|
+
continuous = continuous and self.hops[-1].target_port == self.sink_port
|
|
167
|
+
continuous = continuous and all(
|
|
168
|
+
left.target_port == right.origin_port
|
|
169
|
+
for left, right in zip(self.hops, self.hops[1:], strict=False)
|
|
170
|
+
)
|
|
171
|
+
if self.status == "verified_source" and (
|
|
172
|
+
not continuous
|
|
173
|
+
or self.gaps
|
|
174
|
+
or any(h.verification.status != "verified_source" for h in self.hops)
|
|
175
|
+
):
|
|
176
|
+
raise ValueError("A verified path requires a continuous, fully verified chain")
|
|
177
|
+
if self.status in {"partial", "unresolved"} and not self.gaps:
|
|
178
|
+
raise ValueError("An incomplete path requires a named gap")
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Budget(StrictModel):
|
|
183
|
+
max_steps: int = Field(default=12, ge=0)
|
|
184
|
+
max_tokens: int = Field(default=250000, ge=0)
|
|
185
|
+
max_seconds: float = Field(default=600, ge=0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class InvestigationTask(StrictModel):
|
|
189
|
+
task_id: str
|
|
190
|
+
repository_id: str
|
|
191
|
+
commit_sha: str
|
|
192
|
+
task_type: Literal["asset", "relationship", "flow"]
|
|
193
|
+
objective: str
|
|
194
|
+
anchors: list[str] = Field(default_factory=list)
|
|
195
|
+
relevant_files: list[str] = Field(default_factory=list)
|
|
196
|
+
hypothesis: str | None = None
|
|
197
|
+
stopping_condition: str = "Verified adjacent claims, explicit abstention, or budget exhaustion"
|
|
198
|
+
allowed_tools: list[str] = Field(
|
|
199
|
+
default_factory=lambda: [
|
|
200
|
+
"repo_map",
|
|
201
|
+
"find_symbols",
|
|
202
|
+
"find_references",
|
|
203
|
+
"read_symbol",
|
|
204
|
+
"read_source_window",
|
|
205
|
+
"find_registrations",
|
|
206
|
+
"get_flow_slice",
|
|
207
|
+
"submit_claim",
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
budget: Budget = Field(default_factory=Budget)
|
|
211
|
+
steps: int = 0
|
|
212
|
+
tokens: int = 0
|
|
213
|
+
elapsed_seconds: float = 0
|
|
214
|
+
failed_verifications: int = 0
|
|
215
|
+
status: Literal["pending", "running", "complete", "budget_exhausted", "unresolved"] = "pending"
|
|
216
|
+
claims: list[CandidateClaim] = Field(default_factory=list)
|
|
217
|
+
results: list[VerificationResult] = Field(default_factory=list)
|
|
218
|
+
gaps: list[Gap] = Field(default_factory=list)
|
|
219
|
+
model_events: list[dict[str, Any]] = Field(default_factory=list)
|
|
220
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
221
|
+
routing_hints: dict[str, Any] = Field(default_factory=dict)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class EvidenceGraph(StrictModel):
|
|
225
|
+
schema_version: Literal[2] = 2
|
|
226
|
+
repository_id: str
|
|
227
|
+
commit_sha: str
|
|
228
|
+
graph_version_id: str
|
|
229
|
+
ports: list[Port]
|
|
230
|
+
assets: list[CandidateClaim]
|
|
231
|
+
relationships: list[CandidateClaim]
|
|
232
|
+
verifications: dict[str, VerificationResult]
|
|
233
|
+
hops: list[FlowHop]
|
|
234
|
+
paths: list[FlowPath]
|
|
235
|
+
gaps: list[Gap]
|
|
236
|
+
tasks: list[InvestigationTask] = Field(default_factory=list)
|
|
237
|
+
|
|
238
|
+
@model_validator(mode="after")
|
|
239
|
+
def check_links(self):
|
|
240
|
+
ids = [p.port_id for p in self.ports]
|
|
241
|
+
if len(ids) != len(set(ids)):
|
|
242
|
+
raise ValueError("Duplicate port IDs")
|
|
243
|
+
for hop in self.hops:
|
|
244
|
+
if hop.origin_port not in ids or hop.target_port not in ids:
|
|
245
|
+
raise ValueError("Unknown hop port")
|
|
246
|
+
for claim in [*self.assets, *self.relationships]:
|
|
247
|
+
if claim.claim_id not in self.verifications:
|
|
248
|
+
raise ValueError("Missing claim verification")
|
|
249
|
+
for ev in [p.evidence for p in self.ports]:
|
|
250
|
+
if (ev.repository_id, ev.commit_sha) != (self.repository_id, self.commit_sha):
|
|
251
|
+
raise ValueError("Port belongs to another source revision")
|
|
252
|
+
known_hops = {hop.hop_id: hop for hop in self.hops}
|
|
253
|
+
for path in self.paths:
|
|
254
|
+
if any(known_hops.get(hop.hop_id) != hop for hop in path.hops):
|
|
255
|
+
raise ValueError("Path references an absent or altered hop")
|
|
256
|
+
return self
|
messaging.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Scan-completed events and the publish/subscribe transport that carries them.
|
|
2
|
+
|
|
3
|
+
The scanner publishes to one topic after its artifacts are on disk. The message
|
|
4
|
+
is a claim check: it names the scanner's output files (``assets.json`` and the
|
|
5
|
+
deployment manifest) rather than embedding them, so the topic stays small and the
|
|
6
|
+
immutable artifacts remain the source of truth.
|
|
7
|
+
Consumers pull from their own subscription, so several pipelines can read the
|
|
8
|
+
same topic independently.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import queue
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, Protocol
|
|
20
|
+
|
|
21
|
+
from contracts import StrictModel
|
|
22
|
+
from pydantic import Field
|
|
23
|
+
|
|
24
|
+
SCAN_COMPLETED_TOPIC = "runtime-labs-scan-completed"
|
|
25
|
+
PIPELINE_SUBSCRIPTION = "data-processing-pipelines"
|
|
26
|
+
SPANS_INGESTED_TOPIC = "runtime-labs-spans-ingested"
|
|
27
|
+
CORRELATION_SUBSCRIPTION = "data-processing-pipelines-correlation"
|
|
28
|
+
CORRELATION_PREFIX = "runtime_labs.ctx."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScanCompleted(StrictModel):
|
|
32
|
+
event_id: str = Field(min_length=1)
|
|
33
|
+
workspace_repository_id: str = Field(min_length=1)
|
|
34
|
+
scan_id: str = Field(min_length=1)
|
|
35
|
+
source_repository_id: str
|
|
36
|
+
commit_sha: str
|
|
37
|
+
assets_path: str # the scanner's asset graph; consumers never read source
|
|
38
|
+
manifest_path: str
|
|
39
|
+
published_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class IngestedSpan(StrictModel):
|
|
43
|
+
"""A span as the pipelines receive it: the raw span plus the facts they correlate on."""
|
|
44
|
+
|
|
45
|
+
trace_id: str
|
|
46
|
+
span_id: str
|
|
47
|
+
parent_span_id: str | None = None
|
|
48
|
+
start_time_ns: int | None = None
|
|
49
|
+
end_time_ns: int | None = None
|
|
50
|
+
node_id: str | None = None
|
|
51
|
+
# The stored span as received, so the pipelines can build trace views from it.
|
|
52
|
+
payload: dict | None = None
|
|
53
|
+
# Raw ``runtime_labs.ctx.*`` attributes without the prefix, e.g.
|
|
54
|
+
# {"langgraph.thread_id": "conv-42"}. Never interpreted by the SDK or the API.
|
|
55
|
+
identifiers: dict[str, str] = Field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SpansIngested(StrictModel):
|
|
59
|
+
event_id: str = Field(min_length=1)
|
|
60
|
+
workspace_repository_id: str = Field(min_length=1)
|
|
61
|
+
spans: list[IngestedSpan] = Field(min_length=1)
|
|
62
|
+
published_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_spans(cls, event_id, workspace_repository_id, spans):
|
|
66
|
+
"""Build from stored span dicts (trace_id, span_id, attributes, ...)."""
|
|
67
|
+
facts = []
|
|
68
|
+
for span in spans:
|
|
69
|
+
attributes = span.get("attributes") or {}
|
|
70
|
+
facts.append(
|
|
71
|
+
IngestedSpan(
|
|
72
|
+
trace_id=span["trace_id"],
|
|
73
|
+
span_id=span["span_id"],
|
|
74
|
+
parent_span_id=span.get("parent_span_id"),
|
|
75
|
+
start_time_ns=span.get("start_time_ns"),
|
|
76
|
+
end_time_ns=span.get("end_time_ns"),
|
|
77
|
+
node_id=attributes.get("runtime_labs.node_id"),
|
|
78
|
+
payload=span,
|
|
79
|
+
identifiers={
|
|
80
|
+
key.removeprefix(CORRELATION_PREFIX): str(value)
|
|
81
|
+
for key, value in attributes.items()
|
|
82
|
+
if key.startswith(CORRELATION_PREFIX) and value not in (None, "")
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return cls(event_id=event_id, workspace_repository_id=workspace_repository_id, spans=facts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Received:
|
|
91
|
+
"""One delivered message. Ack removes it; nack makes it deliverable again."""
|
|
92
|
+
|
|
93
|
+
data: bytes
|
|
94
|
+
ack: Callable[[], None]
|
|
95
|
+
nack: Callable[[], None]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Broker(Protocol):
|
|
99
|
+
def ensure(self, topic: str, subscription: str | None = None) -> None: ...
|
|
100
|
+
|
|
101
|
+
def publish(self, topic: str, data: bytes) -> None: ...
|
|
102
|
+
|
|
103
|
+
def pull(
|
|
104
|
+
self, subscription: str, *, max_messages: int = 10, timeout: float = 5.0
|
|
105
|
+
) -> list[Received]: ...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MemoryBroker:
|
|
109
|
+
"""Process-local broker with fan-out per subscription, for tests and demos."""
|
|
110
|
+
|
|
111
|
+
def __init__(self):
|
|
112
|
+
self._topics: dict[str, list[str]] = {}
|
|
113
|
+
self._queues: dict[str, queue.Queue] = {}
|
|
114
|
+
self._lock = threading.Lock()
|
|
115
|
+
|
|
116
|
+
def ensure(self, topic, subscription=None):
|
|
117
|
+
with self._lock:
|
|
118
|
+
subscriptions = self._topics.setdefault(topic, [])
|
|
119
|
+
if subscription and subscription not in subscriptions:
|
|
120
|
+
subscriptions.append(subscription)
|
|
121
|
+
self._queues[subscription] = queue.Queue()
|
|
122
|
+
|
|
123
|
+
def publish(self, topic, data):
|
|
124
|
+
with self._lock:
|
|
125
|
+
if topic not in self._topics:
|
|
126
|
+
raise KeyError(f"Unknown topic {topic}")
|
|
127
|
+
targets = [self._queues[name] for name in self._topics[topic]]
|
|
128
|
+
for target in targets:
|
|
129
|
+
target.put(data)
|
|
130
|
+
|
|
131
|
+
def pull(self, subscription, *, max_messages=10, timeout=5.0):
|
|
132
|
+
source = self._queues[subscription]
|
|
133
|
+
received = []
|
|
134
|
+
try:
|
|
135
|
+
while len(received) < max_messages:
|
|
136
|
+
data = source.get(timeout=timeout if not received else 0)
|
|
137
|
+
received.append(
|
|
138
|
+
Received(data, ack=lambda: None, nack=lambda data=data: source.put(data))
|
|
139
|
+
)
|
|
140
|
+
except queue.Empty:
|
|
141
|
+
pass
|
|
142
|
+
return received
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PubSubBroker:
|
|
146
|
+
"""Google Cloud Pub/Sub. ``PUBSUB_EMULATOR_HOST`` selects the local emulator."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, project: str | None = None):
|
|
149
|
+
from google.cloud import pubsub_v1
|
|
150
|
+
|
|
151
|
+
self.project = (
|
|
152
|
+
project
|
|
153
|
+
or os.environ.get("RUNTIME_LABS_PUBSUB_PROJECT")
|
|
154
|
+
or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
155
|
+
or "runtime-labs-local"
|
|
156
|
+
)
|
|
157
|
+
self._publisher = pubsub_v1.PublisherClient()
|
|
158
|
+
self._subscriber = pubsub_v1.SubscriberClient()
|
|
159
|
+
|
|
160
|
+
def _topic(self, topic):
|
|
161
|
+
return self._publisher.topic_path(self.project, topic)
|
|
162
|
+
|
|
163
|
+
def _subscription(self, subscription):
|
|
164
|
+
return self._subscriber.subscription_path(self.project, subscription)
|
|
165
|
+
|
|
166
|
+
def ensure(self, topic, subscription=None):
|
|
167
|
+
from google.api_core.exceptions import AlreadyExists
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
self._publisher.create_topic(request={"name": self._topic(topic)})
|
|
171
|
+
except AlreadyExists:
|
|
172
|
+
pass
|
|
173
|
+
if subscription:
|
|
174
|
+
try:
|
|
175
|
+
self._subscriber.create_subscription(
|
|
176
|
+
request={
|
|
177
|
+
"name": self._subscription(subscription),
|
|
178
|
+
"topic": self._topic(topic),
|
|
179
|
+
"ack_deadline_seconds": 60,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
except AlreadyExists:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
def publish(self, topic, data):
|
|
186
|
+
self._publisher.publish(self._topic(topic), data).result(timeout=30)
|
|
187
|
+
|
|
188
|
+
def pull(self, subscription, *, max_messages=10, timeout=5.0):
|
|
189
|
+
from google.api_core.exceptions import DeadlineExceeded
|
|
190
|
+
|
|
191
|
+
name = self._subscription(subscription)
|
|
192
|
+
try:
|
|
193
|
+
response = self._subscriber.pull(
|
|
194
|
+
request={"subscription": name, "max_messages": max_messages},
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
)
|
|
197
|
+
except DeadlineExceeded:
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
def ack(ack_id):
|
|
201
|
+
self._subscriber.acknowledge(request={"subscription": name, "ack_ids": [ack_id]})
|
|
202
|
+
|
|
203
|
+
def nack(ack_id):
|
|
204
|
+
self._subscriber.modify_ack_deadline(
|
|
205
|
+
request={"subscription": name, "ack_ids": [ack_id], "ack_deadline_seconds": 0}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
Received(
|
|
210
|
+
m.message.data,
|
|
211
|
+
ack=lambda i=m.ack_id: ack(i),
|
|
212
|
+
nack=lambda i=m.ack_id: nack(i),
|
|
213
|
+
)
|
|
214
|
+
for m in response.received_messages
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class LocalBroker:
|
|
219
|
+
"""Durable broker in one SQLite file, safe across processes. No services to run.
|
|
220
|
+
|
|
221
|
+
Same semantics as the topic/subscription model: every subscription that exists
|
|
222
|
+
when a message is published gets its own copy; unacked messages are redelivered
|
|
223
|
+
once their lease expires.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
LEASE_SECONDS = 60
|
|
227
|
+
|
|
228
|
+
def __init__(self, path):
|
|
229
|
+
self.path = str(path)
|
|
230
|
+
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
with self._db() as db:
|
|
232
|
+
db.executescript(
|
|
233
|
+
"""
|
|
234
|
+
PRAGMA journal_mode=WAL;
|
|
235
|
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
236
|
+
topic TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY(topic, name));
|
|
237
|
+
CREATE TABLE IF NOT EXISTS topics (name TEXT PRIMARY KEY);
|
|
238
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
239
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, subscription TEXT NOT NULL,
|
|
240
|
+
data BLOB NOT NULL, leased_until REAL NOT NULL DEFAULT 0);
|
|
241
|
+
"""
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _db(self):
|
|
245
|
+
db = sqlite3.connect(self.path, timeout=30)
|
|
246
|
+
return _Transaction(db)
|
|
247
|
+
|
|
248
|
+
def ensure(self, topic, subscription=None):
|
|
249
|
+
with self._db() as db:
|
|
250
|
+
db.execute("INSERT OR IGNORE INTO topics VALUES(?)", (topic,))
|
|
251
|
+
if subscription:
|
|
252
|
+
db.execute("INSERT OR IGNORE INTO subscriptions VALUES(?,?)", (topic, subscription))
|
|
253
|
+
|
|
254
|
+
def publish(self, topic, data):
|
|
255
|
+
with self._db() as db:
|
|
256
|
+
if not db.execute("SELECT 1 FROM topics WHERE name=?", (topic,)).fetchone():
|
|
257
|
+
raise KeyError(f"Unknown topic {topic}")
|
|
258
|
+
names = [
|
|
259
|
+
r[0] for r in db.execute("SELECT name FROM subscriptions WHERE topic=?", (topic,))
|
|
260
|
+
]
|
|
261
|
+
db.executemany(
|
|
262
|
+
"INSERT INTO messages(subscription, data) VALUES(?,?)", [(n, data) for n in names]
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def pull(self, subscription, *, max_messages=10, timeout=5.0):
|
|
266
|
+
deadline = time.monotonic() + timeout
|
|
267
|
+
while True:
|
|
268
|
+
now = time.time()
|
|
269
|
+
with self._db() as db:
|
|
270
|
+
rows = db.execute(
|
|
271
|
+
"SELECT id, data FROM messages WHERE subscription=? AND leased_until<=? "
|
|
272
|
+
"ORDER BY id LIMIT ?",
|
|
273
|
+
(subscription, now, max_messages),
|
|
274
|
+
).fetchall()
|
|
275
|
+
db.executemany(
|
|
276
|
+
"UPDATE messages SET leased_until=? WHERE id=?",
|
|
277
|
+
[(now + self.LEASE_SECONDS, r[0]) for r in rows],
|
|
278
|
+
)
|
|
279
|
+
if rows or time.monotonic() >= deadline:
|
|
280
|
+
break
|
|
281
|
+
time.sleep(0.25)
|
|
282
|
+
|
|
283
|
+
def ack(message_id):
|
|
284
|
+
with self._db() as db:
|
|
285
|
+
db.execute("DELETE FROM messages WHERE id=?", (message_id,))
|
|
286
|
+
|
|
287
|
+
def nack(message_id):
|
|
288
|
+
with self._db() as db:
|
|
289
|
+
db.execute("UPDATE messages SET leased_until=0 WHERE id=?", (message_id,))
|
|
290
|
+
|
|
291
|
+
return [
|
|
292
|
+
Received(data, ack=lambda i=i: ack(i), nack=lambda i=i: nack(i)) for i, data in rows
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Transaction:
|
|
297
|
+
"""Commit on success, always close (sqlite3's own context manager never closes)."""
|
|
298
|
+
|
|
299
|
+
def __init__(self, db):
|
|
300
|
+
self.db = db
|
|
301
|
+
|
|
302
|
+
def __enter__(self):
|
|
303
|
+
return self.db
|
|
304
|
+
|
|
305
|
+
def __exit__(self, exc_type, *_):
|
|
306
|
+
try:
|
|
307
|
+
self.db.rollback() if exc_type else self.db.commit()
|
|
308
|
+
finally:
|
|
309
|
+
self.db.close()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def local_data_dir() -> Path:
|
|
313
|
+
return Path(os.environ.get("RUNTIME_LABS_DATA", ".runtime-labs")).resolve()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_default_broker = None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def broker_from_environment() -> Broker:
|
|
320
|
+
"""Local SQLite queue by default; ``RUNTIME_LABS_BROKER=pubsub`` selects Pub/Sub
|
|
321
|
+
(the emulator when ``PUBSUB_EMULATOR_HOST`` is set). One broker per process."""
|
|
322
|
+
global _default_broker
|
|
323
|
+
if _default_broker is None:
|
|
324
|
+
if os.environ.get("RUNTIME_LABS_BROKER", "local") == "pubsub":
|
|
325
|
+
_default_broker = PubSubBroker()
|
|
326
|
+
else:
|
|
327
|
+
_default_broker = LocalBroker(local_data_dir() / "broker.db")
|
|
328
|
+
return _default_broker
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def publish_scan_completed(broker: Broker, event: ScanCompleted) -> None:
|
|
332
|
+
# A topic drops messages that arrive before any subscription exists, so the
|
|
333
|
+
# publisher provisions the pipeline's subscription and no scan is lost while
|
|
334
|
+
# the pipeline is down.
|
|
335
|
+
broker.ensure(SCAN_COMPLETED_TOPIC, PIPELINE_SUBSCRIPTION)
|
|
336
|
+
broker.publish(SCAN_COMPLETED_TOPIC, event.model_dump_json().encode())
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def publish_spans_ingested(broker: Broker, event: SpansIngested) -> None:
|
|
340
|
+
broker.ensure(SPANS_INGESTED_TOPIC, CORRELATION_SUBSCRIPTION)
|
|
341
|
+
broker.publish(SPANS_INGESTED_TOPIC, event.model_dump_json().encode())
|
repository_identity.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Canonical Git repository identities shared by onboarding and SDK bootstrap."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_git_url(value: str) -> str:
|
|
10
|
+
value = value.strip()
|
|
11
|
+
if not value or any(c.isspace() for c in value):
|
|
12
|
+
raise ValueError("Invalid Git remote")
|
|
13
|
+
canonical_port = False
|
|
14
|
+
if "://" not in value and re.match(r"^[^/@:]+:[0-9]+/", value):
|
|
15
|
+
value = "ssh://" + value
|
|
16
|
+
canonical_port = True
|
|
17
|
+
if "://" not in value:
|
|
18
|
+
scp = re.fullmatch(r"(?:[^/@:]+@)?([^/:]+):(.+)", value)
|
|
19
|
+
value = f"ssh://{scp[1]}/{scp[2]}" if scp else "https://" + value
|
|
20
|
+
parsed = urlsplit(value)
|
|
21
|
+
if parsed.scheme not in {"https", "http", "ssh", "git"} or not parsed.hostname:
|
|
22
|
+
raise ValueError("Unsupported Git remote")
|
|
23
|
+
path = parsed.path.strip("/").removesuffix(".git")
|
|
24
|
+
parts = path.split("/")
|
|
25
|
+
if len(parts) < 2 or any(p in {"", ".", ".."} for p in parts):
|
|
26
|
+
raise ValueError("Git remote must include owner and repository")
|
|
27
|
+
host = parsed.hostname.lower()
|
|
28
|
+
if host == "github.com":
|
|
29
|
+
path = path.lower()
|
|
30
|
+
port = parsed.port
|
|
31
|
+
if port and (
|
|
32
|
+
canonical_port
|
|
33
|
+
or (parsed.scheme, port) not in {("ssh", 22), ("http", 80), ("https", 443), ("git", 9418)}
|
|
34
|
+
):
|
|
35
|
+
host += f":{port}"
|
|
36
|
+
return f"{host}/{path}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _git(directory, *args):
|
|
40
|
+
return subprocess.run(
|
|
41
|
+
["git", "-C", str(directory), *args],
|
|
42
|
+
check=True,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=3,
|
|
46
|
+
).stdout.strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def git_identity(directory: Path) -> tuple[str, str | None]:
|
|
50
|
+
def git(*args):
|
|
51
|
+
return _git(directory, *args)
|
|
52
|
+
|
|
53
|
+
remote = normalize_git_url(git("config", "--get", "remote.origin.url"))
|
|
54
|
+
try:
|
|
55
|
+
commit = git("rev-parse", "HEAD")
|
|
56
|
+
except subprocess.SubprocessError:
|
|
57
|
+
commit = None
|
|
58
|
+
return remote, commit
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def git_subdirectory(directory: Path) -> str:
|
|
62
|
+
"""Where ``directory`` sits inside its checkout: "" at the root, else "a/b"."""
|
|
63
|
+
return _git(directory, "rev-parse", "--show-prefix").strip("/")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runtime-labs-contracts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared validation models and deployment contracts for Runtime Labs
|
|
5
|
+
Project-URL: Repository, https://github.com/arpandesai0/runtime-labs
|
|
6
|
+
Requires-Python: <3.14,>=3.11
|
|
7
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
8
|
+
Provides-Extra: pubsub
|
|
9
|
+
Requires-Dist: google-cloud-pubsub<3,>=2.27; extra == 'pubsub'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Runtime Labs contracts
|
|
13
|
+
|
|
14
|
+
Shared validation models, deployment manifests, source identities, and messaging
|
|
15
|
+
contracts used by the Runtime Labs SDK and services.
|
|
16
|
+
|
|
17
|
+
Requires Python 3.11, 3.12, or 3.13.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install runtime-labs-contracts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Installing `runtime-labs-sdk` also installs this package automatically.
|
|
24
|
+
|
|
25
|
+
See the [Runtime Labs repository](https://github.com/arpandesai0/runtime-labs)
|
|
26
|
+
for SDK usage and development instructions.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
asset_contracts.py,sha256=8tNlRwfAASabM9eDFMIB3Gh18TqpauFxqNe4BPdYL_I,6646
|
|
2
|
+
contracts.py,sha256=F4txfnWPbi5XOyYrBc1fx-cJjuy7OZ2mwNlD5qNMuSw,4217
|
|
3
|
+
deployment_contracts.py,sha256=LUaLmK5sJWtso2xOdTU0cW_AMoYcsSWyVVagAjxhpSk,4041
|
|
4
|
+
evidence_contracts.py,sha256=Wy31fzXaZYSN44cPyUqRJTWJUjc-76_jHg0RZ34Q6H4,8334
|
|
5
|
+
messaging.py,sha256=v8fe6CUEpBExMqAuri6HTUED6ODjWU2sPZYu-JimAsE,12476
|
|
6
|
+
repository_identity.py,sha256=9VKnNkhux1hlMtXwdK3dx_PNKPPIYhyNQwIr86MJqsA,2115
|
|
7
|
+
runtime_labs_contracts-0.1.0.dist-info/METADATA,sha256=90Wh4NWdzeTJJ7mNzi8K5gdl9QaEbiZPubJ2rPexcSk,843
|
|
8
|
+
runtime_labs_contracts-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
runtime_labs_contracts-0.1.0.dist-info/RECORD,,
|