ccx-native 0.0.1 → 0.1.0

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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,681 @@
1
+ """Durable storage for task-specific Python controller runs.
2
+
3
+ Controller records are deliberately independent from Claude and Codex worker
4
+ records. They retain qualified worker handles, never duplicate or own worker
5
+ lifecycle state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import contextmanager
11
+ import copy
12
+ import fcntl
13
+ import hashlib
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import tempfile
18
+ import time
19
+ import uuid
20
+ from typing import Any, Callable, Iterator
21
+
22
+ from ccx import constants
23
+ from ccx.system import oslayer
24
+ from ccx.errors import ControllerError, ControllerRunNotFound
25
+
26
+
27
+ CONTROLLER_SCHEMA_VERSION = 1
28
+ # Operation ordinals changed when WorkerClient.steer/kill became observable.
29
+ # Missing/unknown layout markers MUST NOT silently adopt the new slot order.
30
+ CONTROLLER_OPERATION_JOURNAL_LAYOUT = 3
31
+ TERMINAL_LIFECYCLES = frozenset({"succeeded", "failed", "stopped", "unavailable"})
32
+
33
+ #: Mechanical form each execution attempt began with. These describe only the
34
+ #: CLI shape that started the attempt, never why an orchestrator started it.
35
+ ATTEMPT_START_INITIAL = "initial"
36
+ ATTEMPT_START_RESUME = "resume"
37
+ ATTEMPT_START_RESUME_NOTE = "resume-note"
38
+ ATTEMPT_START_RESUME_WORKER_COMMENT = "resume-worker-comment"
39
+ ATTEMPT_START_FORMS = frozenset({
40
+ ATTEMPT_START_INITIAL,
41
+ ATTEMPT_START_RESUME,
42
+ ATTEMPT_START_RESUME_NOTE,
43
+ ATTEMPT_START_RESUME_WORKER_COMMENT,
44
+ })
45
+
46
+
47
+ def now_ms() -> int:
48
+ return int(time.time() * 1000)
49
+
50
+
51
+ def controllers_root() -> Path:
52
+ return constants.CCX_ROOT / "controllers"
53
+
54
+
55
+ def bootstrap_root() -> Path:
56
+ return controllers_root() / ".bootstrap"
57
+
58
+
59
+ def launch_root() -> Path:
60
+ return controllers_root() / ".launch"
61
+
62
+
63
+ def stop_marker_path(run_id: str) -> Path:
64
+ return launch_root() / f"{run_id}.stopped"
65
+
66
+
67
+ def clear_stop_marker(run_id: str) -> None:
68
+ try:
69
+ stop_marker_path(run_id).unlink()
70
+ except FileNotFoundError:
71
+ pass
72
+
73
+
74
+ def ensure_controller_dirs() -> None:
75
+ from ccx.store.paths import private_mkdir
76
+
77
+ for path in (controllers_root(), bootstrap_root(), launch_root()):
78
+ private_mkdir(path)
79
+
80
+
81
+ def validate_controller_name(name: str) -> str:
82
+ if not isinstance(name, str) or not name.strip():
83
+ raise ControllerError("controller name must be a non-empty string")
84
+ value = name.strip()
85
+ if len(value) > 80:
86
+ raise ControllerError("controller name must be at most 80 characters")
87
+ if value.startswith(".") or os.sep in value or (os.altsep and os.altsep in value):
88
+ raise ControllerError(
89
+ f"controller name must be a single path segment, got {name!r}"
90
+ )
91
+ if any(ord(char) < 32 or ord(char) == 127 for char in value):
92
+ raise ControllerError("controller name cannot contain control characters")
93
+ return value
94
+
95
+
96
+ def source_sha256(path: Path) -> str:
97
+ digest = hashlib.sha256()
98
+ with path.open("rb") as handle:
99
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
100
+ digest.update(block)
101
+ return digest.hexdigest()
102
+
103
+
104
+ @contextmanager
105
+ def file_lock(path: Path) -> Iterator[None]:
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ with path.open("a+b") as handle:
108
+ fcntl.flock(handle, fcntl.LOCK_EX)
109
+ try:
110
+ yield
111
+ finally:
112
+ fcntl.flock(handle, fcntl.LOCK_UN)
113
+
114
+
115
+ def read_json(path: Path) -> dict[str, Any]:
116
+ try:
117
+ value = json.loads(path.read_text(encoding="utf-8"))
118
+ except FileNotFoundError as error:
119
+ raise ControllerError(f"controller state not found: {path}") from error
120
+ except (OSError, json.JSONDecodeError) as error:
121
+ raise ControllerError(f"cannot read controller state {path}: {error}") from error
122
+ if not isinstance(value, dict):
123
+ raise ControllerError(f"controller state is not a JSON object: {path}")
124
+ return value
125
+
126
+
127
+ def write_json_atomic(path: Path, value: dict[str, Any]) -> None:
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ fd, temporary = tempfile.mkstemp(
130
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
131
+ )
132
+ try:
133
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
134
+ json.dump(value, handle, indent=2, sort_keys=True)
135
+ handle.write("\n")
136
+ handle.flush()
137
+ os.fsync(handle.fileno())
138
+ os.replace(temporary, path)
139
+ except BaseException:
140
+ try:
141
+ os.unlink(temporary)
142
+ except FileNotFoundError:
143
+ pass
144
+ raise
145
+
146
+
147
+ def append_ndjson(path: Path, value: dict[str, Any]) -> None:
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ line = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
150
+ with path.open("ab", buffering=0) as handle:
151
+ fcntl.flock(handle, fcntl.LOCK_EX)
152
+ try:
153
+ handle.write(line)
154
+ os.fsync(handle.fileno())
155
+ finally:
156
+ fcntl.flock(handle, fcntl.LOCK_UN)
157
+
158
+
159
+ def run_record_path(run_dir: Path) -> Path:
160
+ return run_dir / "run.json"
161
+
162
+
163
+ def require_operation_journal_layout(
164
+ run_dir: Path, record: dict[str, Any] | None = None,
165
+ ) -> None:
166
+ """Refuse replay across incompatible positional WorkerClient journals.
167
+
168
+ Never repair, infer or auto-upgrade old runs: operations absent from their
169
+ journals (notably steer/kill) may already have mutated a real worker.
170
+ """
171
+ if record is None:
172
+ record = read_json(run_record_path(run_dir))
173
+ found = record.get("operationJournalLayout")
174
+ if type(found) is int and found == CONTROLLER_OPERATION_JOURNAL_LAYOUT:
175
+ return
176
+ label = "missing" if found is None else repr(found)
177
+ raise ControllerError(
178
+ f"controller run {record.get('runId') or run_dir.name} uses "
179
+ f"unsupported operation journal layout {label} "
180
+ f"(required {CONTROLLER_OPERATION_JOURNAL_LAYOUT}); start a new run."
181
+ )
182
+
183
+
184
+ def mutate_run(run_dir: Path, update: Callable[[dict[str, Any]], None]) -> dict[str, Any]:
185
+ with file_lock(run_dir / "state.lock"):
186
+ record = read_json(run_record_path(run_dir))
187
+ snapshot_attempt_outcome(record)
188
+ update(record)
189
+ snapshot_attempt_outcome(record)
190
+ record["updatedAt"] = now_ms()
191
+ write_json_atomic(run_record_path(run_dir), record)
192
+ return copy.deepcopy(record)
193
+
194
+
195
+ def snapshot_attempt_outcome(record: dict[str, Any]) -> None:
196
+ """Keep a settled attempt's report stable across later resumes."""
197
+ attempts = record.get("attempts") or []
198
+ if not attempts:
199
+ return
200
+ attempt = attempts[-1]
201
+ state = attempt.get("lifecycle")
202
+ if state not in TERMINAL_LIFECYCLES or "outcome" in attempt:
203
+ return
204
+ reports = record.get("reports") or []
205
+ reports = reports[int(attempt.get("reportStartIndex", 0)):]
206
+ report = reports[-1] if reports else None
207
+ attempt["outcome"] = copy.deepcopy({
208
+ "status": report.get("status") if report else ("completed" if state == "succeeded" else None),
209
+ "checkpoint": report.get("checkpoint") if report else None,
210
+ "summary": report.get("summary") if report else None,
211
+ "report": report,
212
+ "error": record.get("error"),
213
+ })
214
+
215
+
216
+ def append_event(run_dir: Path, event_type: str, **data: Any) -> None:
217
+ append_ndjson(
218
+ run_dir / "events.ndjson",
219
+ {
220
+ "schemaVersion": CONTROLLER_SCHEMA_VERSION,
221
+ "type": event_type,
222
+ "ts": now_ms(),
223
+ **data,
224
+ },
225
+ )
226
+
227
+
228
+ def create_bootstrap(
229
+ script: Path,
230
+ launch_cwd: Path,
231
+ argv: list[str],
232
+ *,
233
+ controller_name: str | None = None,
234
+ ) -> tuple[str, Path]:
235
+ requested_name = (
236
+ validate_controller_name(controller_name)
237
+ if controller_name is not None
238
+ else None
239
+ )
240
+ ensure_controller_dirs()
241
+ run_id = str(uuid.uuid4())
242
+ directory = bootstrap_root() / run_id
243
+ directory.mkdir(parents=True)
244
+ for child in ("reports", "tasks", "worktrees", "operations"):
245
+ (directory / child).mkdir()
246
+ created = now_ms()
247
+ record = {
248
+ "schemaVersion": CONTROLLER_SCHEMA_VERSION,
249
+ "operationJournalLayout": CONTROLLER_OPERATION_JOURNAL_LAYOUT,
250
+ "runId": run_id,
251
+ "controller": None,
252
+ "requestedControllerName": requested_name,
253
+ "declaredControllerName": None,
254
+ "scriptPath": str(script),
255
+ "sourceSha256": source_sha256(script),
256
+ "launchCwd": str(launch_cwd),
257
+ "workspace": str(launch_cwd),
258
+ "createdAt": created,
259
+ "updatedAt": created,
260
+ "lifecycle": {
261
+ "state": "starting", "since": created, "pid": None,
262
+ "pidStart": None,
263
+ },
264
+ "status": None,
265
+ "checkpoint": None,
266
+ "summary": None,
267
+ "latestReport": None,
268
+ "reports": [],
269
+ "result": None,
270
+ "error": None,
271
+ "attempts": [{
272
+ "number": 1,
273
+ "attemptToken": str(uuid.uuid4()),
274
+ "reportStartIndex": 0,
275
+ "startedAt": created,
276
+ "argv": list(argv),
277
+ "startForm": ATTEMPT_START_INITIAL,
278
+ "startInputId": None,
279
+ }],
280
+ "tasks": {},
281
+ "workers": [],
282
+ "inputs": 0,
283
+ "worktrees": {},
284
+ }
285
+ write_json_atomic(run_record_path(directory), record)
286
+ append_event(directory, "run-created", runId=run_id, scriptPath=str(script))
287
+ return run_id, directory
288
+
289
+
290
+ def _controller_dirs() -> list[Path]:
291
+ root = controllers_root()
292
+ if not root.is_dir():
293
+ return []
294
+ return sorted(
295
+ (
296
+ path
297
+ for path in root.iterdir()
298
+ if path.is_dir() and not path.name.startswith(".")
299
+ ),
300
+ key=lambda path: path.name.casefold(),
301
+ )
302
+
303
+
304
+ def _controller_entry_by_name(name: str) -> tuple[Path, dict[str, Any]] | None:
305
+ folded = name.casefold()
306
+ matches: list[tuple[Path, dict[str, Any]]] = []
307
+ for directory in _controller_dirs():
308
+ try:
309
+ record = read_json(directory / "controller.json")
310
+ except ControllerError:
311
+ continue
312
+ if str(record.get("name", "")).casefold() == folded:
313
+ matches.append((directory, record))
314
+ if len(matches) > 1:
315
+ raise ControllerError(f"ambiguous controller name: {name!r}")
316
+ return matches[0] if matches else None
317
+
318
+
319
+ def publish_bootstrap(
320
+ bootstrap: Path,
321
+ *,
322
+ name: str,
323
+ declared_name: str | None = None,
324
+ script: Path,
325
+ workspace: Path,
326
+ ) -> Path:
327
+ name = validate_controller_name(name)
328
+ if declared_name is not None:
329
+ declared_name = validate_controller_name(declared_name)
330
+ script = script.resolve()
331
+ workspace = workspace.resolve()
332
+ if not workspace.is_dir():
333
+ raise ControllerError(f"controller workspace is not a directory: {workspace}")
334
+ with file_lock(controllers_root() / ".controllers.lock"):
335
+ existing = _controller_entry_by_name(name)
336
+ if existing is not None:
337
+ directory, controller_record = existing
338
+ bound = Path(str(controller_record.get("scriptPath", ""))).resolve()
339
+ if bound != script:
340
+ raise ControllerError(
341
+ f"controller name {name!r} is already bound to {bound}; "
342
+ f"choose a different controller name for {script}"
343
+ )
344
+ name = str(controller_record["name"])
345
+ else:
346
+ directory = controllers_root() / name
347
+ directory.mkdir(parents=True)
348
+ created = now_ms()
349
+ controller_record = {
350
+ "schemaVersion": CONTROLLER_SCHEMA_VERSION,
351
+ "name": name,
352
+ "scriptPath": str(script),
353
+ "createdAt": created,
354
+ "updatedAt": created,
355
+ }
356
+ write_json_atomic(directory / "controller.json", controller_record)
357
+ (directory / "runs").mkdir(exist_ok=True)
358
+ record = read_json(run_record_path(bootstrap))
359
+ run_id = str(record["runId"])
360
+ destination = directory / "runs" / run_id
361
+ if destination.exists():
362
+ raise ControllerError(f"controller run already exists: {run_id}")
363
+ record["controller"] = name
364
+ record["declaredControllerName"] = declared_name
365
+ record["workspace"] = str(workspace)
366
+ write_json_atomic(run_record_path(bootstrap), record)
367
+ os.replace(bootstrap, destination)
368
+ controller_record["updatedAt"] = now_ms()
369
+ controller_record["latestRunId"] = run_id
370
+ write_json_atomic(directory / "controller.json", controller_record)
371
+ append_event(
372
+ destination, "run-published", controller=name,
373
+ declaredControllerName=declared_name, workspace=str(workspace),
374
+ )
375
+ return destination
376
+
377
+
378
+ def launch_verdict_path(run_id: str) -> Path:
379
+ return launch_root() / f"{run_id}.json"
380
+
381
+
382
+ def write_launch_verdict(run_id: str, value: dict[str, Any]) -> None:
383
+ ensure_controller_dirs()
384
+ write_json_atomic(launch_verdict_path(run_id), value)
385
+
386
+
387
+ def read_launch_verdict(run_id: str) -> dict[str, Any] | None:
388
+ path = launch_verdict_path(run_id)
389
+ if not path.exists():
390
+ return None
391
+ try:
392
+ return read_json(path)
393
+ except ControllerError:
394
+ return None
395
+
396
+
397
+ def controller_inventory() -> list[dict[str, Any]]:
398
+ rows: list[dict[str, Any]] = []
399
+ for directory in _controller_dirs():
400
+ try:
401
+ controller_record = read_json(directory / "controller.json")
402
+ except ControllerError:
403
+ continue
404
+ runs = run_inventory(str(controller_record.get("name", directory.name)))
405
+ latest = runs[0] if runs else None
406
+ rows.append({
407
+ "name": controller_record.get("name", directory.name),
408
+ "scriptPath": controller_record.get("scriptPath"),
409
+ "runs": len(runs),
410
+ "latest": latest,
411
+ })
412
+ return sorted(rows, key=lambda row: str(row["name"]).casefold())
413
+
414
+
415
+ def run_inventory(name: str) -> list[dict[str, Any]]:
416
+ entry = _controller_entry_by_name(name)
417
+ if entry is None:
418
+ raise ControllerError(f"controller not found: {name}")
419
+ directory, _record = entry
420
+ rows: list[dict[str, Any]] = []
421
+ runs = directory / "runs"
422
+ if runs.is_dir():
423
+ for run_dir in runs.iterdir():
424
+ if not run_dir.is_dir():
425
+ continue
426
+ try:
427
+ run = refresh_liveness(run_dir)
428
+ except ControllerError:
429
+ continue
430
+ rows.append(run_summary(run, run_dir))
431
+ return sorted(rows, key=lambda row: int(row.get("createdAt") or 0), reverse=True)
432
+
433
+
434
+ def latest_attempt_start(record: dict[str, Any]) -> dict[str, Any]:
435
+ """Project the recorded mechanical start form of the newest attempt.
436
+
437
+ Attempts written before this field existed stay valid and are reported as
438
+ unrecorded rather than guessed at or backfilled.
439
+ """
440
+ attempts = [item for item in (record.get("attempts") or []) if isinstance(item, dict)]
441
+ if not attempts:
442
+ return {"number": None, "form": None, "inputId": None, "recorded": False}
443
+ latest = attempts[-1]
444
+ form = latest.get("startForm")
445
+ if form not in ATTEMPT_START_FORMS:
446
+ form = None
447
+ input_id = latest.get("startInputId")
448
+ return {
449
+ "number": latest.get("number"),
450
+ "form": form,
451
+ "inputId": str(input_id) if isinstance(input_id, str) and input_id else None,
452
+ "recorded": form is not None,
453
+ }
454
+
455
+
456
+ def run_summary(record: dict[str, Any], run_dir: Path | None = None) -> dict[str, Any]:
457
+ lifecycle = record.get("lifecycle") or {}
458
+ tasks = record.get("tasks") or {}
459
+ complete = sum(
460
+ 1 for task_record in tasks.values()
461
+ if isinstance(task_record, dict) and task_record.get("state") == "succeeded"
462
+ )
463
+ summary = {
464
+ "runId": record.get("runId"),
465
+ "controller": record.get("controller"),
466
+ "declaredControllerName": record.get("declaredControllerName"),
467
+ "requestedControllerName": record.get("requestedControllerName"),
468
+ "lifecycle": lifecycle.get("state"),
469
+ "pid": lifecycle.get("pid"),
470
+ "status": record.get("status"),
471
+ "checkpoint": record.get("checkpoint"),
472
+ "summary": record.get("summary"),
473
+ "createdAt": record.get("createdAt"),
474
+ "updatedAt": record.get("updatedAt"),
475
+ "tasks": len(tasks),
476
+ "tasksSucceeded": complete,
477
+ "workers": len(record.get("workers") or []),
478
+ "attempts": len([
479
+ item for item in (record.get("attempts") or []) if isinstance(item, dict)
480
+ ]),
481
+ "latestAttemptStart": latest_attempt_start(record),
482
+ }
483
+ if run_dir is not None:
484
+ summary["runDir"] = str(run_dir)
485
+ return summary
486
+
487
+
488
+ def _all_run_dirs() -> Iterator[Path]:
489
+ for controller_dir in _controller_dirs():
490
+ runs = controller_dir / "runs"
491
+ if not runs.is_dir():
492
+ continue
493
+ yield from (path for path in runs.iterdir() if path.is_dir())
494
+
495
+
496
+ def resolve_run(target: str) -> Path:
497
+ exact_controller = _controller_entry_by_name(target)
498
+ if exact_controller is not None:
499
+ runs = run_inventory(target)
500
+ if not runs:
501
+ raise ControllerRunNotFound(f"controller has no runs: {target}")
502
+ return Path(str(runs[0]["runDir"]))
503
+
504
+ candidates: list[Path] = []
505
+ for run_dir in _all_run_dirs():
506
+ run_id = run_dir.name
507
+ if run_id == target:
508
+ return run_dir
509
+ if run_id.startswith(target):
510
+ candidates.append(run_dir)
511
+ if not candidates:
512
+ raise ControllerRunNotFound(f"controller run not found: {target}")
513
+ if len(candidates) > 1:
514
+ raise ControllerError(f"ambiguous controller run prefix: {target}")
515
+ return candidates[0]
516
+
517
+
518
+ def append_controller_input(
519
+ run_dir: Path,
520
+ *,
521
+ worker_target: str | None,
522
+ comment: str,
523
+ ) -> dict[str, Any]:
524
+ if not isinstance(comment, str) or not comment.strip():
525
+ raise ControllerError("controller input comment must be non-empty")
526
+ record = read_json(run_record_path(run_dir))
527
+ worker = None
528
+ if worker_target is not None:
529
+ from ccx.store.identity import IdentityError, parse_managed_target
530
+ try:
531
+ target = parse_managed_target(worker_target)
532
+ if target.qualified_engine is None:
533
+ raise IdentityError("controller worker input needs a qualified full UUID")
534
+ except IdentityError as error:
535
+ raise ControllerError(f"controller worker must be an exact qualified full UUID: {error}") from error
536
+ matches = [
537
+ item for item in record.get("workers", [])
538
+ if isinstance(item, dict)
539
+ and item.get("worker") == worker_target
540
+ and item.get("worker_id") == target.worker_id
541
+ and item.get("engine") == target.qualified_engine
542
+ ]
543
+ if len(matches) != 1:
544
+ raise ControllerError(
545
+ f"worker {worker_target!r} is not uniquely recorded by controller run {record['runId']}"
546
+ )
547
+ worker = matches[0]
548
+ entry = {
549
+ "id": str(uuid.uuid4()),
550
+ "createdAt": now_ms(),
551
+ "worker": worker["worker"] if worker is not None else None,
552
+ "workerName": worker.get("name") if worker is not None else None,
553
+ "comment": comment,
554
+ }
555
+ append_ndjson(run_dir / "inputs.ndjson", entry)
556
+ mutate_run(run_dir, lambda value: value.__setitem__("inputs", int(value.get("inputs") or 0) + 1))
557
+ append_event(run_dir, "input-appended", inputId=entry["id"], worker=entry["worker"])
558
+ return entry
559
+
560
+
561
+ def read_inputs(run_dir: Path) -> list[dict[str, Any]]:
562
+ path = run_dir / "inputs.ndjson"
563
+ if not path.exists():
564
+ return []
565
+ values: list[dict[str, Any]] = []
566
+ try:
567
+ with path.open("r", encoding="utf-8") as handle:
568
+ fcntl.flock(handle, fcntl.LOCK_SH)
569
+ try:
570
+ lines = handle.read().splitlines()
571
+ finally:
572
+ fcntl.flock(handle, fcntl.LOCK_UN)
573
+ for line in lines:
574
+ if not line.strip():
575
+ continue
576
+ parsed = json.loads(line)
577
+ if isinstance(parsed, dict):
578
+ values.append(parsed)
579
+ except (OSError, json.JSONDecodeError) as error:
580
+ raise ControllerError(f"cannot read controller inputs: {error}") from error
581
+ return values
582
+
583
+
584
+ def process_presence(pid: Any, start: Any) -> oslayer.Presence:
585
+ """Controller PID/start identity, never an argv-only signal authority."""
586
+ from ccx.store.liveness import owned_process
587
+
588
+ if pid is None:
589
+ return "dead"
590
+ if type(pid) is not int or pid <= 0 or not isinstance(start, str) or not start:
591
+ return "unprovable"
592
+ return owned_process(pid, start)
593
+
594
+
595
+ def refresh_liveness(run_dir: Path) -> dict[str, Any]:
596
+ record = read_json(run_record_path(run_dir))
597
+ lifecycle = record.get("lifecycle") or {}
598
+ state = lifecycle.get("state")
599
+ run_id = str(record.get("runId") or "")
600
+ if state in {"starting", "running"} and process_presence(
601
+ lifecycle.get("pid"), lifecycle.get("pidStart")
602
+ ) == "dead":
603
+ stopped = stop_marker_path(run_id).exists()
604
+ changed = False
605
+
606
+ def terminal(value: dict[str, Any]) -> None:
607
+ nonlocal changed
608
+ current = value.setdefault("lifecycle", {})
609
+ # A resume or host startup may land while liveness is checked.
610
+ # Never apply a dead-PID observation to that newer lifecycle.
611
+ if (current.get("state") in {"starting", "running"}
612
+ and current.get("pid") == lifecycle.get("pid")
613
+ and current.get("pidStart") == lifecycle.get("pidStart")
614
+ and current.get("since") == lifecycle.get("since")
615
+ and len(value.get("attempts") or []) == len(record.get("attempts") or [])):
616
+ changed = True
617
+ ended_at = now_ms()
618
+ current.update(
619
+ state="stopped" if stopped else "unavailable",
620
+ since=ended_at,
621
+ pid=None,
622
+ pidStart=None,
623
+ )
624
+ attempts = value.setdefault("attempts", [])
625
+ if attempts:
626
+ attempts[-1].update(
627
+ completedAt=ended_at,
628
+ lifecycle="stopped" if stopped else "unavailable",
629
+ )
630
+ if stopped:
631
+ value["error"] = None
632
+ else:
633
+ value["error"] = value.get("error") or {
634
+ "type": "ControllerHostUnavailable",
635
+ "message": "controller host exited without publishing a terminal state",
636
+ }
637
+
638
+ record = mutate_run(run_dir, terminal)
639
+ if changed:
640
+ append_event(run_dir, "run-stopped" if stopped else "host-unavailable")
641
+ return record
642
+
643
+
644
+ __all__ = [
645
+ "ATTEMPT_START_FORMS",
646
+ "ATTEMPT_START_INITIAL",
647
+ "ATTEMPT_START_RESUME",
648
+ "ATTEMPT_START_RESUME_NOTE",
649
+ "ATTEMPT_START_RESUME_WORKER_COMMENT",
650
+ "CONTROLLER_SCHEMA_VERSION",
651
+ "TERMINAL_LIFECYCLES",
652
+ "latest_attempt_start",
653
+ "append_controller_input",
654
+ "append_event",
655
+ "append_ndjson",
656
+ "bootstrap_root",
657
+ "clear_stop_marker",
658
+ "controller_inventory",
659
+ "controllers_root",
660
+ "create_bootstrap",
661
+ "ensure_controller_dirs",
662
+ "file_lock",
663
+ "launch_verdict_path",
664
+ "mutate_run",
665
+ "now_ms",
666
+ "process_presence",
667
+ "publish_bootstrap",
668
+ "read_inputs",
669
+ "read_json",
670
+ "read_launch_verdict",
671
+ "refresh_liveness",
672
+ "resolve_run",
673
+ "run_inventory",
674
+ "run_record_path",
675
+ "run_summary",
676
+ "source_sha256",
677
+ "stop_marker_path",
678
+ "validate_controller_name",
679
+ "write_json_atomic",
680
+ "write_launch_verdict",
681
+ ]