patchshuttle 0.1.0a2__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 (44) hide show
  1. patchshuttle/__init__.py +98 -0
  2. patchshuttle/_diff.py +317 -0
  3. patchshuttle/_process.py +198 -0
  4. patchshuttle/_version.py +3 -0
  5. patchshuttle/actions/__init__.py +80 -0
  6. patchshuttle/actions/constructors.py +211 -0
  7. patchshuttle/actions/create.py +155 -0
  8. patchshuttle/actions/modify.py +174 -0
  9. patchshuttle/audit.py +588 -0
  10. patchshuttle/backup.py +712 -0
  11. patchshuttle/checks/__init__.py +37 -0
  12. patchshuttle/checks/constructors.py +67 -0
  13. patchshuttle/checks/runner.py +233 -0
  14. patchshuttle/cli.py +766 -0
  15. patchshuttle/config.py +247 -0
  16. patchshuttle/context.py +370 -0
  17. patchshuttle/errors.py +291 -0
  18. patchshuttle/execution.py +651 -0
  19. patchshuttle/formatters/__init__.py +25 -0
  20. patchshuttle/formatters/runner.py +240 -0
  21. patchshuttle/identifiers.py +20 -0
  22. patchshuttle/inventory.py +331 -0
  23. patchshuttle/logging.py +741 -0
  24. patchshuttle/models.py +496 -0
  25. patchshuttle/operations.py +292 -0
  26. patchshuttle/parser.py +243 -0
  27. patchshuttle/planner.py +1144 -0
  28. patchshuttle/policy.py +377 -0
  29. patchshuttle/py.typed +1 -0
  30. patchshuttle/registry.py +275 -0
  31. patchshuttle/resources/AI_GUIDE.md +163 -0
  32. patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
  33. patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
  34. patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
  35. patchshuttle/resources/__init__.py +1 -0
  36. patchshuttle/rollback.py +306 -0
  37. patchshuttle/runner.py +880 -0
  38. patchshuttle/verification.py +107 -0
  39. patchshuttle/workspace.py +382 -0
  40. patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
  41. patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
  42. patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
  43. patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
  44. patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,741 @@
1
+ """Predictable UTF-8 run logs, exact job archives, and best-effort redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import stat
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path, PurePosixPath
12
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
13
+
14
+ from patchshuttle._version import __version__
15
+ from patchshuttle.audit import AuditActionResult
16
+ from patchshuttle.checks import CheckResult
17
+ from patchshuttle.errors import ExecutionError, ExecutionErrorCode
18
+ from patchshuttle.formatters import FormatterResult
19
+ from patchshuttle.inventory import WorkspaceComparison
20
+ from patchshuttle.models import Job
21
+ from patchshuttle.planner import ActionDisposition, Plan
22
+ from patchshuttle.runner import TransactionResult
23
+ from patchshuttle.workspace import Workspace
24
+
25
+ STANDARD_SECTIONS = (
26
+ "HEADER",
27
+ "WORKSPACE",
28
+ "JOB",
29
+ "PLAN",
30
+ "AUDIT",
31
+ "BACKUP",
32
+ "ACTIONS",
33
+ "INITIAL_CHECKS",
34
+ "FORMAT_ISORT",
35
+ "FORMAT_BLACK",
36
+ "FINAL_CHECKS",
37
+ "WORKSPACE_COMPARISON",
38
+ "ROLLBACK",
39
+ "SUMMARY",
40
+ "PATCHSHUTTLE_AI_HANDOFF",
41
+ )
42
+
43
+ _AVAILABLE_JOB_KINDS = "[audit, patch, verify]"
44
+ _AVAILABLE_AUDIT_ACTIONS = (
45
+ "[tree, read, search, find_files, file_info, hash, git_status, environment]"
46
+ )
47
+ _AVAILABLE_CHANGE_ACTIONS = (
48
+ "[create_directory, create_file, replace_exact, insert_before, insert_after, "
49
+ "delete_exact, apply_diff]"
50
+ )
51
+ _AVAILABLE_CHECKS = (
52
+ "[compileall, pytest, unittest, django_check, django_migrations_check, "
53
+ "django_test, import_check, profile]"
54
+ )
55
+ _PRIVATE_KEY = re.compile(
56
+ r"-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----.*?" r"-----END \1-----",
57
+ re.DOTALL,
58
+ )
59
+ _AUTHORIZATION = re.compile(
60
+ r"(?i)(\bauthorization\s*[:=]\s*(?:bearer|basic)\s+)[^\s\"']+"
61
+ )
62
+ _ASSIGNMENT = re.compile(
63
+ r"(?i)([\"']?\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|"
64
+ r"password|passwd|secret|token)[\"']?\s*[:=]\s*)"
65
+ r"(?:[\"'][^\"'\r\n]*[\"']|[^\s,;]+)"
66
+ )
67
+ _FLAG_VALUE = re.compile(
68
+ r"(?i)(--(?:api-key|access-token|auth-token|client-secret|password|secret|token)"
69
+ r"(?:=|\s+))[^\s\"']+"
70
+ )
71
+ _KNOWN_TOKEN = re.compile(
72
+ r"(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{20,}|"
73
+ r"sk-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{16,})(?![A-Za-z0-9])"
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class RunClock:
79
+ """One timezone-aware instant shared by all artifacts for a run."""
80
+
81
+ occurred_at: datetime
82
+
83
+ @property
84
+ def iso_timestamp(self) -> str:
85
+ return self.occurred_at.isoformat(timespec="seconds")
86
+
87
+ @property
88
+ def filename_timestamp(self) -> str:
89
+ return self.occurred_at.strftime("%Y_%m_%d_%H_%M_%S")
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class RunLogData:
94
+ """Complete bounded information used to render one stable run log."""
95
+
96
+ workspace: Workspace
97
+ job: Job
98
+ job_hash: str
99
+ clock: RunClock
100
+ result: str
101
+ exit_code: int
102
+ failure_stage: str | None
103
+ failure_code: str | None
104
+ archived_job_path: Path
105
+ plan: Plan | None = None
106
+ transaction: TransactionResult | None = None
107
+ error: ExecutionError | None = None
108
+ audit_results: tuple[AuditActionResult, ...] = ()
109
+ verification_checks: tuple[CheckResult, ...] = ()
110
+ workspace_comparison: WorkspaceComparison | None = None
111
+ manual_rollback: ManualRollbackLogRecord | None = None
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class ManualRollbackLogRecord:
116
+ """Paths and outcome recorded for a user-requested rollback."""
117
+
118
+ status: str
119
+ backup_path: Path
120
+ restored_files: tuple[PurePosixPath, ...] = ()
121
+ removed_files: tuple[PurePosixPath, ...] = ()
122
+ removed_directories: tuple[PurePosixPath, ...] = ()
123
+ unresolved: tuple[PurePosixPath, ...] = ()
124
+
125
+
126
+ def current_run_clock(workspace: Workspace) -> RunClock:
127
+ """Resolve the configured local or IANA timezone for a new run."""
128
+
129
+ setting = workspace.config.logging.timezone
130
+ instant = _utc_now()
131
+ try:
132
+ if setting.lower() == "local":
133
+ localized = instant.astimezone()
134
+ elif setting.upper() == "UTC":
135
+ localized = instant.astimezone(timezone.utc)
136
+ else:
137
+ localized = instant.astimezone(ZoneInfo(setting))
138
+ except (ValueError, ZoneInfoNotFoundError) as exc:
139
+ raise _record_error(
140
+ "configured log timezone is invalid",
141
+ path="patches/patchshuttle.toml",
142
+ ) from exc
143
+ return RunClock(localized)
144
+
145
+
146
+ def archive_job_source(
147
+ workspace: Workspace,
148
+ *,
149
+ job: Job,
150
+ job_hash: str,
151
+ clock: RunClock,
152
+ source: bytes,
153
+ successful: bool,
154
+ ) -> Path:
155
+ """Store an exact immutable source copy in ``applied`` or ``failed``."""
156
+
157
+ directory = workspace.patches_dir / ("applied" if successful else "failed")
158
+ _require_managed_directory(workspace, directory)
159
+ stem = f"{job.id}_{clock.filename_timestamp}_{job_hash[:8]}"
160
+ path = _unique_path(directory, stem=stem, suffix=".psh.yaml")
161
+ _write_new_file(path, source)
162
+ return path
163
+
164
+
165
+ def write_run_log(data: RunLogData) -> Path:
166
+ """Render, redact, and publish one fixed-section run log."""
167
+
168
+ directory = data.workspace.patches_dir / "logs"
169
+ _require_managed_directory(data.workspace, directory)
170
+ stem = f"log_{data.clock.filename_timestamp}_{data.job.id}"
171
+ path = _unique_path(directory, stem=stem, suffix=".log")
172
+ rendered = _render_log(data, path)
173
+ if data.workspace.config.logging.redact_known_secrets:
174
+ rendered = redact_text(rendered)
175
+ _write_new_file(path, rendered.encode("utf-8"))
176
+ return path
177
+
178
+
179
+ def write_named_log(
180
+ workspace: Workspace,
181
+ *,
182
+ clock: RunClock,
183
+ label: str,
184
+ content: str,
185
+ ) -> Path:
186
+ """Publish one bounded non-job snapshot or handoff log."""
187
+
188
+ if not re.fullmatch(r"[A-Z][A-Z0-9_-]{1,31}", label):
189
+ raise ValueError("operational log label is invalid")
190
+ directory = workspace.patches_dir / "logs"
191
+ _require_managed_directory(workspace, directory)
192
+ stem = f"log_{clock.filename_timestamp}_{label}"
193
+ path = _unique_path(directory, stem=stem, suffix=".log")
194
+ rendered = content if content.endswith("\n") else content + "\n"
195
+ if workspace.config.logging.redact_known_secrets:
196
+ rendered = redact_text(rendered)
197
+ _write_new_file(path, rendered.encode("utf-8"))
198
+ return path
199
+
200
+
201
+ def latest_log_path(workspace: Workspace) -> Path:
202
+ """Return the newest safe regular PatchShuttle log by mtime and name."""
203
+
204
+ directory = workspace.patches_dir / "logs"
205
+ _require_managed_directory(workspace, directory)
206
+ candidates: list[tuple[int, str, Path]] = []
207
+ try:
208
+ for path in directory.iterdir():
209
+ if not path.name.startswith("log_") or path.suffix != ".log":
210
+ continue
211
+ metadata = path.lstat()
212
+ if stat.S_ISREG(metadata.st_mode):
213
+ candidates.append((metadata.st_mtime_ns, path.name, path))
214
+ except OSError as exc:
215
+ raise _record_error("log directory could not be inspected") from exc
216
+ if not candidates:
217
+ raise ExecutionError(
218
+ ExecutionErrorCode.LOG_NOT_FOUND,
219
+ "workspace does not contain a PatchShuttle run log",
220
+ path="patches/logs",
221
+ )
222
+ return max(candidates)[2]
223
+
224
+
225
+ def redact_text(value: str) -> str:
226
+ """Mask common credential shapes without claiming exhaustive removal."""
227
+
228
+ value = _PRIVATE_KEY.sub(
229
+ lambda match: (
230
+ f"-----BEGIN {match.group(1)}-----\n"
231
+ "[REDACTED PRIVATE KEY]\n"
232
+ f"-----END {match.group(1)}-----"
233
+ ),
234
+ value,
235
+ )
236
+ value = _AUTHORIZATION.sub(r"\1[REDACTED]", value)
237
+ value = _ASSIGNMENT.sub(r"\1[REDACTED]", value)
238
+ value = _FLAG_VALUE.sub(r"\1[REDACTED]", value)
239
+ return _KNOWN_TOKEN.sub("[REDACTED]", value)
240
+
241
+
242
+ def _render_log(data: RunLogData, log_path: Path) -> str:
243
+ sections = {
244
+ "HEADER": _header_section(data),
245
+ "WORKSPACE": _workspace_section(data),
246
+ "JOB": _job_section(data),
247
+ "PLAN": _plan_section(data.plan),
248
+ "AUDIT": _audit_section(data),
249
+ "BACKUP": _backup_section(data),
250
+ "ACTIONS": _actions_section(data),
251
+ "INITIAL_CHECKS": _checks_section(_split_checks(data)[0], data.workspace),
252
+ "FORMAT_ISORT": _formatter_section(_formatter(data, "isort"), data.workspace),
253
+ "FORMAT_BLACK": _formatter_section(_formatter(data, "black"), data.workspace),
254
+ "FINAL_CHECKS": _checks_section(_split_checks(data)[1], data.workspace),
255
+ "WORKSPACE_COMPARISON": _comparison_section(data),
256
+ "ROLLBACK": _rollback_section(data),
257
+ "SUMMARY": _summary_section(data, log_path),
258
+ "PATCHSHUTTLE_AI_HANDOFF": _handoff_section(data),
259
+ }
260
+ lines: list[str] = []
261
+ for name in STANDARD_SECTIONS:
262
+ lines.append(f"=== {name} ===")
263
+ lines.append(sections[name])
264
+ lines.append("=== END_PATCHSHUTTLE_AI_HANDOFF ===")
265
+ return "\n".join(lines) + "\n"
266
+
267
+
268
+ def _header_section(data: RunLogData) -> str:
269
+ redaction = (
270
+ "BEST_EFFORT_ENABLED"
271
+ if data.workspace.config.logging.redact_known_secrets
272
+ else "DISABLED_BY_LOCAL_POLICY"
273
+ )
274
+ return _fields(
275
+ patchshuttle_version=__version__,
276
+ protocol=1,
277
+ timestamp=data.clock.iso_timestamp,
278
+ redaction=redaction,
279
+ redaction_guarantee="NONE",
280
+ )
281
+
282
+
283
+ def _workspace_section(data: RunLogData) -> str:
284
+ return _fields(
285
+ project_id=data.workspace.project_id,
286
+ root=data.workspace.root.as_posix(),
287
+ origin=data.workspace.origin.value,
288
+ )
289
+
290
+
291
+ def _job_section(data: RunLogData) -> str:
292
+ return _fields(
293
+ job_id=data.job.id,
294
+ job_hash=data.job_hash,
295
+ kind=data.job.kind.value,
296
+ title=data.job.title,
297
+ description=data.job.description,
298
+ archived_job_copy=_relative(data.workspace, data.archived_job_path),
299
+ )
300
+
301
+
302
+ def _plan_section(plan: Plan | None) -> str:
303
+ if plan is None:
304
+ return "NOT_APPLICABLE"
305
+ lines = [
306
+ f"planned_actions: {len(plan.actions)}",
307
+ f"planned_checks: {len(plan.checks)}",
308
+ f"files_to_create: {_json_paths(plan.files_to_create)}",
309
+ f"files_to_modify: {_json_paths(plan.files_to_modify)}",
310
+ f"directories_to_create: {_json_paths(plan.directories_to_create)}",
311
+ f"formatting_scope: {_json_paths(plan.formatting_targets)}",
312
+ "protected_paths: PASS",
313
+ f"automatic_rollback: {'enabled' if plan.auto_rollback else 'disabled'}",
314
+ ]
315
+ return "\n".join(lines)
316
+
317
+
318
+ def _backup_section(data: RunLogData) -> str:
319
+ backup = _backup_path(data)
320
+ if backup is None:
321
+ return "NOT_APPLICABLE"
322
+ status = "COMPLETED"
323
+ if data.error is not None:
324
+ if data.error.rollback_skipped:
325
+ status = "CHANGES_KEPT" if data.error.changes_kept else "FAILED"
326
+ else:
327
+ status = {
328
+ True: "ROLLED_BACK",
329
+ False: "ROLLBACK_FAILED",
330
+ None: "FAILED",
331
+ }[data.error.rollback_succeeded]
332
+ return _fields(path=_relative(data.workspace, backup), status=status)
333
+
334
+
335
+ def _actions_section(data: RunLogData) -> str:
336
+ if data.plan is None or not data.plan.actions or data.job.kind.value == "audit":
337
+ return "NOT_APPLICABLE"
338
+ records: list[str] = []
339
+ for action in data.plan.actions:
340
+ if (
341
+ data.error is not None
342
+ and data.error.code is ExecutionErrorCode.USER_DECLINED
343
+ ):
344
+ status = "NOT_STARTED"
345
+ elif data.error is not None and data.error.item_id == action.id:
346
+ status = "FAILED"
347
+ elif data.error is not None:
348
+ status = "UNKNOWN_AFTER_FAILURE"
349
+ elif action.disposition is ActionDisposition.NO_CHANGE:
350
+ status = "NO_CHANGE"
351
+ else:
352
+ status = "COMPLETED"
353
+ records.append(
354
+ _fields(
355
+ action_id=action.id,
356
+ action_type=action.name,
357
+ path_or_scope=_json_paths(action.paths),
358
+ status=status,
359
+ started_at=(
360
+ "NOT_STARTED" if status == "NOT_STARTED" else "TRANSACTION_SCOPE"
361
+ ),
362
+ duration_ms=(0 if status == "NOT_STARTED" else "NOT_RECORDED"),
363
+ expected=action.disposition.value,
364
+ actual=status,
365
+ details=action.detail,
366
+ )
367
+ )
368
+ return "\n---\n".join(records)
369
+
370
+
371
+ def _audit_section(data: RunLogData) -> str:
372
+ results = data.audit_results
373
+ if data.error is not None and data.error.audit_results:
374
+ results = data.error.audit_results
375
+ if not results:
376
+ return "NOT_APPLICABLE"
377
+ records: list[str] = []
378
+ for item in results:
379
+ header = _fields(
380
+ action_id=item.id,
381
+ action_type=item.name,
382
+ path_or_scope=_json_paths(item.scope),
383
+ status=item.status,
384
+ started_at=item.started_at,
385
+ duration_ms=item.duration_ms,
386
+ expected="READ_ONLY_OBSERVATION",
387
+ actual=item.status,
388
+ details=(
389
+ "OUTPUT_TRUNCATED" if item.output_truncated else "OUTPUT_COMPLETE"
390
+ ),
391
+ )
392
+ records.append(f"{header}\noutput_begin\n{item.output}\noutput_end")
393
+ return "\n---\n".join(records)
394
+
395
+
396
+ def _checks_section(
397
+ checks: tuple[CheckResult, ...],
398
+ workspace: Workspace,
399
+ ) -> str:
400
+ if not checks:
401
+ return "NOT_APPLICABLE"
402
+ return "\n---\n".join(_check_record(item, workspace) for item in checks)
403
+
404
+
405
+ def _check_record(item: CheckResult, workspace: Workspace) -> str:
406
+ include_output = workspace.config.logging.include_command_output
407
+ return _fields(
408
+ check_id=item.id,
409
+ profile=item.name,
410
+ argument_summary=json.dumps(item.argv, ensure_ascii=False),
411
+ working_directory=item.working_directory.as_posix(),
412
+ timeout=item.timeout_seconds,
413
+ exit_code=item.return_code,
414
+ duration_ms=item.duration_ms,
415
+ stdout=(item.stdout if include_output else "OMITTED_BY_LOCAL_POLICY"),
416
+ stderr=(item.stderr if include_output else "OMITTED_BY_LOCAL_POLICY"),
417
+ stdout_truncated=item.stdout_truncated,
418
+ stderr_truncated=item.stderr_truncated,
419
+ status=item.status.value,
420
+ )
421
+
422
+
423
+ def _formatter_section(
424
+ item: FormatterResult | None,
425
+ workspace: Workspace,
426
+ ) -> str:
427
+ if item is None:
428
+ return "NOT_APPLICABLE"
429
+ include_output = workspace.config.logging.include_command_output
430
+ return _fields(
431
+ formatter_id=item.id,
432
+ formatter=item.name,
433
+ argument_summary=json.dumps(item.argv, ensure_ascii=False),
434
+ working_directory=item.working_directory.as_posix(),
435
+ timeout=item.timeout_seconds,
436
+ exit_code=item.return_code,
437
+ duration_ms=item.duration_ms,
438
+ stdout=(item.stdout if include_output else "OMITTED_BY_LOCAL_POLICY"),
439
+ stderr=(item.stderr if include_output else "OMITTED_BY_LOCAL_POLICY"),
440
+ stdout_truncated=item.stdout_truncated,
441
+ stderr_truncated=item.stderr_truncated,
442
+ status=item.status.value,
443
+ )
444
+
445
+
446
+ def _comparison_section(data: RunLogData) -> str:
447
+ comparison = (
448
+ data.workspace_comparison
449
+ if data.workspace_comparison is not None
450
+ else (
451
+ data.transaction.workspace_comparison
452
+ if data.transaction is not None
453
+ else data.error.workspace_comparison if data.error is not None else None
454
+ )
455
+ )
456
+ if comparison is None:
457
+ return "NOT_APPLICABLE"
458
+ lines = [
459
+ f"status: {'PASS' if comparison.success else 'UNEXPECTED_CHANGES'}",
460
+ f"changes: {len(comparison.changes)}",
461
+ f"unexpected_changes: {len(comparison.unexpected_changes)}",
462
+ ]
463
+ lines.extend(
464
+ "change: "
465
+ + json.dumps(
466
+ {
467
+ "expected": change.expected,
468
+ "kind": change.kind.value,
469
+ "path": change.path.as_posix(),
470
+ },
471
+ ensure_ascii=False,
472
+ sort_keys=True,
473
+ )
474
+ for change in comparison.changes
475
+ )
476
+ return "\n".join(lines)
477
+
478
+
479
+ def _rollback_section(data: RunLogData) -> str:
480
+ if data.manual_rollback is not None:
481
+ item = data.manual_rollback
482
+ return _fields(
483
+ status=item.status,
484
+ cause="USER_REQUESTED",
485
+ backup=_relative(data.workspace, item.backup_path),
486
+ restored_files=_json_paths(item.restored_files),
487
+ removed_files=_json_paths(item.removed_files),
488
+ removed_directories=_json_paths(item.removed_directories),
489
+ unresolved=_json_paths(item.unresolved),
490
+ )
491
+ if data.error is None:
492
+ return "NOT_APPLICABLE"
493
+ state = _automatic_rollback_state(data.error)
494
+ return _fields(
495
+ status=state,
496
+ cause=(
497
+ data.error.cause_code.value
498
+ if data.error.cause_code is not None
499
+ else data.error.code.value
500
+ ),
501
+ backup=(
502
+ _relative(data.workspace, data.error.backup_path)
503
+ if data.error.backup_path is not None
504
+ else None
505
+ ),
506
+ )
507
+
508
+
509
+ def _summary_section(data: RunLogData, log_path: Path) -> str:
510
+ created_files, created_directories, modified_files = _changed_paths(data)
511
+ initial, final = _split_checks(data)
512
+ formatters = _formatters(data)
513
+ formatting_status = (
514
+ "NOT_APPLICABLE"
515
+ if data.plan is None or not data.plan.formatting_targets
516
+ else (
517
+ "PASSED"
518
+ if len(formatters) == 2 and all(item.success for item in formatters)
519
+ else "FAILED" if formatters else "NOT_STARTED"
520
+ )
521
+ )
522
+ rollback = (
523
+ data.manual_rollback.status
524
+ if data.manual_rollback is not None
525
+ else (
526
+ "NOT_REQUIRED"
527
+ if data.error is None
528
+ else _automatic_rollback_state(data.error)
529
+ )
530
+ )
531
+ return _fields(
532
+ result=data.result,
533
+ failure_stage=data.failure_stage,
534
+ failure_code=data.failure_code,
535
+ exit_code=data.exit_code,
536
+ changed_files=_json_paths((*created_files, *modified_files)),
537
+ created_files=_json_paths(created_files),
538
+ created_directories=_json_paths(created_directories),
539
+ checks_passed=sum(item.success for item in (*initial, *final)),
540
+ formatting_status=formatting_status,
541
+ rollback_status=rollback,
542
+ log_path=_relative(data.workspace, log_path),
543
+ next_recommended_step=_next_step(data.result),
544
+ )
545
+
546
+
547
+ def _handoff_section(data: RunLogData) -> str:
548
+ rollback = (
549
+ data.manual_rollback.status
550
+ if data.manual_rollback is not None
551
+ else (
552
+ "NOT_REQUIRED"
553
+ if data.error is None
554
+ else _automatic_rollback_state(data.error)
555
+ )
556
+ )
557
+ return _fields(
558
+ protocol=1,
559
+ project_id=data.workspace.project_id,
560
+ job_id=data.job.id,
561
+ job_hash=data.job_hash[:8],
562
+ kind=data.job.kind.value,
563
+ result=data.result,
564
+ failure_stage=data.failure_stage,
565
+ failure_code=data.failure_code,
566
+ failed_item=(data.error.item_id if data.error is not None else None),
567
+ rollback=rollback,
568
+ available_job_kinds=_AVAILABLE_JOB_KINDS,
569
+ available_audit_actions=_AVAILABLE_AUDIT_ACTIONS,
570
+ available_change_actions=_AVAILABLE_CHANGE_ACTIONS,
571
+ available_checks=_AVAILABLE_CHECKS,
572
+ next_expected_response=(
573
+ "next_patch_or_audit"
574
+ if data.result in {"COMPLETED", "NO_CHANGE", "ALREADY_APPLIED"}
575
+ else (
576
+ "same_job_after_user_approval"
577
+ if data.result == "USER_DECLINED"
578
+ else "corrected_patch_or_audit"
579
+ )
580
+ ),
581
+ )
582
+
583
+
584
+ def _automatic_rollback_state(error: ExecutionError) -> str:
585
+ if error.rollback_skipped:
586
+ return "SKIPPED_CHANGES_KEPT" if error.changes_kept else "SKIPPED_NO_CHANGES"
587
+ return {None: "NOT_STARTED", True: "SUCCESS", False: "FAILED"}[
588
+ error.rollback_succeeded
589
+ ]
590
+
591
+
592
+ def _split_checks(
593
+ data: RunLogData,
594
+ ) -> tuple[tuple[CheckResult, ...], tuple[CheckResult, ...]]:
595
+ if data.transaction is not None:
596
+ return data.transaction.initial_checks, data.transaction.final_checks
597
+ if data.verification_checks:
598
+ return data.verification_checks, ()
599
+ if data.error is None or data.plan is None:
600
+ return (), ()
601
+ checks = data.error.check_results
602
+ if not data.error.formatting_results:
603
+ return checks, ()
604
+ boundary = min(len(data.plan.checks), len(checks))
605
+ return checks[:boundary], checks[boundary:]
606
+
607
+
608
+ def _formatters(data: RunLogData) -> tuple[FormatterResult, ...]:
609
+ if data.transaction is not None:
610
+ return data.transaction.formatting_results
611
+ if data.error is not None:
612
+ return data.error.formatting_results
613
+ return ()
614
+
615
+
616
+ def _formatter(data: RunLogData, name: str) -> FormatterResult | None:
617
+ return next((item for item in _formatters(data) if item.name == name), None)
618
+
619
+
620
+ def _changed_paths(data: RunLogData) -> tuple[tuple, tuple, tuple]:
621
+ if data.transaction is None:
622
+ return (), (), ()
623
+ return (
624
+ data.transaction.created_files,
625
+ data.transaction.created_directories,
626
+ data.transaction.modified_files,
627
+ )
628
+
629
+
630
+ def _backup_path(data: RunLogData) -> Path | None:
631
+ if data.transaction is not None:
632
+ return data.transaction.backup_path
633
+ if data.error is not None:
634
+ return data.error.backup_path
635
+ return None
636
+
637
+
638
+ def _fields(**values: object) -> str:
639
+ return "\n".join(f"{key}: {_scalar(value)}" for key, value in values.items())
640
+
641
+
642
+ def _scalar(value: object) -> str:
643
+ if value is None:
644
+ return "NOT_APPLICABLE"
645
+ if isinstance(value, bool):
646
+ return str(value).lower()
647
+ if isinstance(value, (int, float)):
648
+ return str(value)
649
+ return str(value).replace("\r", "\\r").replace("\n", "\\n")
650
+
651
+
652
+ def _json_paths(paths: tuple) -> str:
653
+ return json.dumps([path.as_posix() for path in paths], ensure_ascii=False)
654
+
655
+
656
+ def _next_step(result: str) -> str:
657
+ if result in {"COMPLETED", "NO_CHANGE", "ALREADY_APPLIED"}:
658
+ return "review_log_and_continue"
659
+ if result == "PATCH_ID_CONFLICT":
660
+ return "use_a_new_job_id_or_restore_the_original_job_content"
661
+ if result == "USER_DECLINED":
662
+ return "review_the_plan_and_run_it_only_when_ready"
663
+ return "return_this_log_to_the_ai_for_a_corrected_job"
664
+
665
+
666
+ def _relative(workspace: Workspace, path: Path) -> str:
667
+ try:
668
+ return path.relative_to(workspace.root).as_posix()
669
+ except ValueError:
670
+ return path.as_posix()
671
+
672
+
673
+ def _require_managed_directory(workspace: Workspace, directory: Path) -> None:
674
+ try:
675
+ relative = directory.relative_to(workspace.root)
676
+ metadata = directory.lstat()
677
+ if not stat.S_ISDIR(metadata.st_mode):
678
+ raise OSError("managed path is not a directory")
679
+ except (OSError, ValueError) as exc:
680
+ raise _record_error(
681
+ "managed artifact directory is missing or unsafe",
682
+ path=(
683
+ relative.as_posix() if "relative" in locals() else directory.as_posix()
684
+ ),
685
+ ) from exc
686
+
687
+
688
+ def _unique_path(directory: Path, *, stem: str, suffix: str) -> Path:
689
+ candidate = directory / f"{stem}{suffix}"
690
+ counter = 2
691
+ while candidate.exists() or candidate.is_symlink():
692
+ candidate = directory / f"{stem}_{counter}{suffix}"
693
+ counter += 1
694
+ return candidate
695
+
696
+
697
+ def _write_new_file(path: Path, content: bytes) -> None:
698
+ created = False
699
+ try:
700
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
701
+ created = True
702
+ with os.fdopen(descriptor, "wb") as stream:
703
+ stream.write(content)
704
+ stream.flush()
705
+ os.fsync(stream.fileno())
706
+ except OSError as exc:
707
+ if created:
708
+ try:
709
+ path.unlink()
710
+ except OSError:
711
+ pass
712
+ raise _record_error(
713
+ "operational artifact could not be written",
714
+ path=path.as_posix(),
715
+ ) from exc
716
+
717
+
718
+ def _record_error(message: str, *, path: str | None = None) -> ExecutionError:
719
+ return ExecutionError(
720
+ ExecutionErrorCode.OPERATIONAL_RECORD_FAILED,
721
+ message,
722
+ path=path,
723
+ )
724
+
725
+
726
+ def _utc_now() -> datetime:
727
+ return datetime.now(timezone.utc)
728
+
729
+
730
+ __all__ = [
731
+ "ManualRollbackLogRecord",
732
+ "RunClock",
733
+ "RunLogData",
734
+ "STANDARD_SECTIONS",
735
+ "archive_job_source",
736
+ "current_run_clock",
737
+ "latest_log_path",
738
+ "redact_text",
739
+ "write_run_log",
740
+ "write_named_log",
741
+ ]