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.
@@ -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).")]