cdx-manager 0.12.4 → 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/README.md +132 -3
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +274 -33
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +177 -2
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +562 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +681 -0
- package/src/config.py +41 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_runtime.py +46 -11
- package/src/run_command.py +112 -4
- package/src/run_registry.py +53 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +574 -1426
- package/src/session_status.py +534 -0
- package/src/status_view.py +15 -34
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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]
|
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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}"']
|
package/src/run_command.py
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import sys
|
|
2
3
|
|
|
3
|
-
from .
|
|
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
|
-
|
|
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
|
}
|
package/src/run_registry.py
CHANGED
|
@@ -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
|
-
|
|
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):
|