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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/logging.py
ADDED
|
@@ -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
|
+
]
|