reasonwork 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reasonwork-0.4.0.dist-info/METADATA +144 -0
- reasonwork-0.4.0.dist-info/RECORD +25 -0
- reasonwork-0.4.0.dist-info/WHEEL +5 -0
- reasonwork-0.4.0.dist-info/entry_points.txt +2 -0
- reasonwork-0.4.0.dist-info/licenses/LICENSE +52 -0
- reasonwork-0.4.0.dist-info/top_level.txt +1 -0
- reasonwork_exchange/__init__.py +3 -0
- reasonwork_exchange/__main__.py +6 -0
- reasonwork_exchange/analyzer.py +908 -0
- reasonwork_exchange/auth.py +47 -0
- reasonwork_exchange/cli/__init__.py +55 -0
- reasonwork_exchange/cli/config.py +89 -0
- reasonwork_exchange/cli/login.py +39 -0
- reasonwork_exchange/cli/scan.py +99 -0
- reasonwork_exchange/cli/schedule.py +140 -0
- reasonwork_exchange/cli/sync.py +317 -0
- reasonwork_exchange/cli/update.py +177 -0
- reasonwork_exchange/cli/upload.py +95 -0
- reasonwork_exchange/client.py +273 -0
- reasonwork_exchange/config.py +70 -0
- reasonwork_exchange/discovery.py +59 -0
- reasonwork_exchange/errors.py +59 -0
- reasonwork_exchange/output.py +73 -0
- reasonwork_exchange/state.py +249 -0
- reasonwork_exchange/version_check.py +63 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""HTTP client for the standalone contributor upload API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from reasonwork_exchange.errors import AuthError, ConflictError, ExchangeError, RemoteError, ValidationError
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://ce-staging.collinear.dev"
|
|
17
|
+
|
|
18
|
+
_MIN_UPLOAD_TIMEOUT_SECONDS = 120
|
|
19
|
+
_UPLOAD_BYTES_PER_SECOND_FLOOR = 256 * 1024 # assume at least a slow-DSL-grade link
|
|
20
|
+
_MAX_PUT_ATTEMPTS = 3
|
|
21
|
+
_PUT_RETRY_BACKOFF_SECONDS = 3.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _upload_timeout_seconds(byte_size: int) -> int:
|
|
25
|
+
"""Scale the PUT timeout to file size so 100 MB trajectories on slow links don't time out early."""
|
|
26
|
+
return max(_MIN_UPLOAD_TIMEOUT_SECONDS, int(byte_size / _UPLOAD_BYTES_PER_SECOND_FLOOR))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _require_https(url: str, *, error_code: str, error_cls: type[ExchangeError]) -> None:
|
|
33
|
+
"""Reject a non-HTTPS URL (localhost exempted for local dev/testing).
|
|
34
|
+
|
|
35
|
+
Applied both to the configured exchange URL and to any upload URL the
|
|
36
|
+
server hands back -- a presigned S3 URL is server-supplied, not
|
|
37
|
+
attacker-controlled, but it's still a URL this process is about to make
|
|
38
|
+
an outbound request to, so it gets the exact same scheme check rather
|
|
39
|
+
than being trusted implicitly. error_cls lets each call site keep its
|
|
40
|
+
existing failure behavior: a bad base_url is a one-time setup mistake
|
|
41
|
+
(ValidationError, raised once at client construction, same as before this
|
|
42
|
+
check existed); a bad upload_url shows up per-attempt deep inside the
|
|
43
|
+
upload retry loop in sync.py, where only RemoteError is handled
|
|
44
|
+
gracefully -- ValidationError there would crash the whole sync run
|
|
45
|
+
instead of reporting one item's failure cleanly.
|
|
46
|
+
"""
|
|
47
|
+
parsed = urllib.parse.urlsplit(url)
|
|
48
|
+
if not parsed.hostname or (parsed.scheme != "https" and parsed.hostname not in _LOCAL_HOSTS):
|
|
49
|
+
raise error_cls(
|
|
50
|
+
"URL must use HTTPS (HTTP is allowed only for localhost)",
|
|
51
|
+
code=error_code,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ContributeClient:
|
|
56
|
+
"""Small stdlib-only client so contributor uploads need no new dependency."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, token: str, base_url: str | None = None) -> None:
|
|
59
|
+
"""Configure the contributor token and exchange URL."""
|
|
60
|
+
self.token = token
|
|
61
|
+
self.base_url = (base_url or os.environ.get("REASONWORK_EXCHANGE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
62
|
+
_require_https(self.base_url, error_code="insecure_contributor_url", error_cls=ValidationError)
|
|
63
|
+
|
|
64
|
+
def _json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
request = urllib.request.Request(
|
|
66
|
+
f"{self.base_url}{path}",
|
|
67
|
+
data=json.dumps(payload).encode(),
|
|
68
|
+
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
|
69
|
+
method="POST",
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - fixed HTTPS/user override
|
|
73
|
+
return dict(json.loads(response.read()))
|
|
74
|
+
except urllib.error.HTTPError as exc:
|
|
75
|
+
try:
|
|
76
|
+
body = json.loads(exc.read())
|
|
77
|
+
except (json.JSONDecodeError, AttributeError):
|
|
78
|
+
body = {}
|
|
79
|
+
message = body.get("error", str(exc))
|
|
80
|
+
if exc.code == 401:
|
|
81
|
+
raise AuthError(message, code="invalid_contributor_token") from exc
|
|
82
|
+
if exc.code in {400, 403}:
|
|
83
|
+
raise ValidationError(message, code="contributor_validation") from exc
|
|
84
|
+
if exc.code == 409:
|
|
85
|
+
raise ConflictError(
|
|
86
|
+
message,
|
|
87
|
+
code="duplicate_trajectory",
|
|
88
|
+
details={"existing_id": body.get("existing_id"), "existing_status": body.get("existing_status")},
|
|
89
|
+
) from exc
|
|
90
|
+
if exc.code == 429:
|
|
91
|
+
raise RemoteError(
|
|
92
|
+
message, code="contributor_rate_limited", hint="wait for prepared uploads to complete, then retry"
|
|
93
|
+
) from exc
|
|
94
|
+
if exc.code == 422:
|
|
95
|
+
# /complete's own verification failed (partial PUT, S3
|
|
96
|
+
# eventual-consistency lag) — distinct from a 409 duplicate
|
|
97
|
+
# conflict; must not be treated as "this file already exists".
|
|
98
|
+
raise RemoteError(message, code="upload_verification_failed") from exc
|
|
99
|
+
raise RemoteError(message, code="contributor_api_error", details={"status": exc.code}) from exc
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
raise RemoteError(str(exc), code="contributor_api_unreachable") from exc
|
|
102
|
+
|
|
103
|
+
def whoami(self) -> dict[str, Any]:
|
|
104
|
+
"""Verify the token and return its contributor identity."""
|
|
105
|
+
request = urllib.request.Request(
|
|
106
|
+
f"{self.base_url}/api/contribute/whoami", headers={"Authorization": f"Bearer {self.token}"}, method="GET"
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - fixed HTTPS/user override
|
|
110
|
+
return dict(json.loads(response.read()))
|
|
111
|
+
except urllib.error.HTTPError as exc:
|
|
112
|
+
if exc.code == 401:
|
|
113
|
+
raise AuthError("invalid or revoked contributor token", code="invalid_contributor_token") from exc
|
|
114
|
+
raise RemoteError(str(exc), code="contributor_api_error", details={"status": exc.code}) from exc
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
raise RemoteError(str(exc), code="contributor_api_unreachable") from exc
|
|
117
|
+
|
|
118
|
+
def report_local_rejection(
|
|
119
|
+
self, path: Path, *, source: str, analysis: dict[str, Any], is_subagent: bool = False
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Report a session the CLI rejected locally for privacy reasons, sending only metadata."""
|
|
122
|
+
return self._json(
|
|
123
|
+
"/api/contribute/local-rejections",
|
|
124
|
+
{
|
|
125
|
+
"source": source,
|
|
126
|
+
"client_session_id": analysis.get("session_id"),
|
|
127
|
+
"is_subagent": is_subagent,
|
|
128
|
+
"agent_id": analysis.get("agent_id"),
|
|
129
|
+
"original_filename": path.name,
|
|
130
|
+
"content_sha256": analysis["content_sha256"],
|
|
131
|
+
"byte_size": analysis["byte_size"],
|
|
132
|
+
"line_count": analysis["line_count"],
|
|
133
|
+
"privacy_risk": analysis["privacy_risk"],
|
|
134
|
+
"rejection_codes": analysis["rejection_codes"],
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def report_local_duplicate(
|
|
139
|
+
self,
|
|
140
|
+
path: Path,
|
|
141
|
+
*,
|
|
142
|
+
source: str,
|
|
143
|
+
analysis: dict[str, Any],
|
|
144
|
+
is_subagent: bool = False,
|
|
145
|
+
duplicate_of_content_sha256: str,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Report a session the CLI skipped locally because it's a byte-exact prefix of an already-known file."""
|
|
148
|
+
return self._json(
|
|
149
|
+
"/api/contribute/local-duplicates",
|
|
150
|
+
{
|
|
151
|
+
"source": source,
|
|
152
|
+
"client_session_id": analysis.get("session_id"),
|
|
153
|
+
"is_subagent": is_subagent,
|
|
154
|
+
"agent_id": analysis.get("agent_id"),
|
|
155
|
+
"original_filename": path.name,
|
|
156
|
+
"content_sha256": analysis["content_sha256"],
|
|
157
|
+
"byte_size": analysis["byte_size"],
|
|
158
|
+
"line_count": analysis["line_count"],
|
|
159
|
+
"duplicate_of_content_sha256": duplicate_of_content_sha256,
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def upload(
|
|
164
|
+
self,
|
|
165
|
+
path: Path,
|
|
166
|
+
*,
|
|
167
|
+
source: str,
|
|
168
|
+
analysis: dict[str, Any],
|
|
169
|
+
is_subagent: bool = False,
|
|
170
|
+
supersedes_content_sha256: str | None = None,
|
|
171
|
+
superseded_byte_size: int | None = None,
|
|
172
|
+
override_body: bytes | None = None,
|
|
173
|
+
pii_redacted: bool = False,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Prepare, PUT, and complete one immutable trajectory.
|
|
176
|
+
|
|
177
|
+
`override_body`, when set, is uploaded instead of the file's own bytes — used when
|
|
178
|
+
`analyzer.redact_trajectory` produced a cleaned copy that must replace the original on
|
|
179
|
+
the wire. The `content_sha256`/`byte_size` in `analysis` are expected to already
|
|
180
|
+
describe whatever body is actually being sent.
|
|
181
|
+
"""
|
|
182
|
+
body = path.read_bytes() if override_body is None else override_body
|
|
183
|
+
try:
|
|
184
|
+
prepared = self._json(
|
|
185
|
+
"/api/contribute/uploads",
|
|
186
|
+
{
|
|
187
|
+
"source": source,
|
|
188
|
+
"client_session_id": analysis.get("session_id"),
|
|
189
|
+
"is_subagent": is_subagent,
|
|
190
|
+
"agent_id": analysis.get("agent_id"),
|
|
191
|
+
"original_filename": path.name,
|
|
192
|
+
"content_sha256": analysis["content_sha256"],
|
|
193
|
+
"byte_size": analysis["byte_size"],
|
|
194
|
+
"line_count": analysis["line_count"],
|
|
195
|
+
"pii_redacted": pii_redacted,
|
|
196
|
+
"client_metrics": {
|
|
197
|
+
"uploader": "reasonwork-exchange-cli",
|
|
198
|
+
"schema_version": 1,
|
|
199
|
+
"deterministic_analysis": analysis,
|
|
200
|
+
"rights_reaffirmed": True,
|
|
201
|
+
},
|
|
202
|
+
"supersedes_content_sha256": supersedes_content_sha256,
|
|
203
|
+
"superseded_byte_size": superseded_byte_size,
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
except ConflictError as exc:
|
|
207
|
+
# A previous attempt may have PUT the object to S3 but never reached
|
|
208
|
+
# /complete (dropped connection, killed process), or an operator
|
|
209
|
+
# reopened a row an earlier attempt genuinely failed to upload
|
|
210
|
+
# (status flipped back to "prepared" via the ops dashboard retry).
|
|
211
|
+
# Resume that same row instead of permanently orphaning it as a
|
|
212
|
+
# false duplicate.
|
|
213
|
+
existing_id = exc.details.get("existing_id")
|
|
214
|
+
existing_status = exc.details.get("existing_status")
|
|
215
|
+
if existing_id and existing_status == "prepared":
|
|
216
|
+
return self._resume(existing_id, body)
|
|
217
|
+
if existing_id and existing_status == "upload_failed":
|
|
218
|
+
# The row exists but isn't resumable yet: /resume and /complete
|
|
219
|
+
# both require status == "prepared" server-side, which only an
|
|
220
|
+
# operator's Retry click restores. Surface this distinctly so
|
|
221
|
+
# sync.py leaves the path unrecorded (instead of caching it as
|
|
222
|
+
# a permanent duplicate) and re-checks it on a future sync.
|
|
223
|
+
raise RemoteError(
|
|
224
|
+
"Upload previously failed; awaiting an operator retry before it can resume.",
|
|
225
|
+
code="upload_pending_operator_retry",
|
|
226
|
+
details=exc.details,
|
|
227
|
+
) from exc
|
|
228
|
+
raise
|
|
229
|
+
self._put_bytes(str(prepared["upload_url"]), dict(prepared["upload_headers"]), body)
|
|
230
|
+
return self._json(f"/api/contribute/uploads/{prepared['id']}/complete", {})
|
|
231
|
+
|
|
232
|
+
def _resume(self, existing_id: str, body: bytes) -> dict[str, Any]:
|
|
233
|
+
"""Finish a trajectory row that already exists on the server.
|
|
234
|
+
|
|
235
|
+
Tries /complete first, which succeeds outright if the earlier attempt's
|
|
236
|
+
PUT actually landed (dropped connection before /complete). If the bytes
|
|
237
|
+
genuinely never reached S3 - the case an operator retry reopens - /complete
|
|
238
|
+
reports upload_verification_failed, so re-request a presigned URL (the
|
|
239
|
+
original one is long expired) and re-upload before completing again.
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
return self._json(f"/api/contribute/uploads/{existing_id}/complete", {})
|
|
243
|
+
except RemoteError as exc:
|
|
244
|
+
if exc.code != "upload_verification_failed":
|
|
245
|
+
raise
|
|
246
|
+
reissued = self._json(f"/api/contribute/uploads/{existing_id}/resume", {})
|
|
247
|
+
self._put_bytes(str(reissued["upload_url"]), dict(reissued["upload_headers"]), body)
|
|
248
|
+
return self._json(f"/api/contribute/uploads/{existing_id}/complete", {})
|
|
249
|
+
|
|
250
|
+
def _put_bytes(self, upload_url: str, upload_headers: dict[str, Any], body: bytes) -> None:
|
|
251
|
+
# Same scheme check as the exchange URL itself: a presigned S3 URL is
|
|
252
|
+
# server-supplied rather than attacker-controlled, but nothing else
|
|
253
|
+
# here re-verifies that before this process opens an outbound
|
|
254
|
+
# connection to it.
|
|
255
|
+
_require_https(upload_url, error_code="insecure_upload_url", error_cls=RemoteError)
|
|
256
|
+
headers = {str(key): str(value) for key, value in upload_headers.items()}
|
|
257
|
+
timeout = _upload_timeout_seconds(len(body))
|
|
258
|
+
backoff = _PUT_RETRY_BACKOFF_SECONDS
|
|
259
|
+
last_error: OSError | None = None
|
|
260
|
+
for attempt in range(_MAX_PUT_ATTEMPTS):
|
|
261
|
+
upload_request = urllib.request.Request(upload_url, data=body, headers=headers, method="PUT")
|
|
262
|
+
try:
|
|
263
|
+
with urllib.request.urlopen(upload_request, timeout=timeout): # noqa: S310 - presigned HTTPS URL
|
|
264
|
+
return
|
|
265
|
+
except OSError as exc:
|
|
266
|
+
# A partial/failed PUT never creates the S3 object, so retrying with the
|
|
267
|
+
# same presigned URL is safe as long as it's within its expiry.
|
|
268
|
+
last_error = exc
|
|
269
|
+
if attempt == _MAX_PUT_ATTEMPTS - 1:
|
|
270
|
+
break
|
|
271
|
+
time.sleep(backoff)
|
|
272
|
+
backoff *= 2
|
|
273
|
+
raise RemoteError(str(last_error), code="trajectory_upload_failed") from last_error
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Local consent settings for what gets shared to the exchange."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
VALID_SHARING_MODES = frozenset({"all", "ask", "none"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ConsentConfig:
|
|
15
|
+
"""Settings the contributor has agreed to for automatic sharing."""
|
|
16
|
+
|
|
17
|
+
sharing_mode: str = "ask"
|
|
18
|
+
excluded_sources: list[str] = field(default_factory=list)
|
|
19
|
+
excluded_paths: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
def is_excluded(self, source: str, path: Path) -> bool:
|
|
22
|
+
"""Return True if source or path has been opted out of sharing."""
|
|
23
|
+
if source in self.excluded_sources:
|
|
24
|
+
return True
|
|
25
|
+
resolved = str(path.resolve())
|
|
26
|
+
return any(
|
|
27
|
+
resolved.startswith(str(Path(prefix).expanduser().resolve()))
|
|
28
|
+
for prefix in self.excluded_paths
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConsentConfigStore:
|
|
33
|
+
"""Persist consent settings alongside the contributor auth token."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
36
|
+
"""Use an explicit path or default to the contributor config location."""
|
|
37
|
+
self.path = path or (Path.home() / ".config" / "reasonwork" / "exchange-config.json")
|
|
38
|
+
|
|
39
|
+
def read(self) -> ConsentConfig:
|
|
40
|
+
"""Return stored settings, or `ask` defaults if none exist yet."""
|
|
41
|
+
try:
|
|
42
|
+
raw = json.loads(self.path.read_text())
|
|
43
|
+
except (OSError, json.JSONDecodeError):
|
|
44
|
+
return ConsentConfig()
|
|
45
|
+
mode = raw.get("sharing_mode", "ask")
|
|
46
|
+
return ConsentConfig(
|
|
47
|
+
sharing_mode=mode if mode in VALID_SHARING_MODES else "ask",
|
|
48
|
+
excluded_sources=list(raw.get("excluded_sources", [])),
|
|
49
|
+
excluded_paths=list(raw.get("excluded_paths", [])),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def write(self, config: ConsentConfig) -> None:
|
|
53
|
+
"""Write settings mode 600, alongside the contributor auth token."""
|
|
54
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
56
|
+
with os.fdopen(fd, "w") as handle:
|
|
57
|
+
os.fchmod(handle.fileno(), 0o600)
|
|
58
|
+
handle.write(
|
|
59
|
+
json.dumps(
|
|
60
|
+
{
|
|
61
|
+
"sharing_mode": config.sharing_mode,
|
|
62
|
+
"excluded_sources": config.excluded_sources,
|
|
63
|
+
"excluded_paths": config.excluded_paths,
|
|
64
|
+
},
|
|
65
|
+
indent=2,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["ConsentConfig", "ConsentConfigStore", "VALID_SHARING_MODES"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Discover local agent session files without reading their contents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DiscoveredTrajectory:
|
|
12
|
+
"""One local trajectory candidate."""
|
|
13
|
+
|
|
14
|
+
source: str
|
|
15
|
+
path: Path
|
|
16
|
+
is_subagent: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def discover(home: Path | None = None, limit_per_source: int | None = None) -> list[DiscoveredTrajectory]:
|
|
20
|
+
"""Return Codex, Claude Code, and Cursor CLI JSONL files, newest first.
|
|
21
|
+
|
|
22
|
+
When limit_per_source is set, each source's newest N files are kept
|
|
23
|
+
independently before merging, so a source with sparser or older activity
|
|
24
|
+
(e.g. Codex or Cursor on a machine used mostly for Claude Code) isn't
|
|
25
|
+
crowded out of a combined top-N cut.
|
|
26
|
+
"""
|
|
27
|
+
user_home = home or Path.home()
|
|
28
|
+
codex_home = Path(os.environ.get("CODEX_HOME", user_home / ".codex"))
|
|
29
|
+
cursor_home = Path(os.environ.get("CURSOR_HOME", user_home / ".cursor"))
|
|
30
|
+
by_source = {
|
|
31
|
+
"codex": [DiscoveredTrajectory("codex", path) for path in (codex_home / "sessions").glob("**/*.jsonl")],
|
|
32
|
+
"claude_code": [
|
|
33
|
+
DiscoveredTrajectory("claude_code", path, is_subagent="subagents" in path.parts)
|
|
34
|
+
for path in (user_home / ".claude" / "projects").glob("**/*.jsonl")
|
|
35
|
+
],
|
|
36
|
+
# cursor-agent (the Cursor CLI) writes one JSONL transcript per session under
|
|
37
|
+
# <project>/agent-transcripts/<session-id>/; the IDE's own chat history lives
|
|
38
|
+
# in separate SQLite stores and isn't covered here. Background subagents are
|
|
39
|
+
# written to a separate top-level ~/.cursor/subagents/ tree (per Cursor's own
|
|
40
|
+
# docs), not nested under their parent project -- same Claude-Code-compatible
|
|
41
|
+
# JSONL schema, so the analyzer's existing session/agent-id extraction applies
|
|
42
|
+
# unchanged once flagged here.
|
|
43
|
+
"cursor": [
|
|
44
|
+
DiscoveredTrajectory("cursor", path)
|
|
45
|
+
for path in (cursor_home / "projects").glob("**/agent-transcripts/**/*.jsonl")
|
|
46
|
+
]
|
|
47
|
+
+ [
|
|
48
|
+
DiscoveredTrajectory("cursor", path, is_subagent=True)
|
|
49
|
+
for path in (cursor_home / "subagents").glob("**/*.jsonl")
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def newest_first(items: list[DiscoveredTrajectory]) -> list[DiscoveredTrajectory]:
|
|
54
|
+
return sorted(items, key=lambda item: (-item.path.stat().st_mtime, str(item.path)))
|
|
55
|
+
|
|
56
|
+
if limit_per_source is not None:
|
|
57
|
+
by_source = {source: newest_first(items)[:limit_per_source] for source, items in by_source.items()}
|
|
58
|
+
|
|
59
|
+
return newest_first([item for items in by_source.values() for item in items])
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Typed public CLI errors with stable process exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExchangeError(Exception):
|
|
9
|
+
"""Base error rendered by the contributor CLI."""
|
|
10
|
+
|
|
11
|
+
exit_code: int = 1
|
|
12
|
+
code: str = "error"
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
code: str | None = None,
|
|
19
|
+
hint: str | None = None,
|
|
20
|
+
details: dict[str, Any] | None = None,
|
|
21
|
+
exit_code: int | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Create an error with a stable machine code and optional recovery hint."""
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
if code is not None:
|
|
27
|
+
self.code = code
|
|
28
|
+
if exit_code is not None:
|
|
29
|
+
self.exit_code = exit_code
|
|
30
|
+
self.hint = hint
|
|
31
|
+
self.details = details or {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationError(ExchangeError):
|
|
35
|
+
"""The caller supplied invalid input or an unsafe trajectory."""
|
|
36
|
+
|
|
37
|
+
exit_code = 4
|
|
38
|
+
code = "validation"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConflictError(ExchangeError):
|
|
42
|
+
"""The trajectory already exists or conflicts with server state."""
|
|
43
|
+
|
|
44
|
+
exit_code = 3
|
|
45
|
+
code = "conflict"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthError(ExchangeError):
|
|
49
|
+
"""Contributor authentication is missing or invalid."""
|
|
50
|
+
|
|
51
|
+
exit_code = 5
|
|
52
|
+
code = "auth"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RemoteError(ExchangeError):
|
|
56
|
+
"""The exchange service or upload store could not be reached."""
|
|
57
|
+
|
|
58
|
+
exit_code = 6
|
|
59
|
+
code = "remote"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Stable human and JSON output for the standalone CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console, RenderableType
|
|
14
|
+
|
|
15
|
+
from reasonwork_exchange.errors import ExchangeError
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def emit_success(json_mode: bool, human_lines: list[str], payload: dict[str, Any]) -> int:
|
|
21
|
+
"""Write a successful result in human or stable JSON form."""
|
|
22
|
+
if json_mode:
|
|
23
|
+
print(json.dumps({"schema_version": 1, **payload}))
|
|
24
|
+
else:
|
|
25
|
+
for line in human_lines:
|
|
26
|
+
print(line)
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emit(json_mode: bool, payload: dict[str, Any], *, human: RenderableType) -> int:
|
|
31
|
+
"""Write a successful Rich result or stable JSON envelope."""
|
|
32
|
+
if json_mode:
|
|
33
|
+
print(json.dumps({"schema_version": 1, **payload}))
|
|
34
|
+
else:
|
|
35
|
+
console.print(human)
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def emit_error(json_mode: bool, error: ExchangeError) -> None:
|
|
40
|
+
"""Write an expected error to stderr."""
|
|
41
|
+
if json_mode:
|
|
42
|
+
print(
|
|
43
|
+
json.dumps({"error": error.message, "code": error.code, "hint": error.hint, "details": error.details}),
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
print(f"error: {error.message}", file=sys.stderr)
|
|
48
|
+
if error.hint:
|
|
49
|
+
print(f"hint: {error.hint}", file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def handle_errors(function: Callable[..., int | None]) -> Callable[..., None]:
|
|
53
|
+
"""Render expected errors and propagate command return codes through Typer."""
|
|
54
|
+
if "json_" not in inspect.signature(function).parameters:
|
|
55
|
+
name = getattr(function, "__qualname__", repr(function))
|
|
56
|
+
raise TypeError(f"{name} must declare a json_ option")
|
|
57
|
+
|
|
58
|
+
@functools.wraps(function)
|
|
59
|
+
def wrapper(*args: object, **kwargs: object) -> None:
|
|
60
|
+
try:
|
|
61
|
+
return_code = function(*args, **kwargs)
|
|
62
|
+
except ExchangeError as error:
|
|
63
|
+
emit_error(bool(kwargs.get("json_", False)), error)
|
|
64
|
+
raise typer.Exit(error.exit_code) from error
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
raise typer.Exit(1) from None
|
|
67
|
+
if return_code:
|
|
68
|
+
raise typer.Exit(return_code)
|
|
69
|
+
|
|
70
|
+
return wrapper
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Emit stable JSON on stdout (schema_version: 1).")]
|