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
@@ -0,0 +1,494 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from hashlib import sha1
5
+
6
+ from agentic_runner_contracts.github_port import (
7
+ ApprovalRequest,
8
+ ApprovalResponse,
9
+ ApprovalState,
10
+ BranchRequest,
11
+ BranchResponse,
12
+ CommentRequest,
13
+ CommentResponse,
14
+ MergeRequest,
15
+ MergeResponse,
16
+ PullRequestCloseRequest,
17
+ PullRequestCloseResponse,
18
+ PullRequestFilesRequest,
19
+ PullRequestFilesResponse,
20
+ PullRequestReadyRequest,
21
+ PullRequestReadyResponse,
22
+ PullRequestRequest,
23
+ PullRequestResponse,
24
+ PullRequestRetargetRequest,
25
+ PullRequestRetargetResponse,
26
+ PullRequestReviewRequest,
27
+ PullRequestReviewResponse,
28
+ RepositoryReadRequest,
29
+ RepositoryReadResponse,
30
+ ReviewEvent,
31
+ ReviewRequest,
32
+ ReviewResponse,
33
+ is_human_reviewer,
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class _PullRequestRecord:
39
+ repo: str
40
+ pr_number: int
41
+ branch: str
42
+ base: str
43
+ title: str
44
+ body: str
45
+ head_revision: int = 1
46
+ draft: bool = False
47
+ reviewers: list[str] = field(default_factory=list)
48
+ comments: list[str] = field(default_factory=list)
49
+ # Reviews submitted through ``submit_review`` (the `pr.comment` seam), in order. They
50
+ # are an Agent's, so like a bot's review on GitHub they never move the approval state.
51
+ reviews: list[PullRequestReviewRequest] = field(default_factory=list)
52
+ changed_paths: list[str] = field(default_factory=list)
53
+ approver: str | None = None
54
+ approval_head_sha: str | None = None
55
+ # Every standing approval, login -> the head SHA it was submitted against. The merge
56
+ # seam counts the distinct human logins standing on the current head (ADR-0011 s6);
57
+ # ``approver``/``approval_head_sha`` stay the newest one, which is the review
58
+ # decision the Reviewer Gate reports.
59
+ approvals: dict[str, str] = field(default_factory=dict)
60
+ changes_requested_by: str | None = None
61
+ changes_requested_head_sha: str | None = None
62
+ merge_commit_sha: str | None = None
63
+ merged: bool = False
64
+ merged_by: str | None = None
65
+ closed: bool = False
66
+
67
+
68
+ class FakeGitHubClient:
69
+ """Deterministic in-memory GitHub client for tests and Ralph loop rehearsal."""
70
+
71
+ # The login every App-token call arrives as, as on GitHub: an Agent has no identity of
72
+ # its own there (``contracts.is_human_reviewer``).
73
+ app_login = "agentic-os[bot]"
74
+
75
+ def __init__(self) -> None:
76
+ self._branches: dict[tuple[str, str], BranchResponse] = {}
77
+ self._pull_requests_by_number: dict[tuple[str, int], _PullRequestRecord] = {}
78
+ self._pull_requests_by_branch: dict[tuple[str, str], _PullRequestRecord] = {}
79
+ self._review_requests: list[tuple[str, int, str]] = []
80
+ self._retargets: list[tuple[str, int, str]] = []
81
+ self._default_branches: dict[str, str] = {}
82
+ self._next_pr_number = 1
83
+
84
+ @property
85
+ def pull_requests(self) -> tuple[_PullRequestRecord, ...]:
86
+ """Return current pull requests in deterministic PR-number order for assertions."""
87
+
88
+ return tuple(
89
+ sorted(
90
+ self._pull_requests_by_number.values(),
91
+ key=lambda record: (record.repo, record.pr_number),
92
+ )
93
+ )
94
+
95
+ @property
96
+ def review_requests(self) -> tuple[tuple[str, int, str], ...]:
97
+ """Return recorded review requests for deterministic workflow assertions."""
98
+
99
+ return tuple(self._review_requests)
100
+
101
+ def create_branch(self, request: BranchRequest) -> BranchResponse:
102
+ key = (request.repo, request.branch)
103
+ existing = self._branches.get(key)
104
+ if existing is not None:
105
+ return BranchResponse(
106
+ repo=existing.repo,
107
+ branch=existing.branch,
108
+ base_ref=existing.base_ref,
109
+ created=False,
110
+ )
111
+
112
+ response = BranchResponse(
113
+ repo=request.repo,
114
+ branch=request.branch,
115
+ base_ref=request.base_ref,
116
+ created=True,
117
+ )
118
+ self._branches[key] = response
119
+ return response
120
+
121
+ def create_or_update_pr(self, request: PullRequestRequest) -> PullRequestResponse:
122
+ self._require_branch(repo=request.repo, branch=request.branch)
123
+ key = (request.repo, request.branch)
124
+ existing = self._pull_requests_by_branch.get(key)
125
+ if existing is not None:
126
+ self._ensure_pr_is_open(existing)
127
+ existing.base = request.base
128
+ existing.title = request.title
129
+ existing.body = request.body
130
+ return self._to_pr_response(existing, created=False)
131
+
132
+ record = _PullRequestRecord(
133
+ repo=request.repo,
134
+ pr_number=self._next_pr_number,
135
+ branch=request.branch,
136
+ base=request.base,
137
+ title=request.title,
138
+ body=request.body,
139
+ draft=request.draft,
140
+ )
141
+ self._next_pr_number += 1
142
+ self._pull_requests_by_branch[key] = record
143
+ self._pull_requests_by_number[(request.repo, record.pr_number)] = record
144
+ return self._to_pr_response(record, created=True)
145
+
146
+ def mark_pr_ready_for_review(
147
+ self,
148
+ request: PullRequestReadyRequest,
149
+ ) -> PullRequestReadyResponse:
150
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
151
+ self._ensure_pr_is_open(record)
152
+ marked_ready = record.draft
153
+ record.draft = False
154
+ return PullRequestReadyResponse(
155
+ repo=request.repo,
156
+ pr_number=request.pr_number,
157
+ ready=True,
158
+ marked_ready=marked_ready,
159
+ )
160
+
161
+ def close_pr(self, request: PullRequestCloseRequest) -> PullRequestCloseResponse:
162
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
163
+ already_settled = record.merged or record.closed
164
+ record.closed = True
165
+ return PullRequestCloseResponse(
166
+ repo=request.repo,
167
+ pr_number=request.pr_number,
168
+ closed=not already_settled,
169
+ )
170
+
171
+ def retarget_pr(self, request: PullRequestRetargetRequest) -> PullRequestRetargetResponse:
172
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
173
+ if record.merged or record.closed:
174
+ return PullRequestRetargetResponse(
175
+ repo=request.repo, pr_number=request.pr_number, base=record.base, retargeted=False
176
+ )
177
+ record.base = request.base
178
+ self._retargets.append((request.repo, request.pr_number, request.base))
179
+ return PullRequestRetargetResponse(
180
+ repo=request.repo, pr_number=request.pr_number, base=request.base, retargeted=True
181
+ )
182
+
183
+ @property
184
+ def retargets(self) -> tuple[tuple[str, int, str], ...]:
185
+ """Every base change recorded through ``retarget_pr``, in order (PRD issue 59)."""
186
+
187
+ return tuple(self._retargets)
188
+
189
+ def request_review(self, request: ReviewRequest) -> ReviewResponse:
190
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
191
+ self._ensure_pr_is_open(record)
192
+ self._review_requests.append((request.repo, request.pr_number, request.reviewer))
193
+ if request.reviewer not in record.reviewers:
194
+ record.reviewers.append(request.reviewer)
195
+
196
+ return ReviewResponse(
197
+ repo=request.repo,
198
+ pr_number=request.pr_number,
199
+ reviewer=request.reviewer,
200
+ reviewers=tuple(record.reviewers),
201
+ requested=True,
202
+ )
203
+
204
+ def wait_for_approval(self, request: ApprovalRequest) -> ApprovalResponse:
205
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
206
+ current_head_sha = self._head_sha(record)
207
+ # Mirrors the real adapter: the PR lifecycle supersedes any review verdict — a
208
+ # merged or closed PR reports PENDING plus the lifecycle flags, never a review
209
+ # state the gate could act on.
210
+ if record.merged:
211
+ return ApprovalResponse(
212
+ repo=request.repo,
213
+ pr_number=request.pr_number,
214
+ state=ApprovalState.PENDING,
215
+ current_head_sha=current_head_sha,
216
+ pr_merged=True,
217
+ merged_by=record.merged_by,
218
+ merge_commit_sha=record.merge_commit_sha,
219
+ )
220
+ if record.closed:
221
+ return ApprovalResponse(
222
+ repo=request.repo,
223
+ pr_number=request.pr_number,
224
+ state=ApprovalState.PENDING,
225
+ current_head_sha=current_head_sha,
226
+ pr_closed=True,
227
+ )
228
+ approved = record.approver is not None and record.approval_head_sha == current_head_sha
229
+ if approved:
230
+ return ApprovalResponse(
231
+ repo=request.repo,
232
+ pr_number=request.pr_number,
233
+ state=ApprovalState.APPROVED,
234
+ current_head_sha=current_head_sha,
235
+ approved_head_sha=record.approval_head_sha,
236
+ approver=record.approver,
237
+ approving_reviewers=self._human_approvers(record, current_head_sha),
238
+ )
239
+ # Mirrors the real adapter: a changes-requested review only denies the head it
240
+ # was submitted against; a new push returns the gate to PENDING.
241
+ if (
242
+ record.changes_requested_by is not None
243
+ and record.changes_requested_head_sha == current_head_sha
244
+ ):
245
+ return ApprovalResponse(
246
+ repo=request.repo,
247
+ pr_number=request.pr_number,
248
+ state=ApprovalState.CHANGES_REQUESTED,
249
+ current_head_sha=current_head_sha,
250
+ approver=record.changes_requested_by,
251
+ )
252
+ return ApprovalResponse(
253
+ repo=request.repo,
254
+ pr_number=request.pr_number,
255
+ state=ApprovalState.PENDING,
256
+ current_head_sha=current_head_sha,
257
+ )
258
+
259
+ def merge_pr(self, request: MergeRequest) -> MergeResponse:
260
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
261
+ self._ensure_pr_is_open(record)
262
+ if record.approver is None:
263
+ raise PermissionError(
264
+ f"Pull request '{request.repo}#{request.pr_number}' requires approval before merge"
265
+ )
266
+ current_head_sha = self._head_sha(record)
267
+ if record.approval_head_sha != current_head_sha:
268
+ raise PermissionError(
269
+ f"Pull request '{request.repo}#{request.pr_number}' requires current head approval"
270
+ )
271
+ if request.expected_head_sha != current_head_sha:
272
+ raise ValueError(
273
+ f"Pull request '{request.repo}#{request.pr_number}' expected head SHA did not match"
274
+ )
275
+
276
+ record.merged = True
277
+ record.merge_commit_sha = self._merge_commit_sha(record)
278
+ return MergeResponse(
279
+ repo=request.repo,
280
+ pr_number=request.pr_number,
281
+ commit_title=request.commit_title,
282
+ merged=True,
283
+ merge_commit_sha=record.merge_commit_sha,
284
+ )
285
+
286
+ def post_comment(self, request: CommentRequest) -> CommentResponse:
287
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
288
+ self._ensure_pr_is_open(record)
289
+ record.comments.append(request.body)
290
+ return CommentResponse(
291
+ repo=request.repo,
292
+ pr_number=request.pr_number,
293
+ comment_number=len(record.comments),
294
+ body=request.body,
295
+ comments=tuple(record.comments),
296
+ )
297
+
298
+ def submit_review(self, request: PullRequestReviewRequest) -> PullRequestReviewResponse:
299
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
300
+ self._ensure_pr_is_open(record)
301
+ record.reviews.append(request)
302
+ return PullRequestReviewResponse(
303
+ repo=request.repo,
304
+ pr_number=request.pr_number,
305
+ review_id=len(record.reviews),
306
+ state=(
307
+ "CHANGES_REQUESTED" if request.event is ReviewEvent.REQUEST_CHANGES else "COMMENTED"
308
+ ),
309
+ reviewer_login=self.app_login,
310
+ )
311
+
312
+ def list_changed_files(self, request: PullRequestFilesRequest) -> PullRequestFilesResponse:
313
+ # No open-PR guard: the real API serves the file listing for merged and closed
314
+ # PRs too, and a review may legitimately read one.
315
+ record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
316
+ return PullRequestFilesResponse(
317
+ repo=request.repo,
318
+ pr_number=request.pr_number,
319
+ changed_paths=tuple(record.changed_paths),
320
+ )
321
+
322
+ def read_repository(self, request: RepositoryReadRequest) -> RepositoryReadResponse:
323
+ try:
324
+ default_branch = self._default_branches[request.repo]
325
+ except KeyError as error:
326
+ raise KeyError(f"Repository '{request.repo}' not found") from error
327
+ return RepositoryReadResponse(repo=request.repo, default_branch=default_branch)
328
+
329
+ def record_repository(self, *, repo: str, default_branch: str = "main") -> None:
330
+ """Test helper: make ``repo`` readable; any other repository answers not-found."""
331
+
332
+ self._default_branches[repo] = default_branch
333
+
334
+ def record_changed_paths(self, *, repo: str, pr_number: int, changed_paths: list[str]) -> None:
335
+ """Test helper for deterministically setting a PR's changed file paths."""
336
+
337
+ record = self._get_pr(repo=repo, pr_number=pr_number)
338
+ record.changed_paths = list(changed_paths)
339
+
340
+ def record_approval(self, *, repo: str, pr_number: int, approver: str) -> ApprovalResponse:
341
+ """Test helper for deterministically recording an external approval."""
342
+
343
+ record = self._get_pr(repo=repo, pr_number=pr_number)
344
+ self._ensure_pr_is_open(record)
345
+ record.approver = approver
346
+ record.approval_head_sha = self._head_sha(record)
347
+ record.approvals[approver] = record.approval_head_sha
348
+ record.changes_requested_by = None
349
+ record.changes_requested_head_sha = None
350
+ return ApprovalResponse(
351
+ repo=repo,
352
+ pr_number=pr_number,
353
+ state=ApprovalState.APPROVED,
354
+ current_head_sha=record.approval_head_sha,
355
+ approved_head_sha=record.approval_head_sha,
356
+ approver=approver,
357
+ approving_reviewers=self._human_approvers(record, record.approval_head_sha),
358
+ )
359
+
360
+ def record_changes_requested(
361
+ self, *, repo: str, pr_number: int, reviewer: str
362
+ ) -> ApprovalResponse:
363
+ """Test helper for deterministically recording a changes-requested review."""
364
+
365
+ record = self._get_pr(repo=repo, pr_number=pr_number)
366
+ self._ensure_pr_is_open(record)
367
+ current_head_sha = self._head_sha(record)
368
+ record.approver = None
369
+ record.approval_head_sha = None
370
+ record.approvals.clear()
371
+ record.changes_requested_by = reviewer
372
+ record.changes_requested_head_sha = current_head_sha
373
+ return ApprovalResponse(
374
+ repo=repo,
375
+ pr_number=pr_number,
376
+ state=ApprovalState.CHANGES_REQUESTED,
377
+ current_head_sha=current_head_sha,
378
+ approver=reviewer,
379
+ )
380
+
381
+ def record_external_merge(self, *, repo: str, pr_number: int, actor: str) -> ApprovalResponse:
382
+ """Test helper for deterministically merging the PR outside the Ralph loop."""
383
+
384
+ record = self._get_pr(repo=repo, pr_number=pr_number)
385
+ self._ensure_pr_is_open(record)
386
+ record.merged = True
387
+ record.merged_by = actor
388
+ record.merge_commit_sha = self._merge_commit_sha(record)
389
+ return ApprovalResponse(
390
+ repo=repo,
391
+ pr_number=pr_number,
392
+ state=ApprovalState.PENDING,
393
+ current_head_sha=self._head_sha(record),
394
+ pr_merged=True,
395
+ merged_by=actor,
396
+ merge_commit_sha=record.merge_commit_sha,
397
+ )
398
+
399
+ def record_external_close(self, *, repo: str, pr_number: int) -> ApprovalResponse:
400
+ """Test helper for deterministically closing the PR without merging."""
401
+
402
+ record = self._get_pr(repo=repo, pr_number=pr_number)
403
+ self._ensure_pr_is_open(record)
404
+ record.closed = True
405
+ return ApprovalResponse(
406
+ repo=repo,
407
+ pr_number=pr_number,
408
+ state=ApprovalState.PENDING,
409
+ current_head_sha=self._head_sha(record),
410
+ pr_closed=True,
411
+ )
412
+
413
+ def record_dismissal(self, *, repo: str, pr_number: int) -> ApprovalResponse:
414
+ """Test helper for deterministically dismissing the current approval."""
415
+
416
+ record = self._get_pr(repo=repo, pr_number=pr_number)
417
+ self._ensure_pr_is_open(record)
418
+ current_head_sha = self._head_sha(record)
419
+ record.approver = None
420
+ record.approval_head_sha = None
421
+ record.approvals.clear()
422
+ record.changes_requested_by = None
423
+ record.changes_requested_head_sha = None
424
+ return ApprovalResponse(
425
+ repo=repo,
426
+ pr_number=pr_number,
427
+ state=ApprovalState.PENDING,
428
+ current_head_sha=current_head_sha,
429
+ )
430
+
431
+ def record_branch_head_change(self, *, repo: str, branch: str) -> None:
432
+ """Test helper for deterministically modeling pushed branch content changes."""
433
+
434
+ self._require_branch(repo=repo, branch=branch)
435
+ try:
436
+ record = self._pull_requests_by_branch[(repo, branch)]
437
+ except KeyError as error:
438
+ raise KeyError(f"Pull request for branch '{repo}:{branch}' not found") from error
439
+ self._ensure_pr_is_open(record)
440
+ record.head_revision += 1
441
+
442
+ def _require_branch(self, *, repo: str, branch: str) -> None:
443
+ if (repo, branch) not in self._branches:
444
+ raise KeyError(f"Branch '{repo}:{branch}' not found")
445
+
446
+ def _get_pr(self, *, repo: str, pr_number: int) -> _PullRequestRecord:
447
+ try:
448
+ return self._pull_requests_by_number[(repo, pr_number)]
449
+ except KeyError as error:
450
+ raise KeyError(f"Pull request '{repo}#{pr_number}' not found") from error
451
+
452
+ def _ensure_pr_is_open(self, record: _PullRequestRecord) -> None:
453
+ if record.merged:
454
+ raise ValueError(f"Pull request '{record.repo}#{record.pr_number}' is already merged")
455
+ if record.closed:
456
+ raise ValueError(f"Pull request '{record.repo}#{record.pr_number}' is closed")
457
+
458
+ def _to_pr_response(
459
+ self,
460
+ record: _PullRequestRecord,
461
+ *,
462
+ created: bool,
463
+ ) -> PullRequestResponse:
464
+ return PullRequestResponse(
465
+ repo=record.repo,
466
+ pr_number=record.pr_number,
467
+ branch=record.branch,
468
+ base=record.base,
469
+ title=record.title,
470
+ body=record.body,
471
+ created=created,
472
+ merged=record.merged,
473
+ draft=record.draft,
474
+ )
475
+
476
+ @staticmethod
477
+ def _human_approvers(record: _PullRequestRecord, head_sha: str) -> tuple[str, ...]:
478
+ return tuple(
479
+ sorted(
480
+ login
481
+ for login, approved_head_sha in record.approvals.items()
482
+ if approved_head_sha == head_sha and is_human_reviewer(login)
483
+ )
484
+ )
485
+
486
+ @staticmethod
487
+ def _head_sha(record: _PullRequestRecord) -> str:
488
+ seed = f"fake-pr:{record.repo}:{record.pr_number}:{record.branch}:{record.head_revision}"
489
+ return sha1(seed.encode("utf-8"), usedforsecurity=False).hexdigest()
490
+
491
+ @classmethod
492
+ def _merge_commit_sha(cls, record: _PullRequestRecord) -> str:
493
+ seed = f"fake-merge:{record.repo}:{record.pr_number}:{cls._head_sha(record)}"
494
+ return sha1(seed.encode("utf-8"), usedforsecurity=False).hexdigest()