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,317 @@
1
+ """`reasonwork sync` — bulk-submit newly discovered, consented sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import fcntl
7
+ import os
8
+ import sys
9
+ import time
10
+ from collections import Counter
11
+ from collections.abc import Iterator
12
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
+ from pathlib import Path
14
+ from typing import Annotated, Any
15
+
16
+ import typer
17
+ from rich.table import Table
18
+
19
+ from reasonwork_exchange import version_check
20
+ from reasonwork_exchange.config import ConsentConfigStore
21
+ from reasonwork_exchange.errors import ConflictError, RemoteError
22
+ from reasonwork_exchange.output import JsonOpt, console, emit, handle_errors
23
+ from reasonwork_exchange.state import GrowthCandidate, SubmissionLedger
24
+
25
+ _EPILOG = "example: reasonwork sync --limit 20 --concurrency 4"
26
+
27
+ _MAX_UPLOAD_ATTEMPTS = 3
28
+ _RATE_LIMIT_BACKOFF_SECONDS = 2.0
29
+
30
+
31
+ def _sync_lock_path() -> Path:
32
+ return Path.home() / ".config" / "reasonwork" / "sync.lock"
33
+
34
+
35
+ @contextlib.contextmanager
36
+ def _try_acquire_sync_lock() -> Iterator[bool]:
37
+ """Yield True if this process holds the exclusive sync lock, else False without blocking.
38
+
39
+ A scheduled run and a manual run can otherwise overlap and race each other
40
+ writing to the submission ledger; the loser here skips instead of racing.
41
+ """
42
+ lock_path = _sync_lock_path()
43
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
44
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
45
+ try:
46
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
47
+ except BlockingIOError:
48
+ os.close(fd)
49
+ yield False
50
+ return
51
+ try:
52
+ yield True
53
+ finally:
54
+ fcntl.flock(fd, fcntl.LOCK_UN)
55
+ os.close(fd)
56
+
57
+
58
+ def _submit_one(
59
+ path: Path,
60
+ *,
61
+ source: str,
62
+ token: str,
63
+ ledger: SubmissionLedger,
64
+ is_subagent: bool = False,
65
+ latest_version_sink: list[str] | None = None,
66
+ ) -> dict[str, Any]:
67
+ """Skip via the ledger when possible, otherwise analyze and submit one session."""
68
+ from reasonwork_exchange.analyzer import analyze
69
+ from reasonwork_exchange.client import ContributeClient
70
+
71
+ cached = ledger.cached_fingerprint(path)
72
+ if cached is not None:
73
+ # This path's own recorded status is authoritative for itself; status_for_hash
74
+ # is shared across any other path with identical content, so it must only be
75
+ # consulted as a fallback for a path that has no record of its own yet.
76
+ cached_status = ledger.last_recorded_status(path) or ledger.status_for_hash(cached.content_sha256)
77
+ if cached_status is not None:
78
+ return {"path": str(path), "source": source, "status": cached_status, "skipped": True}
79
+
80
+ # A live-captured session only grows from here (prior turns aren't retroactively
81
+ # scrubbed or rewritten), so every later poll would otherwise re-hash as "new"
82
+ # content and create another server record for what is really the same session.
83
+ # Once any outcome is recorded for this exact path, stop scanning and reporting
84
+ # it — unless it has since grown enough that the un-submitted tail is worth
85
+ # reconsidering, in which case fall through and re-analyze.
86
+ recorded_status = ledger.last_recorded_status(path)
87
+ growth: GrowthCandidate | None = None
88
+ if recorded_status is not None:
89
+ growth = ledger.growth_supersession(path, path.stat().st_size)
90
+ if growth is None:
91
+ return {"path": str(path), "source": source, "status": recorded_status, "skipped": True}
92
+
93
+ try:
94
+ analysis = analyze(path, discovered_source=source)
95
+ except (OSError, ValueError) as exc:
96
+ return {"path": str(path), "source": source, "status": "error", "detail": str(exc)}
97
+ # Codex subagent rollouts have no path signal (they sit alongside ordinary
98
+ # sessions, unlike Claude Code/Cursor's dedicated subagents/ folders), so
99
+ # discover()'s path-based guess is only ever a hint there; the analyzer's
100
+ # content-based determination is authoritative whenever it says True.
101
+ is_subagent = is_subagent or analysis.is_subagent
102
+
103
+ contained = ledger.find_contained_match(
104
+ path, content_sha256=analysis.content_sha256, byte_size=analysis.byte_size
105
+ )
106
+ if contained is not None:
107
+ report_id = None
108
+ try:
109
+ report_id = ContributeClient(token).report_local_duplicate(
110
+ path,
111
+ source=analysis.source or source,
112
+ analysis=analysis.to_dict(),
113
+ is_subagent=is_subagent,
114
+ duplicate_of_content_sha256=contained.content_sha256,
115
+ ).get("id")
116
+ except (RemoteError, ConflictError):
117
+ # Best-effort like report_local_rejection: the local skip stands either way.
118
+ pass
119
+ ledger.record(
120
+ path,
121
+ content_sha256=analysis.content_sha256,
122
+ byte_size=analysis.byte_size,
123
+ status="duplicate_content",
124
+ upload_id=report_id,
125
+ )
126
+ return {
127
+ "path": str(path),
128
+ "source": source,
129
+ "status": "duplicate_content",
130
+ "duplicate_of": str(contained.path),
131
+ }
132
+
133
+ redaction = None
134
+ if analysis.privacy_risk in {"medium", "high", "blocked"}:
135
+ from reasonwork_exchange.analyzer import redact_trajectory
136
+
137
+ redaction = redact_trajectory(path)
138
+ if not redaction.clean:
139
+ # Redaction couldn't fully clean the content (e.g. a PII shape our patterns
140
+ # don't cover) — fall back to the original hard rejection rather than risk
141
+ # uploading a partially-scrubbed file.
142
+ report_id = None
143
+ try:
144
+ report_id = ContributeClient(token).report_local_rejection(
145
+ path, source=analysis.source or source, analysis=analysis.to_dict(), is_subagent=is_subagent
146
+ ).get("id")
147
+ except (RemoteError, ConflictError):
148
+ # Reporting the rejection is best-effort: the file never leaves this
149
+ # machine either way, so a network hiccup shouldn't block the local skip.
150
+ pass
151
+ ledger.record(
152
+ path,
153
+ content_sha256=analysis.content_sha256,
154
+ byte_size=analysis.byte_size,
155
+ status="rejected_privacy",
156
+ upload_id=report_id,
157
+ )
158
+ return {"path": str(path), "source": source, "status": "rejected_privacy", "risk": analysis.privacy_risk}
159
+
160
+ resolved_source = analysis.source or source
161
+ client = ContributeClient(token)
162
+ backoff = _RATE_LIMIT_BACKOFF_SECONDS
163
+
164
+ # The anti-gaming per-file checks live in analyzer.analyze(); this last check needs
165
+ # cross-submission history, which only the ledger has, so it's applied here instead.
166
+ analysis_payload = analysis.to_dict()
167
+ if redaction is not None:
168
+ # What's actually uploaded is the redacted body, so the metadata sent to the
169
+ # server must describe that body, not the original file on disk. The ledger
170
+ # below still records the original identity (content_sha256/byte_size of the
171
+ # file as it sits locally) since that's what growth/containment detection key
172
+ # off of; the redacted identity is only ever visible to the server.
173
+ analysis_payload["content_sha256"] = redaction.content_sha256
174
+ analysis_payload["byte_size"] = redaction.byte_size
175
+ analysis_payload["line_count"] = redaction.line_count
176
+ # The real score/price no longer get computed locally at all (see
177
+ # analyzer.analyze()'s own docstring/comment) -- deterministic_score is a
178
+ # neutral placeholder here, not a real number, so there's nothing
179
+ # meaningful to locally re-adjust for score-uniformity before submitting.
180
+ # The worker re-derives and applies that check itself, authoritatively.
181
+ submitted_score = analysis.deterministic_score
182
+
183
+ for attempt in range(_MAX_UPLOAD_ATTEMPTS):
184
+ try:
185
+ result = client.upload(
186
+ path,
187
+ source=resolved_source,
188
+ analysis=analysis_payload,
189
+ is_subagent=is_subagent,
190
+ supersedes_content_sha256=growth.uploaded_content_sha256 if growth else None,
191
+ superseded_byte_size=growth.byte_size if growth else None,
192
+ override_body=redaction.redacted_bytes if redaction is not None else None,
193
+ pii_redacted=redaction is not None,
194
+ )
195
+ except ConflictError:
196
+ ledger.record(
197
+ path, content_sha256=analysis.content_sha256, byte_size=analysis.byte_size, status="duplicate"
198
+ )
199
+ return {"path": str(path), "source": resolved_source, "status": "duplicate"}
200
+ except RemoteError as exc:
201
+ if exc.code == "upload_pending_operator_retry":
202
+ # Deliberately not recorded in the ledger: the row isn't
203
+ # resumable yet, but nothing about this path is permanent, so
204
+ # leave it unrecorded and let a future sync notice once an
205
+ # operator's retry flips the row back to "prepared".
206
+ return {"path": str(path), "source": resolved_source, "status": "pending_operator_retry"}
207
+ is_last_attempt = attempt == _MAX_UPLOAD_ATTEMPTS - 1
208
+ if exc.code != "contributor_rate_limited" or is_last_attempt:
209
+ return {"path": str(path), "source": resolved_source, "status": "error", "detail": exc.message}
210
+ time.sleep(backoff)
211
+ backoff *= 2
212
+ continue
213
+ if latest_version_sink is not None and result.get("latest_cli_version"):
214
+ latest_version_sink.append(result["latest_cli_version"])
215
+ ledger.record(
216
+ path,
217
+ content_sha256=analysis.content_sha256,
218
+ byte_size=analysis.byte_size,
219
+ status="submitted",
220
+ upload_id=result.get("id"),
221
+ score=submitted_score,
222
+ uploaded_content_sha256=analysis_payload["content_sha256"],
223
+ )
224
+ return {"path": str(path), "source": resolved_source, "status": "submitted", "id": result.get("id")}
225
+ return {"path": str(path), "source": resolved_source, "status": "error", "detail": "rate limit retries exhausted"}
226
+
227
+
228
+ @handle_errors
229
+ def sync(
230
+ limit: Annotated[int, typer.Option("--limit", min=1, max=2000, help="Newest local sessions to consider, per source.")] = 20,
231
+ concurrency: Annotated[int, typer.Option("--concurrency", min=1, max=16, help="Concurrent uploads.")] = 4,
232
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Report what would sync; submit nothing.")] = False,
233
+ yes: Annotated[bool, typer.Option("--yes", help="Skip the confirmation prompt under sharing_mode=ask.")] = False,
234
+ json_: JsonOpt = False,
235
+ ) -> int:
236
+ """Submit every newly discovered, consented session not already recorded in the ledger."""
237
+ from reasonwork_exchange.auth import ContributorAuthStore
238
+ from reasonwork_exchange.discovery import discover
239
+
240
+ consent = ConsentConfigStore().read()
241
+ if consent.sharing_mode == "none":
242
+ return emit(
243
+ json_,
244
+ {"synced": [], "total": 0, "skipped_reason": "sharing_mode_none"},
245
+ human="sharing_mode is 'none'; nothing to sync.",
246
+ )
247
+
248
+ # sharing_mode=ask means "ask a human before uploading" — it must never fall back
249
+ # to uploading unattended just because no one was there to answer the prompt. An
250
+ # unattended caller (the launchd timer, a cron job) has no controlling terminal, so
251
+ # detect that case up front and skip cleanly instead of letting typer.confirm's
252
+ # EOFError-turned-Abort silently enforce this by accident on every scheduled tick.
253
+ if not dry_run and consent.sharing_mode == "ask" and not yes and not sys.stdin.isatty():
254
+ return emit(
255
+ json_,
256
+ {"synced": [], "total": 0, "skipped_reason": "sharing_mode_ask_unattended"},
257
+ human="sharing_mode is 'ask' and no one is here to confirm; nothing to sync.",
258
+ )
259
+
260
+ candidates = [
261
+ item for item in discover(limit_per_source=limit) if not consent.is_excluded(item.source, item.path)
262
+ ]
263
+
264
+ if dry_run:
265
+ results = [
266
+ {
267
+ "path": str(item.path),
268
+ "source": item.source,
269
+ "is_subagent": item.is_subagent,
270
+ "status": "would_sync",
271
+ }
272
+ for item in candidates
273
+ ]
274
+ else:
275
+ if consent.sharing_mode == "ask" and not yes:
276
+ typer.confirm(f"Submit up to {len(candidates)} local session(s) to the exchange?", abort=True)
277
+ with _try_acquire_sync_lock() as acquired:
278
+ if not acquired:
279
+ return emit(
280
+ json_,
281
+ {"results": [], "counts": {}, "total": 0, "skipped_reason": "sync_in_progress"},
282
+ human="another sync is already running; skipping.",
283
+ )
284
+ token = ContributorAuthStore().read()
285
+ ledger = SubmissionLedger()
286
+ results = []
287
+ latest_version_seen: list[str] = []
288
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
289
+ futures = [
290
+ pool.submit(
291
+ _submit_one,
292
+ item.path,
293
+ source=item.source,
294
+ token=token,
295
+ ledger=ledger,
296
+ is_subagent=item.is_subagent,
297
+ latest_version_sink=latest_version_seen,
298
+ )
299
+ for item in candidates
300
+ ]
301
+ for future in as_completed(futures):
302
+ results.append(future.result())
303
+ # Best-effort update nudge, piggybacked off whatever this sync happened to
304
+ # upload/report -- there's no dedicated version-check endpoint. Skipped
305
+ # under --json so a stray notice line can't corrupt scripted stdout parsing.
306
+ if latest_version_seen and not json_:
307
+ notice = version_check.maybe_notice(latest_version_seen[0])
308
+ if notice:
309
+ console.print(f"[yellow]{notice}[/yellow]")
310
+
311
+ counts = Counter(result["status"] for result in results)
312
+ table = Table(title="Sync results")
313
+ for heading in ("Status", "Count"):
314
+ table.add_column(heading)
315
+ for status, count in sorted(counts.items()):
316
+ table.add_row(status, str(count))
317
+ return emit(json_, {"results": results, "counts": dict(counts), "total": len(results)}, human=table)
@@ -0,0 +1,177 @@
1
+ """`reasonwork update` -- pull/upgrade to the latest version, however it was installed.
2
+
3
+ Two install paths exist and need two different update mechanisms:
4
+
5
+ - Editable install from a local git checkout (`uv tool install --editable
6
+ ./tools/contributor-cli`) -- what the README documented before this package
7
+ was published to PyPI. "Update" means `git pull` the checkout, then
8
+ reinstall so any new dependencies/entry points take effect.
9
+ - A real installed package (`pipx install reasonwork` or `uv tool
10
+ install reasonwork`, from PyPI) -- there's no git checkout to pull
11
+ at all. "Update" means asking whichever tool manager did the install to
12
+ upgrade it, since that's what actually knows how to fetch the newer
13
+ version and re-resolve dependencies.
14
+
15
+ Which of these applies is detected automatically; a contributor never needs
16
+ to know or specify which.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from reasonwork_exchange.output import JsonOpt, emit_success, handle_errors
27
+
28
+ _EPILOG = "example: reasonwork update"
29
+
30
+ _MAX_PARENTS_TO_SEARCH = 6
31
+
32
+
33
+ def _package_root() -> Path:
34
+ """Return the `tools/contributor-cli` directory this install's code lives in.
35
+
36
+ For an editable install, `reasonwork_exchange.__file__` resolves back to the
37
+ real source tree (not a copy), so this is the same path a contributor would
38
+ pass to `uv tool install --editable`.
39
+ """
40
+ import reasonwork_exchange
41
+
42
+ return Path(reasonwork_exchange.__file__).resolve().parents[2]
43
+
44
+
45
+ def _git_repo_root(start: Path) -> Path | None:
46
+ current = start
47
+ for _ in range(_MAX_PARENTS_TO_SEARCH):
48
+ if (current / ".git").exists():
49
+ return current
50
+ if current.parent == current:
51
+ break
52
+ current = current.parent
53
+ return None
54
+
55
+
56
+ def _installed_via() -> str:
57
+ """Guess which tool manager owns this install, from where its interpreter lives.
58
+
59
+ Both pipx and `uv tool install` give every tool its own isolated venv
60
+ named after the package, under a directory that names the manager itself
61
+ (`.../pipx/venvs/reasonwork/...` or `.../uv/tools/reasonwork/...`)
62
+ -- reliable across platforms without needing either tool's own APIs.
63
+ """
64
+ prefix = str(Path(sys.prefix).resolve())
65
+ if "pipx" in prefix:
66
+ return "pipx"
67
+ if "uv" in prefix and "tools" in prefix:
68
+ return "uv"
69
+ return "unknown"
70
+
71
+
72
+ @handle_errors
73
+ def update(
74
+ json_: JsonOpt = False,
75
+ ) -> int:
76
+ """Pull/upgrade to the latest CLI version, detecting how it was installed."""
77
+ package_root = _package_root()
78
+ repo_root = _git_repo_root(package_root)
79
+ if repo_root is not None:
80
+ return _update_from_git_checkout(json_, package_root, repo_root)
81
+ return _update_installed_package(json_)
82
+
83
+
84
+ def _update_from_git_checkout(json_: bool, package_root: Path, repo_root: Path) -> int:
85
+ if shutil.which("git") is None or shutil.which("uv") is None:
86
+ return emit_success(
87
+ json_,
88
+ ["Both `git` and `uv` are required to self-update; install them and retry, or update manually."],
89
+ {"updated": False, "reason": "missing_git_or_uv"},
90
+ )
91
+
92
+ pull = subprocess.run( # noqa: S603 - fixed argv, no shell, no user input
93
+ ["git", "-C", str(repo_root), "pull", "--ff-only"], capture_output=True, text=True, check=False
94
+ )
95
+ if pull.returncode != 0:
96
+ return emit_success(
97
+ json_,
98
+ [
99
+ "`git pull --ff-only` failed; resolve this manually in your checkout, then re-run `update`.",
100
+ (pull.stderr or pull.stdout).strip(),
101
+ ],
102
+ {"updated": False, "reason": "git_pull_failed", "detail": (pull.stderr or pull.stdout).strip()},
103
+ )
104
+
105
+ reinstall = subprocess.run( # noqa: S603 - fixed argv, no shell, no user input
106
+ ["uv", "tool", "install", "--editable", str(package_root), "--reinstall"],
107
+ capture_output=True,
108
+ text=True,
109
+ check=False,
110
+ )
111
+ if reinstall.returncode != 0:
112
+ return emit_success(
113
+ json_,
114
+ [
115
+ "`git pull` succeeded but `uv tool install --reinstall` failed:",
116
+ (reinstall.stderr or reinstall.stdout).strip(),
117
+ ],
118
+ {
119
+ "updated": False,
120
+ "reason": "reinstall_failed",
121
+ "detail": (reinstall.stderr or reinstall.stdout).strip(),
122
+ },
123
+ )
124
+
125
+ return emit_success(
126
+ json_,
127
+ [
128
+ f"Pulled {repo_root} and reinstalled the CLI.",
129
+ "Run `reasonwork --version` to confirm the new version.",
130
+ ],
131
+ {"updated": True, "repo": str(repo_root)},
132
+ )
133
+
134
+
135
+ def _update_installed_package(json_: bool) -> int:
136
+ manager = _installed_via()
137
+ if manager == "pipx":
138
+ command = ["pipx", "upgrade", "reasonwork"]
139
+ elif manager == "uv":
140
+ command = ["uv", "tool", "upgrade", "reasonwork"]
141
+ else:
142
+ return emit_success(
143
+ json_,
144
+ [
145
+ "Could not tell how this install was managed.",
146
+ "Try `pipx upgrade reasonwork` or `uv tool upgrade reasonwork` directly, "
147
+ "whichever you originally used to install it.",
148
+ ],
149
+ {"updated": False, "reason": "unknown_install_manager"},
150
+ )
151
+
152
+ if shutil.which(command[0]) is None:
153
+ return emit_success(
154
+ json_,
155
+ [f"`{command[0]}` was used to install this but isn't on PATH anymore; upgrade manually."],
156
+ {"updated": False, "reason": f"missing_{command[0]}"},
157
+ )
158
+
159
+ result = subprocess.run(command, capture_output=True, text=True, check=False) # noqa: S603 - fixed argv
160
+ if result.returncode != 0:
161
+ return emit_success(
162
+ json_,
163
+ [f"`{' '.join(command)}` failed:", (result.stderr or result.stdout).strip()],
164
+ {"updated": False, "reason": "upgrade_failed", "detail": (result.stderr or result.stdout).strip()},
165
+ )
166
+
167
+ return emit_success(
168
+ json_,
169
+ [
170
+ f"Ran `{' '.join(command)}`.",
171
+ "Run `reasonwork --version` to confirm the new version.",
172
+ ],
173
+ {"updated": True, "via": manager},
174
+ )
175
+
176
+
177
+ __all__ = ["update"]
@@ -0,0 +1,95 @@
1
+ """`reasonwork upload` — analyze and submit one selected session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from reasonwork_exchange.errors import ValidationError
11
+ from reasonwork_exchange.output import JsonOpt, emit_success, handle_errors
12
+
13
+ _EPILOG = (
14
+ "examples:\n"
15
+ "\n reasonwork upload --path ~/.codex/sessions/2026/08/04/rollout.jsonl --dry-run"
16
+ "\n\n reasonwork upload --path ~/.codex/sessions/2026/08/04/rollout.jsonl "
17
+ "--source codex --confirm-rights"
18
+ )
19
+
20
+
21
+ @handle_errors
22
+ def upload(
23
+ path: Annotated[Path, typer.Option("--path", exists=True, dir_okay=False, readable=True)],
24
+ source: Annotated[
25
+ str, typer.Option("--source", help="codex, claude_code, cursor, or auto (detected from the file).")
26
+ ] = "auto",
27
+ confirm_rights: Annotated[
28
+ bool,
29
+ typer.Option(
30
+ "--confirm-rights", help="Reaffirm that you control the rights and removed secrets/confidential data."
31
+ ),
32
+ ] = False,
33
+ dry_run: Annotated[
34
+ bool, typer.Option("--dry-run", help="Analyze and price locally; upload nothing and make no network request.")
35
+ ] = False,
36
+ json_: JsonOpt = False,
37
+ ) -> int:
38
+ """Run safety checks and upload, or preview locally under --dry-run."""
39
+ if not confirm_rights and not dry_run:
40
+ raise ValidationError(
41
+ "rights confirmation is required for every submission",
42
+ code="rights_confirmation_required",
43
+ hint="review the file, then pass --confirm-rights",
44
+ )
45
+ if source not in {"auto", "codex", "claude_code", "cursor"}:
46
+ raise ValidationError("source must be auto, codex, claude_code, or cursor", code="invalid_source")
47
+
48
+ from reasonwork_exchange.analyzer import analyze
49
+ from reasonwork_exchange.auth import ContributorAuthStore
50
+ from reasonwork_exchange.client import ContributeClient
51
+
52
+ try:
53
+ analysis = analyze(path, discovered_source=None if source == "auto" else source)
54
+ except (OSError, ValueError) as exc:
55
+ raise ValidationError(str(exc), code="invalid_trajectory") from exc
56
+ resolved_source = analysis.source if source == "auto" else source
57
+ if resolved_source is None:
58
+ raise ValidationError("could not identify this as a Codex, Claude Code, or Cursor session", code="unknown_source")
59
+ if analysis.source and analysis.source != resolved_source:
60
+ raise ValidationError(f"file looks like {analysis.source}, not {resolved_source}", code="source_mismatch")
61
+ if analysis.privacy_risk in {"medium", "high", "blocked"}:
62
+ raise ValidationError(
63
+ "local privacy scan found secrets or personal data; redact before uploading",
64
+ code="privacy_scan_failed",
65
+ details={"risk": analysis.privacy_risk, "findings": analysis.secret_findings},
66
+ )
67
+ # Score and price are no longer computed locally at all (see
68
+ # analyzer.analyze()'s own docstring) -- only the server, after actually
69
+ # re-downloading and reviewing the content, knows what a session is worth.
70
+ if dry_run:
71
+ return emit_success(
72
+ json_,
73
+ [
74
+ f"[dry-run] would submit {path.name} as {resolved_source}; no network request made.",
75
+ f"{analysis.message_count} messages, {analysis.tool_call_count} tool calls. "
76
+ "Score and price are determined server-side after review.",
77
+ ],
78
+ {
79
+ "submitted": False,
80
+ "dry_run": True,
81
+ "would_submit": {"path": str(path), "source": resolved_source},
82
+ "analysis": analysis.to_dict(),
83
+ },
84
+ )
85
+ token = ContributorAuthStore().read()
86
+ result = ContributeClient(token).upload(path, source=resolved_source, analysis=analysis.to_dict())
87
+ return emit_success(
88
+ json_,
89
+ [
90
+ f"Submitted {path.name} for review.",
91
+ f"{analysis.message_count} messages, {analysis.tool_call_count} tool calls. "
92
+ "Score and price are determined server-side after review.",
93
+ ],
94
+ {"submitted": True, "id": result.get("id"), "status": result.get("status"), "analysis": analysis.to_dict()},
95
+ )