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,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
|
+
)
|