ccx-native 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
package/ccx/models.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Read-only, engine-neutral model catalogs with private per-engine caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from ccx import constants
|
|
12
|
+
from ccx.claude.oauth import oauth_organization_uuid
|
|
13
|
+
from ccx.claude.usage_control import request_models
|
|
14
|
+
from ccx.codex.runtime import list_model_entries
|
|
15
|
+
from ccx.errors import InvalidRequest
|
|
16
|
+
from ccx.system.private_json import _atomic_private_json, _read_private_json
|
|
17
|
+
from ccx.types import ModelCatalog, ModelInfo
|
|
18
|
+
|
|
19
|
+
_CACHE_TTL_S = 24 * 60 * 60
|
|
20
|
+
_CACHE_VERSION = 1
|
|
21
|
+
_ENGINES = ("claude", "codex")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _text(value: object) -> str | None:
|
|
25
|
+
return value if isinstance(value, str) and value else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _efforts(value: object) -> list[str]:
|
|
29
|
+
if not isinstance(value, list):
|
|
30
|
+
return []
|
|
31
|
+
return list(dict.fromkeys(entry for entry in value
|
|
32
|
+
if isinstance(entry, str) and entry))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_claude_models(entries: object) -> list[ModelInfo]:
|
|
36
|
+
"""Project Claude initialize's models only, with per-model capabilities."""
|
|
37
|
+
if not isinstance(entries, list):
|
|
38
|
+
return []
|
|
39
|
+
models = []
|
|
40
|
+
for item in entries:
|
|
41
|
+
if not isinstance(item, dict):
|
|
42
|
+
continue
|
|
43
|
+
ident = _text(item.get("value"))
|
|
44
|
+
resolved = _text(item.get("resolvedModel"))
|
|
45
|
+
name = _text(item.get("displayName"))
|
|
46
|
+
if not (ident and resolved and name):
|
|
47
|
+
continue
|
|
48
|
+
models.append(ModelInfo(
|
|
49
|
+
id=ident,
|
|
50
|
+
resolved=resolved,
|
|
51
|
+
display_name=name,
|
|
52
|
+
description=(item.get("description")
|
|
53
|
+
if isinstance(item.get("description"), str) else None),
|
|
54
|
+
alias=ident != resolved,
|
|
55
|
+
default=ident == "default",
|
|
56
|
+
efforts=(_efforts(item.get("supportedEffortLevels"))
|
|
57
|
+
if item.get("supportsEffort") is True else []),
|
|
58
|
+
default_effort=None,
|
|
59
|
+
features={
|
|
60
|
+
"fast": item.get("supportsFastMode") is True,
|
|
61
|
+
"auto": item.get("supportsAutoMode") is True,
|
|
62
|
+
"thinkingOff": item.get("supportsThinkingOff") is True,
|
|
63
|
+
},
|
|
64
|
+
))
|
|
65
|
+
return models
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_codex_models(entries: object) -> list[ModelInfo]:
|
|
69
|
+
"""Project model/list items; Codex's model ID is the --model spelling."""
|
|
70
|
+
if not isinstance(entries, list):
|
|
71
|
+
return []
|
|
72
|
+
models = []
|
|
73
|
+
seen = set()
|
|
74
|
+
for item in entries:
|
|
75
|
+
if not isinstance(item, dict) or item.get("hidden") is True:
|
|
76
|
+
continue
|
|
77
|
+
ident = _text(item.get("model")) or _text(item.get("id"))
|
|
78
|
+
if not ident or ident in seen:
|
|
79
|
+
continue
|
|
80
|
+
seen.add(ident)
|
|
81
|
+
efforts = item.get("supportedReasoningEfforts")
|
|
82
|
+
levels = (_efforts([entry.get("reasoningEffort") for entry in efforts
|
|
83
|
+
if isinstance(entry, dict)])
|
|
84
|
+
if isinstance(efforts, list) else [])
|
|
85
|
+
models.append(ModelInfo(
|
|
86
|
+
id=ident,
|
|
87
|
+
resolved=ident,
|
|
88
|
+
display_name=_text(item.get("displayName")) or ident,
|
|
89
|
+
description=(item.get("description")
|
|
90
|
+
if isinstance(item.get("description"), str) else None),
|
|
91
|
+
alias=False,
|
|
92
|
+
default=item.get("isDefault") is True,
|
|
93
|
+
efforts=levels,
|
|
94
|
+
default_effort=_text(item.get("defaultReasoningEffort")),
|
|
95
|
+
features={},
|
|
96
|
+
))
|
|
97
|
+
return models
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cache_path(engine: str) -> Path:
|
|
101
|
+
return constants.CCX_ROOT / f"models-{engine}.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _local_claude_version() -> str | None:
|
|
105
|
+
# Bypass oauth.py's process-wide lru_cache: a live CcxClient must notice
|
|
106
|
+
# when the installed Claude Code binary changes beneath it.
|
|
107
|
+
from ccx.claude.oauth import claude_code_version
|
|
108
|
+
return claude_code_version.__wrapped__()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cache_key(engine: str) -> dict:
|
|
112
|
+
if engine == "codex":
|
|
113
|
+
# No cheap native Codex version helper; this engine uses the TTL only.
|
|
114
|
+
return {}
|
|
115
|
+
org = oauth_organization_uuid()
|
|
116
|
+
return {
|
|
117
|
+
"version": _local_claude_version(),
|
|
118
|
+
"orgDigest": hashlib.sha256(org.encode()).hexdigest() if org else None,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_cache(engine: str) -> tuple[ModelCatalog | None, dict]:
|
|
123
|
+
try:
|
|
124
|
+
data = _read_private_json(_cache_path(engine))
|
|
125
|
+
if not isinstance(data, dict) or data.get("version") != _CACHE_VERSION:
|
|
126
|
+
return None, {}
|
|
127
|
+
catalog = ModelCatalog.from_dict(data["catalog"])
|
|
128
|
+
if catalog.engine != engine:
|
|
129
|
+
return None, {}
|
|
130
|
+
key = data.get("key")
|
|
131
|
+
return catalog, key if isinstance(key, dict) else {}
|
|
132
|
+
except (OSError, ValueError, KeyError, TypeError, UnicodeError):
|
|
133
|
+
return None, {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _fresh(catalog: ModelCatalog, saved_key: dict, current_key: dict) -> bool:
|
|
137
|
+
if catalog.engine == "claude":
|
|
138
|
+
if saved_key.get("orgDigest") != current_key.get("orgDigest"):
|
|
139
|
+
return False
|
|
140
|
+
# If the installed version cannot be read, only TTL/org can be checked.
|
|
141
|
+
local_version = current_key.get("version")
|
|
142
|
+
if local_version is not None and catalog.engine_version != local_version:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
stamped = datetime.fromisoformat(catalog.fetched_at.replace("Z", "+00:00"))
|
|
146
|
+
if stamped.tzinfo is None:
|
|
147
|
+
return False
|
|
148
|
+
age = time.time() - stamped.timestamp()
|
|
149
|
+
except (ValueError, OverflowError):
|
|
150
|
+
return False
|
|
151
|
+
return 0 <= age < _CACHE_TTL_S
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _probe(engine: str) -> tuple[str | None, list[ModelInfo]]:
|
|
155
|
+
if engine == "claude":
|
|
156
|
+
reply = request_models()
|
|
157
|
+
if reply.get("kind") != "success":
|
|
158
|
+
raise RuntimeError(reply.get("error") or "initialize unavailable")
|
|
159
|
+
body = reply["body"]
|
|
160
|
+
return _text(body.get("claude_code_version")), normalize_claude_models(body.get("models"))
|
|
161
|
+
# Account-level catalog, independent of the calling project's cwd.
|
|
162
|
+
return None, normalize_codex_models(list_model_entries(cwd=str(Path.home())))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _builtin(engine: str, note: str) -> ModelCatalog:
|
|
166
|
+
# Only Claude has known aliases; inventing Codex model names would be wrong.
|
|
167
|
+
models = ([ModelInfo(
|
|
168
|
+
id=alias, resolved=None, display_name=alias.title(),
|
|
169
|
+
description=None, alias=True, default=False, efforts=[],
|
|
170
|
+
default_effort=None, features={},
|
|
171
|
+
) for alias in constants.MODEL_ALIASES] if engine == "claude" else [])
|
|
172
|
+
return ModelCatalog(
|
|
173
|
+
engine=engine, engine_version=None, source="builtin",
|
|
174
|
+
fetched_at=_now(), note=note, models=models,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _now() -> str:
|
|
179
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _catalog(engine: str, *, refresh: bool) -> ModelCatalog:
|
|
183
|
+
cached, saved_key = _read_cache(engine)
|
|
184
|
+
try:
|
|
185
|
+
current_key = _cache_key(engine)
|
|
186
|
+
except Exception:
|
|
187
|
+
# Broken or unavailable local identity metadata cannot block the other
|
|
188
|
+
# engine's catalog (or the provider read for this engine).
|
|
189
|
+
current_key = {}
|
|
190
|
+
if not refresh and cached is not None and _fresh(cached, saved_key, current_key):
|
|
191
|
+
return replace(cached, source="cache")
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
version, models = _probe(engine)
|
|
195
|
+
if not models:
|
|
196
|
+
raise RuntimeError("no valid model entries")
|
|
197
|
+
except Exception as error:
|
|
198
|
+
note = f"{engine.capitalize()} model list unavailable: {error}"
|
|
199
|
+
if cached is not None:
|
|
200
|
+
return replace(cached, source="stale", note=note)
|
|
201
|
+
return _builtin(engine, note)
|
|
202
|
+
|
|
203
|
+
result = ModelCatalog(
|
|
204
|
+
engine=engine, engine_version=version, source="live",
|
|
205
|
+
fetched_at=_now(), note=None, models=models,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
_atomic_private_json(_cache_path(engine), {
|
|
209
|
+
"version": _CACHE_VERSION,
|
|
210
|
+
"key": ({"version": version, "orgDigest": current_key.get("orgDigest")}
|
|
211
|
+
if engine == "claude" else {}),
|
|
212
|
+
"catalog": result.as_dict(),
|
|
213
|
+
})
|
|
214
|
+
except OSError:
|
|
215
|
+
# A live listing remains useful if the private cache cannot be saved.
|
|
216
|
+
pass
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def read_models(*, engine: str | None = None, refresh: bool = False) -> list[ModelCatalog]:
|
|
221
|
+
"""Return one or both model catalogs; probe failures degrade per engine."""
|
|
222
|
+
if engine is not None and engine not in _ENGINES:
|
|
223
|
+
raise InvalidRequest("engine must be claude or codex")
|
|
224
|
+
return [_catalog(name, refresh=refresh)
|
|
225
|
+
for name in ((engine,) if engine else _ENGINES)]
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Public observation and cancellation of durable parent continuations.
|
|
2
|
+
|
|
3
|
+
The notification supervisor owns persistence and delivery. This module exposes a
|
|
4
|
+
small supported surface so dashboards and orchestrators can inspect armed
|
|
5
|
+
registrations and cancel one explicitly without importing parent_notify internals
|
|
6
|
+
or reading its state files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from ccx.errors import InvalidRequest
|
|
15
|
+
from ccx.notify import registry as parent_notify
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _event_id(value: str) -> str:
|
|
19
|
+
if not isinstance(value, str) or not value:
|
|
20
|
+
raise InvalidRequest("notification event_id must be a canonical UUID")
|
|
21
|
+
try:
|
|
22
|
+
if str(UUID(value)) != value:
|
|
23
|
+
raise ValueError("noncanonical event id")
|
|
24
|
+
except (ValueError, AttributeError) as error:
|
|
25
|
+
raise InvalidRequest("notification event_id must be a canonical UUID") from error
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ParentNotificationClient:
|
|
30
|
+
"""Inspect durable parent notifications and explicitly cancel one."""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _public(value: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
return parent_notify.public_registration(value)
|
|
35
|
+
|
|
36
|
+
def get(self, event_id: str) -> dict[str, Any] | None:
|
|
37
|
+
event_id = _event_id(event_id)
|
|
38
|
+
value = parent_notify.read_registration(event_id)
|
|
39
|
+
if not isinstance(value, dict):
|
|
40
|
+
return None
|
|
41
|
+
return self._public(value)
|
|
42
|
+
|
|
43
|
+
def cancel(self, event_id: str, *, reason: str = "cancelled by operator") -> dict[str, Any] | None:
|
|
44
|
+
event_id = _event_id(event_id)
|
|
45
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
46
|
+
raise InvalidRequest("notification cancel reason must be a non-empty string")
|
|
47
|
+
value = parent_notify.read_registration(event_id)
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
return None
|
|
50
|
+
parent_notify.cancel_registration(event_id, reason.strip())
|
|
51
|
+
updated = parent_notify.read_registration(event_id)
|
|
52
|
+
return self._public(updated) if isinstance(updated, dict) else None
|
|
53
|
+
|
|
54
|
+
def list(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
parent: str | None = None,
|
|
58
|
+
worker: str | None = None,
|
|
59
|
+
source: str | None = None,
|
|
60
|
+
state: str | None = None,
|
|
61
|
+
include_terminal: bool = False,
|
|
62
|
+
limit: int | None = None,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
if limit is not None and (
|
|
65
|
+
isinstance(limit, bool) or not isinstance(limit, int) or limit < 1
|
|
66
|
+
):
|
|
67
|
+
raise InvalidRequest("notification limit must be a positive integer")
|
|
68
|
+
|
|
69
|
+
from ccx.store.identity import canonical_worker_id, IdentityError
|
|
70
|
+
filters = []
|
|
71
|
+
for candidate, kinds in ((parent, {"codex"}),
|
|
72
|
+
(worker, {"codex", "claude", "controller"})):
|
|
73
|
+
if candidate is None:
|
|
74
|
+
filters.append(None)
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
kind, separator, identity = candidate.partition(":")
|
|
78
|
+
if separator:
|
|
79
|
+
if kind not in kinds:
|
|
80
|
+
raise ValueError("unsupported notification subject kind")
|
|
81
|
+
else:
|
|
82
|
+
identity, kind = candidate, None
|
|
83
|
+
filters.append((kind, canonical_worker_id(identity)))
|
|
84
|
+
except (IdentityError, ValueError, AttributeError) as error:
|
|
85
|
+
raise InvalidRequest("notification filter needs FULL_UUID or KIND:FULL_UUID with a supported kind") from error
|
|
86
|
+
parent_filter, worker_filter = filters
|
|
87
|
+
rows: list[dict[str, Any]] = []
|
|
88
|
+
for value in parent_notify.iter_registrations(
|
|
89
|
+
include_terminal=include_terminal
|
|
90
|
+
):
|
|
91
|
+
row = self._public(value)
|
|
92
|
+
if any(
|
|
93
|
+
selected is not None and (
|
|
94
|
+
not isinstance(row.get(field), str)
|
|
95
|
+
or row[field].split(":", 1)[-1] != selected[1]
|
|
96
|
+
or (selected[0] is not None and row[field] != f"{selected[0]}:{selected[1]}")
|
|
97
|
+
)
|
|
98
|
+
for field, selected in (("parent", parent_filter), ("worker", worker_filter))
|
|
99
|
+
):
|
|
100
|
+
continue
|
|
101
|
+
if source is not None and value.get("source") != source:
|
|
102
|
+
continue
|
|
103
|
+
if state is not None and row.get("state") != state:
|
|
104
|
+
continue
|
|
105
|
+
rows.append(row)
|
|
106
|
+
|
|
107
|
+
rows.sort(
|
|
108
|
+
key=lambda row: (
|
|
109
|
+
int(row.get("created_at_ms") or 0),
|
|
110
|
+
str(row.get("event_id") or ""),
|
|
111
|
+
),
|
|
112
|
+
reverse=True,
|
|
113
|
+
)
|
|
114
|
+
if limit is not None:
|
|
115
|
+
rows = rows[:limit]
|
|
116
|
+
return {"count": len(rows), "notifications": rows}
|