skillstate-kit 0.1.1__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.
- skillstate/__init__.py +35 -0
- skillstate/__main__.py +3 -0
- skillstate/artifacts.py +55 -0
- skillstate/cli.py +256 -0
- skillstate/compiler.py +367 -0
- skillstate/demo.py +74 -0
- skillstate/errors.py +33 -0
- skillstate/hosts.py +322 -0
- skillstate/jsonio.py +143 -0
- skillstate/mcp_server.py +142 -0
- skillstate/models.py +88 -0
- skillstate/providers.py +89 -0
- skillstate/py.typed +0 -0
- skillstate/runtime.py +179 -0
- skillstate/schema.py +91 -0
- skillstate/service.py +59 -0
- skillstate/store.py +332 -0
- skillstate_kit-0.1.1.dist-info/METADATA +157 -0
- skillstate_kit-0.1.1.dist-info/RECORD +22 -0
- skillstate_kit-0.1.1.dist-info/WHEEL +4 -0
- skillstate_kit-0.1.1.dist-info/entry_points.txt +2 -0
- skillstate_kit-0.1.1.dist-info/licenses/LICENSE +21 -0
skillstate/providers.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Optional, stateless JSON adapter for compatible chat-completions endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from .errors import BudgetExceeded, ValidationError
|
|
9
|
+
from .jsonio import dumps, loads
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JSONChatModel:
|
|
13
|
+
"""User-configured endpoint; never reads an IDE's session credentials.
|
|
14
|
+
|
|
15
|
+
Install the http extra. Each call sends only the supplied context. Endpoint
|
|
16
|
+
compatibility must include messages and response_format=json_object.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
model: str,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
timeout: float = 120,
|
|
25
|
+
*,
|
|
26
|
+
transport=None,
|
|
27
|
+
):
|
|
28
|
+
parsed = urlsplit(base_url)
|
|
29
|
+
if (
|
|
30
|
+
parsed.scheme not in ("http", "https")
|
|
31
|
+
or not parsed.hostname
|
|
32
|
+
or parsed.username
|
|
33
|
+
or parsed.password
|
|
34
|
+
):
|
|
35
|
+
raise ValidationError("Use an http(s) endpoint without credentials in the URL")
|
|
36
|
+
if not isinstance(model, str) or not model:
|
|
37
|
+
raise ValidationError("A model name is required")
|
|
38
|
+
if not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
|
|
39
|
+
raise ValidationError("Model timeout must be positive and finite")
|
|
40
|
+
self.url = base_url.rstrip("/") + "/chat/completions"
|
|
41
|
+
self.model, self.api_key, self.timeout, self.transport = model, api_key, timeout, transport
|
|
42
|
+
|
|
43
|
+
async def __call__(self, context: dict) -> dict:
|
|
44
|
+
try:
|
|
45
|
+
import httpx
|
|
46
|
+
except ImportError as exc:
|
|
47
|
+
raise ValidationError("Install skillstate-kit[http] for HTTP model access") from exc
|
|
48
|
+
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
|
49
|
+
payload = {
|
|
50
|
+
"model": self.model,
|
|
51
|
+
"response_format": {"type": "json_object"},
|
|
52
|
+
"messages": [
|
|
53
|
+
{
|
|
54
|
+
"role": "system",
|
|
55
|
+
"content": "Return only the requested JSON object. Do not include reasoning traces.",
|
|
56
|
+
},
|
|
57
|
+
{"role": "user", "content": dumps(context, 1_000_000)},
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
try:
|
|
61
|
+
async with (
|
|
62
|
+
httpx.AsyncClient(
|
|
63
|
+
timeout=self.timeout,
|
|
64
|
+
transport=self.transport,
|
|
65
|
+
trust_env=False,
|
|
66
|
+
follow_redirects=False,
|
|
67
|
+
) as client,
|
|
68
|
+
client.stream("POST", self.url, headers=headers, json=payload) as response,
|
|
69
|
+
):
|
|
70
|
+
if response.status_code != 200:
|
|
71
|
+
raise ValidationError(f"Model endpoint returned HTTP {response.status_code}")
|
|
72
|
+
chunks, size = [], 0
|
|
73
|
+
async for chunk in response.aiter_bytes():
|
|
74
|
+
size += len(chunk)
|
|
75
|
+
if size > 1_000_000:
|
|
76
|
+
raise BudgetExceeded("Model HTTP response exceeds 1000000 bytes")
|
|
77
|
+
chunks.append(chunk)
|
|
78
|
+
except httpx.HTTPError as exc:
|
|
79
|
+
raise ValidationError(
|
|
80
|
+
f"Model endpoint transport failed ({type(exc).__name__})"
|
|
81
|
+
) from exc
|
|
82
|
+
try:
|
|
83
|
+
envelope = loads(b"".join(chunks).decode("utf-8"))
|
|
84
|
+
content = envelope["choices"][0]["message"]["content"]
|
|
85
|
+
if not isinstance(content, str):
|
|
86
|
+
raise ValidationError("Model response content must be JSON text")
|
|
87
|
+
return loads(content, 256_000)
|
|
88
|
+
except (KeyError, IndexError, TypeError, UnicodeError) as exc:
|
|
89
|
+
raise ValidationError("Model endpoint returned an incompatible response") from exc
|
skillstate/py.typed
ADDED
|
File without changes
|
skillstate/runtime.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""History-free managed execution with bounded inputs and durable tool intents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import ConflictError, OperationUncertain, StepLimitExceeded, ValidationError
|
|
12
|
+
from .jsonio import dumps, loads, normalize
|
|
13
|
+
from .models import Skill, Tool, ToolResult
|
|
14
|
+
from .schema import apply_patch, check_schema, validate
|
|
15
|
+
from .store import SQLiteStore
|
|
16
|
+
|
|
17
|
+
CONTRACT = {
|
|
18
|
+
"patch": [{"op": "set or delete", "path": "/field", "value": "set only"}],
|
|
19
|
+
"action": {"name": "registered tool", "arguments": {}},
|
|
20
|
+
"done": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def context(snapshot: dict, tools: list[dict] | None = None, feedback: str | None = None) -> dict:
|
|
25
|
+
skill = Skill.from_dict(snapshot["skill"])
|
|
26
|
+
value = {
|
|
27
|
+
"instructions": skill.instructions,
|
|
28
|
+
"state_schema": skill.state_schema,
|
|
29
|
+
"state": snapshot["state"],
|
|
30
|
+
"observation": snapshot["observation"],
|
|
31
|
+
"tools": tools or [],
|
|
32
|
+
"response_contract": CONTRACT,
|
|
33
|
+
"response_rules": "Return only JSON with patch, action, done. To finish, set done=true and action=null. Do not include reasoning traces.",
|
|
34
|
+
}
|
|
35
|
+
if feedback is not None:
|
|
36
|
+
value["validation_feedback"] = feedback[:2000]
|
|
37
|
+
return loads(dumps(value, skill.limits.context_bytes))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _call(function: Callable, *args):
|
|
41
|
+
if inspect.iscoroutinefunction(function):
|
|
42
|
+
return await function(*args)
|
|
43
|
+
result = await asyncio.to_thread(function, *args)
|
|
44
|
+
return await result if inspect.isawaitable(result) else result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SkillRuntime:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
store: SQLiteStore,
|
|
51
|
+
model: Callable,
|
|
52
|
+
tools: list[Tool],
|
|
53
|
+
*,
|
|
54
|
+
max_retries: int = 2,
|
|
55
|
+
model_timeout: float = 120,
|
|
56
|
+
completion_check: Callable[[dict], bool] | None = None,
|
|
57
|
+
):
|
|
58
|
+
if type(max_retries) is not int or not 0 <= max_retries <= 10:
|
|
59
|
+
raise ValidationError("max_retries must be an integer from 0 to 10")
|
|
60
|
+
if (
|
|
61
|
+
not isinstance(model_timeout, (int, float))
|
|
62
|
+
or not math.isfinite(model_timeout)
|
|
63
|
+
or model_timeout <= 0
|
|
64
|
+
):
|
|
65
|
+
raise ValidationError("model_timeout must be positive and finite")
|
|
66
|
+
self.store, self.model = store, model
|
|
67
|
+
self.tools = {}
|
|
68
|
+
for tool in tools:
|
|
69
|
+
if not tool.name or tool.name in self.tools:
|
|
70
|
+
raise ValidationError("Tool names must be nonempty and unique")
|
|
71
|
+
check_schema(tool.input_schema)
|
|
72
|
+
if tool.output_schema is not None:
|
|
73
|
+
check_schema(tool.output_schema)
|
|
74
|
+
if not math.isfinite(tool.timeout) or tool.timeout <= 0:
|
|
75
|
+
raise ValidationError("Tool timeout must be positive and finite")
|
|
76
|
+
self.tools[tool.name] = tool
|
|
77
|
+
self.max_retries, self.model_timeout = max_retries, model_timeout
|
|
78
|
+
self.completion_check = completion_check
|
|
79
|
+
|
|
80
|
+
def _decision(self, raw: Any, snapshot: dict) -> dict:
|
|
81
|
+
skill = Skill.from_dict(snapshot["skill"])
|
|
82
|
+
decision = (
|
|
83
|
+
loads(raw, skill.limits.output_bytes)
|
|
84
|
+
if isinstance(raw, str)
|
|
85
|
+
else loads(dumps(raw, skill.limits.output_bytes))
|
|
86
|
+
)
|
|
87
|
+
if type(decision) is not dict or set(decision) != {"patch", "action", "done"}:
|
|
88
|
+
raise ValidationError("Decision requires exactly patch, action, done")
|
|
89
|
+
if type(decision["done"]) is not bool:
|
|
90
|
+
raise ValidationError("done must be boolean")
|
|
91
|
+
candidate = apply_patch(snapshot["state"], decision["patch"])
|
|
92
|
+
validate(skill.state_schema, candidate)
|
|
93
|
+
dumps(candidate, skill.limits.state_bytes)
|
|
94
|
+
action = decision["action"]
|
|
95
|
+
if decision["done"]:
|
|
96
|
+
if action is not None:
|
|
97
|
+
raise ValidationError("A final decision must have action=null")
|
|
98
|
+
if self.completion_check is not None and self.completion_check(candidate) is not True:
|
|
99
|
+
raise ValidationError("Application completion check did not pass")
|
|
100
|
+
else:
|
|
101
|
+
if (
|
|
102
|
+
type(action) is not dict
|
|
103
|
+
or set(action) != {"name", "arguments"}
|
|
104
|
+
or type(action["name"]) is not str
|
|
105
|
+
or action["name"] not in self.tools
|
|
106
|
+
):
|
|
107
|
+
raise ValidationError("Action must name a registered tool")
|
|
108
|
+
validate(self.tools[action["name"]].input_schema, action["arguments"])
|
|
109
|
+
return decision
|
|
110
|
+
|
|
111
|
+
async def step(self, run_id: str, owner: str) -> dict:
|
|
112
|
+
snapshot = self.store.get(run_id)
|
|
113
|
+
if (
|
|
114
|
+
snapshot["owner"] != owner
|
|
115
|
+
or snapshot["pending_operation"]
|
|
116
|
+
or snapshot["status"] != "ready"
|
|
117
|
+
):
|
|
118
|
+
raise ConflictError("Run is not ready for this owner")
|
|
119
|
+
feedback = None
|
|
120
|
+
for attempt in range(self.max_retries + 1):
|
|
121
|
+
payload = context(snapshot, [t.specification() for t in self.tools.values()], feedback)
|
|
122
|
+
raw = await asyncio.wait_for(_call(self.model, payload), self.model_timeout)
|
|
123
|
+
try:
|
|
124
|
+
decision = self._decision(raw, snapshot)
|
|
125
|
+
break
|
|
126
|
+
except ValidationError as exc:
|
|
127
|
+
if attempt == self.max_retries:
|
|
128
|
+
raise
|
|
129
|
+
feedback = str(exc)
|
|
130
|
+
if decision["done"]:
|
|
131
|
+
return self.store.update(
|
|
132
|
+
run_id,
|
|
133
|
+
owner,
|
|
134
|
+
snapshot["revision"],
|
|
135
|
+
decision["patch"],
|
|
136
|
+
snapshot["observation"],
|
|
137
|
+
done=True,
|
|
138
|
+
)
|
|
139
|
+
reservation = self.store.reserve(
|
|
140
|
+
run_id, owner, snapshot["revision"], decision["action"], decision["patch"]
|
|
141
|
+
)
|
|
142
|
+
op_id = reservation["operation_id"]
|
|
143
|
+
tool = self.tools[decision["action"]["name"]]
|
|
144
|
+
try:
|
|
145
|
+
result = await asyncio.wait_for(
|
|
146
|
+
_call(tool.handler, normalize(decision["action"]["arguments"]), op_id), tool.timeout
|
|
147
|
+
)
|
|
148
|
+
if type(result) is not ToolResult or type(result.success) is not bool:
|
|
149
|
+
raise ValidationError("Tool must return ToolResult with boolean success")
|
|
150
|
+
if tool.output_schema is not None:
|
|
151
|
+
validate(tool.output_schema, result.observation)
|
|
152
|
+
return self.store.record_result(
|
|
153
|
+
op_id, owner, result.success, result.observation, origin="runtime_observed"
|
|
154
|
+
)
|
|
155
|
+
except BaseException as exc:
|
|
156
|
+
# Cancellation/timeout does not imply the external side effect stopped.
|
|
157
|
+
# If persistence itself is broken, the prior pending intent is retained.
|
|
158
|
+
try:
|
|
159
|
+
if self.store.operation(op_id)["status"] in ("pending", "unknown"):
|
|
160
|
+
self.store.mark_unknown(op_id, owner, type(exc).__name__)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt, SystemExit)):
|
|
164
|
+
raise
|
|
165
|
+
raise OperationUncertain(
|
|
166
|
+
f"Operation {op_id} needs reconciliation ({type(exc).__name__})"
|
|
167
|
+
) from exc
|
|
168
|
+
|
|
169
|
+
async def run(self, run_id: str, owner: str, *, max_steps: int = 30) -> dict:
|
|
170
|
+
if type(max_steps) is not int or not 1 <= max_steps <= 10_000:
|
|
171
|
+
raise ValidationError("max_steps must be an integer between 1 and 10000")
|
|
172
|
+
snapshot = self.store.get(run_id)
|
|
173
|
+
if snapshot["status"] == "completed":
|
|
174
|
+
return snapshot
|
|
175
|
+
for _ in range(max_steps):
|
|
176
|
+
snapshot = await self.step(run_id, owner)
|
|
177
|
+
if snapshot["status"] == "completed":
|
|
178
|
+
return snapshot
|
|
179
|
+
raise StepLimitExceeded("Step budget exhausted; checkpoint retained for an explicit resume")
|
skillstate/schema.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Inline JSON Schema validation and explicit dictionary patches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
9
|
+
from jsonschema.exceptions import SchemaError
|
|
10
|
+
from jsonschema.exceptions import ValidationError as JSONSchemaError
|
|
11
|
+
|
|
12
|
+
from .errors import ValidationError
|
|
13
|
+
from .jsonio import dumps, normalize
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_schema(schema: dict) -> None:
|
|
17
|
+
dumps(schema, 128_000)
|
|
18
|
+
if type(schema) is not dict or schema.get("type") != "object":
|
|
19
|
+
raise ValidationError("Root schema must have type=object")
|
|
20
|
+
if (
|
|
21
|
+
schema.get("$schema", "https://json-schema.org/draft/2020-12/schema")
|
|
22
|
+
!= "https://json-schema.org/draft/2020-12/schema"
|
|
23
|
+
):
|
|
24
|
+
raise ValidationError("Only JSON Schema draft 2020-12 is supported")
|
|
25
|
+
|
|
26
|
+
def visit(node: Any) -> None:
|
|
27
|
+
if isinstance(node, dict):
|
|
28
|
+
if "$ref" in node or "$dynamicRef" in node:
|
|
29
|
+
raise ValidationError("Use inline schemas; references are disabled")
|
|
30
|
+
if "patternProperties" in node:
|
|
31
|
+
raise ValidationError("patternProperties is disabled; use named properties")
|
|
32
|
+
if "pattern" in node and node["pattern"] not in ("^[a-z0-9-]+$",):
|
|
33
|
+
raise ValidationError(
|
|
34
|
+
"Unrestricted regex patterns are disabled; use enum or a trusted application validator"
|
|
35
|
+
)
|
|
36
|
+
for value in node.values():
|
|
37
|
+
visit(value)
|
|
38
|
+
elif isinstance(node, list):
|
|
39
|
+
for value in node:
|
|
40
|
+
visit(value)
|
|
41
|
+
|
|
42
|
+
visit(schema)
|
|
43
|
+
try:
|
|
44
|
+
Draft202012Validator.check_schema(schema)
|
|
45
|
+
except SchemaError as exc:
|
|
46
|
+
raise ValidationError(f"Invalid schema: {exc.message}") from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate(schema: dict, value: Any) -> None:
|
|
50
|
+
check_schema(schema)
|
|
51
|
+
value = normalize(value)
|
|
52
|
+
try:
|
|
53
|
+
Draft202012Validator(schema, format_checker=FormatChecker()).validate(value)
|
|
54
|
+
except (JSONSchemaError, RecursionError) as exc:
|
|
55
|
+
message = exc.message if isinstance(exc, JSONSchemaError) else "schema recursion"
|
|
56
|
+
raise ValidationError(f"State/arguments do not match schema: {message}") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def apply_patch(state: dict, operations: list[dict]) -> dict:
|
|
60
|
+
"""Set/delete dictionary keys using JSON Pointer; replace arrays as a whole."""
|
|
61
|
+
state = normalize(state)
|
|
62
|
+
operations = normalize(operations)
|
|
63
|
+
if type(state) is not dict or type(operations) is not list or len(operations) > 128:
|
|
64
|
+
raise ValidationError("Expected object state and at most 128 patch operations")
|
|
65
|
+
result = deepcopy(state)
|
|
66
|
+
for op in operations:
|
|
67
|
+
if not isinstance(op, dict) or op.get("op") not in ("set", "delete"):
|
|
68
|
+
raise ValidationError("Patch operation must be set or delete")
|
|
69
|
+
expected = {"op", "path", "value"} if op["op"] == "set" else {"op", "path"}
|
|
70
|
+
if set(op) != expected:
|
|
71
|
+
raise ValidationError("Unexpected patch fields")
|
|
72
|
+
path = op["path"]
|
|
73
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
74
|
+
raise ValidationError("Patch path must be a non-root JSON Pointer")
|
|
75
|
+
segments = path[1:].split("/")
|
|
76
|
+
for segment in segments:
|
|
77
|
+
if "~" in segment.replace("~0", "").replace("~1", ""):
|
|
78
|
+
raise ValidationError("Invalid JSON Pointer escape")
|
|
79
|
+
segments = [s.replace("~1", "/").replace("~0", "~") for s in segments]
|
|
80
|
+
parent = result
|
|
81
|
+
for segment in segments[:-1]:
|
|
82
|
+
if segment not in parent or type(parent[segment]) is not dict:
|
|
83
|
+
raise ValidationError("Patch parent must be an existing object")
|
|
84
|
+
parent = parent[segment]
|
|
85
|
+
if op["op"] == "set":
|
|
86
|
+
parent[segments[-1]] = deepcopy(op["value"])
|
|
87
|
+
else:
|
|
88
|
+
if segments[-1] not in parent:
|
|
89
|
+
raise ValidationError("Cannot delete a missing key")
|
|
90
|
+
del parent[segments[-1]]
|
|
91
|
+
return result
|
skillstate/service.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""One project-bound facade shared by CLI and MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import compiler
|
|
9
|
+
from .errors import SourceChanged, ValidationError
|
|
10
|
+
from .jsonio import within
|
|
11
|
+
from .models import Skill
|
|
12
|
+
from .runtime import context
|
|
13
|
+
from .store import SQLiteStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProjectService:
|
|
17
|
+
def __init__(self, project: Path):
|
|
18
|
+
self.project = project.resolve()
|
|
19
|
+
if not self.project.is_dir():
|
|
20
|
+
raise ValidationError("Project root must be an existing directory")
|
|
21
|
+
|
|
22
|
+
def store(self) -> SQLiteStore:
|
|
23
|
+
return SQLiteStore(within(self.project, ".skillstate/local/state.sqlite3"))
|
|
24
|
+
|
|
25
|
+
def open_run(self, name: str, owner: str, run_id: str | None = None, observation=None) -> dict:
|
|
26
|
+
bundle = compiler.load_bundle(self.project, name)
|
|
27
|
+
skill = Skill.from_dict(bundle["skill"])
|
|
28
|
+
with self.store() as store:
|
|
29
|
+
return store.create(run_id or uuid.uuid4().hex, skill, owner, observation)
|
|
30
|
+
|
|
31
|
+
def run_context(self, run_id: str) -> dict:
|
|
32
|
+
with self.store() as store:
|
|
33
|
+
snapshot = store.get(run_id)
|
|
34
|
+
drift = None
|
|
35
|
+
try:
|
|
36
|
+
bundle = compiler.load_bundle(self.project, snapshot["skill"]["name"])
|
|
37
|
+
if (
|
|
38
|
+
Skill.from_dict(bundle["skill"]).fingerprint
|
|
39
|
+
!= Skill.from_dict(snapshot["skill"]).fingerprint
|
|
40
|
+
):
|
|
41
|
+
drift = "Definition changed; this run keeps its original immutable definition"
|
|
42
|
+
except SourceChanged as exc:
|
|
43
|
+
drift = str(exc)
|
|
44
|
+
return {
|
|
45
|
+
"run_id": run_id,
|
|
46
|
+
"owner": snapshot["owner"],
|
|
47
|
+
"revision": snapshot["revision"],
|
|
48
|
+
"status": snapshot["status"],
|
|
49
|
+
"pending_operation": snapshot["pending_operation"],
|
|
50
|
+
"source_drift": drift,
|
|
51
|
+
"context": context(snapshot),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def handoff(self, run_id: str, owner: str, revision: int, target: str) -> dict:
|
|
55
|
+
snapshot = self.run_context(run_id)
|
|
56
|
+
if snapshot["source_drift"]:
|
|
57
|
+
raise SourceChanged("Resolve source/definition drift before handing off")
|
|
58
|
+
with self.store() as store:
|
|
59
|
+
return store.handoff(run_id, owner, revision, target)
|