agentic-runner 2.6.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.
Files changed (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
agentic_runner/mcp.py ADDED
@@ -0,0 +1,342 @@
1
+ """MCP config assembly and Runner-hosted MCP servers (PRD issue 58; 05, 17 A4, ADR-0015 §3).
2
+
3
+ **Assembly** is the seam ``mcp:<server>`` lands with (ADR-0011 §3, §8): per Directive,
4
+ each registry row bound to the Work Record's Product is decided against the Agent's
5
+ Effective Grant (:func:`agentic_runner_contracts.grants.decide_mcp_server`) and only a
6
+ granted server reaches the CLI's config. ``confirm`` asks the owner *before* the server is
7
+ written -- MCP has no per-call seam inside the CLI, so config assembly is the narrowest
8
+ place a confirmation can bite.
9
+
10
+ **Placement** follows 17 A4. A stdio server with no credential is written as a command:
11
+ the CLI spawns it, so it runs as the Contract's uid, a grandchild of the Runner, with the
12
+ Directive's own credential-free environment. A row naming a Credential Reference is
13
+ **Runner-hosted**: :class:`RunnerHostedServer` spawns it as the Runner's own uid with the
14
+ value in its env, and exposes it to the CLI as Streamable HTTP on loopback behind the
15
+ attempt's bearer -- the Agent talks to the server and never holds what the server holds.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import json
23
+ import os
24
+ from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence
25
+ from dataclasses import dataclass, field
26
+ from datetime import UTC, datetime
27
+ from types import TracebackType
28
+ from typing import Any, Final
29
+
30
+ from agentic_runner.tiny_http import bearer_matches, read_request, response_head, write_json
31
+ from agentic_runner.workers.mcp_config import McpServerEntry
32
+ from agentic_runner_contracts.grants import (
33
+ Decision,
34
+ GrantSnapshot,
35
+ McpServerDecision,
36
+ decide_mcp_server,
37
+ )
38
+ from agentic_runner_contracts.runner_registration import ToolServerHealth
39
+ from agentic_runner_contracts.runtime_context import McpServerSpec
40
+
41
+ __all__ = [
42
+ "MCP_ASSEMBLY_SOURCE",
43
+ "McpPlan",
44
+ "RunnerHostedServer",
45
+ "Spawner",
46
+ "ToolServerHealthLog",
47
+ "entry_for",
48
+ "plan_mcp",
49
+ ]
50
+
51
+ # One Evidence Event per config assembly: granted, withheld and why (PRD issue 58).
52
+ MCP_ASSEMBLY_SOURCE: Final[str] = "runner.mcp_config_assembled"
53
+
54
+ # The one path a Runner-hosted server answers on, as Streamable HTTP's single endpoint.
55
+ MCP_PATH: Final[str] = "/mcp"
56
+
57
+ # What a Runner-hosted server inherits from the Runner besides its credential. A
58
+ # credential-bearing child must not also inherit the Runner's own environment (its
59
+ # Agent Token, a git token): only what a program needs to start.
60
+ _HOSTED_ENV_ALLOWLIST: Final[tuple[str, ...]] = ("PATH", "HOME", "LANG", "TZ")
61
+ _DEFAULT_CALL_TIMEOUT_SECONDS: Final[float] = 300.0
62
+ # The heartbeat's own bound on `tool_servers`.
63
+ _HEALTH_MAX: Final[int] = 64
64
+
65
+ Spawner = Callable[..., Awaitable[asyncio.subprocess.Process]]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class McpPlan:
70
+ """What one Directive's config assembly decided, before anything is started."""
71
+
72
+ granted: tuple[tuple[McpServerSpec, McpServerDecision], ...] = ()
73
+ withheld: tuple[tuple[McpServerSpec, McpServerDecision, str], ...] = ()
74
+ to_confirm: tuple[tuple[McpServerSpec, McpServerDecision], ...] = ()
75
+
76
+ def evidence(self, *, egress_allow_list: Sequence[str] = ()) -> dict[str, object]:
77
+ return {
78
+ "event": "mcp.config_assembled",
79
+ "granted": [
80
+ {**decision.evidence(), "runner_hosted": spec.credential_reference is not None}
81
+ for spec, decision in self.granted
82
+ ],
83
+ "withheld": [
84
+ {**decision.evidence(), "withheld_because": why}
85
+ for _, decision, why in self.withheld
86
+ ],
87
+ "egress_allow_list": list(egress_allow_list),
88
+ }
89
+
90
+
91
+ def plan_mcp(
92
+ snapshot: GrantSnapshot,
93
+ servers: Sequence[McpServerSpec],
94
+ *,
95
+ consented: Collection[str] = (),
96
+ declined: Collection[str] = (),
97
+ ) -> McpPlan:
98
+ """Decide every bound server against the Effective Grant.
99
+
100
+ ``consented`` / ``declined`` are the ``mcp:<server>`` names the owner has already
101
+ answered for this step; anything else that evaluates ``confirm`` is left in
102
+ ``to_confirm`` for the caller to ask about before a single server is started.
103
+ """
104
+
105
+ granted: list[tuple[McpServerSpec, McpServerDecision]] = []
106
+ withheld: list[tuple[McpServerSpec, McpServerDecision, str]] = []
107
+ to_confirm: list[tuple[McpServerSpec, McpServerDecision]] = []
108
+ for spec in servers:
109
+ decision = decide_mcp_server(snapshot, server=spec.slug, tools=spec.tools)
110
+ confirm = decision.decision is Decision.CONFIRM
111
+ if decision.decision is Decision.ALLOW or (confirm and decision.verb in consented):
112
+ granted.append((spec, decision))
113
+ elif confirm and decision.verb in declined:
114
+ withheld.append((spec, decision, "owner_declined"))
115
+ elif confirm:
116
+ to_confirm.append((spec, decision))
117
+ else:
118
+ withheld.append((spec, decision, "denied"))
119
+ return McpPlan(granted=tuple(granted), withheld=tuple(withheld), to_confirm=tuple(to_confirm))
120
+
121
+
122
+ def entry_for(
123
+ spec: McpServerSpec, *, hosted_url: str | None = None, bearer_env: str | None = None
124
+ ) -> McpServerEntry:
125
+ """The CLI's view of one granted server: its command, or the URL to call."""
126
+
127
+ if hosted_url is not None:
128
+ return McpServerEntry(
129
+ slug=spec.slug, url=hosted_url, bearer_token_env=bearer_env, required=spec.required
130
+ )
131
+ if spec.transport == "streamable_http":
132
+ return McpServerEntry(slug=spec.slug, url=str(spec.config["url"]), required=spec.required)
133
+ return McpServerEntry(
134
+ slug=spec.slug,
135
+ command=str(spec.config["command"]),
136
+ args=tuple(str(arg) for arg in _list(spec.config.get("args"))),
137
+ required=spec.required,
138
+ )
139
+
140
+
141
+ def _list(value: object) -> list[object]:
142
+ return list(value) if isinstance(value, list) else []
143
+
144
+
145
+ @dataclass
146
+ class ToolServerHealthLog:
147
+ """The latest start of each Runner-hosted Tool Server, for the heartbeat (issue 29).
148
+
149
+ Keyed by slug, not by Work Record: it is a fact about this host, like the load and
150
+ the slots, and a later Directive's start of the same server replaces the earlier one.
151
+ """
152
+
153
+ clock: Callable[[], datetime] = field(default=lambda: datetime.now(UTC))
154
+ _latest: dict[str, ToolServerHealth] = field(default_factory=dict)
155
+
156
+ def record(self, slug: str, *, started: bool, at: datetime) -> None:
157
+ self._latest[slug] = ToolServerHealth(slug=slug, started=started, last_started_at=at)
158
+
159
+ def latest(self) -> list[ToolServerHealth]:
160
+ newest = sorted(self._latest.values(), key=lambda entry: entry.last_started_at)
161
+ return newest[-_HEALTH_MAX:]
162
+
163
+
164
+ class RunnerHostedServer:
165
+ """One credentialed stdio server, spawned by the Runner and bridged to loopback HTTP.
166
+
167
+ The Runner speaks stdio JSON-RPC to the child and answers the CLI's Streamable HTTP
168
+ POSTs on ``127.0.0.1:<ephemeral>/mcp``, one request at a time, only for a caller
169
+ presenting the attempt's bearer. The whole bridge dies with the attempt, which is
170
+ what bounds the bearer (ADR-0011 §9).
171
+
172
+ No SSE stream: a request is answered with one ``application/json`` body, a
173
+ notification with ``202`` -- the subset of the transport both CLIs use for tool calls.
174
+ """
175
+
176
+ def __init__(
177
+ self,
178
+ spec: McpServerSpec,
179
+ *,
180
+ credential_env: Mapping[str, str],
181
+ token: str,
182
+ spawner: Spawner | None = None,
183
+ host: str = "127.0.0.1",
184
+ call_timeout_seconds: float = _DEFAULT_CALL_TIMEOUT_SECONDS,
185
+ health: ToolServerHealthLog | None = None,
186
+ ) -> None:
187
+ self.spec = spec
188
+ self._credential_env = dict(credential_env)
189
+ self._token = token
190
+ self._spawner: Spawner = spawner or asyncio.create_subprocess_exec
191
+ self._host = host
192
+ self._timeout = call_timeout_seconds
193
+ self._process: asyncio.subprocess.Process | None = None
194
+ self._server: asyncio.Server | None = None
195
+ self._lock = asyncio.Lock()
196
+ self._port = 0
197
+ self._health = health
198
+ self._started_at: datetime | None = None
199
+ self._reported = False
200
+
201
+ @property
202
+ def url(self) -> str:
203
+ return f"http://{self._host}:{self._port}{MCP_PATH}"
204
+
205
+ async def __aenter__(self) -> RunnerHostedServer:
206
+ env = {name: os.environ[name] for name in _HOSTED_ENV_ALLOWLIST if name in os.environ}
207
+ env.update(self._credential_env)
208
+ if self._health is not None:
209
+ self._started_at = self._health.clock()
210
+ # The Runner's own uid: no `user=` / `group=` here, deliberately. The credential
211
+ # is the Runner's to hold, and the Contract's uid must not be able to read the
212
+ # process that holds it (17 A4).
213
+ try:
214
+ self._process = await self._spawner(
215
+ str(self.spec.config["command"]),
216
+ *(str(arg) for arg in _list(self.spec.config.get("args"))),
217
+ env=env,
218
+ stdin=asyncio.subprocess.PIPE,
219
+ stdout=asyncio.subprocess.PIPE,
220
+ stderr=asyncio.subprocess.DEVNULL,
221
+ start_new_session=True,
222
+ )
223
+ except BaseException:
224
+ self._report(started=False)
225
+ raise
226
+ try:
227
+ self._server = await asyncio.start_server(self._serve, host=self._host, port=0)
228
+ except BaseException:
229
+ self._report(started=False)
230
+ await self._stop_process()
231
+ raise
232
+ self._port = int(self._server.sockets[0].getsockname()[1])
233
+ return self
234
+
235
+ async def __aexit__(
236
+ self,
237
+ exc_type: type[BaseException] | None,
238
+ exc: BaseException | None,
239
+ traceback: TracebackType | None,
240
+ ) -> None:
241
+ if self._server is not None:
242
+ self._server.close()
243
+ with contextlib.suppress(Exception):
244
+ await self._server.wait_closed()
245
+ # No call reached it: it came up if it was still running when the attempt ended.
246
+ process = self._process
247
+ self._report(started=process is not None and process.returncode is None)
248
+ await self._stop_process()
249
+
250
+ def _report(self, *, started: bool) -> None:
251
+ """Record this start once: the first call's outcome, else the state at exit."""
252
+
253
+ if self._health is None or self._started_at is None or self._reported:
254
+ return
255
+ self._reported = True
256
+ self._health.record(self.spec.slug, started=started, at=self._started_at)
257
+
258
+ async def _stop_process(self) -> None:
259
+ process = self._process
260
+ if process is None or process.returncode is not None:
261
+ return
262
+ with contextlib.suppress(ProcessLookupError):
263
+ process.kill()
264
+ with contextlib.suppress(TimeoutError, ProcessLookupError):
265
+ await asyncio.wait_for(process.wait(), timeout=5)
266
+
267
+ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
268
+ try:
269
+ request = await read_request(reader)
270
+ if isinstance(request, tuple):
271
+ await write_json(writer, request[0], request[1])
272
+ return
273
+ if not bearer_matches(request.authorization, self._token):
274
+ await write_json(writer, 401, {"detail": "the attempt bearer is required"})
275
+ return
276
+ if request.target.split("?", 1)[0] != MCP_PATH:
277
+ await write_json(writer, 404, {"detail": "not found"})
278
+ return
279
+ if request.method != "POST":
280
+ await write_json(writer, 405, {"detail": "only POST is served"})
281
+ return
282
+ try:
283
+ message = json.loads(request.body)
284
+ except ValueError:
285
+ await write_json(writer, 400, {"detail": "the body is not JSON"})
286
+ return
287
+ if not isinstance(message, dict):
288
+ await write_json(writer, 400, {"detail": "one JSON-RPC message per request"})
289
+ return
290
+ try:
291
+ reply = await self._call(message)
292
+ except (TimeoutError, ConnectionError, RuntimeError):
293
+ self._report(started=False)
294
+ raise
295
+ if reply is None:
296
+ writer.write(response_head(202, content_type="application/json", content_length=0))
297
+ with contextlib.suppress(ConnectionError):
298
+ await writer.drain()
299
+ return
300
+ # Only an answer proves the server up: a notification is a write to a pipe,
301
+ # which a dead child's buffer can still take.
302
+ self._report(started=True)
303
+ await write_json(writer, 200, reply)
304
+ except TimeoutError:
305
+ await write_json(writer, 504, {"detail": "the MCP server did not answer in time"})
306
+ except (ConnectionError, RuntimeError):
307
+ await write_json(writer, 502, {"detail": "the MCP server is not running"})
308
+ finally:
309
+ writer.close()
310
+ with contextlib.suppress(Exception):
311
+ await writer.wait_closed()
312
+
313
+ async def _call(self, message: dict[str, Any]) -> dict[str, Any] | None:
314
+ process = self._process
315
+ if process is None or process.stdin is None or process.stdout is None:
316
+ raise RuntimeError("the MCP server is not running")
317
+ stdout = process.stdout
318
+ expects_reply = "method" in message and "id" in message
319
+ async with self._lock:
320
+ process.stdin.write(json.dumps(message).encode() + b"\n")
321
+ await process.stdin.drain()
322
+ if not expects_reply:
323
+ return None
324
+
325
+ async def read_reply() -> dict[str, Any]:
326
+ while True:
327
+ line = await stdout.readline()
328
+ if not line:
329
+ raise ConnectionError("the MCP server closed its stdout")
330
+ try:
331
+ candidate = json.loads(line)
332
+ except ValueError:
333
+ continue
334
+ # Its own notifications and log lines are not this call's answer.
335
+ if (
336
+ isinstance(candidate, dict)
337
+ and "method" not in candidate
338
+ and candidate.get("id") == message["id"]
339
+ ):
340
+ return candidate
341
+
342
+ return await asyncio.wait_for(read_reply(), timeout=self._timeout)
@@ -0,0 +1,341 @@
1
+ """The per-Work-Record Message store and the wake signal (PRD issue 52, map ticket 10).
2
+
3
+ Where Channel bodies live. Decision 10 held, stricter than 13: Directive payloads cross
4
+ into the control plane unencrypted in Release 1, **Channel bodies never do**. One
5
+ Conversation per Work Record, append-only, keyed by Channel; owned by the Runner
6
+ *process* and reached by the Agent subprocess only through its attempt socket
7
+ (``agentic-runner message send | list``), so a Contract's uid never reads the file
8
+ (17 A1). The tree therefore lives under the Runner's own state directory -- never under
9
+ the Contract's uid-owned Workspace tree -- with ``0700`` directories and ``0600`` files.
10
+
11
+ Lifetime: kept for the Organisation's one retention period and dropped by the same
12
+ sweep that deletes Workspaces. Contract termination **seals** the Contract's stores: the
13
+ org retains, the user loses access, the marker carries the state so a pull-through can
14
+ say why it was refused.
15
+
16
+ Three things leave this module, and this docstring is where the partition is stated:
17
+ ``MessageMetadata`` as Evidence (no body), :data:`PENDING_MESSAGES_SIGNAL` as a Temporal
18
+ signal (ids and a count), and a ``TranscriptDelivery`` over the Runner's signed stream
19
+ when the liable user asks. Nothing else reads a body.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import shutil
27
+ from collections.abc import Mapping
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+ from typing import Final, Literal, Protocol
31
+ from uuid import UUID, uuid4
32
+
33
+ from temporalio.client import Client
34
+
35
+ from agentic_runner_contracts.channel_messages import (
36
+ PENDING_MESSAGES_SIGNAL,
37
+ MessageEnvelope,
38
+ MessageKind,
39
+ MessageReference,
40
+ PendingMessagesSignal,
41
+ ReferenceKind,
42
+ TranscriptDelivery,
43
+ TranscriptRequest,
44
+ )
45
+ from agentic_runner_contracts.public_metadata import OUTCOME_KINDS, workflow_id
46
+
47
+ __all__ = [
48
+ "MESSAGES_DIR",
49
+ "SEALED_MARKER",
50
+ "MessageStore",
51
+ "TemporalWorkflowSignaller",
52
+ "WorkflowSignaller",
53
+ ]
54
+
55
+ MESSAGES_DIR: Final = "messages"
56
+ SEALED_MARKER: Final = "SEALED"
57
+ _DIR_MODE: Final = 0o700
58
+ _FILE_MODE: Final = 0o600
59
+
60
+
61
+ class WorkflowSignaller(Protocol):
62
+ async def signal(self, pending: PendingMessagesSignal) -> None:
63
+ """Wake the Work Record's workflow with ids and a count, nothing else."""
64
+
65
+
66
+ class TemporalWorkflowSignaller:
67
+ """The wake signal over the Runner's own namespace connection.
68
+
69
+ A Runner polls exactly one namespace -- its Organisation's (map ticket 15 §2) -- and
70
+ the client it polls with is the one this sends on, so the signal cannot land in any
71
+ other Organisation's namespace by construction. The workflow id is built by the
72
+ Public Metadata builder like every Temporal-visible name (ADR-0010 §6).
73
+ """
74
+
75
+ def __init__(self, client: Client) -> None:
76
+ self._client = client
77
+
78
+ async def signal(self, pending: PendingMessagesSignal) -> None:
79
+ handle = self._client.get_workflow_handle(
80
+ workflow_id(outcome_kind=OUTCOME_KINDS[0], work_record_id=UUID(pending.work_record_id))
81
+ )
82
+ await handle.signal(PENDING_MESSAGES_SIGNAL, pending)
83
+
84
+
85
+ class MessageStore:
86
+ """``<root>/<contract_id>/<work_record_id>.jsonl``, one envelope per line."""
87
+
88
+ def __init__(self, root: Path) -> None:
89
+ self._root = root.resolve(strict=False)
90
+
91
+ @property
92
+ def root(self) -> Path:
93
+ return self._root
94
+
95
+ def append(
96
+ self,
97
+ *,
98
+ contract_id: str,
99
+ work_record_id: str,
100
+ sender_agent_id: str,
101
+ channel_id: str,
102
+ kind: MessageKind,
103
+ body: str,
104
+ references: list[MessageReference],
105
+ now: datetime | None = None,
106
+ recipient_agent_id: str | None = None,
107
+ recipient_role: str | None = None,
108
+ verdict: Literal["block", "clear"] | None = None,
109
+ head_sha: str | None = None,
110
+ ) -> MessageEnvelope:
111
+ """Store one envelope. Raises ``PermissionError`` on a sealed Contract.
112
+
113
+ A `result` that names no addressee answers the `message` it references, so its
114
+ recipient is that Message's sender (PRD issue 53: `result` wakes the requester).
115
+ """
116
+
117
+ self._require_open(contract_id)
118
+ if kind is MessageKind.RESULT and not recipient_agent_id and not recipient_role:
119
+ recipient_agent_id = self._requester_of(contract_id, work_record_id, references)
120
+ envelope = MessageEnvelope(
121
+ message_id=uuid4(),
122
+ sender_agent_id=UUID(sender_agent_id),
123
+ channel_id=UUID(channel_id),
124
+ work_record_id=UUID(work_record_id),
125
+ kind=kind,
126
+ references=list(references),
127
+ size_bytes=len(body.encode("utf-8")),
128
+ sent_at=now or datetime.now(UTC),
129
+ recipient_agent_id=UUID(recipient_agent_id) if recipient_agent_id else None,
130
+ recipient_role=recipient_role or None,
131
+ verdict=verdict,
132
+ head_sha=head_sha or None,
133
+ body=body,
134
+ )
135
+ path = self._path(contract_id, work_record_id)
136
+ _ensure_dir(path.parent)
137
+ with path.open("a", encoding="utf-8") as handle:
138
+ handle.write(envelope.model_dump_json() + "\n")
139
+ path.chmod(_FILE_MODE)
140
+ return envelope
141
+
142
+ def read(
143
+ self, *, contract_id: str, work_record_id: str, channel_id: str | None = None
144
+ ) -> list[MessageEnvelope]:
145
+ """The Conversation, or one Channel of it. Raises ``PermissionError`` when sealed."""
146
+
147
+ self._require_open(contract_id)
148
+ return self._read(contract_id, work_record_id, channel_id)
149
+
150
+ def depth(self, *, contract_id: str, work_record_id: str, channel_id: str) -> int:
151
+ return len(self._read(contract_id, work_record_id, channel_id))
152
+
153
+ def pending(
154
+ self,
155
+ *,
156
+ contract_id: str,
157
+ work_record_id: str,
158
+ channel_id: str,
159
+ agent_id: str,
160
+ roles: tuple[str, ...] = (),
161
+ ) -> list[MessageEnvelope]:
162
+ """What one member has not yet seen: every Message on the Channel after its
163
+ cursor that is addressed to it -- by id, by a role it holds, or to nobody in
164
+ particular (a `note` to the Swarm) -- and not its own (PRD issue 53)."""
165
+
166
+ self._require_open(contract_id)
167
+ cursor = self._cursors(contract_id, work_record_id).get(agent_id, "")
168
+ envelopes = self._read(contract_id, work_record_id, channel_id)
169
+ unseen = _after(envelopes, cursor)
170
+ me = UUID(agent_id)
171
+ return [
172
+ envelope
173
+ for envelope in unseen
174
+ if envelope.sender_agent_id != me
175
+ and (
176
+ envelope.recipient_agent_id == me
177
+ or (envelope.recipient_role is not None and envelope.recipient_role in roles)
178
+ or (envelope.recipient_agent_id is None and envelope.recipient_role is None)
179
+ )
180
+ ]
181
+
182
+ def mark_consumed(
183
+ self, *, contract_id: str, work_record_id: str, agent_id: str, through: MessageEnvelope
184
+ ) -> None:
185
+ """Advance one member's cursor: the Directive that folded ``through`` in ran."""
186
+
187
+ cursors = self._cursors(contract_id, work_record_id)
188
+ cursors[agent_id] = str(through.message_id)
189
+ path = self._cursor_path(contract_id, work_record_id)
190
+ _ensure_dir(path.parent)
191
+ path.write_text(json.dumps(cursors), encoding="utf-8")
192
+ path.chmod(_FILE_MODE)
193
+
194
+ def transcript(self, request: TranscriptRequest) -> TranscriptDelivery:
195
+ """The pull-through's answer: every body, or the Contract state that seals them."""
196
+
197
+ contract_id = str(request.contract_id)
198
+ sealed = self.sealed_state(contract_id)
199
+ if sealed is not None:
200
+ return TranscriptDelivery(
201
+ request_id=request.request_id,
202
+ work_record_id=request.work_record_id,
203
+ refused=sealed,
204
+ )
205
+ return TranscriptDelivery(
206
+ request_id=request.request_id,
207
+ work_record_id=request.work_record_id,
208
+ messages=self._read(contract_id, str(request.work_record_id), None),
209
+ )
210
+
211
+ # ------------------------------------------------------------- lifecycle
212
+
213
+ def seal(self, contract_id: str, *, contract_state: str) -> int:
214
+ """Seal every store of the Contract (map ticket 10): kept, unreadable, until retention.
215
+
216
+ Returns how many Work Record stores the seal covers. Idempotent, and a Contract
217
+ with no store gets no marker -- there is nothing to retain.
218
+ """
219
+
220
+ contract_dir = self._root / contract_id
221
+ stores = _stores(contract_dir)
222
+ if not stores:
223
+ return 0
224
+ marker = contract_dir / SEALED_MARKER
225
+ marker.write_text(json.dumps({"contract_state": contract_state}), encoding="utf-8")
226
+ marker.chmod(_FILE_MODE)
227
+ return len(stores)
228
+
229
+ def sealed_state(self, contract_id: str) -> str | None:
230
+ marker = self._root / contract_id / SEALED_MARKER
231
+ if not marker.is_file():
232
+ return None
233
+ try:
234
+ loaded = json.loads(marker.read_text(encoding="utf-8"))
235
+ except ValueError:
236
+ return "terminated"
237
+ state = loaded.get("contract_state") if isinstance(loaded, Mapping) else None
238
+ return str(state) if state else "terminated"
239
+
240
+ def held(self) -> list[tuple[str, str]]:
241
+ """Every ``(contract_id, work_record_id)`` with a store on this Runner."""
242
+
243
+ if not self._root.is_dir():
244
+ return []
245
+ return [
246
+ (contract_dir.name, store.stem)
247
+ for contract_dir in sorted(self._root.iterdir())
248
+ if contract_dir.is_dir()
249
+ for store in _stores(contract_dir)
250
+ ]
251
+
252
+ def remove(self, contract_id: str, work_record_id: str) -> bool:
253
+ """The retention drop. A Contract whose last store went takes its seal with it."""
254
+
255
+ path = self._path(contract_id, work_record_id)
256
+ if not path.is_file():
257
+ return False
258
+ path.unlink()
259
+ self._cursor_path(contract_id, work_record_id).unlink(missing_ok=True)
260
+ contract_dir = path.parent
261
+ if not _stores(contract_dir):
262
+ shutil.rmtree(contract_dir, ignore_errors=True)
263
+ return True
264
+
265
+ # ------------------------------------------------------------- internals
266
+
267
+ def _require_open(self, contract_id: str) -> None:
268
+ sealed = self.sealed_state(contract_id)
269
+ if sealed is not None:
270
+ raise PermissionError(
271
+ f"the Message store of Contract {contract_id} is sealed ({sealed})"
272
+ )
273
+
274
+ def _path(self, contract_id: str, work_record_id: str) -> Path:
275
+ # Both segments are platform ids (UUIDs) by the time they reach here; refusing
276
+ # anything else keeps a path from escaping the root.
277
+ return self._root / str(UUID(contract_id)) / f"{UUID(work_record_id)}.jsonl"
278
+
279
+ def _cursor_path(self, contract_id: str, work_record_id: str) -> Path:
280
+ return self._path(contract_id, work_record_id).with_suffix(".cursors.json")
281
+
282
+ def _cursors(self, contract_id: str, work_record_id: str) -> dict[str, str]:
283
+ path = self._cursor_path(contract_id, work_record_id)
284
+ if not path.is_file():
285
+ return {}
286
+ loaded = json.loads(path.read_text(encoding="utf-8"))
287
+ return {str(k): str(v) for k, v in loaded.items()} if isinstance(loaded, Mapping) else {}
288
+
289
+ def _requester_of(
290
+ self, contract_id: str, work_record_id: str, references: list[MessageReference]
291
+ ) -> str | None:
292
+ answered = {ref.value for ref in references if ref.kind is ReferenceKind.MESSAGE}
293
+ if not answered:
294
+ return None
295
+ return next(
296
+ (
297
+ str(envelope.sender_agent_id)
298
+ for envelope in self._read(contract_id, work_record_id, None)
299
+ if str(envelope.message_id) in answered
300
+ ),
301
+ None,
302
+ )
303
+
304
+ def _read(
305
+ self, contract_id: str, work_record_id: str, channel_id: str | None
306
+ ) -> list[MessageEnvelope]:
307
+ path = self._path(contract_id, work_record_id)
308
+ if not path.is_file():
309
+ return []
310
+ envelopes = [
311
+ MessageEnvelope.model_validate_json(line)
312
+ for line in path.read_text(encoding="utf-8").splitlines()
313
+ if line.strip()
314
+ ]
315
+ if channel_id is None:
316
+ return envelopes
317
+ wanted = UUID(channel_id)
318
+ return [envelope for envelope in envelopes if envelope.channel_id == wanted]
319
+
320
+
321
+ def _stores(contract_dir: Path) -> list[Path]:
322
+ if not contract_dir.is_dir():
323
+ return []
324
+ return sorted(entry for entry in contract_dir.iterdir() if entry.suffix == ".jsonl")
325
+
326
+
327
+ def _after(envelopes: list[MessageEnvelope], cursor: str) -> list[MessageEnvelope]:
328
+ if not cursor:
329
+ return envelopes
330
+ for index, envelope in enumerate(envelopes):
331
+ if str(envelope.message_id) == cursor:
332
+ return envelopes[index + 1 :]
333
+ return envelopes
334
+
335
+
336
+ def _ensure_dir(contract_dir: Path) -> None:
337
+ """Root and Contract directory alike: the Runner's own, closed to every other uid."""
338
+
339
+ for directory in (contract_dir.parent, contract_dir):
340
+ directory.mkdir(parents=True, exist_ok=True)
341
+ os.chmod(directory, _DIR_MODE)
File without changes