syncmill 0.2.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 (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/preflight.py ADDED
@@ -0,0 +1,1129 @@
1
+ """Toolgraph advisory preflight (S5) and strict enforcement (P3.1).
2
+
3
+ Before dispatching a strategy, the Supervisor optionally runs
4
+ ``toolgraph preflight`` once — for the lead matched agent's mapped toolgraph
5
+ identity — to check the run's ambient MCP surface (memtomem, syncmill's own
6
+ read-only ``serve`` gateway) against toolgraph's policy graph. It is
7
+ **advisory**: every failure class (binary absent, timeout, non-zero exit, bad
8
+ JSON, unknown major version) degrades to a yellow warning and the run
9
+ CONTINUES — preflight never blocks or aborts orchestration (the memory idiom).
10
+ The outcome lands in the run envelope's ``preflight`` block; when reporting is
11
+ enabled the artifact is copied to ``{run_dir}/preflight.json`` and digested.
12
+
13
+ syncmill agents are CLI subprocesses that do not themselves call MCP tools, so
14
+ this checks the *ecosystem's* knowledge surface for the mapped identity — it
15
+ does not claim per-attempt tool mediation (the design's honest tier: identity
16
+ validation + generation stamp always; ambient-surface policy check when
17
+ ``candidate_tools`` is configured; per-attempt mediation deliberately reserved).
18
+
19
+ Strict mode keeps Toolgraph as an advisory producer but validates every selected
20
+ identity, applies SyncMill's category policies, and writes a body-free aggregate
21
+ before the Supervisor decides whether to dispatch. Disabled by default
22
+ (``build``-idiom ``None``): no subprocess, byte-identical behavior.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import subprocess
31
+ import tempfile
32
+ from collections import Counter
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ from syncmill.agents import _sync_run
37
+ from syncmill.config import ToolgraphConfig
38
+ # `planning` does not import `preflight`, so this stays acyclic. The sentinel
39
+ # spelling has exactly one producer, and this is the only consumer that has to
40
+ # recognise it.
41
+ from syncmill.planning import unmapped_principal_id
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ PREFLIGHT_FILE = "preflight.json"
46
+ CONTROL_PLAN_FILE = "control-plan.json"
47
+ CONTROL_PREFLIGHT_FILE = "control-preflight.json"
48
+ SCHEMA_VERSION = 1 # the ecosystem-wide contract version (strict `!=` major check)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PreflightOutcome:
53
+ """The result of one preflight run: the envelope block (``status`` always
54
+ present), the yellow CLI warning lines, and the copied artifact path (``None``
55
+ when reporting is disabled — nothing pretends an artifact exists)."""
56
+
57
+ block: dict
58
+ warnings: list[str] = field(default_factory=list)
59
+ artifact_path: Path | None = None
60
+ should_block: bool = False
61
+ block_reasons: tuple[str, ...] = ()
62
+
63
+
64
+ def _build_request(cfg: ToolgraphConfig, *, run_id: str, agent: str) -> dict:
65
+ """The request contract (``contracts/preflight-request.schema.json``) — the
66
+ serialized form of the argv sent to ``toolgraph preflight``."""
67
+ return {
68
+ "schema_version": SCHEMA_VERSION,
69
+ "run_id": run_id,
70
+ "agent": agent,
71
+ "candidate_tools": list(cfg.candidate_tools),
72
+ "policy_profile": cfg.profile,
73
+ }
74
+
75
+
76
+ def _request_argv(cfg: ToolgraphConfig, request: dict, out_path: Path) -> list[str]:
77
+ """argv only — no stdin. ``command`` is the invocation seam tests override."""
78
+ return [
79
+ *cfg.command,
80
+ request["agent"],
81
+ *request["candidate_tools"],
82
+ "--profile",
83
+ request["policy_profile"],
84
+ "--run-id",
85
+ request["run_id"],
86
+ "--out",
87
+ str(out_path),
88
+ ]
89
+
90
+
91
+ def _error_block(profile: str, status: str, *, mode: str = "advisory") -> dict:
92
+ """A minimal envelope block for a failed/unresolved preflight (schema only
93
+ requires ``status`` off the ``ok`` path)."""
94
+ return {"status": status, "mode": mode, "profile": profile}
95
+
96
+
97
+ def run_preflight(
98
+ cfg: ToolgraphConfig,
99
+ *,
100
+ run_id: str,
101
+ agent_ids: list[str],
102
+ out_dir: Path | None,
103
+ control_plan: dict | bytes | None = None,
104
+ ) -> PreflightOutcome | None:
105
+ """Run advisory preflight once for the lead mapped agent. ``None`` when
106
+ disabled; otherwise NEVER raises — every failure degrades to a warning + a
107
+ ``status`` block and the caller continues the run.
108
+
109
+ ``out_dir`` is the run directory (report enabled) or ``None`` (report
110
+ disabled) — in the latter case the subprocess still runs (its identity
111
+ warning is the whole tier-1 value) but the artifact is parsed from a
112
+ tempdir and discarded, so ``artifact_path`` is ``None``.
113
+ """
114
+ if not cfg.enabled:
115
+ return None
116
+ try:
117
+ if cfg.control_plan_enabled:
118
+ if control_plan is None:
119
+ raise ValueError(
120
+ "control-plan preflight enabled without a control plan"
121
+ )
122
+ return _run_control_preflight(
123
+ cfg,
124
+ run_id=run_id,
125
+ agent_ids=agent_ids,
126
+ out_dir=out_dir,
127
+ control_plan=control_plan,
128
+ )
129
+ if cfg.mode == "strict":
130
+ return _run_strict_preflight(
131
+ cfg, run_id=run_id, agent_ids=agent_ids, out_dir=out_dir
132
+ )
133
+ return _run_preflight_inner(
134
+ cfg, run_id=run_id, agent_ids=agent_ids, out_dir=out_dir
135
+ )
136
+ except Exception as exc: # last-resort net — preflight must never abort a run
137
+ logger.warning("preflight failed unexpectedly; continuing run: %s", exc)
138
+ strict = cfg.mode == "strict"
139
+ block = _error_block(cfg.profile, "error", mode=cfg.mode)
140
+ if strict:
141
+ block.update(
142
+ enforcement_decision="block",
143
+ evaluation_count=0,
144
+ block_count=1,
145
+ warning_count=0,
146
+ )
147
+ return PreflightOutcome(
148
+ block=block,
149
+ warnings=[f"preflight failed unexpectedly: {exc}"],
150
+ should_block=strict,
151
+ block_reasons=("INTERNAL_ERROR",) if strict else (),
152
+ )
153
+
154
+
155
+ _CONTROL_FINDING_CODES = frozenset(
156
+ {
157
+ "START_NODE_COUNT",
158
+ "END_NODE_COUNT",
159
+ "UNKNOWN_NODE_REFERENCE",
160
+ "UNREACHABLE_NODE",
161
+ "NO_TERMINAL_PATH",
162
+ "CONTROL_CYCLE_UNSUPPORTED",
163
+ "MODEL_FANOUT_UNBOUNDED",
164
+ "CALL_BOUND_TOO_LOW",
165
+ "PARALLEL_BOUND_TOO_LOW",
166
+ }
167
+ )
168
+
169
+
170
+ def _canonical_json(value: object) -> bytes:
171
+ return (
172
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
173
+ + "\n"
174
+ ).encode("utf-8")
175
+
176
+
177
+ def canonical_control_plan(value: object) -> bytes:
178
+ """Return the canonical bytes shared by preflight and durable workflows."""
179
+ return _canonical_json(value)
180
+
181
+
182
+ def _control_request_argv(
183
+ cfg: ToolgraphConfig, plan_path: Path, out_path: Path
184
+ ) -> list[str]:
185
+ return [
186
+ *cfg.control_plan_command,
187
+ str(plan_path),
188
+ "--profile",
189
+ cfg.profile,
190
+ "--out",
191
+ str(out_path),
192
+ ]
193
+
194
+
195
+ def _invoke_control_toolgraph(
196
+ cfg: ToolgraphConfig, plan_path: Path, out_path: Path
197
+ ) -> tuple[str | None, str]:
198
+ argv = _control_request_argv(cfg, plan_path, out_path)
199
+ try:
200
+ proc = _sync_run( # owned-group runner: a timeout kills the whole tree
201
+ argv, capture_output=True, text=True, errors="replace",
202
+ timeout=cfg.timeout_seconds,
203
+ )
204
+ except FileNotFoundError:
205
+ return (
206
+ "error",
207
+ f"toolgraph binary {cfg.control_plan_command[0]!r} not found in PATH",
208
+ )
209
+ except subprocess.TimeoutExpired:
210
+ return "timeout", f"control preflight timed out after {cfg.timeout_seconds}s"
211
+ except OSError as exc:
212
+ return (
213
+ "error",
214
+ f"control preflight could not launch {cfg.control_plan_command[0]!r}: {exc}",
215
+ )
216
+ if proc.returncode != 0:
217
+ snippet = (proc.stderr or "").strip()[:200]
218
+ return "error", f"control preflight exited {proc.returncode}: {snippet}"
219
+ return None, ""
220
+
221
+
222
+ def _control_malformed_reason(
223
+ doc: object, *, plan: dict, plan_digest: str, profile: str, run_id: str
224
+ ) -> str | None:
225
+ if not isinstance(doc, dict):
226
+ return "artifact is not an object"
227
+ expected = {
228
+ "schema_version": SCHEMA_VERSION,
229
+ "kind": "toolgraph.control-preflight",
230
+ "run_id": run_id,
231
+ "profile": profile,
232
+ "mode": "advisory",
233
+ "plan_digest": plan_digest,
234
+ }
235
+ for key, value in expected.items():
236
+ if doc.get(key) != value:
237
+ return f"{key} does not match the control plan request"
238
+ if not isinstance(doc.get("created_at"), str) or not doc["created_at"]:
239
+ return "created_at is empty"
240
+ graph_state = doc.get("graph_state")
241
+ if not isinstance(graph_state, dict):
242
+ return "graph_state is not an object"
243
+ if set(graph_state) != {"instance_id", "generation"}:
244
+ return "graph_state has an unexpected shape"
245
+ if (
246
+ not isinstance(graph_state.get("instance_id"), str)
247
+ or not graph_state["instance_id"]
248
+ ):
249
+ return "graph_state.instance_id is empty"
250
+ generation = graph_state.get("generation")
251
+ if (
252
+ not isinstance(generation, int)
253
+ or isinstance(generation, bool)
254
+ or generation < 0
255
+ ):
256
+ return "graph_state.generation is not a non-negative integer"
257
+
258
+ expected_principals = {
259
+ row["id"]: row["candidate_tools"] for row in plan["principals"]
260
+ }
261
+ evaluations = doc.get("evaluations")
262
+ if not isinstance(evaluations, list) or len(evaluations) != len(
263
+ expected_principals
264
+ ):
265
+ return "evaluation coverage does not match plan principals"
266
+ seen: set[str] = set()
267
+ producer_warn = False
268
+ for row in evaluations:
269
+ if not isinstance(row, dict) or not isinstance(row.get("principal"), str):
270
+ return "evaluation row has no principal"
271
+ principal = row["principal"]
272
+ if principal in seen or principal not in expected_principals:
273
+ return "evaluation principals are duplicated or unknown"
274
+ seen.add(principal)
275
+ if not isinstance(row.get("agent_found"), bool):
276
+ return "evaluation agent_found is not boolean"
277
+ eligible = row.get("eligible")
278
+ rejected = row.get("rejected")
279
+ if not isinstance(eligible, list) or not all(
280
+ isinstance(item, str) for item in eligible
281
+ ):
282
+ return "evaluation eligible is not a string list"
283
+ if not isinstance(rejected, list):
284
+ return "evaluation rejected is not a list"
285
+ for rejected_row in rejected:
286
+ if (
287
+ not isinstance(rejected_row, dict)
288
+ or rejected_row.get("reason") not in _KNOWN_REASONS
289
+ or not isinstance(rejected_row.get("candidate"), str)
290
+ ):
291
+ return "evaluation rejected row is malformed"
292
+ if rejected_row["reason"] == "AMBIGUOUS_TOOL" and not isinstance(
293
+ rejected_row.get("candidates"), list
294
+ ):
295
+ return "ambiguous rejected row has no candidates"
296
+ if rejected_row["reason"] in {
297
+ "DENY_VIOLATION",
298
+ "DENY_GOVERNED",
299
+ } and not isinstance(rejected_row.get("paths"), list):
300
+ return "deny rejected row has no evidence paths"
301
+ if row["agent_found"]:
302
+ accounted = [
303
+ *eligible,
304
+ *(rejected_row["candidate"] for rejected_row in rejected),
305
+ ]
306
+ if Counter(accounted) != Counter(expected_principals[principal]):
307
+ return "evaluation candidate coverage does not match the plan"
308
+ elif eligible or rejected:
309
+ return "unresolved principal shape is inconsistent"
310
+ producer_warn = producer_warn or not row["agent_found"] or bool(rejected)
311
+ if seen != set(expected_principals):
312
+ return "evaluation coverage does not match plan principals"
313
+
314
+ findings = doc.get("findings")
315
+ if not isinstance(findings, list):
316
+ return "findings is not a list"
317
+ if any(
318
+ not isinstance(row, dict) or row.get("code") not in _CONTROL_FINDING_CODES
319
+ for row in findings
320
+ ):
321
+ return "finding has an unknown code"
322
+ producer_warn = producer_warn or bool(findings)
323
+ expected_decision = "advisory_warn" if producer_warn else "advisory_allow"
324
+ if doc.get("decision") != expected_decision:
325
+ return "decision is inconsistent with evaluations and findings"
326
+ return None
327
+
328
+
329
+ def _control_unavailable_outcome(
330
+ cfg: ToolgraphConfig, *, status: str, message: str
331
+ ) -> PreflightOutcome:
332
+ strict = cfg.mode == "strict"
333
+ action = _policy_action(cfg, "TOOLGRAPH_UNAVAILABLE")
334
+ should_block = strict and action == "block"
335
+ block = _error_block(cfg.profile, status, mode=cfg.mode)
336
+ if strict:
337
+ block.update(
338
+ enforcement_decision="block" if should_block else "allow",
339
+ evaluation_count=0,
340
+ block_count=1 if should_block else 0,
341
+ warning_count=0 if should_block else 1,
342
+ )
343
+ return PreflightOutcome(
344
+ block=block,
345
+ warnings=[message],
346
+ should_block=should_block,
347
+ block_reasons=("TOOLGRAPH_UNAVAILABLE",) if should_block else (),
348
+ )
349
+
350
+
351
+ def _run_control_preflight(
352
+ cfg: ToolgraphConfig,
353
+ *,
354
+ run_id: str,
355
+ agent_ids: list[str],
356
+ out_dir: Path | None,
357
+ control_plan: dict | bytes,
358
+ ) -> PreflightOutcome:
359
+ """Consume one Toolgraph batch verdict and optionally enforce it strictly."""
360
+ from syncmill.report import sha256_digest
361
+
362
+ if isinstance(control_plan, bytes):
363
+ raw_plan = control_plan
364
+ try:
365
+ decoded_plan = json.loads(raw_plan)
366
+ except (UnicodeDecodeError, ValueError, TypeError, RecursionError) as exc:
367
+ raise ValueError("stored control plan is not valid JSON") from exc
368
+ if not isinstance(decoded_plan, dict):
369
+ raise ValueError("stored control plan is not a JSON object")
370
+ else:
371
+ decoded_plan = control_plan
372
+ raw_plan = _canonical_json(decoded_plan)
373
+ plan_digest = sha256_digest(raw_plan)
374
+ with tempfile.TemporaryDirectory() as tmp:
375
+ plan_path = Path(tmp) / CONTROL_PLAN_FILE
376
+ result_path = Path(tmp) / CONTROL_PREFLIGHT_FILE
377
+ plan_path.write_bytes(raw_plan)
378
+ status, message = _invoke_control_toolgraph(cfg, plan_path, result_path)
379
+ if status is not None:
380
+ return _control_unavailable_outcome(cfg, status=status, message=message)
381
+ try:
382
+ raw_result = result_path.read_bytes()
383
+ doc = json.loads(raw_result)
384
+ except (OSError, ValueError) as exc:
385
+ malformed = f"control preflight produced unreadable output: {exc}"
386
+ if cfg.mode == "strict":
387
+ return PreflightOutcome(
388
+ block={
389
+ **_error_block(cfg.profile, "error", mode="strict"),
390
+ "enforcement_decision": "block",
391
+ "evaluation_count": 0,
392
+ "block_count": 1,
393
+ "warning_count": 0,
394
+ },
395
+ warnings=[malformed],
396
+ should_block=True,
397
+ block_reasons=("CONTRACT_INVALID",),
398
+ )
399
+ return PreflightOutcome(_error_block(cfg.profile, "error"), [malformed])
400
+
401
+ malformed = _control_malformed_reason(
402
+ doc,
403
+ plan=decoded_plan,
404
+ plan_digest=plan_digest,
405
+ profile=cfg.profile,
406
+ run_id=run_id,
407
+ )
408
+ if malformed is not None:
409
+ message = f"control preflight artifact is invalid ({malformed})"
410
+ if cfg.mode == "strict":
411
+ return PreflightOutcome(
412
+ block={
413
+ **_error_block(cfg.profile, "error", mode="strict"),
414
+ "enforcement_decision": "block",
415
+ "evaluation_count": 0,
416
+ "block_count": 1,
417
+ "warning_count": 0,
418
+ },
419
+ warnings=[message],
420
+ should_block=True,
421
+ block_reasons=("CONTRACT_INVALID",),
422
+ )
423
+ return PreflightOutcome(_error_block(cfg.profile, "error"), [message])
424
+
425
+ warnings: list[str] = []
426
+ blocking: list[str] = []
427
+ warning_reasons: list[str] = []
428
+ unmapped_agents = [agent for agent in agent_ids if agent not in cfg.agent_map]
429
+ if unmapped_agents:
430
+ reason = "AGENT_MAPPING_MISSING"
431
+ target = (
432
+ blocking
433
+ if cfg.mode == "strict" and _policy_action(cfg, reason) == "block"
434
+ else warning_reasons
435
+ )
436
+ target.append(reason)
437
+ warnings.append(
438
+ "control preflight has unmapped syncmill agents: "
439
+ + ", ".join(unmapped_agents)
440
+ )
441
+
442
+ # Derived from the agents that ACTUALLY had no mapping, not from how the
443
+ # identity happens to be spelled. Deciding by prefix let a deliberately
444
+ # mapped identity pass for a sentinel: Toolgraph reporting it missing
445
+ # then suppressed AGENT_NOT_FOUND, and AGENT_MAPPING_MISSING could not
446
+ # fire either because the agent IS mapped — strict mode dispatching with
447
+ # neither reason recorded, the outcome it exists to prevent. The config
448
+ # now refuses an identity of the exact synthetic shape, so the two can no
449
+ # longer collide; this is what keeps the classification right if that
450
+ # reservation is ever relaxed, and it is the half that says what the set
451
+ # actually means.
452
+ sentinel_principals = {
453
+ unmapped_principal_id(agent) for agent in unmapped_agents
454
+ }
455
+ for evaluation in doc["evaluations"]:
456
+ principal = evaluation["principal"]
457
+ if not evaluation["agent_found"] and principal not in sentinel_principals:
458
+ reason = "AGENT_NOT_FOUND"
459
+ target = (
460
+ blocking
461
+ if cfg.mode == "strict" and _policy_action(cfg, reason) == "block"
462
+ else warning_reasons
463
+ )
464
+ target.append(reason)
465
+ for rejected in evaluation["rejected"]:
466
+ reason = rejected["reason"]
467
+ target = (
468
+ blocking
469
+ if cfg.mode == "strict" and _policy_action(cfg, reason) == "block"
470
+ else warning_reasons
471
+ )
472
+ target.append(reason)
473
+ if doc["findings"]:
474
+ finding_codes = sorted({row["code"] for row in doc["findings"]})
475
+ warnings.append("control-plan findings: " + ", ".join(finding_codes))
476
+ if cfg.mode == "strict":
477
+ blocking.append("CONTROL_PLAN_FINDING")
478
+ else:
479
+ warning_reasons.append("CONTROL_PLAN_FINDING")
480
+ if blocking or warning_reasons:
481
+ warnings.append(
482
+ f"control preflight: block={sorted(set(blocking)) or 'none'}, "
483
+ f"warn={sorted(set(warning_reasons)) or 'none'}"
484
+ )
485
+
486
+ if out_dir is None and cfg.mode == "strict":
487
+ blocking.append("EVIDENCE_PERSIST_UNAVAILABLE")
488
+ warnings.append(
489
+ "strict control preflight requires a report artifact directory"
490
+ )
491
+
492
+ artifact_path: Path | None = None
493
+ if out_dir is not None:
494
+ written: list[Path] = []
495
+ try:
496
+ written.append(_atomic_write(raw_plan, out_dir / CONTROL_PLAN_FILE))
497
+ artifact_path = _atomic_write(
498
+ raw_result, out_dir / CONTROL_PREFLIGHT_FILE
499
+ )
500
+ written.append(artifact_path)
501
+ except (OSError, ValueError) as exc:
502
+ warnings.append(
503
+ f"control preflight evidence could not be persisted: {exc}"
504
+ )
505
+ if cfg.mode == "strict":
506
+ blocking.append("EVIDENCE_PERSIST_FAILED")
507
+ for stale in written:
508
+ try:
509
+ stale.unlink(missing_ok=True)
510
+ except OSError:
511
+ pass
512
+ artifact_path = None
513
+
514
+ should_block = cfg.mode == "strict" and bool(blocking)
515
+ persistence_error = out_dir is not None and artifact_path is None
516
+ status = (
517
+ "error"
518
+ if (
519
+ {
520
+ "EVIDENCE_PERSIST_UNAVAILABLE",
521
+ "EVIDENCE_PERSIST_FAILED",
522
+ }
523
+ & set(blocking)
524
+ or persistence_error
525
+ )
526
+ else "ok"
527
+ )
528
+ warning_count = len(set(warning_reasons)) + len(doc["findings"])
529
+ block: dict = {
530
+ "status": status,
531
+ "mode": cfg.mode,
532
+ "profile": cfg.profile,
533
+ "graph_generation": doc["graph_state"]["generation"],
534
+ "graph_state": doc["graph_state"],
535
+ "warning_count": warning_count,
536
+ "evaluation_count": len(doc["evaluations"]),
537
+ "producer_decision": doc["decision"],
538
+ }
539
+ if artifact_path is not None:
540
+ block["artifact"] = CONTROL_PREFLIGHT_FILE
541
+ block["digest"] = sha256_digest(raw_result)
542
+ block["control_plan"] = {
543
+ "artifact": CONTROL_PLAN_FILE,
544
+ "digest": plan_digest,
545
+ }
546
+ if cfg.mode == "strict":
547
+ block.update(
548
+ enforcement_decision="block" if should_block else "allow",
549
+ block_count=len(set(blocking)),
550
+ )
551
+ return PreflightOutcome(
552
+ block=block,
553
+ warnings=warnings,
554
+ artifact_path=artifact_path,
555
+ should_block=should_block,
556
+ block_reasons=tuple(sorted(set(blocking))),
557
+ )
558
+
559
+
560
+ _KNOWN_REASONS = frozenset(
561
+ {
562
+ "TOOL_NOT_FOUND",
563
+ "AMBIGUOUS_TOOL",
564
+ "NOT_GRANTED",
565
+ "DENY_VIOLATION",
566
+ "DENY_GOVERNED",
567
+ "DRIFTED",
568
+ "UNMAPPED",
569
+ }
570
+ )
571
+ _UNKNOWN_REASONS = frozenset({"TOOL_NOT_FOUND", "AMBIGUOUS_TOOL"})
572
+
573
+
574
+ def _policy_action(cfg: ToolgraphConfig, reason: str) -> str:
575
+ if reason in _UNKNOWN_REASONS or reason in {
576
+ "AGENT_MAPPING_MISSING",
577
+ "AGENT_NOT_FOUND",
578
+ }:
579
+ return cfg.unknown_policy
580
+ if reason == "DRIFTED":
581
+ return cfg.drift_policy
582
+ if reason == "TOOLGRAPH_UNAVAILABLE":
583
+ return cfg.unavailable_policy
584
+ return "block"
585
+
586
+
587
+ def _run_strict_preflight(
588
+ cfg: ToolgraphConfig,
589
+ *,
590
+ run_id: str,
591
+ agent_ids: list[str],
592
+ out_dir: Path | None,
593
+ ) -> PreflightOutcome:
594
+ """Evaluate every participating identity and enforce the selected profile.
595
+
596
+ Toolgraph remains an advisory producer. This consumer-owned aggregate is
597
+ the P3.1 enforcement boundary; invalid evidence always fails closed.
598
+ """
599
+ from syncmill.report import sha256_digest
600
+
601
+ warnings: list[str] = []
602
+ evaluations: list[dict] = []
603
+ blocking: list[str] = []
604
+ generations: list[int] = []
605
+ persisted: list[tuple[bytes, str]] = []
606
+
607
+ # Preserve lineup order while evaluating a shared Toolgraph identity once.
608
+ grouped: dict[str, list[str]] = {}
609
+ for agent_id in agent_ids:
610
+ mapped = cfg.agent_map.get(agent_id)
611
+ if mapped is None:
612
+ reason = "AGENT_MAPPING_MISSING"
613
+ action = _policy_action(cfg, reason)
614
+ evaluations.append(
615
+ {
616
+ "syncmill_agents": [agent_id],
617
+ "toolgraph_agent": None,
618
+ "status": "unresolved_identity",
619
+ "blocking_reasons": [reason] if action == "block" else [],
620
+ "warning_reasons": [reason] if action == "warn" else [],
621
+ }
622
+ )
623
+ warnings.append(f"agent {agent_id!r} has no toolgraph identity mapping")
624
+ if action == "block":
625
+ blocking.append(reason)
626
+ continue
627
+ grouped.setdefault(mapped, []).append(agent_id)
628
+
629
+ with tempfile.TemporaryDirectory() as tmp:
630
+ for index, (mapped, syncmill_agents) in enumerate(grouped.items()):
631
+ request = _build_request(cfg, run_id=run_id, agent=mapped)
632
+ out_path = Path(tmp) / f"preflight-{index:03d}.json"
633
+ operational = _invoke_toolgraph(cfg, request, out_path)
634
+ if operational[0] is not None:
635
+ status, message = operational[0], operational[1]
636
+ reason = "TOOLGRAPH_UNAVAILABLE"
637
+ action = _policy_action(cfg, reason)
638
+ evaluations.append(
639
+ {
640
+ "syncmill_agents": syncmill_agents,
641
+ "toolgraph_agent": mapped,
642
+ "status": status,
643
+ "blocking_reasons": [reason] if action == "block" else [],
644
+ "warning_reasons": [reason] if action == "warn" else [],
645
+ }
646
+ )
647
+ warnings.append(message)
648
+ if action == "block":
649
+ blocking.append(reason)
650
+ continue
651
+
652
+ try:
653
+ raw = out_path.read_bytes()
654
+ doc = json.loads(raw)
655
+ except (OSError, ValueError) as exc:
656
+ reason = "CONTRACT_INVALID"
657
+ blocking.append(reason)
658
+ warnings.append(f"preflight produced unreadable output: {exc}")
659
+ evaluations.append(
660
+ {
661
+ "syncmill_agents": syncmill_agents,
662
+ "toolgraph_agent": mapped,
663
+ "status": "error",
664
+ "blocking_reasons": [reason],
665
+ "warning_reasons": [],
666
+ }
667
+ )
668
+ continue
669
+
670
+ malformed = _strict_malformed_reason(doc, request)
671
+ if malformed is not None:
672
+ reason = "CONTRACT_INVALID"
673
+ blocking.append(reason)
674
+ warnings.append(f"preflight artifact is invalid ({malformed})")
675
+ evaluations.append(
676
+ {
677
+ "syncmill_agents": syncmill_agents,
678
+ "toolgraph_agent": mapped,
679
+ "status": "error",
680
+ "blocking_reasons": [reason],
681
+ "warning_reasons": [],
682
+ }
683
+ )
684
+ continue
685
+
686
+ generations.append(doc["graph_generation"])
687
+ row_blocks: list[str] = []
688
+ row_warnings: list[str] = []
689
+ if doc["decision"] == "unresolved_identity":
690
+ reason = "AGENT_NOT_FOUND"
691
+ (
692
+ row_blocks
693
+ if _policy_action(cfg, reason) == "block"
694
+ else row_warnings
695
+ ).append(reason)
696
+ else:
697
+ for rejected in doc["rejected"]:
698
+ reason = rejected["reason"]
699
+ (
700
+ row_blocks
701
+ if _policy_action(cfg, reason) == "block"
702
+ else row_warnings
703
+ ).append(reason)
704
+ blocking.extend(row_blocks)
705
+ if row_blocks or row_warnings:
706
+ warnings.append(
707
+ f"preflight for {mapped!r}: block={row_blocks or 'none'}, "
708
+ f"warn={row_warnings or 'none'}"
709
+ )
710
+ rel = f"preflight/eval-{index:03d}.json"
711
+ persisted.append((raw, rel))
712
+ evaluations.append(
713
+ {
714
+ "syncmill_agents": syncmill_agents,
715
+ "toolgraph_agent": mapped,
716
+ "status": (
717
+ "unresolved_identity"
718
+ if doc["decision"] == "unresolved_identity"
719
+ else "ok"
720
+ ),
721
+ "producer_decision": doc["decision"],
722
+ "graph_generation": doc["graph_generation"],
723
+ "artifact": rel,
724
+ "digest": sha256_digest(raw),
725
+ "blocking_reasons": row_blocks,
726
+ "warning_reasons": row_warnings,
727
+ }
728
+ )
729
+
730
+ unique_generations = set(generations)
731
+ if len(unique_generations) > 1:
732
+ blocking.append("GRAPH_GENERATION_MISMATCH")
733
+ warnings.append(
734
+ "strict preflight identities were evaluated at different graph generations"
735
+ )
736
+ if out_dir is None:
737
+ blocking.append("EVIDENCE_PERSIST_UNAVAILABLE")
738
+ warnings.append(
739
+ "strict preflight requires a writable report artifact directory"
740
+ )
741
+
742
+ # Persist the per-eval artifacts BEFORE the decision/manifest are computed,
743
+ # so a persist failure is itself a blocking reason recorded IN the manifest.
744
+ # The old order froze the manifest bytes first and flipped only the RETURNED
745
+ # block on failure — the on-disk decision could read `allow` for a run that
746
+ # was correctly blocked.
747
+ persisted_paths: list[Path] = []
748
+ if out_dir is not None:
749
+ persist_error: str | None = None
750
+ written: set[str] = set()
751
+ for raw, rel in persisted:
752
+ try:
753
+ persisted_paths.append(_atomic_write(raw, out_dir / rel))
754
+ written.add(rel)
755
+ except (OSError, ValueError) as exc:
756
+ persist_error = str(exc)
757
+ break
758
+ if persist_error is not None:
759
+ warnings.append(
760
+ f"strict preflight evidence could not be persisted: {persist_error}"
761
+ )
762
+ blocking.append("EVIDENCE_PERSIST_FAILED")
763
+ # The manifest must reference only files that actually exist: a
764
+ # mid-loop failure leaves earlier artifacts on disk but not the
765
+ # failed/remaining ones — strip their references (the same
766
+ # no-artifact shape the error/timeout rows use) so a consumer can
767
+ # verify every reference the aggregate makes.
768
+ for row in evaluations:
769
+ if row.get("artifact") is not None and row["artifact"] not in written:
770
+ row.pop("artifact", None)
771
+ row.pop("digest", None)
772
+
773
+ should_block = bool(blocking)
774
+ statuses = {row["status"] for row in evaluations}
775
+ status = (
776
+ "error"
777
+ if "error" in statuses
778
+ else "timeout"
779
+ if "timeout" in statuses
780
+ else "unresolved_identity"
781
+ if "unresolved_identity" in statuses
782
+ else "ok"
783
+ )
784
+ if {
785
+ "GRAPH_GENERATION_MISMATCH",
786
+ "EVIDENCE_PERSIST_UNAVAILABLE",
787
+ "EVIDENCE_PERSIST_FAILED",
788
+ } & set(blocking):
789
+ status = "error"
790
+ manifest = {
791
+ "schema_version": 1,
792
+ "kind": "syncmill.toolgraph-enforcement",
793
+ "run_id": run_id,
794
+ "mode": "strict",
795
+ "profile": cfg.profile,
796
+ "policies": {
797
+ "unknown": cfg.unknown_policy,
798
+ "drift": cfg.drift_policy,
799
+ "unavailable": cfg.unavailable_policy,
800
+ },
801
+ "decision": "block" if should_block else "allow",
802
+ "evaluations": evaluations,
803
+ "block_reasons": sorted(set(blocking)),
804
+ }
805
+ raw_manifest = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()
806
+ block: dict = {
807
+ "status": status,
808
+ "mode": "strict",
809
+ "profile": cfg.profile,
810
+ "enforcement_decision": manifest["decision"],
811
+ "evaluation_count": len(evaluations),
812
+ # Count distinct blocking conditions; the manifest exposes the same
813
+ # deduplicated vocabulary in block_reasons.
814
+ "block_count": len(set(blocking)),
815
+ "warning_count": sum(len(row["warning_reasons"]) for row in evaluations),
816
+ }
817
+ if len(unique_generations) == 1:
818
+ block["graph_generation"] = next(iter(unique_generations))
819
+
820
+ # The manifest is written LAST, after every blocking reason (including a
821
+ # failed eval persist above) is final — the on-disk decision always matches
822
+ # the returned outcome.
823
+ artifact_path: Path | None = None
824
+ if out_dir is not None:
825
+ try:
826
+ artifact_path = _atomic_write(raw_manifest, out_dir / PREFLIGHT_FILE)
827
+ block["artifact"] = PREFLIGHT_FILE
828
+ block["digest"] = sha256_digest(raw_manifest)
829
+ except (OSError, ValueError) as exc:
830
+ warnings.append(f"strict preflight manifest could not be persisted: {exc}")
831
+ blocking.append("EVIDENCE_PERSIST_FAILED")
832
+ should_block = True
833
+ block.update(status="error", enforcement_decision="block")
834
+ block["block_count"] = len(set(blocking))
835
+ # Leave no partial evidence pile behind: raw producer docs whose
836
+ # decision reads advisory_allow, with no aggregate manifest, would
837
+ # misrepresent a blocked run. Best-effort — the block is already
838
+ # returned either way.
839
+ artifact_path = None
840
+ for stale in (*persisted_paths, out_dir / PREFLIGHT_FILE):
841
+ try:
842
+ stale.unlink(missing_ok=True)
843
+ except OSError:
844
+ pass
845
+
846
+ return PreflightOutcome(
847
+ block=block,
848
+ warnings=warnings,
849
+ artifact_path=artifact_path,
850
+ should_block=should_block,
851
+ block_reasons=tuple(sorted(set(blocking))),
852
+ )
853
+
854
+
855
+ def _invoke_toolgraph(
856
+ cfg: ToolgraphConfig, request: dict, out_path: Path
857
+ ) -> tuple[str | None, str]:
858
+ argv = _request_argv(cfg, request, out_path)
859
+ try:
860
+ proc = _sync_run( # owned-group runner: a timeout kills the whole tree
861
+ argv, capture_output=True, text=True, errors="replace",
862
+ timeout=cfg.timeout_seconds,
863
+ )
864
+ except FileNotFoundError:
865
+ return "error", f"toolgraph binary {cfg.command[0]!r} not found in PATH"
866
+ except subprocess.TimeoutExpired:
867
+ return "timeout", f"preflight timed out after {cfg.timeout_seconds}s"
868
+ except OSError as exc:
869
+ return "error", f"preflight could not launch {cfg.command[0]!r}: {exc}"
870
+ if proc.returncode != 0:
871
+ snippet = (proc.stderr or "").strip()[:200]
872
+ return "error", f"preflight exited {proc.returncode}: {snippet}"
873
+ return None, ""
874
+
875
+
876
+ def _strict_malformed_reason(doc: object, request: dict) -> str | None:
877
+ if not isinstance(doc, dict):
878
+ return "artifact is not an object"
879
+ expected = {
880
+ "schema_version": SCHEMA_VERSION,
881
+ "kind": "toolgraph.preflight",
882
+ "run_id": request["run_id"],
883
+ "agent": request["agent"],
884
+ "profile": request["policy_profile"],
885
+ "mode": "advisory",
886
+ }
887
+ for key, value in expected.items():
888
+ if doc.get(key) != value:
889
+ return f"{key} does not match the request"
890
+ reason = _malformed_reason(doc)
891
+ if reason is not None:
892
+ return reason
893
+ if not isinstance(doc.get("agent_found"), bool):
894
+ return "agent_found is not boolean"
895
+ eligible = doc.get("eligible")
896
+ rejected = doc.get("rejected")
897
+ if not isinstance(eligible, list) or not all(
898
+ isinstance(item, str) for item in eligible
899
+ ):
900
+ return "eligible is not a string list"
901
+ if not isinstance(rejected, list):
902
+ return "rejected is not a list"
903
+ for row in rejected:
904
+ if not isinstance(row, dict) or row.get("reason") not in _KNOWN_REASONS:
905
+ return "rejected row has an unknown reason"
906
+ if not isinstance(row.get("candidate"), str):
907
+ return "rejected row has no candidate"
908
+ if row["reason"] == "AMBIGUOUS_TOOL" and not isinstance(
909
+ row.get("candidates"), list
910
+ ):
911
+ return "ambiguous row has no candidates"
912
+ if row["reason"] in {"DENY_VIOLATION", "DENY_GOVERNED"} and not isinstance(
913
+ row.get("paths"), list
914
+ ):
915
+ return "deny row has no evidence paths"
916
+ if not doc["agent_found"]:
917
+ if doc["decision"] != "unresolved_identity" or eligible or rejected:
918
+ return "unresolved identity shape is inconsistent"
919
+ return None
920
+ expected_decision = "advisory_warn" if rejected else "advisory_allow"
921
+ if doc["decision"] != expected_decision:
922
+ return "decision is inconsistent with rejected rows"
923
+ accounted = [*eligible, *(row["candidate"] for row in rejected)]
924
+ if Counter(accounted) != Counter(request["candidate_tools"]):
925
+ return "candidate coverage does not match the request"
926
+ return None
927
+
928
+
929
+ def _run_preflight_inner(
930
+ cfg: ToolgraphConfig,
931
+ *,
932
+ run_id: str,
933
+ agent_ids: list[str],
934
+ out_dir: Path | None,
935
+ ) -> PreflightOutcome:
936
+ warnings: list[str] = []
937
+
938
+ # Map ids locally (pure dict lookups). Warn on every unmapped id; the lead
939
+ # (first matched) mapped agent is the identity we probe.
940
+ lead: str | None = None
941
+ for agent_id in agent_ids:
942
+ mapped = cfg.agent_map.get(agent_id)
943
+ if mapped is None:
944
+ warnings.append(
945
+ f"agent {agent_id!r} has no [integration.toolgraph.agent_map] "
946
+ "entry — skipped for preflight"
947
+ )
948
+ elif lead is None:
949
+ lead = mapped
950
+
951
+ if lead is None:
952
+ warnings.append(
953
+ "no agent maps to a toolgraph identity "
954
+ "(configure [integration.toolgraph.agent_map]); preflight skipped"
955
+ )
956
+ return PreflightOutcome(
957
+ block=_error_block(cfg.profile, "unresolved_identity"),
958
+ warnings=warnings,
959
+ )
960
+
961
+ request = _build_request(cfg, run_id=run_id, agent=lead)
962
+
963
+ # The subprocess ALWAYS writes to a tempdir; only validated bytes are then
964
+ # atomically copied into the run dir (_build_outcome). So a rejected artifact
965
+ # (bad JSON, unknown major, non-zero exit) never lingers in the run dir, and
966
+ # the copied bytes are exactly what the envelope digest covers.
967
+ with tempfile.TemporaryDirectory() as tmp:
968
+ out_path = Path(tmp) / PREFLIGHT_FILE
969
+ argv = _request_argv(cfg, request, out_path)
970
+ try:
971
+ proc = _sync_run( # owned-group runner (see gates.py note)
972
+ argv,
973
+ capture_output=True,
974
+ text=True,
975
+ # The binary's stderr becomes a warning snippet, nothing more.
976
+ # Strict decoding turned a latin-1 byte in it into a ValueError
977
+ # that the last-resort net reported as an internal error, which
978
+ # strict mode blocks on regardless of `unavailable_policy`.
979
+ errors="replace",
980
+ timeout=cfg.timeout_seconds,
981
+ )
982
+ except FileNotFoundError:
983
+ warnings.append(f"toolgraph binary {cfg.command[0]!r} not found in PATH")
984
+ return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
985
+ except subprocess.TimeoutExpired:
986
+ warnings.append(f"preflight timed out after {cfg.timeout_seconds}s")
987
+ return PreflightOutcome(_error_block(cfg.profile, "timeout"), warnings)
988
+ except OSError as exc:
989
+ warnings.append(f"preflight could not launch {cfg.command[0]!r}: {exc}")
990
+ return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
991
+
992
+ if proc.returncode != 0:
993
+ snippet = (proc.stderr or "").strip()[:200]
994
+ warnings.append(f"preflight exited {proc.returncode}: {snippet}")
995
+ return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
996
+
997
+ try:
998
+ raw = out_path.read_bytes()
999
+ doc = json.loads(raw)
1000
+ except (OSError, ValueError) as exc:
1001
+ warnings.append(f"preflight produced unreadable output: {exc}")
1002
+ return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
1003
+
1004
+ if not isinstance(doc, dict) or doc.get("schema_version") != SCHEMA_VERSION:
1005
+ warnings.append(
1006
+ f"preflight artifact schema_version {doc.get('schema_version') if isinstance(doc, dict) else '?'} "
1007
+ f"!= {SCHEMA_VERSION}; rejected"
1008
+ )
1009
+ return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
1010
+
1011
+ # A right-major but malformed v1 artifact (missing/typed-wrong
1012
+ # graph_generation, non-list rejected, unknown decision) must NOT reach
1013
+ # the envelope — an "ok" block with graph_generation=None would violate
1014
+ # run-envelope.schema.json. Reject it as an advisory error before copying.
1015
+ reason = _malformed_reason(doc)
1016
+ if reason is not None:
1017
+ warnings.append(f"preflight artifact is malformed ({reason}); rejected")
1018
+ return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
1019
+
1020
+ return _build_outcome(cfg, doc, raw, out_dir, warnings)
1021
+
1022
+
1023
+ _KNOWN_DECISIONS = frozenset({"advisory_allow", "advisory_warn", "unresolved_identity"})
1024
+
1025
+
1026
+ def _malformed_reason(doc: dict) -> str | None:
1027
+ """Validate the v1 fields the envelope block is built from; a reason string
1028
+ if the artifact can't produce a schema-valid block, else ``None``.
1029
+
1030
+ Not full jsonschema (the toolgraph schema isn't a shipped runtime asset) —
1031
+ just the fields consumed: ``graph_generation`` (int ≥0, required by the
1032
+ envelope's ``ok`` block), ``rejected`` (a list — ``warning_count`` counts
1033
+ it), and ``decision`` (a known value — an unknown one must not silently
1034
+ map to ``ok``)."""
1035
+ gen = doc.get("graph_generation")
1036
+ if not isinstance(gen, int) or isinstance(gen, bool) or gen < 0:
1037
+ return "graph_generation is not a non-negative integer"
1038
+ if not isinstance(doc.get("rejected", []), list):
1039
+ return "rejected is not a list"
1040
+ if doc.get("decision") not in _KNOWN_DECISIONS:
1041
+ return f"unknown decision {doc.get('decision')!r}"
1042
+ return None
1043
+
1044
+
1045
+ def _build_outcome(
1046
+ cfg: ToolgraphConfig,
1047
+ doc: dict,
1048
+ raw: bytes,
1049
+ out_dir: Path | None,
1050
+ warnings: list[str],
1051
+ ) -> PreflightOutcome:
1052
+ """Map a valid preflight artifact to the envelope block + copy the artifact."""
1053
+ from syncmill.report import sha256_digest
1054
+
1055
+ decision = doc.get("decision")
1056
+ rejected = doc.get("rejected") or []
1057
+ warning_count = len(rejected) if isinstance(rejected, list) else 0
1058
+
1059
+ if decision == "unresolved_identity":
1060
+ status = "unresolved_identity"
1061
+ warnings.append(
1062
+ f"preflight could not resolve agent identity {doc.get('agent')!r} "
1063
+ "in the toolgraph"
1064
+ )
1065
+ else: # advisory_allow / advisory_warn (validated by _malformed_reason) → ok
1066
+ status = "ok"
1067
+ if warning_count:
1068
+ warnings.append(
1069
+ f"preflight: {warning_count} candidate tool(s) rejected by policy"
1070
+ )
1071
+
1072
+ block: dict = {
1073
+ "status": status,
1074
+ "mode": "advisory",
1075
+ # The REQUESTED profile — guaranteed a valid enum value (the config
1076
+ # validator), and it's exactly what this artifact evaluated.
1077
+ "profile": cfg.profile,
1078
+ "graph_generation": doc["graph_generation"], # validated int by now
1079
+ "warning_count": warning_count,
1080
+ }
1081
+
1082
+ # Copy the exact bytes into the run dir (report enabled) so the envelope's
1083
+ # digest verifies the artifact the consumer reads.
1084
+ if out_dir is not None:
1085
+ try:
1086
+ dest = _atomic_copy(raw, out_dir)
1087
+ block["artifact"] = PREFLIGHT_FILE
1088
+ block["digest"] = sha256_digest(raw)
1089
+ return PreflightOutcome(block, warnings, artifact_path=dest)
1090
+ except (OSError, ValueError) as exc:
1091
+ warnings.append(f"preflight artifact could not be persisted: {exc}")
1092
+ return PreflightOutcome(_error_block(cfg.profile, "error"), warnings)
1093
+ return PreflightOutcome(block, warnings)
1094
+
1095
+
1096
+ def _atomic_copy(raw: bytes, out_dir: Path) -> Path:
1097
+ """Write ``raw`` to ``{out_dir}/preflight.json`` atomically (the report
1098
+ ``write_envelope`` mkstemp+``os.replace`` recipe)."""
1099
+ out_dir.mkdir(parents=True, exist_ok=True)
1100
+ dest = out_dir / PREFLIGHT_FILE
1101
+ fd, tmp = tempfile.mkstemp(dir=str(out_dir), suffix=".tmp")
1102
+ try:
1103
+ with os.fdopen(fd, "wb") as f:
1104
+ f.write(raw)
1105
+ os.replace(tmp, dest)
1106
+ except BaseException:
1107
+ try:
1108
+ os.unlink(tmp)
1109
+ except OSError:
1110
+ pass
1111
+ raise
1112
+ return dest
1113
+
1114
+
1115
+ def _atomic_write(raw: bytes, dest: Path) -> Path:
1116
+ """Atomically write a strict aggregate or one validated producer artifact."""
1117
+ dest.parent.mkdir(parents=True, exist_ok=True)
1118
+ fd, tmp = tempfile.mkstemp(dir=str(dest.parent), suffix=".tmp")
1119
+ try:
1120
+ with os.fdopen(fd, "wb") as handle:
1121
+ handle.write(raw)
1122
+ os.replace(tmp, dest)
1123
+ except BaseException:
1124
+ try:
1125
+ os.unlink(tmp)
1126
+ except OSError:
1127
+ pass
1128
+ raise
1129
+ return dest