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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Public approved execution with registry, archive, and log coordination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from os import PathLike
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from patchshuttle.audit import (
|
|
14
|
+
AuditActionResult,
|
|
15
|
+
AuditRunResult,
|
|
16
|
+
execute_audit_locked,
|
|
17
|
+
)
|
|
18
|
+
from patchshuttle.checks import CheckResult
|
|
19
|
+
from patchshuttle.errors import ExecutionError, ExecutionErrorCode, JobError
|
|
20
|
+
from patchshuttle.formatters import FormattedFileState, FormatterResult
|
|
21
|
+
from patchshuttle.inventory import WorkspaceComparison
|
|
22
|
+
from patchshuttle.logging import (
|
|
23
|
+
RunClock,
|
|
24
|
+
RunLogData,
|
|
25
|
+
archive_job_source,
|
|
26
|
+
current_run_clock,
|
|
27
|
+
write_run_log,
|
|
28
|
+
)
|
|
29
|
+
from patchshuttle.models import Job, JobKind
|
|
30
|
+
from patchshuttle.parser import load_job
|
|
31
|
+
from patchshuttle.planner import Plan, normalized_job_hash
|
|
32
|
+
from patchshuttle.registry import (
|
|
33
|
+
Registry,
|
|
34
|
+
RegistryDecision,
|
|
35
|
+
decide_job,
|
|
36
|
+
load_registry,
|
|
37
|
+
update_registry,
|
|
38
|
+
)
|
|
39
|
+
from patchshuttle.runner import (
|
|
40
|
+
TransactionResult,
|
|
41
|
+
TransactionStatus,
|
|
42
|
+
acquire_workspace_lock,
|
|
43
|
+
execute_change_transaction_locked,
|
|
44
|
+
)
|
|
45
|
+
from patchshuttle.verification import (
|
|
46
|
+
VerificationRunResult,
|
|
47
|
+
execute_verification_locked,
|
|
48
|
+
)
|
|
49
|
+
from patchshuttle.workspace import Workspace
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RunStatus(str, Enum):
|
|
53
|
+
"""Successful outcomes exposed by the public execution API."""
|
|
54
|
+
|
|
55
|
+
COMPLETED = "COMPLETED"
|
|
56
|
+
ALREADY_APPLIED = "ALREADY_APPLIED"
|
|
57
|
+
NO_CHANGE = "NO_CHANGE"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class RunResult:
|
|
62
|
+
"""Immutable public result of one recorded job execution."""
|
|
63
|
+
|
|
64
|
+
status: RunStatus
|
|
65
|
+
plan: Plan = field(repr=False)
|
|
66
|
+
backup_path: Path | None
|
|
67
|
+
created_files: tuple[PurePosixPath, ...]
|
|
68
|
+
created_directories: tuple[PurePosixPath, ...]
|
|
69
|
+
modified_files: tuple[PurePosixPath, ...] = ()
|
|
70
|
+
initial_checks: tuple[CheckResult, ...] = ()
|
|
71
|
+
formatting_results: tuple[FormatterResult, ...] = ()
|
|
72
|
+
formatted_files: tuple[FormattedFileState, ...] = ()
|
|
73
|
+
final_checks: tuple[CheckResult, ...] = ()
|
|
74
|
+
workspace_comparison: WorkspaceComparison | None = None
|
|
75
|
+
audit_results: tuple[AuditActionResult, ...] = ()
|
|
76
|
+
log_path: Path | None = None
|
|
77
|
+
archived_job_path: Path | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class RegisteredRunResult:
|
|
82
|
+
"""An already-applied result resolved before planning is needed."""
|
|
83
|
+
|
|
84
|
+
status: RunStatus
|
|
85
|
+
job: Job
|
|
86
|
+
job_hash: str
|
|
87
|
+
log_path: Path
|
|
88
|
+
archived_job_path: Path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class _Artifacts:
|
|
93
|
+
log_path: Path
|
|
94
|
+
archived_job_path: Path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def execute_plan(
|
|
98
|
+
plan: Plan,
|
|
99
|
+
*,
|
|
100
|
+
approved: bool = False,
|
|
101
|
+
keep_changes: bool = False,
|
|
102
|
+
source_path: str | PathLike[str] | None = None,
|
|
103
|
+
) -> RunResult:
|
|
104
|
+
"""Execute and record an audit, patch, or approved verify plan.
|
|
105
|
+
|
|
106
|
+
A CLI-provided ``source_path`` is archived byte-for-byte. Python callers
|
|
107
|
+
without a source file receive a deterministic YAML archive of the immutable
|
|
108
|
+
job model. ``keep_changes`` is patch-only, remains subject to local policy,
|
|
109
|
+
and records a skipped rollback explicitly if execution fails.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
if keep_changes and plan.job.kind is not JobKind.PATCH:
|
|
113
|
+
raise ExecutionError(
|
|
114
|
+
ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
|
|
115
|
+
"keeping failed-job changes is supported only for patch jobs",
|
|
116
|
+
)
|
|
117
|
+
if keep_changes and not plan.workspace.config.execution.allow_keep_changes:
|
|
118
|
+
raise ExecutionError(
|
|
119
|
+
ExecutionErrorCode.KEEP_CHANGES_FORBIDDEN,
|
|
120
|
+
"local workspace policy does not allow keeping failed-job changes",
|
|
121
|
+
)
|
|
122
|
+
if plan.requires_confirmation and not approved:
|
|
123
|
+
raise ExecutionError(
|
|
124
|
+
ExecutionErrorCode.APPROVAL_REQUIRED,
|
|
125
|
+
"explicit approval is required before project code is executed",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
source = _job_source_bytes(
|
|
129
|
+
plan.workspace,
|
|
130
|
+
plan.job,
|
|
131
|
+
source_path=source_path,
|
|
132
|
+
)
|
|
133
|
+
clock = current_run_clock(plan.workspace)
|
|
134
|
+
with acquire_workspace_lock(plan.workspace):
|
|
135
|
+
registry = load_registry(plan.workspace)
|
|
136
|
+
decision = decide_job(
|
|
137
|
+
registry,
|
|
138
|
+
job_id=plan.job.id,
|
|
139
|
+
job_hash=plan.job_hash,
|
|
140
|
+
)
|
|
141
|
+
if decision is RegistryDecision.PATCH_ID_CONFLICT:
|
|
142
|
+
error = _conflict_error(plan.job.id)
|
|
143
|
+
artifacts = _record_error(
|
|
144
|
+
plan.workspace,
|
|
145
|
+
registry,
|
|
146
|
+
plan.job,
|
|
147
|
+
plan.job_hash,
|
|
148
|
+
source,
|
|
149
|
+
clock,
|
|
150
|
+
error,
|
|
151
|
+
plan=plan,
|
|
152
|
+
)
|
|
153
|
+
_attach_artifacts(error, artifacts)
|
|
154
|
+
raise error
|
|
155
|
+
if decision is RegistryDecision.ALREADY_APPLIED:
|
|
156
|
+
artifacts = _record_success(
|
|
157
|
+
plan.workspace,
|
|
158
|
+
registry,
|
|
159
|
+
plan.job,
|
|
160
|
+
plan.job_hash,
|
|
161
|
+
source,
|
|
162
|
+
clock,
|
|
163
|
+
result=RunStatus.ALREADY_APPLIED,
|
|
164
|
+
plan=plan,
|
|
165
|
+
transaction=None,
|
|
166
|
+
)
|
|
167
|
+
return _already_applied_result(plan, artifacts)
|
|
168
|
+
|
|
169
|
+
transaction: TransactionResult | None = None
|
|
170
|
+
audit_run: AuditRunResult | None = None
|
|
171
|
+
verification: VerificationRunResult | None = None
|
|
172
|
+
try:
|
|
173
|
+
if plan.job.kind is JobKind.AUDIT:
|
|
174
|
+
audit_run = execute_audit_locked(plan)
|
|
175
|
+
status = RunStatus.COMPLETED
|
|
176
|
+
elif plan.job.kind is JobKind.VERIFY:
|
|
177
|
+
verification = execute_verification_locked(plan)
|
|
178
|
+
status = RunStatus.COMPLETED
|
|
179
|
+
else:
|
|
180
|
+
transaction = execute_change_transaction_locked(
|
|
181
|
+
plan,
|
|
182
|
+
approved=True,
|
|
183
|
+
keep_changes=keep_changes,
|
|
184
|
+
)
|
|
185
|
+
status = {
|
|
186
|
+
TransactionStatus.APPLIED: RunStatus.COMPLETED,
|
|
187
|
+
TransactionStatus.NO_CHANGE: RunStatus.NO_CHANGE,
|
|
188
|
+
}[transaction.status]
|
|
189
|
+
except ExecutionError as error:
|
|
190
|
+
artifacts = _record_error(
|
|
191
|
+
plan.workspace,
|
|
192
|
+
registry,
|
|
193
|
+
plan.job,
|
|
194
|
+
plan.job_hash,
|
|
195
|
+
source,
|
|
196
|
+
clock,
|
|
197
|
+
error,
|
|
198
|
+
plan=plan,
|
|
199
|
+
)
|
|
200
|
+
_attach_artifacts(error, artifacts)
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
artifacts = _record_success(
|
|
204
|
+
plan.workspace,
|
|
205
|
+
registry,
|
|
206
|
+
plan.job,
|
|
207
|
+
plan.job_hash,
|
|
208
|
+
source,
|
|
209
|
+
clock,
|
|
210
|
+
result=status,
|
|
211
|
+
plan=plan,
|
|
212
|
+
transaction=transaction,
|
|
213
|
+
audit_run=audit_run,
|
|
214
|
+
verification=verification,
|
|
215
|
+
)
|
|
216
|
+
return _public_result(
|
|
217
|
+
plan,
|
|
218
|
+
status,
|
|
219
|
+
artifacts,
|
|
220
|
+
transaction=transaction,
|
|
221
|
+
audit_run=audit_run,
|
|
222
|
+
verification=verification,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def resolve_registered_job(
|
|
227
|
+
workspace: Workspace,
|
|
228
|
+
job: Job,
|
|
229
|
+
*,
|
|
230
|
+
source_path: str | PathLike[str] | None = None,
|
|
231
|
+
) -> RegisteredRunResult | None:
|
|
232
|
+
"""Resolve completed or conflicting IDs before mutable planning.
|
|
233
|
+
|
|
234
|
+
``None`` means the ID/hash pair may proceed to normal planning. The final
|
|
235
|
+
decision is repeated by ``execute_plan`` under its transaction lock.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
job_hash = normalized_job_hash(job)
|
|
239
|
+
source = _job_source_bytes(workspace, job, source_path=source_path)
|
|
240
|
+
clock = current_run_clock(workspace)
|
|
241
|
+
with acquire_workspace_lock(workspace):
|
|
242
|
+
registry = load_registry(workspace)
|
|
243
|
+
decision = decide_job(registry, job_id=job.id, job_hash=job_hash)
|
|
244
|
+
if decision is RegistryDecision.PROCEED:
|
|
245
|
+
return None
|
|
246
|
+
if decision is RegistryDecision.PATCH_ID_CONFLICT:
|
|
247
|
+
error = _conflict_error(job.id)
|
|
248
|
+
artifacts = _record_error(
|
|
249
|
+
workspace,
|
|
250
|
+
registry,
|
|
251
|
+
job,
|
|
252
|
+
job_hash,
|
|
253
|
+
source,
|
|
254
|
+
clock,
|
|
255
|
+
error,
|
|
256
|
+
plan=None,
|
|
257
|
+
)
|
|
258
|
+
_attach_artifacts(error, artifacts)
|
|
259
|
+
raise error
|
|
260
|
+
|
|
261
|
+
artifacts = _record_success(
|
|
262
|
+
workspace,
|
|
263
|
+
registry,
|
|
264
|
+
job,
|
|
265
|
+
job_hash,
|
|
266
|
+
source,
|
|
267
|
+
clock,
|
|
268
|
+
result=RunStatus.ALREADY_APPLIED,
|
|
269
|
+
plan=None,
|
|
270
|
+
transaction=None,
|
|
271
|
+
)
|
|
272
|
+
return RegisteredRunResult(
|
|
273
|
+
status=RunStatus.ALREADY_APPLIED,
|
|
274
|
+
job=job,
|
|
275
|
+
job_hash=job_hash,
|
|
276
|
+
log_path=artifacts.log_path,
|
|
277
|
+
archived_job_path=artifacts.archived_job_path,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def record_declined_plan(
|
|
282
|
+
plan: Plan,
|
|
283
|
+
*,
|
|
284
|
+
source_path: str | PathLike[str] | None = None,
|
|
285
|
+
) -> ExecutionError:
|
|
286
|
+
"""Record a reviewed patch or verify plan the user explicitly declined."""
|
|
287
|
+
|
|
288
|
+
source = _job_source_bytes(
|
|
289
|
+
plan.workspace,
|
|
290
|
+
plan.job,
|
|
291
|
+
source_path=source_path,
|
|
292
|
+
)
|
|
293
|
+
clock = current_run_clock(plan.workspace)
|
|
294
|
+
with acquire_workspace_lock(plan.workspace):
|
|
295
|
+
registry = load_registry(plan.workspace)
|
|
296
|
+
decision = decide_job(
|
|
297
|
+
registry,
|
|
298
|
+
job_id=plan.job.id,
|
|
299
|
+
job_hash=plan.job_hash,
|
|
300
|
+
)
|
|
301
|
+
error = (
|
|
302
|
+
_conflict_error(plan.job.id)
|
|
303
|
+
if decision is RegistryDecision.PATCH_ID_CONFLICT
|
|
304
|
+
else ExecutionError(
|
|
305
|
+
ExecutionErrorCode.USER_DECLINED,
|
|
306
|
+
"user declined the reviewed job plan",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
artifacts = _record_error(
|
|
310
|
+
plan.workspace,
|
|
311
|
+
registry,
|
|
312
|
+
plan.job,
|
|
313
|
+
plan.job_hash,
|
|
314
|
+
source,
|
|
315
|
+
clock,
|
|
316
|
+
error,
|
|
317
|
+
plan=plan,
|
|
318
|
+
)
|
|
319
|
+
_attach_artifacts(error, artifacts)
|
|
320
|
+
return error
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def execution_exit_code(code: ExecutionErrorCode) -> int:
|
|
324
|
+
"""Map execution failures to the protocol's stable process groups."""
|
|
325
|
+
|
|
326
|
+
if code is ExecutionErrorCode.OPERATIONAL_RECORD_FAILED:
|
|
327
|
+
return 1
|
|
328
|
+
if code in {
|
|
329
|
+
ExecutionErrorCode.APPROVAL_REQUIRED,
|
|
330
|
+
ExecutionErrorCode.USER_DECLINED,
|
|
331
|
+
ExecutionErrorCode.KEEP_CHANGES_FORBIDDEN,
|
|
332
|
+
}:
|
|
333
|
+
return 4
|
|
334
|
+
if code in {
|
|
335
|
+
ExecutionErrorCode.PATCH_ID_CONFLICT,
|
|
336
|
+
ExecutionErrorCode.WORKSPACE_LOCKED,
|
|
337
|
+
ExecutionErrorCode.WORKSPACE_LOCK_FAILED,
|
|
338
|
+
ExecutionErrorCode.LOG_NOT_FOUND,
|
|
339
|
+
ExecutionErrorCode.JOB_NOT_FOUND,
|
|
340
|
+
}:
|
|
341
|
+
return 3
|
|
342
|
+
if code is ExecutionErrorCode.CHECK_FAILED:
|
|
343
|
+
return 6
|
|
344
|
+
if code is ExecutionErrorCode.FORMAT_FAILED:
|
|
345
|
+
return 7
|
|
346
|
+
if code is ExecutionErrorCode.ROLLBACK_FAILED:
|
|
347
|
+
return 8
|
|
348
|
+
return 5
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _record_success(
|
|
352
|
+
workspace: Workspace,
|
|
353
|
+
registry: Registry,
|
|
354
|
+
job: Job,
|
|
355
|
+
job_hash: str,
|
|
356
|
+
source: bytes,
|
|
357
|
+
clock: RunClock,
|
|
358
|
+
*,
|
|
359
|
+
result: RunStatus,
|
|
360
|
+
plan: Plan | None,
|
|
361
|
+
transaction: TransactionResult | None,
|
|
362
|
+
audit_run: AuditRunResult | None = None,
|
|
363
|
+
verification: VerificationRunResult | None = None,
|
|
364
|
+
) -> _Artifacts:
|
|
365
|
+
archived = archive_job_source(
|
|
366
|
+
workspace,
|
|
367
|
+
job=job,
|
|
368
|
+
job_hash=job_hash,
|
|
369
|
+
clock=clock,
|
|
370
|
+
source=source,
|
|
371
|
+
successful=True,
|
|
372
|
+
)
|
|
373
|
+
log_path = write_run_log(
|
|
374
|
+
RunLogData(
|
|
375
|
+
workspace=workspace,
|
|
376
|
+
job=job,
|
|
377
|
+
job_hash=job_hash,
|
|
378
|
+
clock=clock,
|
|
379
|
+
result=result.value,
|
|
380
|
+
exit_code=0,
|
|
381
|
+
failure_stage=None,
|
|
382
|
+
failure_code=None,
|
|
383
|
+
archived_job_path=archived,
|
|
384
|
+
plan=plan,
|
|
385
|
+
transaction=transaction,
|
|
386
|
+
audit_results=(audit_run.results if audit_run is not None else ()),
|
|
387
|
+
verification_checks=(
|
|
388
|
+
verification.checks if verification is not None else ()
|
|
389
|
+
),
|
|
390
|
+
workspace_comparison=(
|
|
391
|
+
verification.workspace_comparison
|
|
392
|
+
if verification is not None
|
|
393
|
+
else (audit_run.workspace_comparison if audit_run is not None else None)
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
update_registry(
|
|
398
|
+
workspace,
|
|
399
|
+
registry,
|
|
400
|
+
job_id=job.id,
|
|
401
|
+
job_hash=job_hash,
|
|
402
|
+
kind=job.kind,
|
|
403
|
+
occurred_at=clock.iso_timestamp,
|
|
404
|
+
result=result.value,
|
|
405
|
+
backup_path=(transaction.backup_path if transaction is not None else None),
|
|
406
|
+
rollback_state="NOT_REQUIRED",
|
|
407
|
+
archived_job_path=archived,
|
|
408
|
+
completed=True,
|
|
409
|
+
)
|
|
410
|
+
return _Artifacts(log_path=log_path, archived_job_path=archived)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _record_error(
|
|
414
|
+
workspace: Workspace,
|
|
415
|
+
registry: Registry,
|
|
416
|
+
job: Job,
|
|
417
|
+
job_hash: str,
|
|
418
|
+
source: bytes,
|
|
419
|
+
clock: RunClock,
|
|
420
|
+
error: ExecutionError,
|
|
421
|
+
*,
|
|
422
|
+
plan: Plan | None,
|
|
423
|
+
) -> _Artifacts:
|
|
424
|
+
result, failure_code = _error_result(error)
|
|
425
|
+
archived = archive_job_source(
|
|
426
|
+
workspace,
|
|
427
|
+
job=job,
|
|
428
|
+
job_hash=job_hash,
|
|
429
|
+
clock=clock,
|
|
430
|
+
source=source,
|
|
431
|
+
successful=False,
|
|
432
|
+
)
|
|
433
|
+
log_path = write_run_log(
|
|
434
|
+
RunLogData(
|
|
435
|
+
workspace=workspace,
|
|
436
|
+
job=job,
|
|
437
|
+
job_hash=job_hash,
|
|
438
|
+
clock=clock,
|
|
439
|
+
result=result,
|
|
440
|
+
exit_code=execution_exit_code(error.code),
|
|
441
|
+
failure_stage=_failure_stage(error, plan),
|
|
442
|
+
failure_code=failure_code,
|
|
443
|
+
archived_job_path=archived,
|
|
444
|
+
plan=plan,
|
|
445
|
+
error=error,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
rollback = _rollback_state(error)
|
|
449
|
+
update_registry(
|
|
450
|
+
workspace,
|
|
451
|
+
registry,
|
|
452
|
+
job_id=job.id,
|
|
453
|
+
job_hash=job_hash,
|
|
454
|
+
kind=job.kind,
|
|
455
|
+
occurred_at=clock.iso_timestamp,
|
|
456
|
+
result=result,
|
|
457
|
+
backup_path=error.backup_path,
|
|
458
|
+
rollback_state=rollback,
|
|
459
|
+
archived_job_path=archived,
|
|
460
|
+
completed=False,
|
|
461
|
+
)
|
|
462
|
+
return _Artifacts(log_path=log_path, archived_job_path=archived)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _public_result(
|
|
466
|
+
plan: Plan,
|
|
467
|
+
status: RunStatus,
|
|
468
|
+
artifacts: _Artifacts,
|
|
469
|
+
*,
|
|
470
|
+
transaction: TransactionResult | None,
|
|
471
|
+
audit_run: AuditRunResult | None,
|
|
472
|
+
verification: VerificationRunResult | None,
|
|
473
|
+
) -> RunResult:
|
|
474
|
+
return RunResult(
|
|
475
|
+
status=status,
|
|
476
|
+
plan=plan,
|
|
477
|
+
backup_path=(transaction.backup_path if transaction is not None else None),
|
|
478
|
+
created_files=(transaction.created_files if transaction is not None else ()),
|
|
479
|
+
created_directories=(
|
|
480
|
+
transaction.created_directories if transaction is not None else ()
|
|
481
|
+
),
|
|
482
|
+
modified_files=(transaction.modified_files if transaction is not None else ()),
|
|
483
|
+
initial_checks=(
|
|
484
|
+
transaction.initial_checks
|
|
485
|
+
if transaction is not None
|
|
486
|
+
else verification.checks if verification is not None else ()
|
|
487
|
+
),
|
|
488
|
+
formatting_results=(
|
|
489
|
+
transaction.formatting_results if transaction is not None else ()
|
|
490
|
+
),
|
|
491
|
+
formatted_files=(
|
|
492
|
+
transaction.formatted_files if transaction is not None else ()
|
|
493
|
+
),
|
|
494
|
+
final_checks=(transaction.final_checks if transaction is not None else ()),
|
|
495
|
+
workspace_comparison=(
|
|
496
|
+
transaction.workspace_comparison
|
|
497
|
+
if transaction is not None
|
|
498
|
+
else (
|
|
499
|
+
verification.workspace_comparison
|
|
500
|
+
if verification is not None
|
|
501
|
+
else (audit_run.workspace_comparison if audit_run is not None else None)
|
|
502
|
+
)
|
|
503
|
+
),
|
|
504
|
+
audit_results=(audit_run.results if audit_run is not None else ()),
|
|
505
|
+
log_path=artifacts.log_path,
|
|
506
|
+
archived_job_path=artifacts.archived_job_path,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _already_applied_result(plan: Plan, artifacts: _Artifacts) -> RunResult:
|
|
511
|
+
return RunResult(
|
|
512
|
+
status=RunStatus.ALREADY_APPLIED,
|
|
513
|
+
plan=plan,
|
|
514
|
+
backup_path=None,
|
|
515
|
+
created_files=(),
|
|
516
|
+
created_directories=(),
|
|
517
|
+
log_path=artifacts.log_path,
|
|
518
|
+
archived_job_path=artifacts.archived_job_path,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _job_source_bytes(
|
|
523
|
+
workspace: Workspace,
|
|
524
|
+
job: Job,
|
|
525
|
+
*,
|
|
526
|
+
source_path: str | PathLike[str] | None,
|
|
527
|
+
) -> bytes:
|
|
528
|
+
if source_path is None:
|
|
529
|
+
payload = job.model_dump(mode="json", exclude_none=True)
|
|
530
|
+
return yaml.safe_dump(
|
|
531
|
+
payload,
|
|
532
|
+
allow_unicode=True,
|
|
533
|
+
sort_keys=False,
|
|
534
|
+
).encode("utf-8")
|
|
535
|
+
|
|
536
|
+
path = Path(source_path)
|
|
537
|
+
try:
|
|
538
|
+
metadata = path.lstat()
|
|
539
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
540
|
+
raise OSError("source job is not a regular file")
|
|
541
|
+
raw = path.read_bytes()
|
|
542
|
+
except OSError as exc:
|
|
543
|
+
raise ExecutionError(
|
|
544
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
545
|
+
"source job file is missing, unsafe, or unreadable",
|
|
546
|
+
path=path.as_posix(),
|
|
547
|
+
) from exc
|
|
548
|
+
if len(raw) > workspace.config.execution.max_job_bytes:
|
|
549
|
+
raise ExecutionError(
|
|
550
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
551
|
+
"source job file exceeds the configured input limit",
|
|
552
|
+
path=path.as_posix(),
|
|
553
|
+
)
|
|
554
|
+
try:
|
|
555
|
+
current = load_job(
|
|
556
|
+
path,
|
|
557
|
+
max_bytes=workspace.config.execution.max_job_bytes,
|
|
558
|
+
)
|
|
559
|
+
except (JobError, ValueError) as exc:
|
|
560
|
+
raise ExecutionError(
|
|
561
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
562
|
+
"source job file no longer matches the approved job",
|
|
563
|
+
path=path.as_posix(),
|
|
564
|
+
) from exc
|
|
565
|
+
if current != job:
|
|
566
|
+
raise ExecutionError(
|
|
567
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
568
|
+
"source job file no longer matches the approved job",
|
|
569
|
+
path=path.as_posix(),
|
|
570
|
+
)
|
|
571
|
+
return raw
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _error_result(error: ExecutionError) -> tuple[str, str]:
|
|
575
|
+
root = error.cause_code or error.code
|
|
576
|
+
if error.rollback_succeeded is True:
|
|
577
|
+
return "ROLLED_BACK", root.value
|
|
578
|
+
if error.rollback_succeeded is False:
|
|
579
|
+
return "ROLLBACK_FAILED", root.value
|
|
580
|
+
return error.code.value, root.value
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _rollback_state(error: ExecutionError) -> str:
|
|
584
|
+
if error.rollback_skipped:
|
|
585
|
+
return "SKIPPED_CHANGES_KEPT" if error.changes_kept else "SKIPPED_NO_CHANGES"
|
|
586
|
+
return {None: "NOT_STARTED", True: "SUCCESS", False: "FAILED"}[
|
|
587
|
+
error.rollback_succeeded
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _failure_stage(error: ExecutionError, plan: Plan | None) -> str:
|
|
592
|
+
root = error.cause_code or error.code
|
|
593
|
+
if root is ExecutionErrorCode.PATCH_ID_CONFLICT:
|
|
594
|
+
return "JOB"
|
|
595
|
+
if root in {
|
|
596
|
+
ExecutionErrorCode.WORKSPACE_LOCKED,
|
|
597
|
+
ExecutionErrorCode.WORKSPACE_LOCK_FAILED,
|
|
598
|
+
}:
|
|
599
|
+
return "WORKSPACE"
|
|
600
|
+
if root in {
|
|
601
|
+
ExecutionErrorCode.PLAN_STALE,
|
|
602
|
+
ExecutionErrorCode.ACTION_UNSUPPORTED,
|
|
603
|
+
ExecutionErrorCode.KEEP_CHANGES_FORBIDDEN,
|
|
604
|
+
}:
|
|
605
|
+
return "PLAN"
|
|
606
|
+
if root is ExecutionErrorCode.USER_DECLINED:
|
|
607
|
+
return "PLAN"
|
|
608
|
+
if root is ExecutionErrorCode.BACKUP_FAILED:
|
|
609
|
+
return "BACKUP"
|
|
610
|
+
if root is ExecutionErrorCode.ACTION_FAILED:
|
|
611
|
+
return (
|
|
612
|
+
"AUDIT"
|
|
613
|
+
if plan is not None and plan.job.kind is JobKind.AUDIT
|
|
614
|
+
else "ACTIONS"
|
|
615
|
+
)
|
|
616
|
+
if root is ExecutionErrorCode.CHECK_FAILED:
|
|
617
|
+
if plan is not None and len(error.check_results) > len(plan.checks):
|
|
618
|
+
return "FINAL_CHECKS"
|
|
619
|
+
return "INITIAL_CHECKS"
|
|
620
|
+
if root is ExecutionErrorCode.FORMAT_FAILED:
|
|
621
|
+
return "FORMAT_BLACK" if error.path == "black" else "FORMAT_ISORT"
|
|
622
|
+
if root in {
|
|
623
|
+
ExecutionErrorCode.WORKSPACE_INVENTORY_FAILED,
|
|
624
|
+
ExecutionErrorCode.UNEXPECTED_WORKSPACE_CHANGE,
|
|
625
|
+
}:
|
|
626
|
+
return "WORKSPACE_COMPARISON"
|
|
627
|
+
return "SUMMARY"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _conflict_error(job_id: str) -> ExecutionError:
|
|
631
|
+
return ExecutionError(
|
|
632
|
+
ExecutionErrorCode.PATCH_ID_CONFLICT,
|
|
633
|
+
"job ID is already registered with different normalized content",
|
|
634
|
+
item_id=job_id,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _attach_artifacts(error: ExecutionError, artifacts: _Artifacts) -> None:
|
|
639
|
+
error.log_path = artifacts.log_path
|
|
640
|
+
error.archived_job_path = artifacts.archived_job_path
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
__all__ = [
|
|
644
|
+
"RegisteredRunResult",
|
|
645
|
+
"RunResult",
|
|
646
|
+
"RunStatus",
|
|
647
|
+
"execute_plan",
|
|
648
|
+
"execution_exit_code",
|
|
649
|
+
"record_declined_plan",
|
|
650
|
+
"resolve_registered_job",
|
|
651
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Internal controlled-formatting execution surface."""
|
|
2
|
+
|
|
3
|
+
from patchshuttle.formatters.runner import (
|
|
4
|
+
FormattedFileState,
|
|
5
|
+
FormatterResult,
|
|
6
|
+
FormatterRunResult,
|
|
7
|
+
FormatterStatus,
|
|
8
|
+
PreparedFormatter,
|
|
9
|
+
capture_formatted_files,
|
|
10
|
+
prepare_formatters,
|
|
11
|
+
run_formatters,
|
|
12
|
+
verify_formatted_files,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"FormattedFileState",
|
|
17
|
+
"FormatterResult",
|
|
18
|
+
"FormatterRunResult",
|
|
19
|
+
"FormatterStatus",
|
|
20
|
+
"PreparedFormatter",
|
|
21
|
+
"capture_formatted_files",
|
|
22
|
+
"prepare_formatters",
|
|
23
|
+
"run_formatters",
|
|
24
|
+
"verify_formatted_files",
|
|
25
|
+
]
|