cdx-manager 0.12.3 → 0.14.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.
package/src/config.py CHANGED
@@ -7,6 +7,47 @@ PROVIDER_ANTIGRAVITY = "antigravity"
7
7
  PROVIDER_OLLAMA = "ollama"
8
8
  PROVIDERS = (PROVIDER_CODEX, PROVIDER_CLAUDE, PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA)
9
9
 
10
+ # The values cdx accepts, defined once. Every validator, normalizer, and
11
+ # `cdx schema --json` derives from these. They used to be restated in
12
+ # cli_args, provider_runtime, and session_service — six copies for two
13
+ # concepts — which let `cdx set` and `cdx run` disagree about the same
14
+ # setting, and let `cdx schema --json` describe only one of them.
15
+ # config.py owns them because it is the leaf: everything imports it and it
16
+ # imports nothing of ours, so no ownership choice here can create a cycle.
17
+
18
+ # Ordered weakest to strongest; the order is meaningful for comparisons such
19
+ # as `--min-reasoning-effort`, so consumers must not re-sort it.
20
+ REASONING_EFFORT_VALUES = ("minimal", "low", "medium", "high", "xhigh")
21
+
22
+ # The canonical permissions, i.e. what cdx stores and what providers are
23
+ # mapped from.
24
+ PERMISSION_VALUES = ("review", "default", "auto", "full")
25
+
26
+ # Provider-native spellings accepted as input and normalized to the
27
+ # canonical value above, so a user can paste what their provider calls it.
28
+ PERMISSION_ALIASES = {
29
+ "workspace-write": "default",
30
+ "read-only": "review",
31
+ "danger-full-access": "full",
32
+ }
33
+
34
+ # Everything accepted as input for a permission, canonical values first.
35
+ PERMISSION_INPUT_VALUES = PERMISSION_VALUES + tuple(PERMISSION_ALIASES)
36
+
37
+
38
+ def normalize_permission(value):
39
+ """Canonical form of a permission input, or None if it is not one.
40
+
41
+ Returning None rather than raising keeps the error message and error code
42
+ the caller's business: `cdx run` and `cdx set` word their failures
43
+ differently, but they must agree on what is valid.
44
+ """
45
+ if value is None:
46
+ return None
47
+ text = str(value).strip().lower()
48
+ text = PERMISSION_ALIASES.get(text, text)
49
+ return text if text in PERMISSION_VALUES else None
50
+
10
51
 
11
52
  def get_cdx_home(env=None):
12
53
  if env is None:
package/src/errors.py CHANGED
@@ -2,3 +2,20 @@ class CdxError(Exception):
2
2
  def __init__(self, message, exit_code=1):
3
3
  super().__init__(message)
4
4
  self.exit_code = exit_code
5
+
6
+
7
+ class CdxArgumentError(CdxError):
8
+ """A usage failure that names its own machine-readable code and arguments.
9
+
10
+ Programmatic callers used to get a single `invalid_request` code carrying the
11
+ whole human usage line, which made a missing `--cwd` indistinguishable from
12
+ passing a session name and `--provider` together. Raising this instead lets
13
+ the JSON payload report `code` plus the offending `arguments` as data, so a
14
+ caller never has to match on message text.
15
+ """
16
+
17
+ def __init__(self, message, *, code, arguments=(), allowed=None, exit_code=1):
18
+ super().__init__(message, exit_code)
19
+ self.code = code
20
+ self.arguments = tuple(arguments)
21
+ self.allowed = tuple(allowed) if allowed is not None else None
package/src/health.py CHANGED
@@ -9,10 +9,23 @@ from datetime import datetime, timezone
9
9
  from urllib.parse import quote, unquote
10
10
 
11
11
  from .cli_render import _pad_table, _style
12
- from .config import PROVIDER_CODEX
13
- from .provider_runtime import codex_auth_diagnostic
12
+ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
13
+ from .provider_runtime import (
14
+ HEADLESS_CODEX_PERMISSION_ARGS,
15
+ LAUNCH_PERMISSION_ARGS,
16
+ codex_auth_diagnostic,
17
+ )
14
18
  from .status_source import _extract_account_identity
15
19
 
20
+ # The executable each provider is driven through, where it differs from the
21
+ # provider name.
22
+ PROVIDER_CLI_COMMANDS = {
23
+ PROVIDER_CODEX: "codex",
24
+ PROVIDER_CLAUDE: "claude",
25
+ PROVIDER_ANTIGRAVITY: "agy",
26
+ PROVIDER_OLLAMA: "ollama",
27
+ }
28
+
16
29
 
17
30
  def _encode(name):
18
31
  return quote(name, safe="")
@@ -36,7 +49,14 @@ def _issue(status, code, message, detail=None, repairable=False):
36
49
  }
37
50
 
38
51
 
39
- def collect_health_report(service, base_dir, env=None, spawn_sync=None):
52
+ def collect_health_report(service, base_dir, env=None, spawn_sync=None, check_provider_flags=False):
53
+ """Health report for the cdx home, sessions, and provider CLIs.
54
+
55
+ `check_provider_flags` is opt-in because it costs a provider CLI
56
+ invocation per configured provider. When it is off the report still says
57
+ so, rather than omitting the check silently — an absent check must not
58
+ read as a passing one.
59
+ """
40
60
  env = env or os.environ
41
61
  issues = []
42
62
 
@@ -96,6 +116,15 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
96
116
 
97
117
  issues.extend(_codex_shared_account_id_issues(codex_diagnostics))
98
118
  issues.extend(_collect_profile_issues(base_dir, session_names))
119
+ if check_provider_flags:
120
+ issues.extend(_provider_flag_issues(service, env, spawn_sync))
121
+ else:
122
+ issues.append(_issue(
123
+ "WARN",
124
+ "provider_permission_flags_unchecked",
125
+ "Provider permission flags were not verified; run cdx doctor --check-provider-flags",
126
+ {"reason": "opt_in", "cost": "one provider CLI invocation per configured provider"},
127
+ ))
99
128
  return {"base_dir": base_dir, "issues": issues, "summary": summarize_health(issues)}
100
129
 
101
130
 
@@ -143,6 +172,125 @@ def _extract_version(text):
143
172
  return match.group(1) if match else None
144
173
 
145
174
 
175
+ def _mapped_provider_flags(provider):
176
+ """Every distinct flag cdx would pass to this provider, by permission.
177
+
178
+ Read off the same mappings the launch specs use, so a permission added
179
+ there is automatically covered here rather than quietly unverified.
180
+ Values (a sandbox name, a config expression) are not flags and are not
181
+ checked — only the options themselves.
182
+ """
183
+ mappings = {}
184
+ interactive = LAUNCH_PERMISSION_ARGS.get(provider)
185
+ if interactive is None:
186
+ # No entry at all means nobody declared this provider's mapping, which
187
+ # is different from declaring that it has none.
188
+ return {}
189
+ for permission, args in interactive.items():
190
+ mappings.setdefault(permission, set()).update(
191
+ arg for arg in args if arg.startswith("-")
192
+ )
193
+ if provider == PROVIDER_CODEX:
194
+ for permission, args in HEADLESS_CODEX_PERMISSION_ARGS.items():
195
+ mappings.setdefault(permission, set()).update(
196
+ arg for arg in args if arg.startswith("-")
197
+ )
198
+ return {permission: sorted(flags) for permission, flags in mappings.items() if flags}
199
+
200
+
201
+ def _provider_help_text(command, env, spawn_sync=None):
202
+ """The provider CLI's own option list, or None if it cannot be obtained.
203
+
204
+ Help output is the cheapest authoritative answer to "does this CLI accept
205
+ this flag". Launching a real run would be authoritative too, and far more
206
+ expensive for a diagnostic.
207
+ """
208
+ try:
209
+ if spawn_sync:
210
+ result = spawn_sync(command, ["--help"], {"env": env, "timeout": 10})
211
+ if isinstance(result, dict):
212
+ text = f"{result.get('stdout') or ''}\n{result.get('stderr') or ''}"
213
+ else:
214
+ text = f"{getattr(result, 'stdout', '') or ''}\n{getattr(result, 'stderr', '') or ''}"
215
+ else:
216
+ completed = subprocess.run(
217
+ [command, "--help"], env=env, capture_output=True, text=True, timeout=10, check=False,
218
+ )
219
+ text = f"{completed.stdout or ''}\n{completed.stderr or ''}"
220
+ except (OSError, subprocess.TimeoutExpired):
221
+ return None
222
+ return text if text.strip() else None
223
+
224
+
225
+ def _provider_flag_issues(service, env, spawn_sync=None):
226
+ """Verify the flags cdx maps for each permission exist in the provider CLI.
227
+
228
+ GitHub issue #8 was exactly this going unchecked: `--experimental-yolo` was
229
+ mapped for ollama, a flag that CLI never had, and nothing noticed until a
230
+ user ran `permission=full` on an ollama session.
231
+
232
+ An unverifiable mapping reports WARN, never OK: "we could not check" is the
233
+ state that bug lived in for months, and it must not read as a pass.
234
+ """
235
+ issues = []
236
+ providers = sorted({
237
+ session.get("provider") for session in service["list_sessions"]()
238
+ if session.get("provider")
239
+ })
240
+ for provider in providers:
241
+ command = PROVIDER_CLI_COMMANDS.get(provider, provider)
242
+ mappings = _mapped_provider_flags(provider)
243
+ if not mappings:
244
+ issues.append(_issue(
245
+ "OK",
246
+ f"{provider}_permission_flags",
247
+ f"{provider} maps no permission to CLI flags; nothing to verify",
248
+ {"provider": provider, "command": command, "mapped": {}},
249
+ ))
250
+ continue
251
+ if not shutil.which(command, path=env.get("PATH")):
252
+ issues.append(_issue(
253
+ "WARN",
254
+ f"{provider}_permission_flags",
255
+ f"{provider} CLI not installed; mapped permission flags could not be verified",
256
+ {"provider": provider, "command": command, "mapped": mappings},
257
+ ))
258
+ continue
259
+ help_text = _provider_help_text(command, env, spawn_sync)
260
+ if help_text is None:
261
+ issues.append(_issue(
262
+ "WARN",
263
+ f"{provider}_permission_flags",
264
+ f"{provider} CLI help could not be read; mapped permission flags could not be verified",
265
+ {"provider": provider, "command": command, "mapped": mappings},
266
+ ))
267
+ continue
268
+ missing = {
269
+ permission: [flag for flag in flags if flag not in help_text]
270
+ for permission, flags in mappings.items()
271
+ }
272
+ missing = {permission: flags for permission, flags in missing.items() if flags}
273
+ detail = {"provider": provider, "command": command, "mapped": mappings, "missing": missing}
274
+ if missing:
275
+ described = "; ".join(
276
+ f"{permission}: {', '.join(flags)}" for permission, flags in sorted(missing.items())
277
+ )
278
+ issues.append(_issue(
279
+ "FAIL",
280
+ f"{provider}_permission_flags",
281
+ f"{provider} CLI does not accept flags cdx maps ({described})",
282
+ detail,
283
+ ))
284
+ else:
285
+ issues.append(_issue(
286
+ "OK",
287
+ f"{provider}_permission_flags",
288
+ f"{provider} CLI accepts every flag cdx maps for its permissions",
289
+ detail,
290
+ ))
291
+ return issues
292
+
293
+
146
294
  def _provider_capability_hints(command):
147
295
  if command == "codex":
148
296
  return [
package/src/notify.py CHANGED
@@ -7,11 +7,11 @@ import time
7
7
  from datetime import datetime, timedelta
8
8
 
9
9
  from .errors import CdxError
10
+ from .session_ranking import rank_sessions
10
11
  from .status_view import (
11
12
  PRIORITY_EMPTY_AVAILABLE_THRESHOLD,
12
13
  _parse_reset_timestamp,
13
14
  _priority_reset_timestamp,
14
- _recommend_priority_sessions,
15
15
  )
16
16
 
17
17
  NOTIFY_USAGE = "Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]"
@@ -103,10 +103,16 @@ def resolve_notify_event(rows, parsed, now_ts=None):
103
103
  now_ts = time.time() if now_ts is None else now_ts
104
104
  if parsed["mode"] == "next-ready":
105
105
  active_rows = [row for row in rows if row.get("enabled", True) is not False]
106
- priority = _recommend_priority_sessions([
107
- row for row in active_rows
108
- if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
109
- ])
106
+ # Same ranking every other selector uses; the pre-filter is what makes
107
+ # this "next to come back" rather than "best right now".
108
+ priority, _decision = rank_sessions(
109
+ [
110
+ row for row in active_rows
111
+ if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
112
+ ],
113
+ now_ts,
114
+ _priority_reset_timestamp,
115
+ )
110
116
  if not priority:
111
117
  return _event(False, "cdx", "No upcoming session reset available", None)
112
118
  first = priority[0]
@@ -12,7 +12,13 @@ from datetime import datetime, timezone
12
12
 
13
13
  from .claude_usage import _clean_oauth_token, _decode_jwt_claims
14
14
  from .codex_usage import codex_auth_lock
15
- from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
15
+ from .config import (
16
+ PROVIDER_ANTIGRAVITY,
17
+ PROVIDER_CLAUDE,
18
+ PROVIDER_CODEX,
19
+ PROVIDER_OLLAMA,
20
+ REASONING_EFFORT_VALUES,
21
+ )
16
22
  from .errors import CdxError
17
23
 
18
24
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
@@ -21,7 +27,6 @@ CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
21
27
  AUTH_PROBE_AUTHENTICATED = "authenticated"
22
28
  AUTH_PROBE_LOGGED_OUT = "logged_out"
23
29
  AUTH_PROBE_DEGRADED = "degraded"
24
- REASONING_EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
25
30
  RTK_PROMPT = (
26
31
  "When running noisy shell commands, prefer RTK wrappers (`rtk <command>`) if `rtk` is available. "
27
32
  "Use raw commands when exact, unfiltered output is required."
@@ -47,6 +52,15 @@ LAUNCH_PERMISSION_ARGS = {
47
52
  "auto": ["-s", "workspace-write", "-a", "never"],
48
53
  "full": ["-s", "danger-full-access", "-a", "never"],
49
54
  },
55
+ PROVIDER_ANTIGRAVITY: {
56
+ "review": ["--sandbox"],
57
+ "full": ["--dangerously-skip-permissions"],
58
+ },
59
+ # ollama has no sandbox or permission concept, so it maps to nothing. The
60
+ # entry is present and deliberately empty: that is what tells the
61
+ # provider-flag health check there is nothing to verify here, as opposed to
62
+ # a provider whose mapping nobody ever declared.
63
+ PROVIDER_OLLAMA: {},
50
64
  }
51
65
  HEADLESS_CODEX_PERMISSION_ARGS = {
52
66
  "review": ["-s", "read-only"],
@@ -54,6 +68,30 @@ HEADLESS_CODEX_PERMISSION_ARGS = {
54
68
  "auto": ["-s", "workspace-write", "-c", 'approval_policy="never"'],
55
69
  "full": ["--dangerously-bypass-approvals-and-sandbox"],
56
70
  }
71
+
72
+
73
+ def headless_permission_disables_network(provider, permission):
74
+ """True when this provider/permission pair silently costs the run network access.
75
+
76
+ Codex ties network access to its sandbox: every permission except `full`
77
+ maps to `-s <sandbox>` above, and inside that sandbox a tool like `gh`
78
+ cannot resolve DNS. The run still exits zero, so without a warning the
79
+ caller only finds out by reading the transcript.
80
+
81
+ Read off the same mapping the launch spec uses rather than a parallel list
82
+ of permissions, so adding a permission cannot leave this behind.
83
+
84
+ No permission at all is the common case, not an exemption: the launch spec
85
+ simply appends no sandbox flag, and `codex exec` then applies its own
86
+ sandboxed default. Only an explicit `full` actually buys network access.
87
+ """
88
+ if provider != PROVIDER_CODEX:
89
+ return False
90
+ if not permission:
91
+ return True
92
+ return "-s" in HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
93
+
94
+
57
95
  REDACTED_PROMPT_ARG = "[prompt redacted]"
58
96
  CLAUDE_CLI_MODEL_ALIASES = {
59
97
  "claude-sonnet": "sonnet",
@@ -328,15 +366,12 @@ def _launch_config_args(session):
328
366
  if permission:
329
367
  args += LAUNCH_PERMISSION_ARGS[PROVIDER_CLAUDE].get(permission, [])
330
368
  return args
331
- if provider == PROVIDER_ANTIGRAVITY:
332
- if permission == "review":
333
- args += ["--sandbox"]
334
- if permission == "full":
335
- args += ["--dangerously-skip-permissions"]
336
- return args
337
- if provider == PROVIDER_OLLAMA:
338
- if permission == "full":
339
- args += ["--experimental-yolo"]
369
+ if provider in (PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA):
370
+ # Both read their mapping from the shared table rather than inline
371
+ # branches, so the provider-flag health check can verify what they emit
372
+ # instead of a hand-copied restatement of it.
373
+ if permission:
374
+ args += LAUNCH_PERMISSION_ARGS[provider].get(permission, [])
340
375
  return args
341
376
  if power:
342
377
  args += ["-c", f'model_reasoning_effort="{power}"']
@@ -1,12 +1,27 @@
1
1
  import os
2
+ import sys
2
3
 
3
- from .errors import CdxError
4
+ from .cli_args import ARG_CODE_INVALID_VALUE
5
+ from .errors import CdxArgumentError, CdxError
6
+ from .provider_runtime import headless_permission_disables_network
4
7
  from .run_usage import empty_usage, extract_run_usage
5
8
 
9
+ PROMPT_STDIN = "-"
6
10
 
7
- def read_run_prompt(parsed):
11
+
12
+ def read_run_prompt(parsed, stdin=None):
8
13
  if parsed.get("prompt") is not None:
9
14
  return parsed["prompt"]
15
+ if parsed.get("prompt_file") == PROMPT_STDIN:
16
+ stream = stdin if stdin is not None else sys.stdin
17
+ if stream is None or getattr(stream, "isatty", lambda: False)():
18
+ raise CdxArgumentError(
19
+ "cdx run: --prompt-file - reads the prompt from standard input, but standard "
20
+ "input is a terminal. Pipe the prompt in, or pass a file path.",
21
+ code=ARG_CODE_INVALID_VALUE,
22
+ arguments=["--prompt-file"],
23
+ )
24
+ return stream.read()
10
25
  try:
11
26
  with open(parsed["prompt_file"], encoding="utf-8") as handle:
12
27
  return handle.read()
@@ -15,6 +30,9 @@ def read_run_prompt(parsed):
15
30
 
16
31
 
17
32
  def run_cdx_error_code(error):
33
+ code = getattr(error, "code", None)
34
+ if code:
35
+ return code
18
36
  message = str(error)
19
37
  if message.startswith("Usage:"):
20
38
  return "invalid_request"
@@ -73,8 +91,96 @@ def _provider_failure_message(error, run_info, error_code):
73
91
  return f"{base}: {detail}" if detail else base
74
92
 
75
93
 
94
+ def run_error_arguments(error):
95
+ """The argument names a `CdxArgumentError` blames, as data.
96
+
97
+ Always a list so the error object keeps one shape: an empty list means the
98
+ failure is not about specific arguments, never that the caller should go
99
+ parse the message to find out.
100
+ """
101
+ return list(getattr(error, "arguments", ()) or [])
102
+
103
+
104
+ def run_error_allowed_values(error):
105
+ allowed = getattr(error, "allowed", None)
106
+ return list(allowed) if allowed else None
107
+
108
+
109
+ def run_effective_permission(parsed, session):
110
+ launch = (session.get("launch") or {}) if session else {}
111
+ return parsed.get("permission") or launch.get("permission")
112
+
113
+
114
+ def run_warnings(parsed, session, selection_row=None):
115
+ """Known degradations that a zero exit code would otherwise hide.
116
+
117
+ Emitted on success as much as on failure: the whole point is that the run
118
+ looks fine and quietly did less than it was asked to.
119
+ """
120
+ warnings = []
121
+ if selection_row is not None and selection_row.get("available_pct") is None:
122
+ # Auto-selection ranked this session without ever having seen its
123
+ # status. That is not "low availability" — it is no information, and on
124
+ # a freshly imported or long-idle set of sessions it is the normal case.
125
+ warnings.append({
126
+ "code": "session_selected_without_status",
127
+ "message": (
128
+ f"Session {selection_row.get('session_name')!r} was auto-selected without any "
129
+ "recorded availability; the choice was made on missing status data. "
130
+ "Pass --refresh to fetch status before selecting."
131
+ ),
132
+ "session": selection_row.get("session_name"),
133
+ })
134
+ provider = session.get("provider") if session else parsed.get("provider")
135
+ permission = run_effective_permission(parsed, session)
136
+ if headless_permission_disables_network(provider, permission):
137
+ described = repr(permission) if permission else "its default (none set)"
138
+ warnings.append({
139
+ "code": "network_disabled_by_permission",
140
+ "message": (
141
+ f"{provider} runs at permission {described} inside a sandbox with no network "
142
+ "access, so tools needing the network (DNS, HTTPS) fail inside this run even "
143
+ "though it exits successfully. Use --permission full if the run needs network."
144
+ ),
145
+ "provider": provider,
146
+ "permission": permission,
147
+ })
148
+ return warnings
149
+
150
+
151
+ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
152
+ pid=None, launch_log_path=None, selection_row=None):
153
+ """Payload for a detached launch: identity and artifact paths, no outcome.
154
+
155
+ Deliberately shaped like `run_result_payload` minus the fields only a
156
+ finished run can fill (`exit_code`, `duration_seconds`, `usage`), so a
157
+ caller reads `run_id` from the same place either way.
158
+ """
159
+ return {
160
+ "schema_version": api_schema_version,
161
+ "ok": True,
162
+ "action": "run",
163
+ "launcher": "cdx",
164
+ "detached": True,
165
+ "session": session.get("name") if session else None,
166
+ "provider": session.get("provider") if session else parsed.get("provider"),
167
+ "model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
168
+ "reasoning_effort": run_payload_reasoning_effort(parsed, session),
169
+ "power": parsed.get("power") or ((session.get("launch") or {}).get("power") if session else None),
170
+ "cwd": os.path.abspath(cwd or parsed.get("cwd") or os.getcwd()),
171
+ "run_id": artifacts.get("run_id"),
172
+ "pid": pid,
173
+ "transcript_path": artifacts.get("transcript_path"),
174
+ "stdout_path": artifacts.get("stdout_path"),
175
+ "stderr_path": artifacts.get("stderr_path"),
176
+ "launch_log_path": launch_log_path,
177
+ "warnings": run_warnings(parsed, session, selection_row),
178
+ "error": None,
179
+ }
180
+
181
+
76
182
  def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
77
- error=None, error_source=None, error_code=None):
183
+ error=None, error_source=None, error_code=None, selection_row=None):
78
184
  run_info = run_info or {}
79
185
  usage = run_info.get("usage") if isinstance(run_info.get("usage"), dict) else (
80
186
  extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
@@ -104,11 +210,13 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
104
210
  "stdout_path": run_info.get("stdout_path"),
105
211
  "stderr_path": run_info.get("stderr_path"),
106
212
  "usage": usage,
107
- "warnings": [],
213
+ "warnings": run_warnings(parsed, session, selection_row),
108
214
  "error": None if ok else {
109
215
  "source": error_source or "cdx",
110
216
  "code": error_code or "cdx_error",
111
217
  "message": message,
218
+ "arguments": run_error_arguments(error),
219
+ "allowed_values": run_error_allowed_values(error),
112
220
  "provider_code": run_info.get("returncode") if error_source == "provider" else None,
113
221
  },
114
222
  }
@@ -75,6 +75,26 @@ def _is_pid_alive(pid):
75
75
  return False
76
76
 
77
77
 
78
+ def _completed_after(run, since):
79
+ """True when this run finished strictly after `since`.
80
+
81
+ `since` is timezone-aware (the CLI resolves it in local time); stored
82
+ timestamps are UTC with a `Z` suffix. A record with an unparseable or
83
+ missing `ended_at` is treated as not-yet-completed rather than matching, so
84
+ a corrupt row cannot be reported as freshly finished on every poll.
85
+ """
86
+ ended_at = run.get("ended_at")
87
+ if not ended_at:
88
+ return False
89
+ try:
90
+ ended = datetime.fromisoformat(str(ended_at).replace("Z", "+00:00"))
91
+ except ValueError:
92
+ return False
93
+ if ended.tzinfo is None:
94
+ ended = ended.replace(tzinfo=timezone.utc)
95
+ return ended > since
96
+
97
+
78
98
  def _refresh_stale_runs(data):
79
99
  now = utc_now_iso()
80
100
  changed = False
@@ -128,6 +148,24 @@ class RunRegistry:
128
148
  _write_registry(self.path, data)
129
149
  return record
130
150
 
151
+ def set_pid(self, run_id, pid):
152
+ """Point a registered run at the process actually doing the work.
153
+
154
+ `start()` records the caller's own pid, which is right when that caller
155
+ stays to supervise. A detached launch does not: it returns immediately,
156
+ so its pid dies within seconds and `_refresh_stale_runs` would find no
157
+ live process, mark the run `stale`, and stamp an `ended_at` — reporting
158
+ a run that is still going as finished, including to `list(since=...)`.
159
+ """
160
+ with _registry_lock(self.path):
161
+ data = _read_registry(self.path)
162
+ for run in data["runs"]:
163
+ if run.get("run_id") == run_id:
164
+ run["pid"] = pid
165
+ _write_registry(self.path, data)
166
+ return run
167
+ return None
168
+
131
169
  def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
132
170
  with _registry_lock(self.path):
133
171
  return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
@@ -167,13 +205,26 @@ class RunRegistry:
167
205
  return run
168
206
  return None
169
207
 
170
- def list(self, limit=20):
208
+ def list(self, limit=20, since=None):
209
+ """Recent runs, newest first.
210
+
211
+ `since` selects every run that *completed* strictly after that moment
212
+ and bypasses `limit` entirely. A cursor caller is asking "what finished
213
+ since I last looked"; capping that by row count would hand back a
214
+ truncated answer that looks complete, which is the silent miss the
215
+ cursor exists to remove. Runs still in flight have no completion time
216
+ and are excluded — they will be returned by a later call, once they
217
+ have actually completed.
218
+ """
171
219
  with _registry_lock(self.path):
172
220
  data = _read_registry(self.path)
173
221
  changed = _refresh_stale_runs(data)
174
222
  if changed:
175
223
  _write_registry(self.path, data)
176
- return data["runs"][: max(0, int(limit or 20))]
224
+ runs = data["runs"]
225
+ if since is not None:
226
+ return [run for run in runs if _completed_after(run, since)]
227
+ return runs[: max(0, int(limit or 20))]
177
228
 
178
229
  def get(self, run_id):
179
230
  with _registry_lock(self.path):