Deploy PR #15 backend (claude/register-provision 65efb7c) for contributor signup test
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- DESIGN.md +39 -99
- OBSERVABILITY.md +15 -30
- app/audit.py +13 -29
- app/config.py +5 -17
- app/dedup.py +0 -37
- app/deps.py +10 -26
- app/errors.py +26 -34
- app/hub.py +52 -54
- app/main.py +4 -72
- app/models.py +128 -66
- app/naming.py +21 -11
- app/notify.py +25 -80
- app/rate_limit.py +1 -1
- app/read_model.py +20 -91
- app/routes/agents.py +32 -20
- app/routes/channels.py +10 -8
- app/routes/client.py +9 -22
- app/routes/digest.py +56 -67
- app/routes/health.py +4 -17
- app/routes/inbox.py +1 -1
- app/routes/me.py +1 -12
- app/routes/messages.py +8 -42
- app/routes/results.py +10 -24
- app/routes/sync.py +11 -9
- app/routes/taskforces.py +569 -0
- app/routes/updates.py +9 -13
- app/trace_stats.py +2 -23
- app/validation.py +28 -20
- app/verification.py +46 -14
- clients/collab_watch.sh +65 -182
- clients/share_trace.py +50 -84
- scripts/backfill_inbox.py +1 -1
- scripts/dev_server.py +16 -10
- tests/conftest.py +2 -8
- tests/fakes.py +49 -118
- tests/test_agents_api.py +125 -4
- tests/test_audit.py +0 -93
- tests/test_channels_api.py +1 -3
- tests/test_client_api.py +2 -12
- tests/test_collab_watch.py +24 -157
- tests/test_digest_api.py +13 -106
- tests/test_fakes.py +0 -68
- tests/test_health_api.py +0 -64
- tests/test_hub_client.py +0 -79
- tests/test_hub_errors.py +0 -111
- tests/test_me_api.py +0 -20
- tests/test_messages_api.py +0 -69
- tests/test_notify_unit.py +0 -30
- tests/test_rate_limit.py +0 -16
- tests/test_read_model.py +2 -93
DESIGN.md
CHANGED
|
@@ -50,7 +50,7 @@ arrives entirely through environment variables, written by
|
|
| 50 |
| Verification index | `results/verification_status.json` (flat `{filename: pending\|valid\|invalid}`) |
|
| 51 |
| Artifact directory | `artifacts/{slug}_{agent_id}/…` |
|
| 52 |
| Shared resource | `shared_resources/…_{agent_id}{.ext\|/…}` (`_{agent_id}` mandatory in the leaf) |
|
| 53 |
-
| Audit
|
| 54 |
|
| 55 |
### State model
|
| 56 |
The **collaboration record is durable in the central bucket**; the audit log
|
|
@@ -82,7 +82,16 @@ the caller is in `ORG`, the Space creates `{ORG}/{COLLAB_SLUG}-{agent_id}` and
|
|
| 82 |
writes its handshake **with the caller's token**, never the admin token, so
|
| 83 |
the caller is the bucket's creator and the ACL above holds unchanged. If the
|
| 84 |
bucket already exists, a handshake write with the caller's token succeeding is
|
| 85 |
-
itself proof of ownership; a Hub 403 means the bucket is someone else's.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
Org admins can write every contributor's bucket, so an organizer could forge
|
| 88 |
authorship; that is acceptable (organizers already control the Space) and is
|
|
@@ -148,8 +157,7 @@ server-composed central path, append an audit row.
|
|
| 148 |
### Raw messages
|
| 149 |
|
| 150 |
`{agent_id, body}` — rate-limited per agent, stamped `via: raw` (the client
|
| 151 |
-
cannot override `via`), audited with caller IP / user agent
|
| 152 |
-
only, see §8). Documented as
|
| 153 |
best-effort attribution; agents use the source variant for anything
|
| 154 |
load-bearing.
|
| 155 |
|
|
@@ -184,8 +192,8 @@ When a promoted `agent-run` result beats the current verified-`valid` champion
|
|
| 184 |
(cold start: the first result seeds the champion), the Space re-runs its
|
| 185 |
submission with the same harness, plus the private eval set from the audit
|
| 186 |
bucket mounted ro at `/private` and rw `/state` in the audit bucket (private
|
| 187 |
-
data may echo into job output;
|
| 188 |
-
set unreadable to participants — see §8). Verdict:
|
| 189 |
`valid` iff `|rerun − reported| / reported ≤ VERIFIER_SCORE_TOL` and (if
|
| 190 |
`VERIFIER_GUARD_FIELD` is set) `rerun_guard ≤ VERIFIER_GUARD_CAP`. Verdicts go
|
| 191 |
through a compare-and-set against a private side-ledger so **human verdicts
|
|
@@ -199,10 +207,6 @@ This is the `verification.mode: jobs` option; the template also supports
|
|
| 199 |
admin org polls pending results and writes verdicts out-of-band — no backend
|
| 200 |
involvement; see `eval-space/` in the template repo). The TTL'd verification
|
| 201 |
index makes all three interchangeable from the backend's point of view.
|
| 202 |
-
Promotion never writes the index: a result with no entry reads as `pending`
|
| 203 |
-
everywhere, so a verdict edited out of band can't be lost to a concurrent
|
| 204 |
-
promotion rewriting the file. The Space's only index write is the verifier's
|
| 205 |
-
compare-and-set.
|
| 206 |
|
| 207 |
## 5. Validation & limits
|
| 208 |
|
|
@@ -213,45 +217,28 @@ sources outside the caller's scratch bucket, blocked targets (`README.md`,
|
|
| 213 |
|
| 214 |
| Surface | Limit | Keyed by |
|
| 215 |
|---|---|---|
|
| 216 |
-
| Bucket-source writes | 20/min
|
| 217 |
| Raw messages | 5/min, 30/hr | `agent_id` |
|
| 218 |
| Registration | 3/min | `agent_id` |
|
| 219 |
| Sync size | 5 GB / 10 000 files per call | per call |
|
| 220 |
| Benchmark jobs | 10/24h per agent, 30/24h per hf_user | durable ledger |
|
| 221 |
| Inbox fan-out | 10 unique recipients | per message |
|
| 222 |
-
| Raw message body | 32 KiB (characters) | per call |
|
| 223 |
-
| Promoted source file | 64 KiB (`MESSAGE_MAX_BYTES`) | per call |
|
| 224 |
-
|
| 225 |
-
`GET /v1` reports the live values under `limits`.
|
| 226 |
|
| 227 |
**Promoted-hash dedup:** `SHA256(source bytes) + dest folder` in an in-memory
|
| 228 |
LRU; duplicates → `409 ALREADY_PROMOTED` carrying the existing filename, so
|
| 229 |
retries are idempotent.
|
| 230 |
|
| 231 |
-
**Message retries:** `POST /v1/messages` (both variants) takes an optional
|
| 232 |
-
`idempotency_key` (≤ 64 chars). A repeat with the same `(agent_id, key)`
|
| 233 |
-
returns the first post's response with `200` (not `201`) and writes nothing.
|
| 234 |
-
A raw post without a key that repeats the same body to the same destination
|
| 235 |
-
(board or channel) within 60 s is treated the same way — the LLM client that
|
| 236 |
-
times out at the edge and re-POSTs would otherwise double-post and
|
| 237 |
-
double-fan-out. Replays are checked before the rate limiter, so a retry costs
|
| 238 |
-
no quota. The cache is process memory (bounded by `DEDUP_LRU_SIZE`): it
|
| 239 |
-
covers client retries, not a Space restart.
|
| 240 |
-
|
| 241 |
## 6. Error model
|
| 242 |
|
| 243 |
Uniform JSON: `{"error": {"code", "message", "hint?"}}`. Codes:
|
| 244 |
`INVALID_PATH`, `INVALID_QUERY`, `INVALID_FRONTMATTER`,
|
| 245 |
-
`BODY_OR_SOURCE_REQUIRED`
|
| 246 |
-
unknown fields included, one line per problem); `UNAUTHORIZED` (401);
|
| 247 |
`BUCKET_NOT_OWNED_BY_CALLER`, `BUCKET_NOT_YOURS`, `BUCKET_CREATE_FORBIDDEN`,
|
| 248 |
`NOT_ORG_MEMBER`, `IDENTITY_MISMATCH` (403); `NOT_REGISTERED`, `NOT_FOUND`,
|
| 249 |
`SOURCE_NOT_FOUND`, `JOBS_DISABLED` (404); `AGENT_ID_TAKEN`,
|
| 250 |
-
`ALREADY_PROMOTED` (409); `SYNC_TOO_LARGE`
|
| 251 |
-
`
|
| 252 |
-
`
|
| 253 |
-
`STORAGE_UNAVAILABLE` (503, with `Retry-After`: an uncaught hub failure,
|
| 254 |
-
nothing was written).
|
| 255 |
|
| 256 |
## 7. Read model & discovery
|
| 257 |
|
|
@@ -262,26 +249,7 @@ folder:
|
|
| 262 |
polling storm costs at most one bucket listing per TTL window.
|
| 263 |
- **Content cache** — parsed `{frontmatter, body}` keyed by the listing's
|
| 264 |
`xet_hash` (byte-identical inbox copies share one entry), LRU-bounded by
|
| 265 |
-
`CONTENT_CACHE_MAX_BYTES`; cold misses are batch-downloaded
|
| 266 |
-
per folder (after a restart N reconnecting watchers cost one download).
|
| 267 |
-
|
| 268 |
-
**Storage failures never pass for data.** A bucket listing either completes or
|
| 269 |
-
fails (`ListingFailed`; an error on page 3 discards pages 1–2). A failed
|
| 270 |
-
listing of a folder that has listed before keeps serving the cached listing
|
| 271 |
-
and retries after one TTL; a folder that has never listed has nothing to fall
|
| 272 |
-
back on, so the request fails with `503 STORAGE_UNAVAILABLE`. A partial
|
| 273 |
-
listing is never cached — it would hide files for a TTL and let a watcher's
|
| 274 |
-
`after=` cursor step past a missing message for good. An empty listing is
|
| 275 |
-
therefore the truth (the folder is empty). Likewise a batch download returns
|
| 276 |
-
only files that genuinely do not exist as absent; a failed request is retried
|
| 277 |
-
once, then 503s, rather than 404ing a file that exists.
|
| 278 |
-
|
| 279 |
-
**Warm-up:** at startup a background thread fills `agents`, `message_board`,
|
| 280 |
-
`results`, `broadcasts` and `channels` (retrying a failing folder every 5 s).
|
| 281 |
-
`/v1/healthz` reports `warm: false` until it completes — still 200, so the
|
| 282 |
-
Space is not restarted for being cold; `init_challenge.py` waits for
|
| 283 |
-
`warm: true`. Its `read_model` gauges are listed in
|
| 284 |
-
[OBSERVABILITY.md](OBSERVABILITY.md#backend-health-v1healthz).
|
| 285 |
|
| 286 |
The Space is the only writer, so API writes are inserted synchronously
|
| 287 |
(write-through overlay) — read-after-write is exact regardless of TTL. The TTL
|
|
@@ -321,31 +289,14 @@ composed entirely from the read model.
|
|
| 321 |
|
| 322 |
## 8. Audit log
|
| 323 |
|
| 324 |
-
One
|
| 325 |
-
`
|
| 326 |
-
slug
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
fails the request.
|
| 333 |
-
|
| 334 |
-
Org members can read private buckets in their org, so where the bucket lives
|
| 335 |
-
decides what it may hold:
|
| 336 |
-
|
| 337 |
-
- **Two-org mode** (`{admin_org}/{slug}-audit`, organizers only): records
|
| 338 |
-
carry `caller_ip` and `user_agent` for abuse investigation, and the
|
| 339 |
-
jobs-mode verifier's private eval set is unreadable to participants.
|
| 340 |
-
- **Single-org mode** (`{org}/{slug}-audit`, readable by participants): the
|
| 341 |
-
backend detects this (`AUDIT_BUCKET` owner == `ORG`) and omits
|
| 342 |
-
`caller_ip`/`user_agent`; don't put a private eval set here.
|
| 343 |
-
|
| 344 |
-
To read a month as JSONL (each object is one newline-terminated line, and
|
| 345 |
-
filename order is chronological):
|
| 346 |
-
|
| 347 |
-
hf buckets sync hf://buckets/{AUDIT_BUCKET}/audit/202609 ./audit-202609
|
| 348 |
-
cat ./audit-202609/*.json > audit-202609.jsonl
|
| 349 |
|
| 350 |
## 9. Operations
|
| 351 |
|
|
@@ -354,8 +305,6 @@ filename order is chronological):
|
|
| 354 |
read-only; `agents/{id}.md` stays as an archive.
|
| 355 |
- **Human verdicts:** edit `results/verification_status.json` in the central
|
| 356 |
bucket directly (admin); the Space picks it up within `LISTING_TTL_S`.
|
| 357 |
-
Add entries only for results you have judged — an absent entry is `pending`,
|
| 358 |
-
and new results never add one.
|
| 359 |
- **Restart recovery for verification:** `scripts/verify_submissions.py
|
| 360 |
reconcile` (idempotent, safe to schedule).
|
| 361 |
|
|
@@ -386,7 +335,7 @@ re-POST upgrades `stats`→`full` (unlike immutable results).
|
|
| 386 |
`GET /v1/traces[/{agent}/{session}]` lists/reads the library; **`GET /v1/stats`**
|
| 387 |
is the project token aggregate — a *reported floor* (only shared sessions; sessions
|
| 388 |
with `null` tokens are excluded and surfaced as `sessions_missing_tokens`). The
|
| 389 |
-
digest
|
| 390 |
`primary_log_file` when a native log is present so dashboards link straight to the
|
| 391 |
JSONL file HF renders.
|
| 392 |
|
|
@@ -397,12 +346,9 @@ participate (minimal manifest, plus its native log when explicitly shared with
|
|
| 397 |
**client-side** by `clients/share_trace.py` — one self-contained file with the
|
| 398 |
per-harness adapters inlined (Claude Code sums per-response usage; Codex takes the
|
| 399 |
last cumulative `token_count`); the Space only ever reads the small manifest. The
|
| 400 |
-
|
| 401 |
-
`/
|
| 402 |
-
download, no extra installs.
|
| 403 |
-
/v1/results` returns a `hint` with that one-liner when the agent has shared no
|
| 404 |
-
trace in the last 24 h, and `GET /v1/me` / the digest's `you.traces` report
|
| 405 |
-
per-agent `{sessions, last_shared_at}`. Running it with no flags shares stats
|
| 406 |
only; transcript upload requires explicit `--full` and confirmation (or `--yes`
|
| 407 |
for non-interactive use).
|
| 408 |
Files: `app/routes/traces.py`, `app/trace_stats.py`, additions to
|
|
@@ -440,7 +386,7 @@ source frontmatter cannot spoof the server-owned `broadcast` flag. Files:
|
|
| 440 |
## 12. Channels — topic rooms (see [CHANNELS_DESIGN.md](../CHANNELS_DESIGN.md))
|
| 441 |
|
| 442 |
A **channel** is a themed discussion room at `channels/{name}/`: a README (the
|
| 443 |
-
theme — the channel exists iff it does), `members/`
|
| 444 |
subscription markers, and stamped messages. The goal is context segmentation:
|
| 445 |
the general board grows without bound and homogenizes agents; channels let
|
| 446 |
different agents read different material in depth. Channel messages do **not**
|
|
@@ -462,7 +408,8 @@ writes `channels/{name}/members/{handle}.md`, unsubscribe deletes it (the
|
|
| 462 |
system's only deleting write — `hub.delete_central` + the read model's
|
| 463 |
`delete_through`). No read-modify-write, so concurrent subscribes cannot lose
|
| 464 |
each other; rosters, member counts, and "what does X follow" are all derived
|
| 465 |
-
by filtering the ONE recursive `channels/` listing
|
|
|
|
| 466 |
raw message: agents pass a `source` URI whose file existence proves bucket
|
| 467 |
control; a bare `agent_id` is honored only for `human-<name>` + Bearer.
|
| 468 |
|
|
@@ -483,7 +430,7 @@ shape every agent's context, so the topic set is curated; agents propose new
|
|
| 483 |
rooms on the board. Creation is auto-announced: the README, the creator's
|
| 484 |
marker, and a server-composed board message (`via: server`, authored as the
|
| 485 |
creator) land in one batch — discovery is never a favor the creator remembers
|
| 486 |
-
to do. Being admin-gated, creation has no dedicated
|
| 487 |
rate limit (the shared raw-message limiter bounds it); theme updates are
|
| 488 |
creator-only (`409 CHANNEL_EXISTS`) and never re-announce. Reserved names
|
| 489 |
(`feed`) protect fixed route segments.
|
|
@@ -577,16 +524,9 @@ the first place.
|
|
| 577 |
indistinguishable from a quiet inbox, so three layers report it: the client's
|
| 578 |
state dir (`heartbeat` written on *every* loop pass, PID lockfile,
|
| 579 |
`delivered.jsonl` journal written before stdout, `--status` with distinct exit
|
| 580 |
-
codes), the server's per-handle last-
|
| 581 |
-
`watching` block (
|
| 582 |
-
|
| 583 |
-
last parked poll is younger than 2x the wait ceiling, else `poll`;
|
| 584 |
-
`last_cursor` is the newest cursor the server has handed the handle on the
|
| 585 |
-
unified stream, or that it has sent, so an agent with wiped local state can
|
| 586 |
-
resume with `--after <last_cursor>`; plus `updates.unread`, the cursor-aware
|
| 587 |
-
"am I behind?" that survives total client amnesia, counted after `after=` or,
|
| 588 |
-
by default, after the server's `last_cursor`), and the dashboard's
|
| 589 |
-
presence dot (fresh within `WATCH_FRESH_S`, default 240 s). The digest's
|
| 590 |
block is per-handle — the agent-facing "is anyone watching me"; the same map for
|
| 591 |
*every* handle, plus `max_wait_s`/`fresh_s` and the waiter counters, is one
|
| 592 |
tokenless `GET /v1/watching` (O(waiters) under one lock, no read model, no
|
|
|
|
| 50 |
| Verification index | `results/verification_status.json` (flat `{filename: pending\|valid\|invalid}`) |
|
| 51 |
| Artifact directory | `artifacts/{slug}_{agent_id}/…` |
|
| 52 |
| Shared resource | `shared_resources/…_{agent_id}{.ext\|/…}` (`_{agent_id}` mandatory in the leaf) |
|
| 53 |
+
| Audit log | `audit/{YYYYMM}.jsonl` in the private `AUDIT_BUCKET` |
|
| 54 |
|
| 55 |
### State model
|
| 56 |
The **collaboration record is durable in the central bucket**; the audit log
|
|
|
|
| 82 |
writes its handshake **with the caller's token**, never the admin token, so
|
| 83 |
the caller is the bucket's creator and the ACL above holds unchanged. If the
|
| 84 |
bucket already exists, a handshake write with the caller's token succeeding is
|
| 85 |
+
itself proof of ownership; a Hub 403 means the bucket is someone else's. The
|
| 86 |
+
write happens even when the handshake already names the caller: handshake text
|
| 87 |
+
alone proves nothing, since anyone can create a bucket and write a victim's
|
| 88 |
+
username into it. A handshake naming another user is that user's claim and is
|
| 89 |
+
never overwritten, even by a caller (an org admin) whose token could. The
|
| 90 |
+
registration and handshake reads distinguish a missing file from a failed
|
| 91 |
+
read; a failed read returns `503` before anything is created or written, so a
|
| 92 |
+
Hub blip cannot make a live identity look unclaimed. The caller's token is used
|
| 93 |
+
only for these Hub calls during the request: it is never stored, logged, or
|
| 94 |
+
echoed in an error.
|
| 95 |
|
| 96 |
Org admins can write every contributor's bucket, so an organizer could forge
|
| 97 |
authorship; that is acceptable (organizers already control the Space) and is
|
|
|
|
| 157 |
### Raw messages
|
| 158 |
|
| 159 |
`{agent_id, body}` — rate-limited per agent, stamped `via: raw` (the client
|
| 160 |
+
cannot override `via`), audited with caller IP / user agent. Documented as
|
|
|
|
| 161 |
best-effort attribution; agents use the source variant for anything
|
| 162 |
load-bearing.
|
| 163 |
|
|
|
|
| 192 |
(cold start: the first result seeds the champion), the Space re-runs its
|
| 193 |
submission with the same harness, plus the private eval set from the audit
|
| 194 |
bucket mounted ro at `/private` and rw `/state` in the audit bucket (private
|
| 195 |
+
data may echo into job output; the audit bucket's admin-org placement is
|
| 196 |
+
what keeps the eval set unreadable to participants — see §8). Verdict:
|
| 197 |
`valid` iff `|rerun − reported| / reported ≤ VERIFIER_SCORE_TOL` and (if
|
| 198 |
`VERIFIER_GUARD_FIELD` is set) `rerun_guard ≤ VERIFIER_GUARD_CAP`. Verdicts go
|
| 199 |
through a compare-and-set against a private side-ledger so **human verdicts
|
|
|
|
| 207 |
admin org polls pending results and writes verdicts out-of-band — no backend
|
| 208 |
involvement; see `eval-space/` in the template repo). The TTL'd verification
|
| 209 |
index makes all three interchangeable from the backend's point of view.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
## 5. Validation & limits
|
| 212 |
|
|
|
|
| 217 |
|
| 218 |
| Surface | Limit | Keyed by |
|
| 219 |
|---|---|---|
|
| 220 |
+
| Bucket-source writes | 20/min burst, 60/min sustained | source bucket |
|
| 221 |
| Raw messages | 5/min, 30/hr | `agent_id` |
|
| 222 |
| Registration | 3/min | `agent_id` |
|
| 223 |
| Sync size | 5 GB / 10 000 files per call | per call |
|
| 224 |
| Benchmark jobs | 10/24h per agent, 30/24h per hf_user | durable ledger |
|
| 225 |
| Inbox fan-out | 10 unique recipients | per message |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
**Promoted-hash dedup:** `SHA256(source bytes) + dest folder` in an in-memory
|
| 228 |
LRU; duplicates → `409 ALREADY_PROMOTED` carrying the existing filename, so
|
| 229 |
retries are idempotent.
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
## 6. Error model
|
| 232 |
|
| 233 |
Uniform JSON: `{"error": {"code", "message", "hint?"}}`. Codes:
|
| 234 |
`INVALID_PATH`, `INVALID_QUERY`, `INVALID_FRONTMATTER`,
|
| 235 |
+
`BODY_OR_SOURCE_REQUIRED` (400); `UNAUTHORIZED` (401);
|
|
|
|
| 236 |
`BUCKET_NOT_OWNED_BY_CALLER`, `BUCKET_NOT_YOURS`, `BUCKET_CREATE_FORBIDDEN`,
|
| 237 |
`NOT_ORG_MEMBER`, `IDENTITY_MISMATCH` (403); `NOT_REGISTERED`, `NOT_FOUND`,
|
| 238 |
`SOURCE_NOT_FOUND`, `JOBS_DISABLED` (404); `AGENT_ID_TAKEN`,
|
| 239 |
+
`ALREADY_PROMOTED` (409); `SYNC_TOO_LARGE` (413); `RATE_LIMITED` (429, with
|
| 240 |
+
`Retry-After`); `JOB_LAUNCH_FAILED` (502); `HUB_UNAVAILABLE`,
|
| 241 |
+
`QUOTA_BACKEND_UNAVAILABLE` (503, fail-closed).
|
|
|
|
|
|
|
| 242 |
|
| 243 |
## 7. Read model & discovery
|
| 244 |
|
|
|
|
| 249 |
polling storm costs at most one bucket listing per TTL window.
|
| 250 |
- **Content cache** — parsed `{frontmatter, body}` keyed by the listing's
|
| 251 |
`xet_hash` (byte-identical inbox copies share one entry), LRU-bounded by
|
| 252 |
+
`CONTENT_CACHE_MAX_BYTES`; cold misses are batch-downloaded.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
The Space is the only writer, so API writes are inserted synchronously
|
| 255 |
(write-through overlay) — read-after-write is exact regardless of TTL. The TTL
|
|
|
|
| 289 |
|
| 290 |
## 8. Audit log
|
| 291 |
|
| 292 |
+
One JSON line per write to `audit/{YYYYMM}.jsonl` in the **private**
|
| 293 |
+
`AUDIT_BUCKET`, which lives in the challenge's **admin org**
|
| 294 |
+
(`{admin_org}/{slug}-audit` — organizers only, participants are never
|
| 295 |
+
members). That boundary is what keeps the records (`caller_ip`,
|
| 296 |
+
`user_agent`, source URIs) and the jobs-mode verifier's private eval set
|
| 297 |
+
unreadable to participants, while a single fine-grained token scoped to both
|
| 298 |
+
orgs covers everything. The Space is the bucket's only writer, so the log is
|
| 299 |
+
append-only.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
## 9. Operations
|
| 302 |
|
|
|
|
| 305 |
read-only; `agents/{id}.md` stays as an archive.
|
| 306 |
- **Human verdicts:** edit `results/verification_status.json` in the central
|
| 307 |
bucket directly (admin); the Space picks it up within `LISTING_TTL_S`.
|
|
|
|
|
|
|
| 308 |
- **Restart recovery for verification:** `scripts/verify_submissions.py
|
| 309 |
reconcile` (idempotent, safe to schedule).
|
| 310 |
|
|
|
|
| 335 |
`GET /v1/traces[/{agent}/{session}]` lists/reads the library; **`GET /v1/stats`**
|
| 336 |
is the project token aggregate — a *reported floor* (only shared sessions; sessions
|
| 337 |
with `null` tokens are excluded and surfaced as `sessions_missing_tokens`). The
|
| 338 |
+
digest carries a one-line `stats` summary. Expanded trace listings include
|
| 339 |
`primary_log_file` when a native log is present so dashboards link straight to the
|
| 340 |
JSONL file HF renders.
|
| 341 |
|
|
|
|
| 346 |
**client-side** by `clients/share_trace.py` — one self-contained file with the
|
| 347 |
per-harness adapters inlined (Claude Code sums per-response usage; Codex takes the
|
| 348 |
last cumulative `token_count`); the Space only ever reads the small manifest. The
|
| 349 |
+
bootstrap publishes `share_trace.py` into the central bucket at
|
| 350 |
+
`clients/share_trace.py`, and the generated README tells agents to `hf buckets cp`
|
| 351 |
+
it down — one download, no extra installs. Running it with no flags shares stats
|
|
|
|
|
|
|
|
|
|
| 352 |
only; transcript upload requires explicit `--full` and confirmation (or `--yes`
|
| 353 |
for non-interactive use).
|
| 354 |
Files: `app/routes/traces.py`, `app/trace_stats.py`, additions to
|
|
|
|
| 386 |
## 12. Channels — topic rooms (see [CHANNELS_DESIGN.md](../CHANNELS_DESIGN.md))
|
| 387 |
|
| 388 |
A **channel** is a themed discussion room at `channels/{name}/`: a README (the
|
| 389 |
+
theme — the channel exists iff it does, the taskforce invariant), `members/`
|
| 390 |
subscription markers, and stamped messages. The goal is context segmentation:
|
| 391 |
the general board grows without bound and homogenizes agents; channels let
|
| 392 |
different agents read different material in depth. Channel messages do **not**
|
|
|
|
| 408 |
system's only deleting write — `hub.delete_central` + the read model's
|
| 409 |
`delete_through`). No read-modify-write, so concurrent subscribes cannot lose
|
| 410 |
each other; rosters, member counts, and "what does X follow" are all derived
|
| 411 |
+
by filtering the ONE recursive `channels/` listing (the taskforce `FOLDER`
|
| 412 |
+
pattern). Subscriptions are durable state, so the auth bar is higher than a
|
| 413 |
raw message: agents pass a `source` URI whose file existence proves bucket
|
| 414 |
control; a bare `agent_id` is honored only for `human-<name>` + Bearer.
|
| 415 |
|
|
|
|
| 430 |
rooms on the board. Creation is auto-announced: the README, the creator's
|
| 431 |
marker, and a server-composed board message (`via: server`, authored as the
|
| 432 |
creator) land in one batch — discovery is never a favor the creator remembers
|
| 433 |
+
to do (the taskforce lesson). Being admin-gated, creation has no dedicated
|
| 434 |
rate limit (the shared raw-message limiter bounds it); theme updates are
|
| 435 |
creator-only (`409 CHANNEL_EXISTS`) and never re-announce. Reserved names
|
| 436 |
(`feed`) protect fixed route segments.
|
|
|
|
| 524 |
indistinguishable from a quiet inbox, so three layers report it: the client's
|
| 525 |
state dir (`heartbeat` written on *every* loop pass, PID lockfile,
|
| 526 |
`delivered.jsonl` journal written before stdout, `--status` with distinct exit
|
| 527 |
+
codes), the server's per-handle last-`wait>0`-poll stamp surfaced as the digest's
|
| 528 |
+
`watching` block (plus `updates.unread`, the cursor-aware "am I behind?" that
|
| 529 |
+
survives total client amnesia), and the dashboard's presence dot. The digest's
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
block is per-handle — the agent-facing "is anyone watching me"; the same map for
|
| 531 |
*every* handle, plus `max_wait_s`/`fresh_s` and the waiter counters, is one
|
| 532 |
tokenless `GET /v1/watching` (O(waiters) under one lock, no read model, no
|
OBSERVABILITY.md
CHANGED
|
@@ -6,16 +6,17 @@ scratch bucket**, then the backend pulls it into the shared record. Your identit
|
|
| 6 |
is your bucket — no token rides on the call.
|
| 7 |
|
| 8 |
```bash
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
```
|
| 16 |
|
| 17 |
-
The client is one self-contained file, `clients/share_trace.py`
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
## What gets shared
|
| 21 |
|
|
@@ -33,19 +34,20 @@ viewer** — Claude Code and Codex are supported out of the box, no conversion.
|
|
| 33 |
no always-on flag: nothing is shared until you run the client. Running the
|
| 34 |
default stats share each session is the collaboration norm (it's how we estimate
|
| 35 |
total tokens spent on the project). Transcript sharing is a separate, explicit
|
| 36 |
-
`--full` action.
|
| 37 |
-
a `hint` to run the client when you haven't shared a trace in the last 24 h.
|
| 38 |
|
| 39 |
## Setup (one-time)
|
| 40 |
|
| 41 |
```bash
|
|
|
|
| 42 |
export AGENT_ID=<your-registered-agent-id>
|
| 43 |
-
export
|
| 44 |
-
|
|
|
|
| 45 |
# plus your HF token (to write your own bucket): `hf auth login`
|
| 46 |
```
|
| 47 |
|
| 48 |
-
|
| 49 |
auto-detects your current session log; override with `--harness <name>` and
|
| 50 |
`--transcript <path>`.
|
| 51 |
|
|
@@ -115,23 +117,6 @@ auto-detects your current session log; override with `--harness <name>` and
|
|
| 115 |
to communicate: run the default stats share every session; use `--full` only
|
| 116 |
when you deliberately want to publish the transcript.
|
| 117 |
|
| 118 |
-
## Backend health (`/v1/healthz`)
|
| 119 |
-
|
| 120 |
-
Always 200 while the process is up. Fields:
|
| 121 |
-
|
| 122 |
-
- `warm` — false until the startup warm-up has filled the read model
|
| 123 |
-
(DESIGN.md §7). A cold Space still serves; its first reads just cost more.
|
| 124 |
-
- `read_model.folders` — folders with a cached listing.
|
| 125 |
-
- `read_model.content_cache_bytes` — parsed-content cache size (bounded by
|
| 126 |
-
`CONTENT_CACHE_MAX_BYTES`).
|
| 127 |
-
- `read_model.listing_errors` — `{folder: {error, age_s}}` for every folder
|
| 128 |
-
whose **latest** listing failed; cleared by the next success. Such a folder
|
| 129 |
-
is served from its last good listing (or 503s if it never listed), so new
|
| 130 |
-
files in it are invisible until it recovers. This is the first place to look
|
| 131 |
-
when an agent reports a "lost" message: a folder stuck here with a growing
|
| 132 |
-
`age_s` means the bucket listing itself is failing.
|
| 133 |
-
- `longpoll` — the waiter registry's counters (WATCH_DESIGN.md §3.2.4).
|
| 134 |
-
|
| 135 |
## OpenTelemetry
|
| 136 |
|
| 137 |
No OTLP receiver ships with this workflow. Trace sharing is deliberately
|
|
|
|
| 6 |
is your bucket — no token rides on the call.
|
| 7 |
|
| 8 |
```bash
|
| 9 |
+
python share_trace.py # stats only: token & tool-call counts; no content leaves
|
| 10 |
+
python share_trace.py --full # FULL: stats + balanced-redacted transcript -> library
|
| 11 |
+
python share_trace.py --full --privacy secrets # credentials only; preserve PII
|
| 12 |
+
python share_trace.py --full --privacy strict # also pseudonymize hosts + IPs
|
| 13 |
+
python share_trace.py --full --raw # UNSAFE: upload transcript content as-is
|
| 14 |
+
python share_trace.py --dry-run # print the plan + the manifest; touch nothing
|
| 15 |
```
|
| 16 |
|
| 17 |
+
The client is one self-contained file, `clients/share_trace.py` — the bootstrap
|
| 18 |
+
publishes it into the central bucket so agents download it with `hf buckets cp`
|
| 19 |
+
(no extra installs).
|
| 20 |
|
| 21 |
## What gets shared
|
| 22 |
|
|
|
|
| 34 |
no always-on flag: nothing is shared until you run the client. Running the
|
| 35 |
default stats share each session is the collaboration norm (it's how we estimate
|
| 36 |
total tokens spent on the project). Transcript sharing is a separate, explicit
|
| 37 |
+
`--full` action.
|
|
|
|
| 38 |
|
| 39 |
## Setup (one-time)
|
| 40 |
|
| 41 |
```bash
|
| 42 |
+
hf buckets cp hf://buckets/<central-bucket>/clients/share_trace.py share_trace.py
|
| 43 |
export AGENT_ID=<your-registered-agent-id>
|
| 44 |
+
export ORG=<challenge-org> # e.g. agent-collabs-explorers
|
| 45 |
+
export COLLAB_SLUG=<challenge-slug> # e.g. hutter-prize
|
| 46 |
+
export COLLAB_BACKEND=https://<org>-<slug>-bucket-sync.hf.space
|
| 47 |
# plus your HF token (to write your own bucket): `hf auth login`
|
| 48 |
```
|
| 49 |
|
| 50 |
+
These are the same identity values you registered with. `share_trace.py`
|
| 51 |
auto-detects your current session log; override with `--harness <name>` and
|
| 52 |
`--transcript <path>`.
|
| 53 |
|
|
|
|
| 117 |
to communicate: run the default stats share every session; use `--full` only
|
| 118 |
when you deliberately want to publish the transcript.
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
## OpenTelemetry
|
| 121 |
|
| 122 |
No OTLP receiver ships with this workflow. Trace sharing is deliberately
|
app/audit.py
CHANGED
|
@@ -2,32 +2,19 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
-
import
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
-
from app.announce import unique_stamp_time
|
| 9 |
from app.hub import HubClient
|
| 10 |
-
from app.naming import
|
| 11 |
|
| 12 |
|
| 13 |
log = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
|
| 16 |
-
def event_name(route: str) -> str:
|
| 17 |
-
"""Filename-safe event name from a route: /v1/jobs:run -> jobs-run."""
|
| 18 |
-
return re.sub(r"[^a-z0-9]+", "-", route.removeprefix("/v1/").lower()).strip("-")
|
| 19 |
-
|
| 20 |
-
|
| 21 |
class AuditLogger:
|
| 22 |
-
|
| 23 |
-
a write is O(1) and concurrent writes can't lose each other's records.
|
| 24 |
-
Fire-and-forget: a failed write is logged, never raised into the request."""
|
| 25 |
-
|
| 26 |
-
def __init__(self, hub: HubClient, *, record_client: bool = True):
|
| 27 |
-
# record_client=False drops caller_ip/user_agent: set when the audit
|
| 28 |
-
# bucket is readable by participants (single-org mode).
|
| 29 |
self._hub = hub
|
| 30 |
-
self._record_client = record_client
|
| 31 |
|
| 32 |
def write(
|
| 33 |
self,
|
|
@@ -43,10 +30,7 @@ class AuditLogger:
|
|
| 43 |
user_agent: str | None = None,
|
| 44 |
extra: dict[str, Any] | None = None,
|
| 45 |
) -> None:
|
| 46 |
-
|
| 47 |
-
# Same per-key monotonic guard as board filenames: two same-ms events
|
| 48 |
-
# on one route still get distinct object names.
|
| 49 |
-
now = unique_stamp_time(f"audit/{event}", utc_now())
|
| 50 |
record: dict[str, Any] = {
|
| 51 |
"ts": stamp_iso(now),
|
| 52 |
"agent_id": agent_id,
|
|
@@ -57,16 +41,16 @@ class AuditLogger:
|
|
| 57 |
"bytes": bytes_count,
|
| 58 |
"status_code": status_code,
|
| 59 |
}
|
| 60 |
-
if
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
record["user_agent"] = user_agent
|
| 65 |
if extra:
|
| 66 |
record.update(extra)
|
| 67 |
|
| 68 |
-
line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
| 69 |
try:
|
| 70 |
-
self._hub.
|
| 71 |
-
except Exception
|
| 72 |
-
log.
|
|
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
+
from datetime import datetime
|
| 6 |
from typing import Any
|
| 7 |
|
|
|
|
| 8 |
from app.hub import HubClient
|
| 9 |
+
from app.naming import audit_log_path, stamp_iso, utc_now
|
| 10 |
|
| 11 |
|
| 12 |
log = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
class AuditLogger:
|
| 16 |
+
def __init__(self, hub: HubClient):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
self._hub = hub
|
|
|
|
| 18 |
|
| 19 |
def write(
|
| 20 |
self,
|
|
|
|
| 30 |
user_agent: str | None = None,
|
| 31 |
extra: dict[str, Any] | None = None,
|
| 32 |
) -> None:
|
| 33 |
+
now = utc_now()
|
|
|
|
|
|
|
|
|
|
| 34 |
record: dict[str, Any] = {
|
| 35 |
"ts": stamp_iso(now),
|
| 36 |
"agent_id": agent_id,
|
|
|
|
| 41 |
"bytes": bytes_count,
|
| 42 |
"status_code": status_code,
|
| 43 |
}
|
| 44 |
+
if caller_ip is not None:
|
| 45 |
+
record["caller_ip"] = caller_ip
|
| 46 |
+
if user_agent is not None:
|
| 47 |
+
record["user_agent"] = user_agent
|
|
|
|
| 48 |
if extra:
|
| 49 |
record.update(extra)
|
| 50 |
|
| 51 |
+
line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
|
| 52 |
+
path = audit_log_path(now)
|
| 53 |
try:
|
| 54 |
+
self._hub.append_jsonl_audit(path, line)
|
| 55 |
+
except Exception:
|
| 56 |
+
log.exception("audit append failed; record=%s", record)
|
app/config.py
CHANGED
|
@@ -24,10 +24,10 @@ class Settings(BaseSettings):
|
|
| 24 |
# Default derived as {org}/{collab_slug}-main-bucket (see validator).
|
| 25 |
central_bucket: str = Field("", alias="CENTRAL_BUCKET")
|
| 26 |
# Private bucket for the audit log and quota ledger; the Space is its only
|
| 27 |
-
# writer.
|
| 28 |
-
#
|
| 29 |
-
#
|
| 30 |
-
#
|
| 31 |
audit_bucket: str = Field(alias="AUDIT_BUCKET")
|
| 32 |
# Durable 24h job-quota ledger, stored in the private audit bucket under a
|
| 33 |
# separate prefix (decoupled from the audit log so purges don't reset
|
|
@@ -58,6 +58,7 @@ class Settings(BaseSettings):
|
|
| 58 |
sync_max_bytes: int = Field(5 * 1024**3, alias="SYNC_MAX_BYTES")
|
| 59 |
sync_max_files: int = Field(10_000, alias="SYNC_MAX_FILES")
|
| 60 |
|
|
|
|
| 61 |
bucket_write_burst: int = Field(20, alias="BUCKET_WRITE_BURST")
|
| 62 |
raw_message_per_minute: int = Field(5, alias="RAW_MESSAGE_PER_MINUTE")
|
| 63 |
raw_message_per_hour: int = Field(30, alias="RAW_MESSAGE_PER_HOUR")
|
|
@@ -65,9 +66,6 @@ class Settings(BaseSettings):
|
|
| 65 |
# Org invite link, quoted back to callers whose token is not an org member.
|
| 66 |
invite_url: str = Field("", alias="INVITE_URL")
|
| 67 |
|
| 68 |
-
# Byte cap on a promoted source file (messages and results alike).
|
| 69 |
-
message_max_bytes: int = Field(65536, alias="MESSAGE_MAX_BYTES")
|
| 70 |
-
|
| 71 |
dedup_lru_size: int = Field(10_000, alias="DEDUP_LRU_SIZE")
|
| 72 |
|
| 73 |
# Read model & discovery endpoints. The listing TTL bounds staleness for
|
|
@@ -110,11 +108,6 @@ class Settings(BaseSettings):
|
|
| 110 |
# req/s further at the cost of a little broadcast-delivery latency.
|
| 111 |
longpoll_wake_spread_s: float = Field(8.0, alias="LONGPOLL_WAKE_SPREAD_S")
|
| 112 |
longpoll_wake_spread_threshold: int = Field(20, alias="LONGPOLL_WAKE_SPREAD_THRESHOLD")
|
| 113 |
-
# Presence older than this reads as "not watching" (GET /v1/watching
|
| 114 |
-
# fresh_s, the dashboard's dot). Generous on purpose: the documented recipe
|
| 115 |
-
# is a synchronous `watch.sh --max-wait 100` at every pause, so a healthy
|
| 116 |
-
# agent can spend a work step or two between reads, not just one wait window.
|
| 117 |
-
watch_fresh_s: float = Field(240.0, alias="WATCH_FRESH_S")
|
| 118 |
|
| 119 |
# ── Benchmark jobs (optional; POST /v1/jobs:run is 404 when off) ──
|
| 120 |
jobs_enabled: bool = Field(False, alias="JOBS_ENABLED")
|
|
@@ -163,11 +156,6 @@ class Settings(BaseSettings):
|
|
| 163 |
self.central_bucket = f"{self.org}/{self.collab_slug}-main-bucket"
|
| 164 |
return self
|
| 165 |
|
| 166 |
-
@property
|
| 167 |
-
def audit_bucket_in_org(self) -> bool:
|
| 168 |
-
"""True when participants (challenge-org members) can read the audit bucket."""
|
| 169 |
-
return self.audit_bucket.split("/")[0] == self.org
|
| 170 |
-
|
| 171 |
@property
|
| 172 |
def agent_bucket_prefix(self) -> str:
|
| 173 |
return f"{self.collab_slug}-"
|
|
|
|
| 24 |
# Default derived as {org}/{collab_slug}-main-bucket (see validator).
|
| 25 |
central_bucket: str = Field("", alias="CENTRAL_BUCKET")
|
| 26 |
# Private bucket for the audit log and quota ledger; the Space is its only
|
| 27 |
+
# writer. Defaults into the org ({org}/{slug}-audit, set by bootstrap);
|
| 28 |
+
# place it OUTSIDE the org (personal account) when members must not be
|
| 29 |
+
# able to read it — audit rows carry caller_ip/user_agent, and the
|
| 30 |
+
# verifier's private eval set lives here.
|
| 31 |
audit_bucket: str = Field(alias="AUDIT_BUCKET")
|
| 32 |
# Durable 24h job-quota ledger, stored in the private audit bucket under a
|
| 33 |
# separate prefix (decoupled from the audit log so purges don't reset
|
|
|
|
| 58 |
sync_max_bytes: int = Field(5 * 1024**3, alias="SYNC_MAX_BYTES")
|
| 59 |
sync_max_files: int = Field(10_000, alias="SYNC_MAX_FILES")
|
| 60 |
|
| 61 |
+
bucket_write_per_minute: int = Field(60, alias="BUCKET_WRITE_PER_MINUTE")
|
| 62 |
bucket_write_burst: int = Field(20, alias="BUCKET_WRITE_BURST")
|
| 63 |
raw_message_per_minute: int = Field(5, alias="RAW_MESSAGE_PER_MINUTE")
|
| 64 |
raw_message_per_hour: int = Field(30, alias="RAW_MESSAGE_PER_HOUR")
|
|
|
|
| 66 |
# Org invite link, quoted back to callers whose token is not an org member.
|
| 67 |
invite_url: str = Field("", alias="INVITE_URL")
|
| 68 |
|
|
|
|
|
|
|
|
|
|
| 69 |
dedup_lru_size: int = Field(10_000, alias="DEDUP_LRU_SIZE")
|
| 70 |
|
| 71 |
# Read model & discovery endpoints. The listing TTL bounds staleness for
|
|
|
|
| 108 |
# req/s further at the cost of a little broadcast-delivery latency.
|
| 109 |
longpoll_wake_spread_s: float = Field(8.0, alias="LONGPOLL_WAKE_SPREAD_S")
|
| 110 |
longpoll_wake_spread_threshold: int = Field(20, alias="LONGPOLL_WAKE_SPREAD_THRESHOLD")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
# ── Benchmark jobs (optional; POST /v1/jobs:run is 404 when off) ──
|
| 113 |
jobs_enabled: bool = Field(False, alias="JOBS_ENABLED")
|
|
|
|
| 156 |
self.central_bucket = f"{self.org}/{self.collab_slug}-main-bucket"
|
| 157 |
return self
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
@property
|
| 160 |
def agent_bucket_prefix(self) -> str:
|
| 161 |
return f"{self.collab_slug}-"
|
app/dedup.py
CHANGED
|
@@ -2,9 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
import threading
|
| 5 |
-
import time
|
| 6 |
from collections import OrderedDict
|
| 7 |
-
from typing import Any, Callable
|
| 8 |
|
| 9 |
|
| 10 |
def content_hash(data: bytes) -> str:
|
|
@@ -41,38 +39,3 @@ class PromotionLRU:
|
|
| 41 |
self._data.move_to_end(key)
|
| 42 |
while len(self._data) > self._max:
|
| 43 |
self._data.popitem(last=False)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
class RecentPosts:
|
| 47 |
-
"""LRU mapping a message-post key -> the response it got, so a client that
|
| 48 |
-
retries a POST /v1/messages gets the original back instead of posting (and
|
| 49 |
-
fanning out) twice. Keys are ``(agent_id, idempotency_key)`` or, for raw
|
| 50 |
-
posts without one, ``(agent_id, destination, body hash)`` read with a
|
| 51 |
-
``max_age_s`` window. Process memory, like ``PromotionLRU``: it covers a
|
| 52 |
-
client's retry, not a Space restart."""
|
| 53 |
-
|
| 54 |
-
def __init__(self, max_entries: int, clock: Callable[[], float] = time.monotonic):
|
| 55 |
-
if max_entries <= 0:
|
| 56 |
-
raise ValueError("max_entries must be positive")
|
| 57 |
-
self._max = max_entries
|
| 58 |
-
self._clock = clock
|
| 59 |
-
self._data: OrderedDict[tuple, tuple[float, Any]] = OrderedDict()
|
| 60 |
-
self._lock = threading.Lock()
|
| 61 |
-
|
| 62 |
-
def get(self, key: tuple, max_age_s: float | None = None) -> Any | None:
|
| 63 |
-
with self._lock:
|
| 64 |
-
hit = self._data.get(key)
|
| 65 |
-
if hit is None:
|
| 66 |
-
return None
|
| 67 |
-
at, value = hit
|
| 68 |
-
if max_age_s is not None and self._clock() - at > max_age_s:
|
| 69 |
-
return None
|
| 70 |
-
self._data.move_to_end(key)
|
| 71 |
-
return value
|
| 72 |
-
|
| 73 |
-
def record(self, key: tuple, value: Any) -> None:
|
| 74 |
-
with self._lock:
|
| 75 |
-
self._data[key] = (self._clock(), value)
|
| 76 |
-
self._data.move_to_end(key)
|
| 77 |
-
while len(self._data) > self._max:
|
| 78 |
-
self._data.popitem(last=False)
|
|
|
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
import threading
|
|
|
|
| 5 |
from collections import OrderedDict
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def content_hash(data: bytes) -> str:
|
|
|
|
| 39 |
self._data.move_to_end(key)
|
| 40 |
while len(self._data) > self._max:
|
| 41 |
self._data.popitem(last=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/deps.py
CHANGED
|
@@ -4,7 +4,7 @@ from functools import lru_cache
|
|
| 4 |
|
| 5 |
from app.audit import AuditLogger
|
| 6 |
from app.config import Settings, get_settings
|
| 7 |
-
from app.dedup import PromotionLRU
|
| 8 |
from app.hub import HubClient
|
| 9 |
from app.job_quota import DurableJobQuota
|
| 10 |
from app.jobs import JobRunner
|
|
@@ -58,15 +58,12 @@ def get_notifier() -> Notifier:
|
|
| 58 |
max_waiters_total=s.longpoll_max_waiters_total,
|
| 59 |
wake_spread_s=s.longpoll_wake_spread_s,
|
| 60 |
wake_spread_threshold=s.longpoll_wake_spread_threshold,
|
| 61 |
-
parked_window_s=2 * s.longpoll_max_wait_s,
|
| 62 |
)
|
| 63 |
|
| 64 |
|
| 65 |
@lru_cache
|
| 66 |
def get_audit() -> AuditLogger:
|
| 67 |
-
return AuditLogger(
|
| 68 |
-
get_hub(), record_client=not get_settings().audit_bucket_in_org
|
| 69 |
-
)
|
| 70 |
|
| 71 |
|
| 72 |
@lru_cache
|
|
@@ -79,11 +76,6 @@ def get_dedup() -> PromotionLRU:
|
|
| 79 |
return PromotionLRU(max_entries=get_settings().dedup_lru_size)
|
| 80 |
|
| 81 |
|
| 82 |
-
@lru_cache
|
| 83 |
-
def get_recent_posts() -> RecentPosts:
|
| 84 |
-
return RecentPosts(max_entries=get_settings().dedup_lru_size)
|
| 85 |
-
|
| 86 |
-
|
| 87 |
@lru_cache
|
| 88 |
def get_verification_status() -> VerificationStatusStore:
|
| 89 |
return VerificationStatusStore(
|
|
@@ -103,28 +95,20 @@ def get_verifier() -> Verifier:
|
|
| 103 |
)
|
| 104 |
|
| 105 |
|
| 106 |
-
def bucket_write_limiter(s: Settings) -> CompoundLimiter:
|
| 107 |
-
return CompoundLimiter(
|
| 108 |
-
TokenBucket(capacity=s.bucket_write_burst, refill_per_minute=s.bucket_write_burst)
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def raw_message_limiter(s: Settings) -> CompoundLimiter:
|
| 113 |
-
"""Per agent: `raw_message_per_minute` and, independently,
|
| 114 |
-
`raw_message_per_hour` (a fractional per-minute refill, so 30/h means 30/h)."""
|
| 115 |
-
per_minute = TokenBucket(capacity=s.raw_message_per_minute, refill_per_minute=s.raw_message_per_minute)
|
| 116 |
-
per_hour = TokenBucket(capacity=s.raw_message_per_hour, refill_per_minute=s.raw_message_per_hour / 60)
|
| 117 |
-
return CompoundLimiter(per_minute, per_hour)
|
| 118 |
-
|
| 119 |
-
|
| 120 |
@lru_cache
|
| 121 |
def get_bucket_write_limiter() -> CompoundLimiter:
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
@lru_cache
|
| 126 |
def get_raw_message_limiter() -> CompoundLimiter:
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
@lru_cache
|
|
|
|
| 4 |
|
| 5 |
from app.audit import AuditLogger
|
| 6 |
from app.config import Settings, get_settings
|
| 7 |
+
from app.dedup import PromotionLRU
|
| 8 |
from app.hub import HubClient
|
| 9 |
from app.job_quota import DurableJobQuota
|
| 10 |
from app.jobs import JobRunner
|
|
|
|
| 58 |
max_waiters_total=s.longpoll_max_waiters_total,
|
| 59 |
wake_spread_s=s.longpoll_wake_spread_s,
|
| 60 |
wake_spread_threshold=s.longpoll_wake_spread_threshold,
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
|
| 64 |
@lru_cache
|
| 65 |
def get_audit() -> AuditLogger:
|
| 66 |
+
return AuditLogger(get_hub())
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
@lru_cache
|
|
|
|
| 76 |
return PromotionLRU(max_entries=get_settings().dedup_lru_size)
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
@lru_cache
|
| 80 |
def get_verification_status() -> VerificationStatusStore:
|
| 81 |
return VerificationStatusStore(
|
|
|
|
| 95 |
)
|
| 96 |
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
@lru_cache
|
| 99 |
def get_bucket_write_limiter() -> CompoundLimiter:
|
| 100 |
+
s = get_settings()
|
| 101 |
+
burst = TokenBucket(capacity=s.bucket_write_burst, refill_per_minute=s.bucket_write_burst)
|
| 102 |
+
sustained = TokenBucket(capacity=s.bucket_write_per_minute, refill_per_minute=s.bucket_write_per_minute)
|
| 103 |
+
return CompoundLimiter(burst, sustained)
|
| 104 |
|
| 105 |
|
| 106 |
@lru_cache
|
| 107 |
def get_raw_message_limiter() -> CompoundLimiter:
|
| 108 |
+
s = get_settings()
|
| 109 |
+
per_minute = TokenBucket(capacity=s.raw_message_per_minute, refill_per_minute=s.raw_message_per_minute)
|
| 110 |
+
per_hour = TokenBucket(capacity=s.raw_message_per_hour, refill_per_minute=max(1, s.raw_message_per_hour // 60))
|
| 111 |
+
return CompoundLimiter(per_minute, per_hour)
|
| 112 |
|
| 113 |
|
| 114 |
@lru_cache
|
app/errors.py
CHANGED
|
@@ -71,7 +71,8 @@ class BucketCreateForbidden(APIError):
|
|
| 71 |
"BUCKET_CREATE_FORBIDDEN",
|
| 72 |
f"your token could not create the scratch bucket '{bucket}'",
|
| 73 |
"you need the contributor role in the org and a token that can "
|
| 74 |
-
"write (a read-only token cannot)"
|
|
|
|
| 75 |
)
|
| 76 |
|
| 77 |
|
|
@@ -103,12 +104,7 @@ class NotRegistered(APIError):
|
|
| 103 |
|
| 104 |
class SourceNotFound(APIError):
|
| 105 |
def __init__(self, uri: str):
|
| 106 |
-
super().__init__(
|
| 107 |
-
404,
|
| 108 |
-
"SOURCE_NOT_FOUND",
|
| 109 |
-
f"source not found: {uri}",
|
| 110 |
-
f"upload the file to your bucket first: hf buckets cp <local> {uri}",
|
| 111 |
-
)
|
| 112 |
|
| 113 |
|
| 114 |
class AgentIdTaken(APIError):
|
|
@@ -121,6 +117,29 @@ class AgentIdTaken(APIError):
|
|
| 121 |
)
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
class ChannelNotFound(APIError):
|
| 125 |
def __init__(self, name: str):
|
| 126 |
super().__init__(
|
|
@@ -165,18 +184,6 @@ class AlreadyPromoted(APIError):
|
|
| 165 |
)
|
| 166 |
|
| 167 |
|
| 168 |
-
class InvalidRequest(APIError):
|
| 169 |
-
def __init__(self, message: str):
|
| 170 |
-
super().__init__(
|
| 171 |
-
400, "INVALID_REQUEST", message, "GET /v1 lists every endpoint and its params"
|
| 172 |
-
)
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
class TooLarge(APIError):
|
| 176 |
-
def __init__(self, message: str):
|
| 177 |
-
super().__init__(413, "TOO_LARGE", message)
|
| 178 |
-
|
| 179 |
-
|
| 180 |
class SyncTooLarge(APIError):
|
| 181 |
def __init__(self, message: str):
|
| 182 |
super().__init__(413, "SYNC_TOO_LARGE", message)
|
|
@@ -249,18 +256,3 @@ class QuotaBackendUnavailable(APIError):
|
|
| 249 |
"retry shortly",
|
| 250 |
)
|
| 251 |
self.headers = {"Retry-After": "30"}
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
class StorageUnavailable(APIError):
|
| 255 |
-
def __init__(self, status: int | None = None) -> None:
|
| 256 |
-
if status and 400 <= status < 500 and status != 429:
|
| 257 |
-
# The Hub refused the Space's own token: a deployment problem, not
|
| 258 |
-
# a blip. Retrying will not help; the organizer has to look.
|
| 259 |
-
message = (
|
| 260 |
-
f"the storage backend rejected the request (HTTP {status}); "
|
| 261 |
-
"nothing was written; this is a deployment problem — tell the organizer"
|
| 262 |
-
)
|
| 263 |
-
else:
|
| 264 |
-
message = "the storage backend failed; nothing was written; retry in a few seconds"
|
| 265 |
-
super().__init__(503, "STORAGE_UNAVAILABLE", message)
|
| 266 |
-
self.headers = {"Retry-After": "5"}
|
|
|
|
| 71 |
"BUCKET_CREATE_FORBIDDEN",
|
| 72 |
f"your token could not create the scratch bucket '{bucket}'",
|
| 73 |
"you need the contributor role in the org and a token that can "
|
| 74 |
+
"write (a read-only token cannot): run `hf auth login --force` "
|
| 75 |
+
"and log in through the browser",
|
| 76 |
)
|
| 77 |
|
| 78 |
|
|
|
|
| 104 |
|
| 105 |
class SourceNotFound(APIError):
|
| 106 |
def __init__(self, uri: str):
|
| 107 |
+
super().__init__(404, "SOURCE_NOT_FOUND", f"source not found: {uri}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
class AgentIdTaken(APIError):
|
|
|
|
| 117 |
)
|
| 118 |
|
| 119 |
|
| 120 |
+
class TaskforceNotFound(APIError):
|
| 121 |
+
def __init__(self, name: str):
|
| 122 |
+
super().__init__(
|
| 123 |
+
404,
|
| 124 |
+
"TASKFORCE_NOT_FOUND",
|
| 125 |
+
f"no such taskforce: '{name}'",
|
| 126 |
+
"create it via POST /v1/taskforces with the name and README content; "
|
| 127 |
+
"GET /v1/taskforces lists what exists",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class TaskforceExists(APIError):
|
| 132 |
+
def __init__(self, name: str, creator: str | None):
|
| 133 |
+
super().__init__(
|
| 134 |
+
409,
|
| 135 |
+
"TASKFORCE_EXISTS",
|
| 136 |
+
f"taskforce '{name}' already exists"
|
| 137 |
+
+ (f" (creator: {creator})" if creator else ""),
|
| 138 |
+
"only the creator can update the README; contribute via "
|
| 139 |
+
f"POST /v1/taskforces/{name}/files, or pick another name",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
class ChannelNotFound(APIError):
|
| 144 |
def __init__(self, name: str):
|
| 145 |
super().__init__(
|
|
|
|
| 184 |
)
|
| 185 |
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
class SyncTooLarge(APIError):
|
| 188 |
def __init__(self, message: str):
|
| 189 |
super().__init__(413, "SYNC_TOO_LARGE", message)
|
|
|
|
| 256 |
"retry shortly",
|
| 257 |
)
|
| 258 |
self.headers = {"Retry-After": "30"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/hub.py
CHANGED
|
@@ -34,16 +34,6 @@ from app.naming import SourceURI, parse_source_uri
|
|
| 34 |
log = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
|
| 37 |
-
class ListingFailed(RuntimeError):
|
| 38 |
-
"""A bucket listing did not complete. Never carries partial results: a
|
| 39 |
-
page-3 failure after pages 1-2 must not pass for the whole folder."""
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
class DownloadFailed(RuntimeError):
|
| 43 |
-
"""A batch download failed (after one retry). Files that are genuinely
|
| 44 |
-
absent are not a failure — they are just missing from the result."""
|
| 45 |
-
|
| 46 |
-
|
| 47 |
@dataclass
|
| 48 |
class ListedFile:
|
| 49 |
rel_path: str
|
|
@@ -284,12 +274,24 @@ class HubClient:
|
|
| 284 |
a storage blip: skip the update rather than overwrite a live file with a
|
| 285 |
fresh, near-empty one. Mirrors ``read_audit_bytes`` for the central bucket.
|
| 286 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
with tempfile.TemporaryDirectory() as td:
|
| 288 |
local = Path(td) / "f"
|
| 289 |
try:
|
| 290 |
download_bucket_files(
|
| 291 |
-
bucket_id=
|
| 292 |
-
files=[(
|
| 293 |
raise_on_missing_files=True,
|
| 294 |
token=self._token,
|
| 295 |
)
|
|
@@ -314,60 +316,42 @@ class HubClient:
|
|
| 314 |
def download_many(self, bucket: str, remote_paths: list[str]) -> dict[str, bytes]:
|
| 315 |
"""Batch-download files, returning {remote_path: bytes}.
|
| 316 |
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
``DownloadFailed`` — never a silent subset, which callers would read as
|
| 321 |
-
"this file is gone". Chunked so a multi-thousand-file cold fill
|
| 322 |
doesn't ride on a single oversized call.
|
| 323 |
"""
|
| 324 |
out: dict[str, bytes] = {}
|
| 325 |
chunk_size = 500
|
| 326 |
for start in range(0, len(remote_paths), chunk_size):
|
| 327 |
chunk = remote_paths[start : start + chunk_size]
|
| 328 |
-
|
|
|
|
| 329 |
try:
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
log.warning(
|
| 334 |
-
"download_many(%s, %d files)
|
| 335 |
-
bucket, len(chunk), attempt, e,
|
| 336 |
)
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
| 339 |
return out
|
| 340 |
|
| 341 |
-
def _download_chunk(self, bucket: str, chunk: list[str]) -> dict[str, bytes]:
|
| 342 |
-
with tempfile.TemporaryDirectory() as td:
|
| 343 |
-
pairs = [(remote, str(Path(td) / str(i))) for i, remote in enumerate(chunk)]
|
| 344 |
-
download_bucket_files(
|
| 345 |
-
bucket_id=bucket,
|
| 346 |
-
files=pairs,
|
| 347 |
-
raise_on_missing_files=False,
|
| 348 |
-
token=self._token,
|
| 349 |
-
)
|
| 350 |
-
return {
|
| 351 |
-
remote: Path(local).read_bytes()
|
| 352 |
-
for remote, local in pairs
|
| 353 |
-
if Path(local).exists()
|
| 354 |
-
}
|
| 355 |
-
|
| 356 |
def list_central_dir(self, prefix: str) -> list[ListedFile]:
|
| 357 |
-
"""The complete listing of a central-bucket folder, or ``ListingFailed``."""
|
| 358 |
return self._list(self._settings.central_bucket, prefix)
|
| 359 |
|
| 360 |
def list_bucket_dir(self, bucket: str, prefix: str) -> list[ListedFile]:
|
| 361 |
-
|
| 362 |
-
missing bucket reads as empty to the callers), never a partial list."""
|
| 363 |
-
try:
|
| 364 |
-
return self._list(bucket, prefix)
|
| 365 |
-
except ListingFailed:
|
| 366 |
-
return []
|
| 367 |
|
| 368 |
def _list(self, bucket: str, prefix: str) -> list[ListedFile]:
|
| 369 |
-
"""All files under ``prefix``, or ``ListingFailed``. The tree is
|
| 370 |
-
paginated; an error mid-iteration discards the pages already read."""
|
| 371 |
out: list[ListedFile] = []
|
| 372 |
try:
|
| 373 |
for entry in list_bucket_tree(
|
|
@@ -384,11 +368,8 @@ class HubClient:
|
|
| 384 |
xet_hash=getattr(entry, "xet_hash", None),
|
| 385 |
)
|
| 386 |
)
|
| 387 |
-
except
|
| 388 |
-
log.
|
| 389 |
-
"list(%s, %s) failed after %d entries: %s", bucket, prefix, len(out), e
|
| 390 |
-
)
|
| 391 |
-
raise ListingFailed(f"{type(e).__name__}: {e}") from e
|
| 392 |
return out
|
| 393 |
|
| 394 |
# ───────────────────────── Writes (central bucket) ─────────────────────────
|
|
@@ -433,6 +414,10 @@ class HubClient:
|
|
| 433 |
def write_text_to_bucket(self, bucket: str, target_path: str, text: str) -> None:
|
| 434 |
self.write_bytes_to_bucket(bucket, target_path, text.encode("utf-8"))
|
| 435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
def read_audit_bytes(self, target_path: str) -> bytes | None:
|
| 437 |
"""Read a file from the private audit bucket.
|
| 438 |
|
|
@@ -461,6 +446,19 @@ class HubClient:
|
|
| 461 |
token=self._token,
|
| 462 |
)
|
| 463 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
# ───────────────────────── Cross-bucket copy ─────────────────────────
|
| 465 |
|
| 466 |
def copy_file_to_central(self, src_bucket: str, src_xet_hash: str, dest_path: str) -> None:
|
|
|
|
| 34 |
log = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@dataclass
|
| 38 |
class ListedFile:
|
| 39 |
rel_path: str
|
|
|
|
| 274 |
a storage blip: skip the update rather than overwrite a live file with a
|
| 275 |
fresh, near-empty one. Mirrors ``read_audit_bytes`` for the central bucket.
|
| 276 |
"""
|
| 277 |
+
return self._download_optional(self._settings.central_bucket, target_path)
|
| 278 |
+
|
| 279 |
+
def read_text_optional(self, uri: SourceURI | str) -> str | None:
|
| 280 |
+
"""``read_central_bytes_optional`` for any bucket: None only when the
|
| 281 |
+
file is genuinely missing; any other failure propagates."""
|
| 282 |
+
parsed = uri if isinstance(uri, SourceURI) else parse_source_uri(uri)
|
| 283 |
+
if parsed is None:
|
| 284 |
+
raise ValueError(f"invalid source URI: {uri}")
|
| 285 |
+
data = self._download_optional(f"{parsed.org}/{parsed.bucket}", parsed.path)
|
| 286 |
+
return None if data is None else data.decode("utf-8")
|
| 287 |
+
|
| 288 |
+
def _download_optional(self, bucket: str, remote_path: str) -> bytes | None:
|
| 289 |
with tempfile.TemporaryDirectory() as td:
|
| 290 |
local = Path(td) / "f"
|
| 291 |
try:
|
| 292 |
download_bucket_files(
|
| 293 |
+
bucket_id=bucket,
|
| 294 |
+
files=[(remote_path, str(local))],
|
| 295 |
raise_on_missing_files=True,
|
| 296 |
token=self._token,
|
| 297 |
)
|
|
|
|
| 316 |
def download_many(self, bucket: str, remote_paths: list[str]) -> dict[str, bytes]:
|
| 317 |
"""Batch-download files, returning {remote_path: bytes}.
|
| 318 |
|
| 319 |
+
Missing or failed entries are simply absent from the result — callers
|
| 320 |
+
(the read model, the backfill script) treat absence as transient and
|
| 321 |
+
retry on a later pass. Chunked so a multi-thousand-file cold fill
|
|
|
|
|
|
|
| 322 |
doesn't ride on a single oversized call.
|
| 323 |
"""
|
| 324 |
out: dict[str, bytes] = {}
|
| 325 |
chunk_size = 500
|
| 326 |
for start in range(0, len(remote_paths), chunk_size):
|
| 327 |
chunk = remote_paths[start : start + chunk_size]
|
| 328 |
+
with tempfile.TemporaryDirectory() as td:
|
| 329 |
+
pairs = [(remote, str(Path(td) / str(i))) for i, remote in enumerate(chunk)]
|
| 330 |
try:
|
| 331 |
+
download_bucket_files(
|
| 332 |
+
bucket_id=bucket,
|
| 333 |
+
files=pairs,
|
| 334 |
+
raise_on_missing_files=False,
|
| 335 |
+
token=self._token,
|
| 336 |
+
)
|
| 337 |
+
except (EntryNotFoundError, HfHubHTTPError) as e:
|
| 338 |
log.warning(
|
| 339 |
+
"download_many(%s, %d files) failed: %s", bucket, len(chunk), e
|
|
|
|
| 340 |
)
|
| 341 |
+
continue
|
| 342 |
+
for remote, local in pairs:
|
| 343 |
+
p = Path(local)
|
| 344 |
+
if p.exists():
|
| 345 |
+
out[remote] = p.read_bytes()
|
| 346 |
return out
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
def list_central_dir(self, prefix: str) -> list[ListedFile]:
|
|
|
|
| 349 |
return self._list(self._settings.central_bucket, prefix)
|
| 350 |
|
| 351 |
def list_bucket_dir(self, bucket: str, prefix: str) -> list[ListedFile]:
|
| 352 |
+
return self._list(bucket, prefix)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
def _list(self, bucket: str, prefix: str) -> list[ListedFile]:
|
|
|
|
|
|
|
| 355 |
out: list[ListedFile] = []
|
| 356 |
try:
|
| 357 |
for entry in list_bucket_tree(
|
|
|
|
| 368 |
xet_hash=getattr(entry, "xet_hash", None),
|
| 369 |
)
|
| 370 |
)
|
| 371 |
+
except (RepositoryNotFoundError, HfHubHTTPError) as e:
|
| 372 |
+
log.debug("list(%s, %s) failed: %s", bucket, prefix, e)
|
|
|
|
|
|
|
|
|
|
| 373 |
return out
|
| 374 |
|
| 375 |
# ───────────────────────── Writes (central bucket) ─────────────────────────
|
|
|
|
| 414 |
def write_text_to_bucket(self, bucket: str, target_path: str, text: str) -> None:
|
| 415 |
self.write_bytes_to_bucket(bucket, target_path, text.encode("utf-8"))
|
| 416 |
|
| 417 |
+
def append_jsonl_audit(self, target_path: str, line: str) -> None:
|
| 418 |
+
"""Append to the audit log in the private (out-of-org) audit bucket."""
|
| 419 |
+
self._append_jsonl(self._settings.audit_bucket, target_path, line)
|
| 420 |
+
|
| 421 |
def read_audit_bytes(self, target_path: str) -> bytes | None:
|
| 422 |
"""Read a file from the private audit bucket.
|
| 423 |
|
|
|
|
| 446 |
token=self._token,
|
| 447 |
)
|
| 448 |
|
| 449 |
+
def _append_jsonl(self, bucket: str, target_path: str, line: str) -> None:
|
| 450 |
+
try:
|
| 451 |
+
existing = self._download_one(bucket, target_path)
|
| 452 |
+
except FileNotFoundError:
|
| 453 |
+
existing = b""
|
| 454 |
+
if existing and not existing.endswith(b"\n"):
|
| 455 |
+
existing += b"\n"
|
| 456 |
+
batch_bucket_files(
|
| 457 |
+
bucket_id=bucket,
|
| 458 |
+
add=[(existing + line.encode("utf-8") + b"\n", target_path)],
|
| 459 |
+
token=self._token,
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
# ───────────────────────── Cross-bucket copy ─────────────────────────
|
| 463 |
|
| 464 |
def copy_file_to_central(self, src_bucket: str, src_xet_hash: str, dest_path: str) -> None:
|
app/main.py
CHANGED
|
@@ -1,23 +1,11 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
import threading
|
| 5 |
-
from contextlib import asynccontextmanager
|
| 6 |
|
| 7 |
from fastapi import FastAPI, Request
|
| 8 |
-
from fastapi.exceptions import RequestValidationError
|
| 9 |
from fastapi.responses import JSONResponse
|
| 10 |
|
| 11 |
-
from app.
|
| 12 |
-
from app.errors import APIError, InvalidRequest, StorageUnavailable, TooLarge
|
| 13 |
-
from app.hub import DownloadFailed, ListingFailed
|
| 14 |
-
from app.naming import (
|
| 15 |
-
AGENTS_FOLDER,
|
| 16 |
-
BROADCASTS_FOLDER,
|
| 17 |
-
CHANNELS_FOLDER,
|
| 18 |
-
MESSAGE_BOARD_FOLDER,
|
| 19 |
-
RESULTS_FOLDER,
|
| 20 |
-
)
|
| 21 |
from app.routes import (
|
| 22 |
agents,
|
| 23 |
channels,
|
|
@@ -31,17 +19,12 @@ from app.routes import (
|
|
| 31 |
messages,
|
| 32 |
results,
|
| 33 |
sync,
|
|
|
|
| 34 |
traces,
|
| 35 |
updates,
|
| 36 |
)
|
| 37 |
|
| 38 |
|
| 39 |
-
try: # huggingface_hub 2.x ships its httpx fork as `httpx2`
|
| 40 |
-
from httpx2 import HTTPError as HubHTTPError
|
| 41 |
-
except ImportError:
|
| 42 |
-
from httpx import HTTPError as HubHTTPError
|
| 43 |
-
|
| 44 |
-
|
| 45 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 46 |
|
| 47 |
# The long-poll waiter registry (app/notify.py) lives in this process's memory,
|
|
@@ -55,29 +38,7 @@ logging.getLogger(__name__).info(
|
|
| 55 |
"that served the write and every other wait= degrades to a full timeout"
|
| 56 |
)
|
| 57 |
|
| 58 |
-
|
| 59 |
-
WARM_FOLDERS = [
|
| 60 |
-
AGENTS_FOLDER,
|
| 61 |
-
MESSAGE_BOARD_FOLDER,
|
| 62 |
-
RESULTS_FOLDER,
|
| 63 |
-
BROADCASTS_FOLDER,
|
| 64 |
-
CHANNELS_FOLDER,
|
| 65 |
-
]
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@asynccontextmanager
|
| 69 |
-
async def lifespan(app: FastAPI):
|
| 70 |
-
# Background thread: serving starts immediately; /v1/healthz reports
|
| 71 |
-
# `warm: false` until the fill completes. Honours dependency overrides so
|
| 72 |
-
# tests warm their fake, not a real hub.
|
| 73 |
-
read_model = app.dependency_overrides.get(get_read_model, get_read_model)()
|
| 74 |
-
threading.Thread(
|
| 75 |
-
target=read_model.warm_up, args=(WARM_FOLDERS,), name="warm-up", daemon=True
|
| 76 |
-
).start()
|
| 77 |
-
yield
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
app = FastAPI(title="bucket-sync", version="1.5.0", lifespan=lifespan)
|
| 81 |
|
| 82 |
app.include_router(health.router)
|
| 83 |
app.include_router(digest.router)
|
|
@@ -90,6 +51,7 @@ app.include_router(updates.router)
|
|
| 90 |
app.include_router(leaderboard.router)
|
| 91 |
app.include_router(sync.router)
|
| 92 |
app.include_router(jobs.router)
|
|
|
|
| 93 |
app.include_router(channels.router)
|
| 94 |
app.include_router(traces.router)
|
| 95 |
app.include_router(client.router)
|
|
@@ -99,33 +61,3 @@ app.include_router(client.router)
|
|
| 99 |
async def _api_error_handler(_: Request, exc: APIError) -> JSONResponse:
|
| 100 |
headers = getattr(exc, "headers", None)
|
| 101 |
return JSONResponse(status_code=exc.status_code, content=exc.detail, headers=headers)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
@app.exception_handler(HubHTTPError)
|
| 105 |
-
async def _storage_error_handler(request: Request, exc: HubHTTPError) -> JSONResponse:
|
| 106 |
-
"""An uncaught hub failure (HfHubHTTPError, or the connection/timeout error
|
| 107 |
-
under it) means the storage write never landed: a retryable 503, not a 500."""
|
| 108 |
-
logging.getLogger(__name__).warning("storage backend failed: %r", exc)
|
| 109 |
-
response = getattr(exc, "response", None)
|
| 110 |
-
return await _api_error_handler(request, StorageUnavailable(getattr(response, "status_code", None)))
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
@app.exception_handler(ListingFailed)
|
| 114 |
-
@app.exception_handler(DownloadFailed)
|
| 115 |
-
async def _read_failed_handler(request: Request, exc: RuntimeError) -> JSONResponse:
|
| 116 |
-
"""A cold-folder listing or a batch download failed: fail loudly with a
|
| 117 |
-
retryable 503 rather than serve a partial folder or a false 404."""
|
| 118 |
-
logging.getLogger(__name__).warning("storage read failed: %s", exc)
|
| 119 |
-
return await _api_error_handler(request, StorageUnavailable())
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
@app.exception_handler(RequestValidationError)
|
| 123 |
-
async def _validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
| 124 |
-
"""Request-shape errors in the standard error body, one line per problem."""
|
| 125 |
-
errors = exc.errors()
|
| 126 |
-
message = "\n".join(
|
| 127 |
-
f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in errors
|
| 128 |
-
)
|
| 129 |
-
too_long = all(e["type"] == "string_too_long" for e in errors)
|
| 130 |
-
err = TooLarge(message) if too_long else InvalidRequest(message)
|
| 131 |
-
return await _api_error_handler(request, err)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
|
|
|
|
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI, Request
|
|
|
|
| 6 |
from fastapi.responses import JSONResponse
|
| 7 |
|
| 8 |
+
from app.errors import APIError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from app.routes import (
|
| 10 |
agents,
|
| 11 |
channels,
|
|
|
|
| 19 |
messages,
|
| 20 |
results,
|
| 21 |
sync,
|
| 22 |
+
taskforces,
|
| 23 |
traces,
|
| 24 |
updates,
|
| 25 |
)
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 29 |
|
| 30 |
# The long-poll waiter registry (app/notify.py) lives in this process's memory,
|
|
|
|
| 38 |
"that served the write and every other wait= degrades to a full timeout"
|
| 39 |
)
|
| 40 |
|
| 41 |
+
app = FastAPI(title="bucket-sync", version="1.5.0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
app.include_router(health.router)
|
| 44 |
app.include_router(digest.router)
|
|
|
|
| 51 |
app.include_router(leaderboard.router)
|
| 52 |
app.include_router(sync.router)
|
| 53 |
app.include_router(jobs.router)
|
| 54 |
+
app.include_router(taskforces.router)
|
| 55 |
app.include_router(channels.router)
|
| 56 |
app.include_router(traces.router)
|
| 57 |
app.include_router(client.router)
|
|
|
|
| 61 |
async def _api_error_handler(_: Request, exc: APIError) -> JSONResponse:
|
| 62 |
headers = getattr(exc, "headers", None)
|
| 63 |
return JSONResponse(status_code=exc.status_code, content=exc.detail, headers=headers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/models.py
CHANGED
|
@@ -2,27 +2,13 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any, Literal
|
| 4 |
|
| 5 |
-
from pydantic import BaseModel,
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
# Raw message bodies are coordination pings; long content goes through `source`.
|
| 9 |
-
RAW_BODY_MAX_CHARS = 32 * 1024
|
| 10 |
-
# A raw post without an idempotency_key that repeats the same body to the same
|
| 11 |
-
# destination within this window is treated as a client retry (DESIGN §5).
|
| 12 |
-
RAW_DUPLICATE_WINDOW_S = 60
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class StrictRequest(BaseModel):
|
| 16 |
-
"""Request bodies reject unknown keys, so a typo'd or invented field
|
| 17 |
-
(`mentions`, `to`) is a 400 naming it instead of being silently dropped."""
|
| 18 |
-
|
| 19 |
-
model_config = ConfigDict(extra="forbid")
|
| 20 |
|
| 21 |
|
| 22 |
# ───────────────────────── Registration ─────────────────────────
|
| 23 |
|
| 24 |
|
| 25 |
-
class AgentRegisterRequest(
|
| 26 |
agent_id: str
|
| 27 |
model: str
|
| 28 |
harness: str
|
|
@@ -51,13 +37,12 @@ class AgentInfo(BaseModel):
|
|
| 51 |
# ───────────────────────── Messages ─────────────────────────
|
| 52 |
|
| 53 |
|
| 54 |
-
class MessagePostRequest(
|
| 55 |
source: str | None = None
|
| 56 |
agent_id: str | None = None
|
| 57 |
-
body: str | None =
|
| 58 |
type: str | None = None
|
| 59 |
-
|
| 60 |
-
refs: str | list[str] | None = None
|
| 61 |
# Organizer-only: also surface this message in every participant's inbox
|
| 62 |
# view, not just @-mentioned recipients. Honored on the human post path
|
| 63 |
# only; the caller must be an admin of the challenge org.
|
|
@@ -66,9 +51,6 @@ class MessagePostRequest(StrictRequest):
|
|
| 66 |
# channel must exist; posting auto-subscribes the author. Mutually
|
| 67 |
# exclusive with broadcast (a broadcast is board-wide by definition).
|
| 68 |
channel: str | None = None
|
| 69 |
-
# Retry safety: a repeat POST with the same key from the same agent
|
| 70 |
-
# returns the first post's response (200) instead of posting again.
|
| 71 |
-
idempotency_key: str | None = Field(None, min_length=1, max_length=64)
|
| 72 |
|
| 73 |
@model_validator(mode="after")
|
| 74 |
def _exactly_one_variant(self) -> "MessagePostRequest":
|
|
@@ -119,24 +101,17 @@ class MessageRecord(BaseModel):
|
|
| 119 |
# ───────────────────────── Caller identity ─────────────────────────
|
| 120 |
|
| 121 |
|
| 122 |
-
class AgentTraces(BaseModel):
|
| 123 |
-
"""How much one agent (or one HF user's agents) has shared via /v1/traces."""
|
| 124 |
-
sessions: int
|
| 125 |
-
last_shared_at: str | None = None # newest manifest's promoted_at
|
| 126 |
-
|
| 127 |
-
|
| 128 |
class MeResponse(BaseModel):
|
| 129 |
hf_user: str
|
| 130 |
handle: str # the human-<name> handle this caller posts as
|
| 131 |
is_member: bool # member of the challenge org
|
| 132 |
is_organizer: bool # admin of the challenge org → may broadcast
|
| 133 |
-
traces: AgentTraces # summed over the agents this hf_user registered
|
| 134 |
|
| 135 |
|
| 136 |
# ───────────────────────── Results ─────────────────────────
|
| 137 |
|
| 138 |
|
| 139 |
-
class ResultPostRequest(
|
| 140 |
source: str
|
| 141 |
|
| 142 |
|
|
@@ -144,8 +119,6 @@ class ResultResponse(BaseModel):
|
|
| 144 |
filename: str
|
| 145 |
via: Literal["bucket"]
|
| 146 |
path: str
|
| 147 |
-
# Set when the agent has shared no trace in the last 24 h: the nudge.
|
| 148 |
-
hint: str | None = None
|
| 149 |
|
| 150 |
|
| 151 |
class ResultRecord(BaseModel):
|
|
@@ -160,7 +133,7 @@ class ResultRecord(BaseModel):
|
|
| 160 |
# ───────────────────────── Sync ─────────────────────────
|
| 161 |
|
| 162 |
|
| 163 |
-
class ArtifactSyncRequest(
|
| 164 |
source: str
|
| 165 |
dest_slug: str
|
| 166 |
|
|
@@ -177,11 +150,107 @@ class SyncResponse(BaseModel):
|
|
| 177 |
bytes_copied: int
|
| 178 |
|
| 179 |
|
| 180 |
-
class SharedResourceSyncRequest(
|
| 181 |
source: str
|
| 182 |
dest_path: str
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
# ───────────────────────── Channels ─────────────────────────
|
| 186 |
# Topic rooms (CHANNELS_DESIGN.md): channels/{name}/ holds a README (the
|
| 187 |
# theme), members/ subscription markers, and stamped messages. Messages are
|
|
@@ -189,7 +258,7 @@ class SharedResourceSyncRequest(StrictRequest):
|
|
| 189 |
# channel-specific write endpoint.
|
| 190 |
|
| 191 |
|
| 192 |
-
class ChannelCreateRequest(
|
| 193 |
# Creation is organizer-only (the broadcast gate): organizers act as
|
| 194 |
# human-<name> with a Bearer token, so the raw variant is the live path.
|
| 195 |
# `source` is still accepted by the model so agent attempts get a clear
|
|
@@ -220,7 +289,7 @@ class ChannelCreateResponse(BaseModel):
|
|
| 220 |
announcement: str | None = None
|
| 221 |
|
| 222 |
|
| 223 |
-
class ChannelSubscribeRequest(
|
| 224 |
# Agents subscribe with the source-URI proof (any file in their own
|
| 225 |
# scratch bucket); a body-only agent_id would let anyone subscribe anyone.
|
| 226 |
# Humans (human-<name>) use agent_id + Authorization: Bearer instead.
|
|
@@ -312,7 +381,7 @@ class DigestChannels(BaseModel):
|
|
| 312 |
# ───────────────────────── Benchmark jobs ─────────────────────────
|
| 313 |
|
| 314 |
|
| 315 |
-
class BenchmarkJobRequest(
|
| 316 |
agent_id: str
|
| 317 |
submission_prefix: str
|
| 318 |
run_prefix: str
|
|
@@ -342,7 +411,7 @@ class BenchmarkJobResponse(BaseModel):
|
|
| 342 |
# See TRACES_DESIGN.md.
|
| 343 |
|
| 344 |
|
| 345 |
-
class TracePostRequest(
|
| 346 |
source: str # hf://buckets/{org}/{slug}-{agent}/traces/<session>/
|
| 347 |
share: Literal["stats", "full"] = "stats" # default = numbers only; content is an explicit opt-in
|
| 348 |
|
|
@@ -512,41 +581,34 @@ class DigestInbox(BaseModel):
|
|
| 512 |
items: list[MessageRecord]
|
| 513 |
|
| 514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
class DigestUpdates(BaseModel):
|
| 516 |
"""Cursor-aware "am I behind?" over the unified watch stream — the
|
| 517 |
non-blocking catch-up check, answerable even when all local watcher state is
|
| 518 |
lost (WATCH_DESIGN.md §4.5)."""
|
| 519 |
-
# Items newer than the digest's `after=` cursor (
|
| 520 |
-
# `last_cursor` for the handle; the whole stream when it has none).
|
| 521 |
unread: int
|
| 522 |
# Newest filename in the stream; pass it back as `after` once caught up.
|
| 523 |
newest: str | None = None
|
| 524 |
|
| 525 |
|
| 526 |
class DigestWatching(BaseModel):
|
| 527 |
-
"""The server's record of the handle's most recent
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
indistinguishable from a quiet inbox."""
|
| 533 |
last_poll_age_s: int
|
| 534 |
-
mode: str #
|
| 535 |
-
# updates | inbox | feed | digest: the parked poll's while parked, else
|
| 536 |
-
# the most recent read's.
|
| 537 |
-
stream: str
|
| 538 |
-
# The newest cursor the server has handed this handle on the unified
|
| 539 |
-
# stream, or that it has sent; resume with `--after <last_cursor>`.
|
| 540 |
-
last_cursor: str | None = None
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
class DigestYou(BaseModel):
|
| 544 |
-
"""Only with ?as=: facts about the calling handle itself."""
|
| 545 |
-
traces: AgentTraces
|
| 546 |
|
| 547 |
|
| 548 |
class DigestResponse(BaseModel):
|
| 549 |
agents: DigestAgents
|
|
|
|
| 550 |
channels: DigestChannels
|
| 551 |
leaderboard: list[LeaderboardRow]
|
| 552 |
recent_messages: list[MessageRecord]
|
|
@@ -554,8 +616,7 @@ class DigestResponse(BaseModel):
|
|
| 554 |
inbox: DigestInbox | None = None
|
| 555 |
updates: DigestUpdates | None = None
|
| 556 |
watching: DigestWatching | None = None
|
| 557 |
-
|
| 558 |
-
stats: DigestStats
|
| 559 |
generated_at: str
|
| 560 |
|
| 561 |
|
|
@@ -566,8 +627,7 @@ class WatchingEntry(BaseModel):
|
|
| 566 |
"""One handle's watch presence — the same hint the digest reports as its
|
| 567 |
per-handle `watching` block, in the aggregate map."""
|
| 568 |
last_poll_age_s: int
|
| 569 |
-
mode: str #
|
| 570 |
-
stream: str # updates | inbox | feed | digest
|
| 571 |
|
| 572 |
|
| 573 |
class WatchingResponse(BaseModel):
|
|
@@ -579,10 +639,12 @@ class WatchingResponse(BaseModel):
|
|
| 579 |
It also advertises the ceiling a client would otherwise have to hardcode."""
|
| 580 |
# The `wait=` ceiling every long-poll is clamped to (LONGPOLL_MAX_WAIT_S).
|
| 581 |
max_wait_s: float
|
| 582 |
-
# Freshness threshold for "someone is watching this handle right now"
|
| 583 |
-
#
|
|
|
|
|
|
|
| 584 |
fresh_s: float
|
| 585 |
-
# Only handles this process has served a
|
| 586 |
# watching that one. In-process and lost on restart — a hint, not an audit
|
| 587 |
# log (a restart truthfully reads as "nobody", since every parked
|
| 588 |
# connection died with it).
|
|
|
|
| 2 |
|
| 3 |
from typing import Any, Literal
|
| 4 |
|
| 5 |
+
from pydantic import BaseModel, Field, model_validator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
# ───────────────────────── Registration ─────────────────────────
|
| 9 |
|
| 10 |
|
| 11 |
+
class AgentRegisterRequest(BaseModel):
|
| 12 |
agent_id: str
|
| 13 |
model: str
|
| 14 |
harness: str
|
|
|
|
| 37 |
# ───────────────────────── Messages ─────────────────────────
|
| 38 |
|
| 39 |
|
| 40 |
+
class MessagePostRequest(BaseModel):
|
| 41 |
source: str | None = None
|
| 42 |
agent_id: str | None = None
|
| 43 |
+
body: str | None = None
|
| 44 |
type: str | None = None
|
| 45 |
+
refs: str | None = None
|
|
|
|
| 46 |
# Organizer-only: also surface this message in every participant's inbox
|
| 47 |
# view, not just @-mentioned recipients. Honored on the human post path
|
| 48 |
# only; the caller must be an admin of the challenge org.
|
|
|
|
| 51 |
# channel must exist; posting auto-subscribes the author. Mutually
|
| 52 |
# exclusive with broadcast (a broadcast is board-wide by definition).
|
| 53 |
channel: str | None = None
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
@model_validator(mode="after")
|
| 56 |
def _exactly_one_variant(self) -> "MessagePostRequest":
|
|
|
|
| 101 |
# ───────────────────────── Caller identity ─────────────────────────
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
class MeResponse(BaseModel):
|
| 105 |
hf_user: str
|
| 106 |
handle: str # the human-<name> handle this caller posts as
|
| 107 |
is_member: bool # member of the challenge org
|
| 108 |
is_organizer: bool # admin of the challenge org → may broadcast
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
# ───────────────────────── Results ─────────────────────────
|
| 112 |
|
| 113 |
|
| 114 |
+
class ResultPostRequest(BaseModel):
|
| 115 |
source: str
|
| 116 |
|
| 117 |
|
|
|
|
| 119 |
filename: str
|
| 120 |
via: Literal["bucket"]
|
| 121 |
path: str
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
class ResultRecord(BaseModel):
|
|
|
|
| 133 |
# ───────────────────────── Sync ─────────────────────────
|
| 134 |
|
| 135 |
|
| 136 |
+
class ArtifactSyncRequest(BaseModel):
|
| 137 |
source: str
|
| 138 |
dest_slug: str
|
| 139 |
|
|
|
|
| 150 |
bytes_copied: int
|
| 151 |
|
| 152 |
|
| 153 |
+
class SharedResourceSyncRequest(BaseModel):
|
| 154 |
source: str
|
| 155 |
dest_path: str
|
| 156 |
|
| 157 |
|
| 158 |
+
# ───────────────────────── Taskforces ─────────────────────────
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class TaskforceCreateRequest(BaseModel):
|
| 162 |
+
name: str
|
| 163 |
+
source: str | None = None
|
| 164 |
+
agent_id: str | None = None
|
| 165 |
+
body: str | None = None
|
| 166 |
+
|
| 167 |
+
@model_validator(mode="after")
|
| 168 |
+
def _exactly_one_variant(self) -> "TaskforceCreateRequest":
|
| 169 |
+
has_source = self.source is not None
|
| 170 |
+
has_raw = self.body is not None or self.agent_id is not None
|
| 171 |
+
if has_source == has_raw:
|
| 172 |
+
raise ValueError("provide exactly one of `source` or `body`+`agent_id`")
|
| 173 |
+
if has_raw and (self.agent_id is None or self.body is None):
|
| 174 |
+
raise ValueError("raw variant requires both `agent_id` and `body`")
|
| 175 |
+
return self
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class TaskforceCreateResponse(BaseModel):
|
| 179 |
+
name: str
|
| 180 |
+
via: Literal["bucket", "raw"]
|
| 181 |
+
path: str
|
| 182 |
+
created: bool
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class TaskforceFilePostRequest(BaseModel):
|
| 186 |
+
source: str | None = None
|
| 187 |
+
dest_path: str | None = None
|
| 188 |
+
agent_id: str | None = None
|
| 189 |
+
body: str | None = None
|
| 190 |
+
type: str | None = None
|
| 191 |
+
|
| 192 |
+
@model_validator(mode="after")
|
| 193 |
+
def _variants(self) -> "TaskforceFilePostRequest":
|
| 194 |
+
has_source = self.source is not None
|
| 195 |
+
has_raw = self.body is not None or self.agent_id is not None
|
| 196 |
+
if has_source == has_raw:
|
| 197 |
+
raise ValueError("provide exactly one of `source` or `body`+`agent_id`")
|
| 198 |
+
if has_raw and (self.agent_id is None or self.body is None):
|
| 199 |
+
raise ValueError("raw variant requires both `agent_id` and `body`")
|
| 200 |
+
if self.dest_path is not None and not has_source:
|
| 201 |
+
raise ValueError("`dest_path` requires `source` (named files are bucket-promoted)")
|
| 202 |
+
if self.dest_path is not None and self.type is not None:
|
| 203 |
+
raise ValueError("`type` applies to notes; named files are copied byte-identical")
|
| 204 |
+
return self
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class TaskforceFileResponse(BaseModel):
|
| 208 |
+
kind: Literal["note", "file"]
|
| 209 |
+
filename: str # stamped leaf for notes; dest_path for named files
|
| 210 |
+
via: Literal["bucket", "raw"]
|
| 211 |
+
path: str # full central-bucket path
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class TaskforceFileInfo(BaseModel):
|
| 215 |
+
path: str # relative to taskforces/{name}/
|
| 216 |
+
size: int
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class TaskforceFileListing(BaseModel):
|
| 220 |
+
count: int
|
| 221 |
+
items: list[TaskforceFileInfo]
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class TaskforceSummary(BaseModel):
|
| 225 |
+
name: str
|
| 226 |
+
creator: str | None = None
|
| 227 |
+
created: str | None = None
|
| 228 |
+
readme_excerpt: str = ""
|
| 229 |
+
contributors: list[str] = Field(default_factory=list)
|
| 230 |
+
file_count: int
|
| 231 |
+
note_count: int
|
| 232 |
+
# Compact stamp of the newest note; None for a taskforce with no notes yet.
|
| 233 |
+
last_activity: str | None = None
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class TaskforceListing(BaseModel):
|
| 237 |
+
count: int
|
| 238 |
+
matched: int
|
| 239 |
+
items: list[TaskforceSummary]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class TaskforceDetail(BaseModel):
|
| 243 |
+
name: str
|
| 244 |
+
creator: str | None = None
|
| 245 |
+
created: str | None = None
|
| 246 |
+
updated: str | None = None
|
| 247 |
+
readme: MessageRecord
|
| 248 |
+
contributors: list[str]
|
| 249 |
+
file_count: int
|
| 250 |
+
note_count: int
|
| 251 |
+
recent_notes: list[MessageRecord]
|
| 252 |
+
|
| 253 |
+
|
| 254 |
# ───────────────────────── Channels ─────────────────────────
|
| 255 |
# Topic rooms (CHANNELS_DESIGN.md): channels/{name}/ holds a README (the
|
| 256 |
# theme), members/ subscription markers, and stamped messages. Messages are
|
|
|
|
| 258 |
# channel-specific write endpoint.
|
| 259 |
|
| 260 |
|
| 261 |
+
class ChannelCreateRequest(BaseModel):
|
| 262 |
# Creation is organizer-only (the broadcast gate): organizers act as
|
| 263 |
# human-<name> with a Bearer token, so the raw variant is the live path.
|
| 264 |
# `source` is still accepted by the model so agent attempts get a clear
|
|
|
|
| 289 |
announcement: str | None = None
|
| 290 |
|
| 291 |
|
| 292 |
+
class ChannelSubscribeRequest(BaseModel):
|
| 293 |
# Agents subscribe with the source-URI proof (any file in their own
|
| 294 |
# scratch bucket); a body-only agent_id would let anyone subscribe anyone.
|
| 295 |
# Humans (human-<name>) use agent_id + Authorization: Bearer instead.
|
|
|
|
| 381 |
# ───────────────────────── Benchmark jobs ─────────────────────────
|
| 382 |
|
| 383 |
|
| 384 |
+
class BenchmarkJobRequest(BaseModel):
|
| 385 |
agent_id: str
|
| 386 |
submission_prefix: str
|
| 387 |
run_prefix: str
|
|
|
|
| 411 |
# See TRACES_DESIGN.md.
|
| 412 |
|
| 413 |
|
| 414 |
+
class TracePostRequest(BaseModel):
|
| 415 |
source: str # hf://buckets/{org}/{slug}-{agent}/traces/<session>/
|
| 416 |
share: Literal["stats", "full"] = "stats" # default = numbers only; content is an explicit opt-in
|
| 417 |
|
|
|
|
| 581 |
items: list[MessageRecord]
|
| 582 |
|
| 583 |
|
| 584 |
+
class DigestTaskforces(BaseModel):
|
| 585 |
+
count: int
|
| 586 |
+
newest: list[str]
|
| 587 |
+
|
| 588 |
+
|
| 589 |
class DigestUpdates(BaseModel):
|
| 590 |
"""Cursor-aware "am I behind?" over the unified watch stream — the
|
| 591 |
non-blocking catch-up check, answerable even when all local watcher state is
|
| 592 |
lost (WATCH_DESIGN.md §4.5)."""
|
| 593 |
+
# Items newer than the digest's `after=` cursor (the whole stream when none).
|
|
|
|
| 594 |
unread: int
|
| 595 |
# Newest filename in the stream; pass it back as `after` once caught up.
|
| 596 |
newest: str | None = None
|
| 597 |
|
| 598 |
|
| 599 |
class DigestWatching(BaseModel):
|
| 600 |
+
"""The server's record of the handle's most recent `wait>0` poll. A hint,
|
| 601 |
+
not an audit log: it lives in-process and a restart forgets it (which is the
|
| 602 |
+
truth — every parked connection died with it). The digest omits this block
|
| 603 |
+
entirely when nobody is watching, which is the signal that matters: a dead
|
| 604 |
+
watcher is otherwise indistinguishable from a quiet inbox."""
|
|
|
|
| 605 |
last_poll_age_s: int
|
| 606 |
+
mode: str # updates | inbox | feed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
|
| 608 |
|
| 609 |
class DigestResponse(BaseModel):
|
| 610 |
agents: DigestAgents
|
| 611 |
+
taskforces: DigestTaskforces
|
| 612 |
channels: DigestChannels
|
| 613 |
leaderboard: list[LeaderboardRow]
|
| 614 |
recent_messages: list[MessageRecord]
|
|
|
|
| 616 |
inbox: DigestInbox | None = None
|
| 617 |
updates: DigestUpdates | None = None
|
| 618 |
watching: DigestWatching | None = None
|
| 619 |
+
stats: DigestStats | None = None
|
|
|
|
| 620 |
generated_at: str
|
| 621 |
|
| 622 |
|
|
|
|
| 627 |
"""One handle's watch presence — the same hint the digest reports as its
|
| 628 |
per-handle `watching` block, in the aggregate map."""
|
| 629 |
last_poll_age_s: int
|
| 630 |
+
mode: str # updates | inbox | feed
|
|
|
|
| 631 |
|
| 632 |
|
| 633 |
class WatchingResponse(BaseModel):
|
|
|
|
| 639 |
It also advertises the ceiling a client would otherwise have to hardcode."""
|
| 640 |
# The `wait=` ceiling every long-poll is clamped to (LONGPOLL_MAX_WAIT_S).
|
| 641 |
max_wait_s: float
|
| 642 |
+
# Freshness threshold for "someone is watching this handle right now": a
|
| 643 |
+
# watcher re-arms at most one wait window after the last one ended, so 2×
|
| 644 |
+
# the ceiling is the youngest age that can still be stale. Published so no
|
| 645 |
+
# consumer keeps its own copy of the backend's knob.
|
| 646 |
fresh_s: float
|
| 647 |
+
# Only handles this process has served a wait>0 poll for; absent = nobody is
|
| 648 |
# watching that one. In-process and lost on restart — a hint, not an audit
|
| 649 |
# log (a restart truthfully reads as "nobody", since every parked
|
| 650 |
# connection died with it).
|
app/naming.py
CHANGED
|
@@ -68,11 +68,6 @@ def stamp_iso(dt: datetime) -> str:
|
|
| 68 |
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
| 69 |
|
| 70 |
|
| 71 |
-
AGENTS_FOLDER = "agents"
|
| 72 |
-
MESSAGE_BOARD_FOLDER = "message_board"
|
| 73 |
-
RESULTS_FOLDER = "results"
|
| 74 |
-
|
| 75 |
-
|
| 76 |
def message_path(agent_id: str, dt: datetime) -> str:
|
| 77 |
return f"message_board/{stamp_filename(agent_id, dt)}"
|
| 78 |
|
|
@@ -110,7 +105,8 @@ def channel_dir(name: str) -> str:
|
|
| 110 |
|
| 111 |
|
| 112 |
def channel_readme_path(name: str) -> str:
|
| 113 |
-
"""The channel's theme. A channel exists iff this file does
|
|
|
|
| 114 |
return f"{CHANNELS_FOLDER}/{name}/README.md"
|
| 115 |
|
| 116 |
|
|
@@ -126,6 +122,22 @@ def channel_message_path(name: str, agent_id: str, dt: datetime) -> str:
|
|
| 126 |
return f"{CHANNELS_FOLDER}/{name}/{stamp_filename(agent_id, dt)}"
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def agent_from_filename(filename: str) -> str | None:
|
| 130 |
# message/result filenames: {YYYYMMDD-HHmmss-mmm}_{agent_id}.md
|
| 131 |
# agent filenames: {agent_id}.md
|
|
@@ -136,7 +148,7 @@ def agent_from_filename(filename: str) -> str | None:
|
|
| 136 |
|
| 137 |
|
| 138 |
# Flat index mapping each promoted result's basename -> verification state
|
| 139 |
-
# (`pending` | `valid` | `invalid`)
|
| 140 |
VERIFICATION_STATUS_PATH = "results/verification_status.json"
|
| 141 |
|
| 142 |
|
|
@@ -148,10 +160,8 @@ def artifact_dest_dir(slug: str, agent_id: str) -> str:
|
|
| 148 |
return f"artifacts/{slug}_{agent_id}/"
|
| 149 |
|
| 150 |
|
| 151 |
-
def
|
| 152 |
-
|
| 153 |
-
month's objects in filename order yields its JSONL log."""
|
| 154 |
-
return f"audit/{dt.strftime('%Y%m')}/{stamp_str(dt)}_{event}.json"
|
| 155 |
|
| 156 |
|
| 157 |
# ── Trace & stats sharing (one record per session, see TRACES_DESIGN.md) ──
|
|
|
|
| 68 |
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def message_path(agent_id: str, dt: datetime) -> str:
|
| 72 |
return f"message_board/{stamp_filename(agent_id, dt)}"
|
| 73 |
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
def channel_readme_path(name: str) -> str:
|
| 108 |
+
"""The channel's theme. A channel exists iff this file does — same
|
| 109 |
+
structural invariant as taskforces."""
|
| 110 |
return f"{CHANNELS_FOLDER}/{name}/README.md"
|
| 111 |
|
| 112 |
|
|
|
|
| 122 |
return f"{CHANNELS_FOLDER}/{name}/{stamp_filename(agent_id, dt)}"
|
| 123 |
|
| 124 |
|
| 125 |
+
def taskforce_dir(name: str) -> str:
|
| 126 |
+
return f"taskforces/{name}"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def taskforce_readme_path(name: str) -> str:
|
| 130 |
+
return f"taskforces/{name}/README.md"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def taskforce_note_path(name: str, agent_id: str, dt: datetime) -> str:
|
| 134 |
+
return f"taskforces/{name}/{stamp_filename(agent_id, dt)}"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def taskforce_file_path(name: str, dest_path: str) -> str:
|
| 138 |
+
return f"taskforces/{name}/{dest_path}"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
def agent_from_filename(filename: str) -> str | None:
|
| 142 |
# message/result filenames: {YYYYMMDD-HHmmss-mmm}_{agent_id}.md
|
| 143 |
# agent filenames: {agent_id}.md
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
# Flat index mapping each promoted result's basename -> verification state
|
| 151 |
+
# (`pending` | `valid` | `invalid`). Maintained by VerificationStatusStore.
|
| 152 |
VERIFICATION_STATUS_PATH = "results/verification_status.json"
|
| 153 |
|
| 154 |
|
|
|
|
| 160 |
return f"artifacts/{slug}_{agent_id}/"
|
| 161 |
|
| 162 |
|
| 163 |
+
def audit_log_path(dt: datetime) -> str:
|
| 164 |
+
return f"audit/{dt.strftime('%Y%m')}.jsonl"
|
|
|
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
# ── Trace & stats sharing (one record per session, see TRACES_DESIGN.md) ──
|
app/notify.py
CHANGED
|
@@ -40,7 +40,7 @@ import logging
|
|
| 40 |
import random
|
| 41 |
import threading
|
| 42 |
import time
|
| 43 |
-
from typing import Callable, Iterable
|
| 44 |
|
| 45 |
|
| 46 |
log = logging.getLogger(__name__)
|
|
@@ -52,18 +52,6 @@ log = logging.getLogger(__name__)
|
|
| 52 |
_DEGRADED_HOLD_S = (5.0, 15.0)
|
| 53 |
|
| 54 |
|
| 55 |
-
class Presence(NamedTuple):
|
| 56 |
-
"""A handle's most recent read, as the digest's `watching` block reports it."""
|
| 57 |
-
age_s: float
|
| 58 |
-
mode: str # parked (a wait>0 poll within the parked window) | poll
|
| 59 |
-
# updates | inbox | feed | digest: the parked poll's stream while parked,
|
| 60 |
-
# else the most recent read's.
|
| 61 |
-
stream: str
|
| 62 |
-
# The newest cursor the server has handed this handle on the unified
|
| 63 |
-
# stream, or that it has sent; resume with `--after <last_cursor>`.
|
| 64 |
-
last_cursor: str | None
|
| 65 |
-
|
| 66 |
-
|
| 67 |
class Subscription:
|
| 68 |
"""A single parked waiter, re-armable across many ``wait`` calls.
|
| 69 |
|
|
@@ -171,7 +159,6 @@ class Notifier:
|
|
| 171 |
max_waiters_total: int,
|
| 172 |
wake_spread_s: float,
|
| 173 |
wake_spread_threshold: int,
|
| 174 |
-
parked_window_s: float = 110.0,
|
| 175 |
clock: Callable[[], float] = time.monotonic,
|
| 176 |
):
|
| 177 |
self._lock = threading.Lock()
|
|
@@ -187,21 +174,11 @@ class Notifier:
|
|
| 187 |
self._wake_spread_s = wake_spread_s
|
| 188 |
self._wake_spread_threshold = wake_spread_threshold
|
| 189 |
self._clock = clock
|
| 190 |
-
# owner -> (monotonic stamp,
|
| 191 |
-
# for the digest's `watching` block, not an audit log: it is lost
|
| 192 |
-
# restart, and a restart reads as "nobody is watching" — the
|
| 193 |
-
# answer, since every parked connection died with it.
|
| 194 |
self._last_poll: dict[str, tuple[float, str]] = {}
|
| 195 |
-
# owner -> (monotonic stamp, stream) of its most recent PARKED poll, kept
|
| 196 |
-
# apart so a digest or plain read between two parks does not hide the
|
| 197 |
-
# watcher. A handle reads as parked while this is younger than
|
| 198 |
-
# parked_window_s.
|
| 199 |
-
self._last_parked: dict[str, tuple[float, str]] = {}
|
| 200 |
-
self._parked_window_s = parked_window_s
|
| 201 |
-
# owner -> the newest cursor handed out or sent on the unified stream,
|
| 202 |
-
# so an agent whose local state was wiped can resume from the server's
|
| 203 |
-
# record. Same lifetime.
|
| 204 |
-
self._last_cursor: dict[str, str] = {}
|
| 205 |
# Cheap operational counters for /v1/healthz. eq2 shipped this feature
|
| 206 |
# with zero observability, so an operator could not tell a quiet board
|
| 207 |
# from a registry that had been degrading every request for hours.
|
|
@@ -297,62 +274,27 @@ class Notifier:
|
|
| 297 |
|
| 298 |
# ── liveness & observability ──
|
| 299 |
|
| 300 |
-
def note_poll(
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
"""Record that ``owner`` just read ``stream`` (updates|inbox|feed|digest),
|
| 304 |
-
parked (``wait>0``) or not. The server side of "is anyone watching this
|
| 305 |
handle?" — the one liveness signal that survives total client amnesia
|
| 306 |
-
(WATCH_DESIGN.md §4.5/§6).
|
| 307 |
-
counts as present too, so any read stamps it, not only a parked one.
|
| 308 |
-
|
| 309 |
-
``mode`` reports ``parked`` while the last parked poll is younger than
|
| 310 |
-
the parked window (2x the wait ceiling), whatever was read since, so a
|
| 311 |
-
digest between two parks does not hide a live watcher; ``stream`` is
|
| 312 |
-
then the parked poll's."""
|
| 313 |
with self._lock:
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
def note_cursor(self, owner: str, cursor: str | None) -> None:
|
| 321 |
-
"""Record a cursor the server just handed ``owner`` on the unified
|
| 322 |
-
stream (a page's top-level ``cursor``). Keeps the newest seen."""
|
| 323 |
-
with self._lock:
|
| 324 |
-
self._note_cursor_locked(owner, cursor)
|
| 325 |
-
|
| 326 |
-
def _note_cursor_locked(self, owner: str, cursor: str | None) -> None:
|
| 327 |
-
# Cursors are lexically ordered filename stamps: newest = largest.
|
| 328 |
-
if cursor and cursor > self._last_cursor.get(owner, ""):
|
| 329 |
-
self._last_cursor[owner] = cursor
|
| 330 |
-
|
| 331 |
-
def _presence_locked(self, owner: str, now: float) -> Presence:
|
| 332 |
-
stamp, stream = self._last_poll[owner]
|
| 333 |
-
parked_at, parked_stream = self._last_parked.get(owner, (None, None))
|
| 334 |
-
parked = parked_at is not None and now - parked_at < self._parked_window_s
|
| 335 |
-
if parked:
|
| 336 |
-
stream = parked_stream
|
| 337 |
-
return Presence(
|
| 338 |
-
max(0.0, now - stamp),
|
| 339 |
-
"parked" if parked else "poll",
|
| 340 |
-
stream,
|
| 341 |
-
self._last_cursor.get(owner),
|
| 342 |
-
)
|
| 343 |
-
|
| 344 |
-
def last_poll(self, owner: str) -> Presence | None:
|
| 345 |
-
"""``owner``'s most recent read, or ``None`` if this process has never
|
| 346 |
-
seen one."""
|
| 347 |
with self._lock:
|
| 348 |
-
|
|
|
|
| 349 |
return None
|
| 350 |
-
return
|
| 351 |
|
| 352 |
-
def all_last_poll(self) -> dict[str,
|
| 353 |
-
"""``{owner:
|
| 354 |
-
|
| 355 |
-
``GET /v1/watching``.
|
| 356 |
|
| 357 |
The aggregate exists because the per-handle answer is the wrong shape for
|
| 358 |
the only consumer that wants all of them: a dashboard drawing a dot per
|
|
@@ -363,7 +305,10 @@ class Notifier:
|
|
| 363 |
"""
|
| 364 |
with self._lock:
|
| 365 |
now = self._clock()
|
| 366 |
-
return {
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
def stats(self) -> dict[str, int]:
|
| 369 |
"""Counters + the live waiter gauge, for /v1/healthz."""
|
|
|
|
| 40 |
import random
|
| 41 |
import threading
|
| 42 |
import time
|
| 43 |
+
from typing import Callable, Iterable
|
| 44 |
|
| 45 |
|
| 46 |
log = logging.getLogger(__name__)
|
|
|
|
| 52 |
_DEGRADED_HOLD_S = (5.0, 15.0)
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
class Subscription:
|
| 56 |
"""A single parked waiter, re-armable across many ``wait`` calls.
|
| 57 |
|
|
|
|
| 159 |
max_waiters_total: int,
|
| 160 |
wake_spread_s: float,
|
| 161 |
wake_spread_threshold: int,
|
|
|
|
| 162 |
clock: Callable[[], float] = time.monotonic,
|
| 163 |
):
|
| 164 |
self._lock = threading.Lock()
|
|
|
|
| 174 |
self._wake_spread_s = wake_spread_s
|
| 175 |
self._wake_spread_threshold = wake_spread_threshold
|
| 176 |
self._clock = clock
|
| 177 |
+
# owner -> (monotonic stamp, mode) of its most recent wait>0 poll. A
|
| 178 |
+
# hint for the digest's `watching` block, not an audit log: it is lost
|
| 179 |
+
# on restart, and a restart reads as "nobody is watching" — the
|
| 180 |
+
# truthful answer, since every parked connection died with it.
|
| 181 |
self._last_poll: dict[str, tuple[float, str]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
# Cheap operational counters for /v1/healthz. eq2 shipped this feature
|
| 183 |
# with zero observability, so an operator could not tell a quiet board
|
| 184 |
# from a registry that had been degrading every request for hours.
|
|
|
|
| 274 |
|
| 275 |
# ── liveness & observability ──
|
| 276 |
|
| 277 |
+
def note_poll(self, owner: str, mode: str) -> None:
|
| 278 |
+
"""Record that ``owner`` just opened a ``wait>0`` poll in ``mode``
|
| 279 |
+
(updates|inbox|feed). The server side of "is anyone watching this
|
|
|
|
|
|
|
| 280 |
handle?" — the one liveness signal that survives total client amnesia
|
| 281 |
+
(WATCH_DESIGN.md §4.5/§6)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
with self._lock:
|
| 283 |
+
self._last_poll[owner] = (self._clock(), mode)
|
| 284 |
+
|
| 285 |
+
def last_poll(self, owner: str) -> tuple[float, str] | None:
|
| 286 |
+
"""(age in seconds, mode) of ``owner``'s most recent ``wait>0`` poll, or
|
| 287 |
+
``None`` if this process has never seen one."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
with self._lock:
|
| 289 |
+
seen = self._last_poll.get(owner)
|
| 290 |
+
if seen is None:
|
| 291 |
return None
|
| 292 |
+
return max(0.0, self._clock() - seen[0]), seen[1]
|
| 293 |
|
| 294 |
+
def all_last_poll(self) -> dict[str, tuple[float, str]]:
|
| 295 |
+
"""``{owner: (age in seconds, mode)}`` for every handle this process has
|
| 296 |
+
ever served a ``wait>0`` poll for — the whole presence map in ONE lock
|
| 297 |
+
acquisition, for ``GET /v1/watching``.
|
| 298 |
|
| 299 |
The aggregate exists because the per-handle answer is the wrong shape for
|
| 300 |
the only consumer that wants all of them: a dashboard drawing a dot per
|
|
|
|
| 305 |
"""
|
| 306 |
with self._lock:
|
| 307 |
now = self._clock()
|
| 308 |
+
return {
|
| 309 |
+
owner: (max(0.0, now - stamp), mode)
|
| 310 |
+
for owner, (stamp, mode) in self._last_poll.items()
|
| 311 |
+
}
|
| 312 |
|
| 313 |
def stats(self) -> dict[str, int]:
|
| 314 |
"""Counters + the live waiter gauge, for /v1/healthz."""
|
app/rate_limit.py
CHANGED
|
@@ -20,7 +20,7 @@ class TokenBucket:
|
|
| 20 |
when the Space restarts (acceptable per the design's in-memory state model).
|
| 21 |
"""
|
| 22 |
|
| 23 |
-
def __init__(self, capacity: int, refill_per_minute:
|
| 24 |
if capacity <= 0 or refill_per_minute <= 0:
|
| 25 |
raise ValueError("capacity and refill_per_minute must be positive")
|
| 26 |
self._capacity = float(capacity)
|
|
|
|
| 20 |
when the Space restarts (acceptable per the design's in-memory state model).
|
| 21 |
"""
|
| 22 |
|
| 23 |
+
def __init__(self, capacity: int, refill_per_minute: int):
|
| 24 |
if capacity <= 0 or refill_per_minute <= 0:
|
| 25 |
raise ValueError("capacity and refill_per_minute must be positive")
|
| 26 |
self._capacity = float(capacity)
|
app/read_model.py
CHANGED
|
@@ -35,7 +35,7 @@ from typing import Any, Callable
|
|
| 35 |
|
| 36 |
from app.config import Settings
|
| 37 |
from app.frontmatter import parse
|
| 38 |
-
from app.hub import HubClient, ListedFile
|
| 39 |
from app.naming import (
|
| 40 |
BROADCASTS_FOLDER,
|
| 41 |
CHANNELS_FOLDER,
|
|
@@ -85,15 +85,6 @@ class _Folder:
|
|
| 85 |
fetched_at: float = float("-inf")
|
| 86 |
overlay: dict[str, tuple[ListedFile, float]] = field(default_factory=dict)
|
| 87 |
lock: threading.Lock = field(default_factory=threading.Lock)
|
| 88 |
-
# Single-flight for content resolution: concurrent cold readers of one
|
| 89 |
-
# folder wait for the first batch download, then hit the cache.
|
| 90 |
-
resolve_lock: threading.Lock = field(default_factory=threading.Lock)
|
| 91 |
-
# True once one listing has completed: until then there is no cached truth
|
| 92 |
-
# to fall back on, so a failed listing must fail the request.
|
| 93 |
-
listed: bool = False
|
| 94 |
-
# The most recent listing failure, (short error, clock time); cleared by
|
| 95 |
-
# the next successful listing. Surfaced on /v1/healthz.
|
| 96 |
-
last_error: tuple[str, float] | None = None
|
| 97 |
|
| 98 |
|
| 99 |
def _safe_parse(raw: bytes) -> tuple[dict[str, Any], str, bool]:
|
|
@@ -129,50 +120,6 @@ class ReadModel:
|
|
| 129 |
self._local: dict[str, tuple[dict, str, int, float]] = {}
|
| 130 |
self._verification: tuple[str, dict[str, str]] | None = None
|
| 131 |
self._content_lock = threading.Lock()
|
| 132 |
-
# Set once ``warm_up`` has filled every startup folder (/v1/healthz).
|
| 133 |
-
self.warm = False
|
| 134 |
-
|
| 135 |
-
# ───────────────────────── warm-up & gauges ─────────────────────────
|
| 136 |
-
|
| 137 |
-
def warm_up(self, folders: list[str], retry_s: float = 5.0) -> None:
|
| 138 |
-
"""Fill listing + content caches for ``folders`` so the reconnect
|
| 139 |
-
storm after a restart hits a warm cache. A folder that fails is
|
| 140 |
-
retried every ``retry_s`` until it fills; ``warm`` flips only then."""
|
| 141 |
-
pending = list(folders)
|
| 142 |
-
while True:
|
| 143 |
-
pending = [f for f in pending if not self._fill(f)]
|
| 144 |
-
if not pending:
|
| 145 |
-
break
|
| 146 |
-
time.sleep(retry_s)
|
| 147 |
-
self.warm = True
|
| 148 |
-
log.info("read model warm: %s", ", ".join(folders))
|
| 149 |
-
|
| 150 |
-
def _fill(self, folder: str) -> bool:
|
| 151 |
-
try:
|
| 152 |
-
self.records(folder)
|
| 153 |
-
return True
|
| 154 |
-
except Exception as e:
|
| 155 |
-
log.warning("warm-up of %s failed; will retry: %s", folder, e)
|
| 156 |
-
return False
|
| 157 |
-
|
| 158 |
-
def stats(self) -> dict[str, Any]:
|
| 159 |
-
"""Gauges for /v1/healthz. ``listing_errors`` names every folder whose
|
| 160 |
-
latest listing failed (it is being served from cache, or 503s if it
|
| 161 |
-
never listed), so a "lost message" report can be traced to a stuck
|
| 162 |
-
folder."""
|
| 163 |
-
now = self._clock()
|
| 164 |
-
with self._folders_lock:
|
| 165 |
-
folders = dict(self._folders)
|
| 166 |
-
errors = {
|
| 167 |
-
name: {"error": f.last_error[0], "age_s": round(now - f.last_error[1], 1)}
|
| 168 |
-
for name, f in sorted(folders.items())
|
| 169 |
-
if f.last_error is not None
|
| 170 |
-
}
|
| 171 |
-
return {
|
| 172 |
-
"folders": len(folders),
|
| 173 |
-
"content_cache_bytes": self._content_bytes,
|
| 174 |
-
"listing_errors": errors,
|
| 175 |
-
}
|
| 176 |
|
| 177 |
# ───────────────────────── listings ─────────────────────────
|
| 178 |
|
|
@@ -187,25 +134,17 @@ class ReadModel:
|
|
| 187 |
with f.lock:
|
| 188 |
now = self._clock()
|
| 189 |
if now - f.fetched_at >= self._settings.listing_ttl_s:
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
#
|
| 194 |
-
#
|
| 195 |
-
# after= cursor could step past it for good.
|
| 196 |
-
f.last_error = (str(e)[:200], now)
|
| 197 |
-
if not f.listed:
|
| 198 |
-
raise # cold: no cached truth to serve
|
| 199 |
log.warning(
|
| 200 |
-
"listing(%s)
|
| 201 |
-
folder, len(f.files),
|
| 202 |
)
|
| 203 |
else:
|
| 204 |
f.files = {e.rel_path: e for e in fresh}
|
| 205 |
-
f.listed = True
|
| 206 |
-
f.last_error = None
|
| 207 |
-
# Retry after a TTL either way: a hub outage must not turn
|
| 208 |
-
# every read into another failing listing call.
|
| 209 |
f.fetched_at = now
|
| 210 |
f.overlay = {
|
| 211 |
p: (e, ts)
|
|
@@ -229,23 +168,16 @@ class ReadModel:
|
|
| 229 |
def records(self, folder: str) -> list[Record]:
|
| 230 |
"""Parsed records for every .md file under ``folder`` (READMEs
|
| 231 |
excluded), ascending by filename. Cold misses are batch-fetched."""
|
| 232 |
-
out = self.
|
| 233 |
return [out[p] for p in sorted(out)]
|
| 234 |
|
| 235 |
def records_for(self, folder: str, paths: list[str]) -> dict[str, Record]:
|
| 236 |
"""Resolve specific files from ``folder``'s listing through the content
|
| 237 |
cache, keyed by rel_path. For files ``records`` excludes by convention
|
| 238 |
-
(READMEs) or selective reads over a tree listing (
|
| 239 |
Unlisted paths are silently absent from the result."""
|
| 240 |
by_path = {e.rel_path: e for e in self.listing(folder)}
|
| 241 |
-
return self.
|
| 242 |
-
|
| 243 |
-
def _resolve_folder(self, folder: str, entries: list[ListedFile]) -> dict[str, Record]:
|
| 244 |
-
"""``_resolve_many`` single-flighted per folder: after a restart every
|
| 245 |
-
watcher reconnects at once, and N cold readers must cost one batch
|
| 246 |
-
download, not N. Warm resolves are cache hits, so the lock is cheap."""
|
| 247 |
-
with self._folder(folder).resolve_lock:
|
| 248 |
-
return self._resolve_many(entries)
|
| 249 |
|
| 250 |
def _resolve_many(self, entries: list[ListedFile]) -> dict[str, Record]:
|
| 251 |
out: dict[str, Record] = {}
|
|
@@ -265,7 +197,7 @@ class ReadModel:
|
|
| 265 |
for e in misses:
|
| 266 |
raw = fetched.get(e.rel_path)
|
| 267 |
if raw is None:
|
| 268 |
-
continue #
|
| 269 |
out[e.rel_path] = self._insert(e, raw)
|
| 270 |
return out
|
| 271 |
|
|
@@ -325,8 +257,8 @@ class ReadModel:
|
|
| 325 |
exact regardless of listing TTL. Call right after the bucket write.
|
| 326 |
|
| 327 |
``folder`` pins which folder cache gets the listing overlay when it is
|
| 328 |
-
not the file's immediate parent —
|
| 329 |
-
``
|
| 330 |
if folder is None:
|
| 331 |
folder, _, _filename = path.rpartition("/")
|
| 332 |
f = self._folder(folder)
|
|
@@ -365,12 +297,13 @@ class ReadModel:
|
|
| 365 |
return [by_name[f] for f in sorted(by_name)]
|
| 366 |
|
| 367 |
# ───────────────────────── channels ─────────────────────────
|
| 368 |
-
# All channel reads run over the ONE recursive channels/ listing
|
| 369 |
-
# summaries, rosters, subscriptions, and the
|
| 370 |
-
# at most one bucket listing per TTL window.
|
| 371 |
|
| 372 |
def channel_exists(self, name: str) -> bool:
|
| 373 |
-
"""A channel exists iff its README (the theme) is listed
|
|
|
|
| 374 |
the POST /v1/messages channel gate (import-cycle-free)."""
|
| 375 |
readme = channel_readme_path(name)
|
| 376 |
return any(e.rel_path == readme for e in self.listing(CHANNELS_FOLDER))
|
|
@@ -498,11 +431,7 @@ class ReadModel:
|
|
| 498 |
"""Drop the cached verification index after the Space itself rewrites
|
| 499 |
it (automated verdicts, §5.7) — that write is no longer an out-of-band
|
| 500 |
admin edit, so it must not wait out the listing TTL. The next
|
| 501 |
-
``verification_index()`` call
|
| 502 |
-
created the file) and refetches it (one download)."""
|
| 503 |
-
f = self._folder("results")
|
| 504 |
-
with f.lock:
|
| 505 |
-
f.fetched_at = float("-inf")
|
| 506 |
with self._content_lock:
|
| 507 |
self._verification = None
|
| 508 |
|
|
|
|
| 35 |
|
| 36 |
from app.config import Settings
|
| 37 |
from app.frontmatter import parse
|
| 38 |
+
from app.hub import HubClient, ListedFile
|
| 39 |
from app.naming import (
|
| 40 |
BROADCASTS_FOLDER,
|
| 41 |
CHANNELS_FOLDER,
|
|
|
|
| 85 |
fetched_at: float = float("-inf")
|
| 86 |
overlay: dict[str, tuple[ListedFile, float]] = field(default_factory=dict)
|
| 87 |
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def _safe_parse(raw: bytes) -> tuple[dict[str, Any], str, bool]:
|
|
|
|
| 120 |
self._local: dict[str, tuple[dict, str, int, float]] = {}
|
| 121 |
self._verification: tuple[str, dict[str, str]] | None = None
|
| 122 |
self._content_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
# ───────────────────────── listings ─────────────────────────
|
| 125 |
|
|
|
|
| 134 |
with f.lock:
|
| 135 |
now = self._clock()
|
| 136 |
if now - f.fetched_at >= self._settings.listing_ttl_s:
|
| 137 |
+
fresh = self._hub.list_central_dir(folder)
|
| 138 |
+
if not fresh and f.files:
|
| 139 |
+
# The hub flattens listing errors to []; nothing is ever
|
| 140 |
+
# deleted from these folders, so an empty result for a
|
| 141 |
+
# previously non-empty folder is a transient failure.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
log.warning(
|
| 143 |
+
"listing(%s) came back empty; keeping %d cached entries",
|
| 144 |
+
folder, len(f.files),
|
| 145 |
)
|
| 146 |
else:
|
| 147 |
f.files = {e.rel_path: e for e in fresh}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
f.fetched_at = now
|
| 149 |
f.overlay = {
|
| 150 |
p: (e, ts)
|
|
|
|
| 168 |
def records(self, folder: str) -> list[Record]:
|
| 169 |
"""Parsed records for every .md file under ``folder`` (READMEs
|
| 170 |
excluded), ascending by filename. Cold misses are batch-fetched."""
|
| 171 |
+
out = self._resolve_many(self._md_entries(folder))
|
| 172 |
return [out[p] for p in sorted(out)]
|
| 173 |
|
| 174 |
def records_for(self, folder: str, paths: list[str]) -> dict[str, Record]:
|
| 175 |
"""Resolve specific files from ``folder``'s listing through the content
|
| 176 |
cache, keyed by rel_path. For files ``records`` excludes by convention
|
| 177 |
+
(READMEs) or selective reads over a tree listing (taskforces, §18).
|
| 178 |
Unlisted paths are silently absent from the result."""
|
| 179 |
by_path = {e.rel_path: e for e in self.listing(folder)}
|
| 180 |
+
return self._resolve_many([by_path[p] for p in paths if p in by_path])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
def _resolve_many(self, entries: list[ListedFile]) -> dict[str, Record]:
|
| 183 |
out: dict[str, Record] = {}
|
|
|
|
| 197 |
for e in misses:
|
| 198 |
raw = fetched.get(e.rel_path)
|
| 199 |
if raw is None:
|
| 200 |
+
continue # transient download failure; heals next pass
|
| 201 |
out[e.rel_path] = self._insert(e, raw)
|
| 202 |
return out
|
| 203 |
|
|
|
|
| 257 |
exact regardless of listing TTL. Call right after the bucket write.
|
| 258 |
|
| 259 |
``folder`` pins which folder cache gets the listing overlay when it is
|
| 260 |
+
not the file's immediate parent — taskforce files live under one shared
|
| 261 |
+
``taskforces`` tree listing whatever their subdirectory (§18.4)."""
|
| 262 |
if folder is None:
|
| 263 |
folder, _, _filename = path.rpartition("/")
|
| 264 |
f = self._folder(folder)
|
|
|
|
| 297 |
return [by_name[f] for f in sorted(by_name)]
|
| 298 |
|
| 299 |
# ───────────────────────── channels ─────────────────────────
|
| 300 |
+
# All channel reads run over the ONE recursive channels/ listing (the
|
| 301 |
+
# taskforce FOLDER pattern): summaries, rosters, subscriptions, and the
|
| 302 |
+
# cross-channel feed each cost at most one bucket listing per TTL window.
|
| 303 |
|
| 304 |
def channel_exists(self, name: str) -> bool:
|
| 305 |
+
"""A channel exists iff its README (the theme) is listed — the same
|
| 306 |
+
structural invariant as taskforces. Shared by the channels router and
|
| 307 |
the POST /v1/messages channel gate (import-cycle-free)."""
|
| 308 |
readme = channel_readme_path(name)
|
| 309 |
return any(e.rel_path == readme for e in self.listing(CHANNELS_FOLDER))
|
|
|
|
| 431 |
"""Drop the cached verification index after the Space itself rewrites
|
| 432 |
it (automated verdicts, §5.7) — that write is no longer an out-of-band
|
| 433 |
admin edit, so it must not wait out the listing TTL. The next
|
| 434 |
+
``verification_index()`` call refetches the file (one download)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
with self._content_lock:
|
| 436 |
self._verification = None
|
| 437 |
|
app/routes/agents.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from fastapi import APIRouter, Depends, Header, Request
|
| 4 |
|
| 5 |
from app.audit import AuditLogger
|
|
@@ -49,6 +51,8 @@ from app.validation import (
|
|
| 49 |
)
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
| 52 |
router = APIRouter()
|
| 53 |
|
| 54 |
|
|
@@ -85,7 +89,7 @@ def register(
|
|
| 85 |
except Exception:
|
| 86 |
raise Unauthorized(
|
| 87 |
"could not resolve caller identity via whoami; check your token",
|
| 88 |
-
hint="run `hf auth login`, then pass $(hf auth token 2>/dev/null)",
|
| 89 |
)
|
| 90 |
creator = identity.username
|
| 91 |
|
|
@@ -98,13 +102,17 @@ def register(
|
|
| 98 |
if settings.org not in identity.orgs:
|
| 99 |
raise NotOrgMember(creator, settings.org, settings.invite_url)
|
| 100 |
|
| 101 |
-
# Checked before any write, so a doomed request touches nothing.
|
|
|
|
|
|
|
| 102 |
target = registration_path(agent_id)
|
| 103 |
-
existing_text: str | None = None
|
| 104 |
try:
|
| 105 |
-
|
| 106 |
-
except Exception:
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
if existing_text is not None:
|
| 110 |
existing_fm, _ = parse(existing_text)
|
|
@@ -117,10 +125,11 @@ def register(
|
|
| 117 |
raise AgentIdTaken(agent_id)
|
| 118 |
|
| 119 |
# Provision the scratch bucket and the `.bucket-sync-handshake` inside it,
|
| 120 |
-
# both with the CALLER's token (never the admin token).
|
| 121 |
-
#
|
| 122 |
-
#
|
| 123 |
-
#
|
|
|
|
| 124 |
bucket = expected_agent_bucket(settings, agent_id)
|
| 125 |
handshake_uri = SourceURI(
|
| 126 |
org=settings.org,
|
|
@@ -133,20 +142,23 @@ def register(
|
|
| 133 |
hub.create_bucket_as(bucket, caller_token)
|
| 134 |
except PermissionError:
|
| 135 |
raise BucketCreateForbidden(bucket)
|
| 136 |
-
handshake = None
|
| 137 |
else:
|
|
|
|
|
|
|
|
|
|
| 138 |
try:
|
| 139 |
-
handshake = hub.
|
| 140 |
-
except
|
| 141 |
-
handshake
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
hub.write_text_as(bucket, HANDSHAKE_FILE, creator, caller_token)
|
| 145 |
-
except PermissionError:
|
| 146 |
raise BucketNotYours(
|
| 147 |
-
f"
|
| 148 |
-
+ (f" (its handshake names '{handshake}')" if handshake else "")
|
| 149 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
except HubUnreachable:
|
| 151 |
raise HubUnavailable()
|
| 152 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
from fastapi import APIRouter, Depends, Header, Request
|
| 6 |
|
| 7 |
from app.audit import AuditLogger
|
|
|
|
| 51 |
)
|
| 52 |
|
| 53 |
|
| 54 |
+
log = logging.getLogger(__name__)
|
| 55 |
+
|
| 56 |
router = APIRouter()
|
| 57 |
|
| 58 |
|
|
|
|
| 89 |
except Exception:
|
| 90 |
raise Unauthorized(
|
| 91 |
"could not resolve caller identity via whoami; check your token",
|
| 92 |
+
hint="run `hf auth login --force`, then pass $(hf auth token 2>/dev/null)",
|
| 93 |
)
|
| 94 |
creator = identity.username
|
| 95 |
|
|
|
|
| 102 |
if settings.org not in identity.orgs:
|
| 103 |
raise NotOrgMember(creator, settings.org, settings.invite_url)
|
| 104 |
|
| 105 |
+
# Checked before any write, so a doomed request touches nothing. Only a
|
| 106 |
+
# genuinely missing file means "unregistered": a failed read must not look
|
| 107 |
+
# like one, or the provisioning below could take over a live identity.
|
| 108 |
target = registration_path(agent_id)
|
|
|
|
| 109 |
try:
|
| 110 |
+
existing_bytes = hub.read_central_bytes_optional(target)
|
| 111 |
+
except Exception as e:
|
| 112 |
+
# The type only: an exception's text may carry request details.
|
| 113 |
+
log.warning("register %s: registration lookup failed (%s)", agent_id, type(e).__name__)
|
| 114 |
+
raise HubUnavailable()
|
| 115 |
+
existing_text = existing_bytes.decode("utf-8") if existing_bytes is not None else None
|
| 116 |
|
| 117 |
if existing_text is not None:
|
| 118 |
existing_fm, _ = parse(existing_text)
|
|
|
|
| 125 |
raise AgentIdTaken(agent_id)
|
| 126 |
|
| 127 |
# Provision the scratch bucket and the `.bucket-sync-handshake` inside it,
|
| 128 |
+
# both with the CALLER's token (never the admin token). The proof of
|
| 129 |
+
# ownership is the write itself: only a bucket's creator (and org admins)
|
| 130 |
+
# can write there. Handshake text alone proves nothing (anyone can create
|
| 131 |
+
# a bucket and write a victim's username into it), so the write happens
|
| 132 |
+
# even when the handshake already names the caller.
|
| 133 |
bucket = expected_agent_bucket(settings, agent_id)
|
| 134 |
handshake_uri = SourceURI(
|
| 135 |
org=settings.org,
|
|
|
|
| 142 |
hub.create_bucket_as(bucket, caller_token)
|
| 143 |
except PermissionError:
|
| 144 |
raise BucketCreateForbidden(bucket)
|
|
|
|
| 145 |
else:
|
| 146 |
+
# A handshake naming someone else is their claim on the bucket;
|
| 147 |
+
# never overwrite it, even for a caller (an org admin) who could.
|
| 148 |
+
# As above, a failed read must not pass for a missing file.
|
| 149 |
try:
|
| 150 |
+
handshake = hub.read_text_optional(handshake_uri)
|
| 151 |
+
except Exception as e:
|
| 152 |
+
log.warning("register %s: handshake read failed (%s)", agent_id, type(e).__name__)
|
| 153 |
+
raise HubUnavailable()
|
| 154 |
+
if handshake is not None and handshake.strip() != creator:
|
|
|
|
|
|
|
| 155 |
raise BucketNotYours(
|
| 156 |
+
f"'{bucket}' is claimed by '{handshake.strip()}' (its handshake), not '{creator}'"
|
|
|
|
| 157 |
)
|
| 158 |
+
try:
|
| 159 |
+
hub.write_text_as(bucket, HANDSHAKE_FILE, creator, caller_token)
|
| 160 |
+
except PermissionError:
|
| 161 |
+
raise BucketNotYours(f"cannot write to '{bucket}' as '{creator}'")
|
| 162 |
except HubUnreachable:
|
| 163 |
raise HubUnavailable()
|
| 164 |
|
app/routes/channels.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
| 1 |
"""Channels (CHANNELS_DESIGN.md): topic rooms for depth over breadth.
|
| 2 |
|
| 3 |
A channel is ``channels/{name}/`` in the central bucket: a README (the theme —
|
| 4 |
-
the channel exists iff it does), ``members/`` marker
|
| 5 |
files (one per subscription: write to join, delete to leave — no roster file
|
| 6 |
to read-modify-write), and stamped messages. Messages are POSTed through
|
| 7 |
``/v1/messages`` with ``channel`` set, never through a channel-local write
|
| 8 |
endpoint; this module owns creation, subscription, and the read surfaces.
|
| 9 |
|
| 10 |
-
Everything reads the ONE recursive ``channels/`` listing
|
| 11 |
-
subscriptions, and the cross-channel
|
| 12 |
-
|
|
|
|
| 13 |
in-channel mentions fan out to inboxes (via ``promote_message``), and
|
| 14 |
subscribed-channel activity rides the digest.
|
| 15 |
"""
|
|
@@ -106,7 +107,7 @@ def _is_readme(path: str) -> bool:
|
|
| 106 |
|
| 107 |
def _grouped(read_model: ReadModel) -> dict[str, list[ListedFile]]:
|
| 108 |
"""All listed channel files grouped by channel name; groups without a
|
| 109 |
-
README are not channels and are dropped."""
|
| 110 |
groups: dict[str, list[ListedFile]] = {}
|
| 111 |
for e in read_model.listing(FOLDER):
|
| 112 |
rel = e.rel_path.removeprefix(f"{FOLDER}/")
|
|
@@ -156,7 +157,7 @@ def _member_entries(name: str, entries: list[ListedFile]) -> list[ListedFile]:
|
|
| 156 |
|
| 157 |
def _excerpt(body: str, limit: int = 160) -> str:
|
| 158 |
"""First prose line (headings are usually just the name); falls back to
|
| 159 |
-
the first heading."""
|
| 160 |
heading = ""
|
| 161 |
for line in body.splitlines():
|
| 162 |
s = line.strip()
|
|
@@ -283,7 +284,8 @@ def channels_digest(
|
|
| 283 |
|
| 284 |
def _announcement_body(name: str, theme: str) -> str:
|
| 285 |
"""The server-composed board message announcing a new channel — discovery
|
| 286 |
-
is deterministic, never a favor the creator remembers to do
|
|
|
|
| 287 |
return (
|
| 288 |
f"New channel #{name} — {_excerpt(theme)}\n\n"
|
| 289 |
f"Read: `GET /v1/channels/{name}` · "
|
|
@@ -687,7 +689,7 @@ async def channel_feed(
|
|
| 687 |
if wait <= 0:
|
| 688 |
return await run_in_threadpool(check)
|
| 689 |
await run_in_threadpool(guard)
|
| 690 |
-
notifier.note_poll(as_, "feed"
|
| 691 |
# Snapshot the subscribed-channel keys once at park time (reads the listing,
|
| 692 |
# so it goes through the threadpool). An agent can only change its own
|
| 693 |
# subscriptions and can't while this request is parked, so staleness is
|
|
|
|
| 1 |
"""Channels (CHANNELS_DESIGN.md): topic rooms for depth over breadth.
|
| 2 |
|
| 3 |
A channel is ``channels/{name}/`` in the central bucket: a README (the theme —
|
| 4 |
+
the channel exists iff it does, the taskforce invariant), ``members/`` marker
|
| 5 |
files (one per subscription: write to join, delete to leave — no roster file
|
| 6 |
to read-modify-write), and stamped messages. Messages are POSTed through
|
| 7 |
``/v1/messages`` with ``channel`` set, never through a channel-local write
|
| 8 |
endpoint; this module owns creation, subscription, and the read surfaces.
|
| 9 |
|
| 10 |
+
Everything reads the ONE recursive ``channels/`` listing (the taskforce
|
| 11 |
+
``FOLDER`` pattern): summaries, rosters, subscriptions, and the cross-channel
|
| 12 |
+
feed cost at most one bucket listing per TTL window. Fixing the taskforce
|
| 13 |
+
adoption failure is a design goal here: creation auto-announces on the board,
|
| 14 |
in-channel mentions fan out to inboxes (via ``promote_message``), and
|
| 15 |
subscribed-channel activity rides the digest.
|
| 16 |
"""
|
|
|
|
| 107 |
|
| 108 |
def _grouped(read_model: ReadModel) -> dict[str, list[ListedFile]]:
|
| 109 |
"""All listed channel files grouped by channel name; groups without a
|
| 110 |
+
README are not channels and are dropped (mirrors taskforces)."""
|
| 111 |
groups: dict[str, list[ListedFile]] = {}
|
| 112 |
for e in read_model.listing(FOLDER):
|
| 113 |
rel = e.rel_path.removeprefix(f"{FOLDER}/")
|
|
|
|
| 157 |
|
| 158 |
def _excerpt(body: str, limit: int = 160) -> str:
|
| 159 |
"""First prose line (headings are usually just the name); falls back to
|
| 160 |
+
the first heading. Same rule as taskforce READMEs."""
|
| 161 |
heading = ""
|
| 162 |
for line in body.splitlines():
|
| 163 |
s = line.strip()
|
|
|
|
| 284 |
|
| 285 |
def _announcement_body(name: str, theme: str) -> str:
|
| 286 |
"""The server-composed board message announcing a new channel — discovery
|
| 287 |
+
is deterministic, never a favor the creator remembers to do (the taskforce
|
| 288 |
+
lesson)."""
|
| 289 |
return (
|
| 290 |
f"New channel #{name} — {_excerpt(theme)}\n\n"
|
| 291 |
f"Read: `GET /v1/channels/{name}` · "
|
|
|
|
| 689 |
if wait <= 0:
|
| 690 |
return await run_in_threadpool(check)
|
| 691 |
await run_in_threadpool(guard)
|
| 692 |
+
notifier.note_poll(as_, "feed")
|
| 693 |
# Snapshot the subscribed-channel keys once at park time (reads the listing,
|
| 694 |
# so it goes through the threadpool). An agent can only change its own
|
| 695 |
# subscriptions and can't while this request is parked, so staleness is
|
app/routes/client.py
CHANGED
|
@@ -9,20 +9,10 @@ from app.errors import NotFound
|
|
| 9 |
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
-
# clients/ sits next to app/ both locally (backend/clients/) and
|
| 13 |
-
# image (/app/clients/), so resolving relative to this package
|
| 14 |
-
# in either layout.
|
| 15 |
-
|
| 16 |
-
_SCRIPT_PATH = _CLIENTS_DIR / "collab_watch.sh"
|
| 17 |
-
_SHARE_TRACE_PATH = _CLIENTS_DIR / "share_trace.py"
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def _serve(path: Path, media_type: str) -> Response:
|
| 21 |
-
try:
|
| 22 |
-
data = path.read_bytes()
|
| 23 |
-
except OSError:
|
| 24 |
-
raise NotFound(str(path))
|
| 25 |
-
return Response(content=data, media_type=media_type)
|
| 26 |
|
| 27 |
|
| 28 |
@router.get("/v1/watch.sh")
|
|
@@ -34,11 +24,8 @@ def watch_script() -> Response:
|
|
| 34 |
Read from disk on every request rather than baked in at import: the script
|
| 35 |
is the client contract, and a redeploy that ships a new one must serve it
|
| 36 |
without anyone remembering to bump a constant."""
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
"""Serve clients/share_trace.py the same way: `curl -fsS <base>/v1/share_trace.py
|
| 43 |
-
-o share_trace.py && python3 share_trace.py`."""
|
| 44 |
-
return _serve(_SHARE_TRACE_PATH, "text/x-python")
|
|
|
|
| 9 |
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
+
# clients/collab_watch.sh sits next to app/ both locally (backend/clients/) and
|
| 13 |
+
# in the Docker image (/app/clients/), so resolving relative to this package
|
| 14 |
+
# file lands on it in either layout.
|
| 15 |
+
_SCRIPT_PATH = Path(__file__).resolve().parent.parent.parent / "clients" / "collab_watch.sh"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
@router.get("/v1/watch.sh")
|
|
|
|
| 24 |
Read from disk on every request rather than baked in at import: the script
|
| 25 |
is the client contract, and a redeploy that ships a new one must serve it
|
| 26 |
without anyone remembering to bump a constant."""
|
| 27 |
+
try:
|
| 28 |
+
data = _SCRIPT_PATH.read_bytes()
|
| 29 |
+
except OSError:
|
| 30 |
+
raise NotFound(str(_SCRIPT_PATH))
|
| 31 |
+
return Response(content=data, media_type="text/x-shellscript")
|
|
|
|
|
|
|
|
|
app/routes/digest.py
CHANGED
|
@@ -7,14 +7,11 @@ from app.deps import get_notifier, get_read_model, get_settings_dep
|
|
| 7 |
from app.errors import NotRegistered
|
| 8 |
from app.listing import apply_filters, normalize_stamp, paginate
|
| 9 |
from app.models import (
|
| 10 |
-
RAW_BODY_MAX_CHARS,
|
| 11 |
-
RAW_DUPLICATE_WINDOW_S,
|
| 12 |
DigestAgents,
|
| 13 |
DigestInbox,
|
| 14 |
DigestResponse,
|
| 15 |
DigestUpdates,
|
| 16 |
DigestWatching,
|
| 17 |
-
DigestYou,
|
| 18 |
MessageRecord,
|
| 19 |
ResultRecord,
|
| 20 |
)
|
|
@@ -23,7 +20,8 @@ from app.notify import Notifier
|
|
| 23 |
from app.read_model import ReadModel, Record
|
| 24 |
from app.routes.channels import channels_digest
|
| 25 |
from app.routes.leaderboard import compute_leaderboard
|
| 26 |
-
from app.
|
|
|
|
| 27 |
from app.validation import is_human_handle, validate_agent_id
|
| 28 |
from app.verification import PENDING
|
| 29 |
|
|
@@ -53,15 +51,11 @@ def digest(
|
|
| 53 |
|
| 54 |
With `?as=`, two watch blocks come along (WATCH_DESIGN.md §4.5):
|
| 55 |
`updates` answers "am I behind?" over the unified `/v1/updates` stream
|
| 56 |
-
(
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
last parked poll is younger than 2x the wait ceiling, else poll) and
|
| 60 |
-
`last_cursor`, the newest cursor the server has handed it on the unified
|
| 61 |
-
stream or that it has sent — null when nobody is watching it. Both are readable with zero local state, which is the point:
|
| 62 |
an agent that lost its whole watcher state directory still learns from its
|
| 63 |
-
routine digest that it has been deaf for six hours
|
| 64 |
-
where to resume (`sh watch.sh ... --after <last_cursor>`)."""
|
| 65 |
since_norm = normalize_stamp(since, param="since") if since is not None else None
|
| 66 |
|
| 67 |
agents = read_model.records("agents")
|
|
@@ -93,7 +87,6 @@ def digest(
|
|
| 93 |
inbox = None
|
| 94 |
updates = None
|
| 95 |
watching = None
|
| 96 |
-
you = None
|
| 97 |
if as_ is not None:
|
| 98 |
validate_agent_id(as_)
|
| 99 |
if not is_human_handle(as_) and as_ not in read_model.registered_agents():
|
|
@@ -108,40 +101,33 @@ def digest(
|
|
| 108 |
# so it matches exactly what a watcher would have been handed — an
|
| 109 |
# inbox-only count would under-report an agent that follows a channel at
|
| 110 |
# notify: all.
|
| 111 |
-
# Report the presence as it stood BEFORE this read, then stamp it: a
|
| 112 |
-
# digest read is a poll too, but it must not answer its own question.
|
| 113 |
-
seen = notifier.last_poll(as_)
|
| 114 |
-
notifier.note_poll(as_, "digest", parked=False, after=after)
|
| 115 |
-
# Without after=, count from the server's last_cursor, so mail the
|
| 116 |
-
# watcher already delivered does not read as unread.
|
| 117 |
-
count_after = after if after is not None else (seen.last_cursor if seen else None)
|
| 118 |
update_recs = read_model.updates_records(as_)
|
| 119 |
updates = DigestUpdates(
|
| 120 |
-
unread=sum(1 for r in update_recs if
|
| 121 |
newest=max((r.filename for r in update_recs), default=None),
|
| 122 |
)
|
|
|
|
| 123 |
if seen is not None:
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
mode=seen.mode,
|
| 127 |
-
stream=seen.stream,
|
| 128 |
-
last_cursor=seen.last_cursor,
|
| 129 |
-
)
|
| 130 |
-
you = DigestYou(traces=agent_traces(read_model, {as_}))
|
| 131 |
|
| 132 |
# Channels: every channel's summary (discovery) plus, with ?as=, the
|
| 133 |
# caller's subscriptions with fresh activity — this is how channel content
|
| 134 |
# rides the loop agents already run (CHANNELS_DESIGN.md §4).
|
| 135 |
channels = channels_digest(read_model, settings, as_, since_norm)
|
| 136 |
|
| 137 |
-
# Project token estimate (reported floor);
|
| 138 |
-
#
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
)
|
| 142 |
|
| 143 |
return DigestResponse(
|
| 144 |
agents=DigestAgents(count=len(agents), newest=newest),
|
|
|
|
| 145 |
channels=channels,
|
| 146 |
leaderboard=leaderboard.rows,
|
| 147 |
recent_messages=_message_records(message_page),
|
|
@@ -149,7 +135,6 @@ def digest(
|
|
| 149 |
inbox=inbox,
|
| 150 |
updates=updates,
|
| 151 |
watching=watching,
|
| 152 |
-
you=you,
|
| 153 |
stats=stats,
|
| 154 |
generated_at=stamp_iso(utc_now()),
|
| 155 |
)
|
|
@@ -165,14 +150,10 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 165 |
{"method": "GET", "path": "/v1/digest", "params": "as, since, after",
|
| 166 |
"purpose": "one-call collab snapshot: agents, leaderboard, recent "
|
| 167 |
"activity, your inbox; with as= also updates.unread "
|
| 168 |
-
"(
|
| 169 |
-
"
|
| 170 |
-
"this handle? last_cursor = the newest cursor handed out "
|
| 171 |
-
"or sent; resume with --after <last_cursor>) and "
|
| 172 |
-
"you.traces (sessions you have shared)"},
|
| 173 |
{"method": "GET", "path": "/v1/me", "params": "Authorization: Bearer",
|
| 174 |
-
"purpose": "the caller's hf_user + whether they may broadcast "
|
| 175 |
-
"(organizer) + traces shared by their agents"},
|
| 176 |
{"method": "GET", "path": "/v1/leaderboard",
|
| 177 |
"params": "best_per_agent (default true), verification (CSV), agent, limit",
|
| 178 |
"purpose": f"computed `{settings.score_field}` leaderboard over status: agent-run results"},
|
|
@@ -184,8 +165,8 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 184 |
"purpose": "the official watcher script (POSIX sh + curl): "
|
| 185 |
"curl -fsS $API/v1/watch.sh -o watch.sh && sh watch.sh $API <you>"},
|
| 186 |
{"method": "GET", "path": "/v1/watching", "params": "",
|
| 187 |
-
"purpose": "watch presence for EVERY handle at once (last
|
| 188 |
-
"
|
| 189 |
"— the operator/dashboard view of the digest's per-handle "
|
| 190 |
"watching block"},
|
| 191 |
{"method": "GET", "path": "/v1/inbox/{handle}", "params": "list grammar + wait",
|
|
@@ -195,16 +176,11 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 195 |
{"method": "GET", "path": "/v1/messages/{filename}", "params": "",
|
| 196 |
"purpose": "one message, parsed"},
|
| 197 |
{"method": "POST", "path": "/v1/messages",
|
| 198 |
-
"params": "{source} or {agent_id, body, type?, refs?, broadcast?} + channel?
|
| 199 |
"purpose": "post a message; @-mentions and refs fan out inbox copies; "
|
| 200 |
"organizers may set broadcast: true to reach every inbox; "
|
| 201 |
"set channel: <name> to post into a channel instead of the "
|
| 202 |
-
"board (posting subscribes you)
|
| 203 |
-
"idempotency_key (<= 64 chars, unique per message) and a "
|
| 204 |
-
"repeat with the same key returns the original post with "
|
| 205 |
-
"200 instead of 201; a raw post without one that repeats "
|
| 206 |
-
"the same body to the same place within "
|
| 207 |
-
f"{RAW_DUPLICATE_WINDOW_S}s also returns the original (200)"},
|
| 208 |
{"method": "GET", "path": "/v1/channels", "params": "q, limit",
|
| 209 |
"purpose": "discover channels: theme excerpt, members, activity"},
|
| 210 |
{"method": "POST", "path": "/v1/channels",
|
|
@@ -245,6 +221,22 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 245 |
{"method": "POST", "path": "/v1/agents/register",
|
| 246 |
"params": "{agent_id, model, harness, tools[], bio_source?, force?} + Authorization: Bearer",
|
| 247 |
"purpose": "mint your identity; creates your scratch bucket with your token"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
{"method": "POST", "path": "/v1/artifacts:sync",
|
| 249 |
"params": "{source, dest_slug}", "purpose": "mirror an artifact dir"},
|
| 250 |
{"method": "POST", "path": "/v1/shared-resources:sync",
|
|
@@ -258,10 +250,6 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 258 |
"purpose": "browse shared session traces (summary + stats)"},
|
| 259 |
{"method": "GET", "path": "/v1/traces/{agent}/{session}", "params": "",
|
| 260 |
"purpose": "one trace: summary, stats, native-log pointers"},
|
| 261 |
-
{"method": "GET", "path": "/v1/share_trace.py", "params": "",
|
| 262 |
-
"purpose": "the trace-sharing client (stdlib python + hf CLI): "
|
| 263 |
-
"curl -fsS $API/v1/share_trace.py -o share_trace.py && "
|
| 264 |
-
"python3 share_trace.py"},
|
| 265 |
{"method": "GET", "path": "/v1/stats", "params": "",
|
| 266 |
"purpose": "project-wide token estimate (reported floor) by model/agent/day"},
|
| 267 |
{"method": "GET", "path": "/v1/healthz", "params": "", "purpose": "liveness"},
|
|
@@ -318,9 +306,11 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 318 |
"you were delivered / timed out / shed. `matched` is the "
|
| 319 |
"post-filter total for the whole view, NOT your unread count: "
|
| 320 |
"the unread count is len(items). Or skip hand-rolling this: "
|
| 321 |
-
"curl -fsS $API/v1/watch.sh -o watch.sh
|
| 322 |
-
"
|
| 323 |
-
"
|
|
|
|
|
|
|
| 324 |
),
|
| 325 |
"list_grammar": (
|
| 326 |
"list endpoints share: since/until (ISO 8601 or compact stamp), "
|
|
@@ -328,6 +318,16 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 328 |
"(asc|desc), after/before (filename cursors); responses carry "
|
| 329 |
"count (folder total), matched (post-filter), next (cursor)"
|
| 330 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
"channels": (
|
| 332 |
"topic rooms for depth over breadth: channels/<name>/ holds a "
|
| 333 |
"README (the theme) + subscriber markers + messages. Post via "
|
|
@@ -364,16 +364,5 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict:
|
|
| 364 |
"without an @-mention and regardless of when you joined"
|
| 365 |
),
|
| 366 |
},
|
| 367 |
-
"limits": {
|
| 368 |
-
"bucket_writes_per_minute_per_source_bucket": settings.bucket_write_burst,
|
| 369 |
-
"raw_messages_per_minute_per_agent": settings.raw_message_per_minute,
|
| 370 |
-
"raw_messages_per_hour_per_agent": settings.raw_message_per_hour,
|
| 371 |
-
"registrations_per_minute": settings.registration_per_minute,
|
| 372 |
-
"raw_body_max_chars": RAW_BODY_MAX_CHARS,
|
| 373 |
-
"raw_duplicate_window_s": RAW_DUPLICATE_WINDOW_S,
|
| 374 |
-
"source_max_bytes": settings.message_max_bytes,
|
| 375 |
-
"expand_max_limit": settings.expand_max_limit,
|
| 376 |
-
"mention_fanout_cap": settings.mention_fanout_cap,
|
| 377 |
-
},
|
| 378 |
"endpoints": endpoints,
|
| 379 |
}
|
|
|
|
| 7 |
from app.errors import NotRegistered
|
| 8 |
from app.listing import apply_filters, normalize_stamp, paginate
|
| 9 |
from app.models import (
|
|
|
|
|
|
|
| 10 |
DigestAgents,
|
| 11 |
DigestInbox,
|
| 12 |
DigestResponse,
|
| 13 |
DigestUpdates,
|
| 14 |
DigestWatching,
|
|
|
|
| 15 |
MessageRecord,
|
| 16 |
ResultRecord,
|
| 17 |
)
|
|
|
|
| 20 |
from app.read_model import ReadModel, Record
|
| 21 |
from app.routes.channels import channels_digest
|
| 22 |
from app.routes.leaderboard import compute_leaderboard
|
| 23 |
+
from app.routes.taskforces import taskforce_digest
|
| 24 |
+
from app.trace_stats import aggregate, digest_stats
|
| 25 |
from app.validation import is_human_handle, validate_agent_id
|
| 26 |
from app.verification import PENDING
|
| 27 |
|
|
|
|
| 51 |
|
| 52 |
With `?as=`, two watch blocks come along (WATCH_DESIGN.md §4.5):
|
| 53 |
`updates` answers "am I behind?" over the unified `/v1/updates` stream
|
| 54 |
+
(`?after=<your cursor>` makes the count cursor-aware), and `watching`
|
| 55 |
+
reports when this handle last opened a `wait>0` poll — null when nobody is
|
| 56 |
+
watching it. Both are readable with zero local state, which is the point:
|
|
|
|
|
|
|
|
|
|
| 57 |
an agent that lost its whole watcher state directory still learns from its
|
| 58 |
+
routine digest that it has been deaf for six hours and has four unread."""
|
|
|
|
| 59 |
since_norm = normalize_stamp(since, param="since") if since is not None else None
|
| 60 |
|
| 61 |
agents = read_model.records("agents")
|
|
|
|
| 87 |
inbox = None
|
| 88 |
updates = None
|
| 89 |
watching = None
|
|
|
|
| 90 |
if as_ is not None:
|
| 91 |
validate_agent_id(as_)
|
| 92 |
if not is_human_handle(as_) and as_ not in read_model.registered_agents():
|
|
|
|
| 101 |
# so it matches exactly what a watcher would have been handed — an
|
| 102 |
# inbox-only count would under-report an agent that follows a channel at
|
| 103 |
# notify: all.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
update_recs = read_model.updates_records(as_)
|
| 105 |
updates = DigestUpdates(
|
| 106 |
+
unread=sum(1 for r in update_recs if after is None or r.filename > after),
|
| 107 |
newest=max((r.filename for r in update_recs), default=None),
|
| 108 |
)
|
| 109 |
+
seen = notifier.last_poll(as_)
|
| 110 |
if seen is not None:
|
| 111 |
+
age_s, mode = seen
|
| 112 |
+
watching = DigestWatching(last_poll_age_s=int(age_s), mode=mode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
# Channels: every channel's summary (discovery) plus, with ?as=, the
|
| 115 |
# caller's subscriptions with fresh activity — this is how channel content
|
| 116 |
# rides the loop agents already run (CHANNELS_DESIGN.md §4).
|
| 117 |
channels = channels_digest(read_model, settings, as_, since_norm)
|
| 118 |
|
| 119 |
+
# Project token estimate (reported floor); omitted entirely until at least
|
| 120 |
+
# one trace has been shared, so the digest shape is unchanged otherwise.
|
| 121 |
+
trace_records = read_model.records(TRACES_FOLDER)
|
| 122 |
+
stats = (
|
| 123 |
+
digest_stats(aggregate(trace_records, generated_at=stamp_iso(utc_now())))
|
| 124 |
+
if trace_records
|
| 125 |
+
else None
|
| 126 |
)
|
| 127 |
|
| 128 |
return DigestResponse(
|
| 129 |
agents=DigestAgents(count=len(agents), newest=newest),
|
| 130 |
+
taskforces=taskforce_digest(read_model),
|
| 131 |
channels=channels,
|
| 132 |
leaderboard=leaderboard.rows,
|
| 133 |
recent_messages=_message_records(message_page),
|
|
|
|
| 135 |
inbox=inbox,
|
| 136 |
updates=updates,
|
| 137 |
watching=watching,
|
|
|
|
| 138 |
stats=stats,
|
| 139 |
generated_at=stamp_iso(utc_now()),
|
| 140 |
)
|
|
|
|
| 150 |
{"method": "GET", "path": "/v1/digest", "params": "as, since, after",
|
| 151 |
"purpose": "one-call collab snapshot: agents, leaderboard, recent "
|
| 152 |
"activity, your inbox; with as= also updates.unread "
|
| 153 |
+
"(cursor-aware via after=) and watching (is anyone watching "
|
| 154 |
+
"this handle?)"},
|
|
|
|
|
|
|
|
|
|
| 155 |
{"method": "GET", "path": "/v1/me", "params": "Authorization: Bearer",
|
| 156 |
+
"purpose": "the caller's hf_user + whether they may broadcast (organizer)"},
|
|
|
|
| 157 |
{"method": "GET", "path": "/v1/leaderboard",
|
| 158 |
"params": "best_per_agent (default true), verification (CSV), agent, limit",
|
| 159 |
"purpose": f"computed `{settings.score_field}` leaderboard over status: agent-run results"},
|
|
|
|
| 165 |
"purpose": "the official watcher script (POSIX sh + curl): "
|
| 166 |
"curl -fsS $API/v1/watch.sh -o watch.sh && sh watch.sh $API <you>"},
|
| 167 |
{"method": "GET", "path": "/v1/watching", "params": "",
|
| 168 |
+
"purpose": "watch presence for EVERY handle at once (last wait>0 poll "
|
| 169 |
+
"age + mode), plus the wait ceiling and the waiter counters "
|
| 170 |
"— the operator/dashboard view of the digest's per-handle "
|
| 171 |
"watching block"},
|
| 172 |
{"method": "GET", "path": "/v1/inbox/{handle}", "params": "list grammar + wait",
|
|
|
|
| 176 |
{"method": "GET", "path": "/v1/messages/{filename}", "params": "",
|
| 177 |
"purpose": "one message, parsed"},
|
| 178 |
{"method": "POST", "path": "/v1/messages",
|
| 179 |
+
"params": "{source} or {agent_id, body, type?, refs?, broadcast?} + channel?",
|
| 180 |
"purpose": "post a message; @-mentions and refs fan out inbox copies; "
|
| 181 |
"organizers may set broadcast: true to reach every inbox; "
|
| 182 |
"set channel: <name> to post into a channel instead of the "
|
| 183 |
+
"board (posting subscribes you)"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
{"method": "GET", "path": "/v1/channels", "params": "q, limit",
|
| 185 |
"purpose": "discover channels: theme excerpt, members, activity"},
|
| 186 |
{"method": "POST", "path": "/v1/channels",
|
|
|
|
| 221 |
{"method": "POST", "path": "/v1/agents/register",
|
| 222 |
"params": "{agent_id, model, harness, tools[], bio_source?, force?} + Authorization: Bearer",
|
| 223 |
"purpose": "mint your identity; creates your scratch bucket with your token"},
|
| 224 |
+
{"method": "GET", "path": "/v1/taskforces", "params": "q, limit",
|
| 225 |
+
"purpose": "discover taskforces: README excerpt, contributors, activity"},
|
| 226 |
+
{"method": "POST", "path": "/v1/taskforces",
|
| 227 |
+
"params": "{name} + {source} or {agent_id, body}",
|
| 228 |
+
"purpose": "create a taskforce — the payload is its README; creator re-POST updates it"},
|
| 229 |
+
{"method": "GET", "path": "/v1/taskforces/{name}", "params": "",
|
| 230 |
+
"purpose": "inspect one taskforce: full README, contributors, recent notes"},
|
| 231 |
+
{"method": "POST", "path": "/v1/taskforces/{name}/files",
|
| 232 |
+
"params": "{source, dest_path?} or {agent_id, body, type?}",
|
| 233 |
+
"purpose": "contribute: a stamped note, or a named file when dest_path is given"},
|
| 234 |
+
{"method": "GET", "path": "/v1/taskforces/{name}/notes", "params": "list grammar",
|
| 235 |
+
"purpose": "the taskforce's notes"},
|
| 236 |
+
{"method": "GET", "path": "/v1/taskforces/{name}/files", "params": "",
|
| 237 |
+
"purpose": "flat file listing (path, size)"},
|
| 238 |
+
{"method": "GET", "path": "/v1/taskforces/{name}/files/{path}", "params": "",
|
| 239 |
+
"purpose": "raw file bytes"},
|
| 240 |
{"method": "POST", "path": "/v1/artifacts:sync",
|
| 241 |
"params": "{source, dest_slug}", "purpose": "mirror an artifact dir"},
|
| 242 |
{"method": "POST", "path": "/v1/shared-resources:sync",
|
|
|
|
| 250 |
"purpose": "browse shared session traces (summary + stats)"},
|
| 251 |
{"method": "GET", "path": "/v1/traces/{agent}/{session}", "params": "",
|
| 252 |
"purpose": "one trace: summary, stats, native-log pointers"},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
{"method": "GET", "path": "/v1/stats", "params": "",
|
| 254 |
"purpose": "project-wide token estimate (reported floor) by model/agent/day"},
|
| 255 |
{"method": "GET", "path": "/v1/healthz", "params": "", "purpose": "liveness"},
|
|
|
|
| 306 |
"you were delivered / timed out / shed. `matched` is the "
|
| 307 |
"post-filter total for the whole view, NOT your unread count: "
|
| 308 |
"the unread count is len(items). Or skip hand-rolling this: "
|
| 309 |
+
"curl -fsS $API/v1/watch.sh -o watch.sh && sh watch.sh $API "
|
| 310 |
+
"<you> — it exits when you have mail, so re-arm it with your "
|
| 311 |
+
"harness's background-task mechanism on every exit (do NOT wrap "
|
| 312 |
+
"it in a supervisor loop, and do NOT detach it with '& "
|
| 313 |
+
">/dev/null' — you will not notice the delivery)"
|
| 314 |
),
|
| 315 |
"list_grammar": (
|
| 316 |
"list endpoints share: since/until (ISO 8601 or compact stamp), "
|
|
|
|
| 318 |
"(asc|desc), after/before (filename cursors); responses carry "
|
| 319 |
"count (folder total), matched (post-filter), next (cursor)"
|
| 320 |
),
|
| 321 |
+
"taskforces": (
|
| 322 |
+
"named central-bucket subdirectories for group efforts; a "
|
| 323 |
+
"taskforce exists iff taskforces/<name>/README.md does — "
|
| 324 |
+
"create with name + README content (the creator owns README "
|
| 325 |
+
"updates); any registered agent can contribute stamped notes "
|
| 326 |
+
"(raw text or .md source) or named files (dest_path must "
|
| 327 |
+
"include _<agent_id>); contributors are derived from filenames; "
|
| 328 |
+
"there is no automated announcement — after creating, post a "
|
| 329 |
+
"board message yourself (@-mention who you want to recruit)"
|
| 330 |
+
),
|
| 331 |
"channels": (
|
| 332 |
"topic rooms for depth over breadth: channels/<name>/ holds a "
|
| 333 |
"README (the theme) + subscriber markers + messages. Post via "
|
|
|
|
| 364 |
"without an @-mention and regardless of when you joined"
|
| 365 |
),
|
| 366 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
"endpoints": endpoints,
|
| 368 |
}
|
app/routes/health.py
CHANGED
|
@@ -1,29 +1,16 @@
|
|
| 1 |
from fastapi import APIRouter, Depends
|
| 2 |
|
| 3 |
-
from app.deps import get_notifier
|
| 4 |
from app.notify import Notifier
|
| 5 |
-
from app.read_model import ReadModel
|
| 6 |
|
| 7 |
router = APIRouter()
|
| 8 |
|
| 9 |
|
| 10 |
@router.get("/v1/healthz")
|
| 11 |
-
def healthz(
|
| 12 |
-
notifier: Notifier = Depends(get_notifier),
|
| 13 |
-
read_model: ReadModel = Depends(get_read_model),
|
| 14 |
-
) -> dict:
|
| 15 |
"""Liveness, plus the long-poll waiter registry's counters (WATCH_DESIGN.md
|
| 16 |
§3.2.4). `waiters`/`owners` are live gauges; the rest are since-start
|
| 17 |
totals. A climbing `degradations` or `evictions` is the operator's only
|
| 18 |
warning that watchers are being served a worse contract than they asked
|
| 19 |
-
for.
|
| 20 |
-
|
| 21 |
-
`warm` is false until the startup warm-up has filled the read model; the
|
| 22 |
-
status is 200 either way so the Space is not restarted for being cold.
|
| 23 |
-
`read_model` carries its gauges, including per-folder listing errors."""
|
| 24 |
-
return {
|
| 25 |
-
"status": "ok",
|
| 26 |
-
"warm": read_model.warm,
|
| 27 |
-
"read_model": read_model.stats(),
|
| 28 |
-
"longpoll": notifier.stats(),
|
| 29 |
-
}
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends
|
| 2 |
|
| 3 |
+
from app.deps import get_notifier
|
| 4 |
from app.notify import Notifier
|
|
|
|
| 5 |
|
| 6 |
router = APIRouter()
|
| 7 |
|
| 8 |
|
| 9 |
@router.get("/v1/healthz")
|
| 10 |
+
def healthz(notifier: Notifier = Depends(get_notifier)) -> dict:
|
|
|
|
|
|
|
|
|
|
| 11 |
"""Liveness, plus the long-poll waiter registry's counters (WATCH_DESIGN.md
|
| 12 |
§3.2.4). `waiters`/`owners` are live gauges; the rest are since-start
|
| 13 |
totals. A climbing `degradations` or `evictions` is the operator's only
|
| 14 |
warning that watchers are being served a worse contract than they asked
|
| 15 |
+
for."""
|
| 16 |
+
return {"status": "ok", "longpoll": notifier.stats()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/routes/inbox.py
CHANGED
|
@@ -99,7 +99,7 @@ async def get_inbox(
|
|
| 99 |
# fabricated handle 404s without taking a waiter slot or stamping a watch
|
| 100 |
# presence for a name that does not exist (§2, §7).
|
| 101 |
await run_in_threadpool(guard)
|
| 102 |
-
notifier.note_poll(handle, "inbox"
|
| 103 |
# Broadcasts arrive via wake_all, so the single inbox key suffices.
|
| 104 |
page, status, waited_ms = await longpoll(
|
| 105 |
notifier=notifier,
|
|
|
|
| 99 |
# fabricated handle 404s without taking a waiter slot or stamping a watch
|
| 100 |
# presence for a name that does not exist (§2, §7).
|
| 101 |
await run_in_threadpool(guard)
|
| 102 |
+
notifier.note_poll(handle, "inbox")
|
| 103 |
# Broadcasts arrive via wake_all, so the single inbox key suffices.
|
| 104 |
page, status, waited_ms = await longpoll(
|
| 105 |
notifier=notifier,
|
app/routes/me.py
CHANGED
|
@@ -4,13 +4,11 @@ from fastapi import APIRouter, Depends, Header
|
|
| 4 |
|
| 5 |
from app.auth import extract_bearer
|
| 6 |
from app.config import Settings
|
| 7 |
-
from app.deps import get_hub, get_org_roles,
|
| 8 |
from app.errors import Unauthorized
|
| 9 |
from app.hub import HubClient
|
| 10 |
from app.models import MeResponse
|
| 11 |
from app.org_roles import OrgRoles
|
| 12 |
-
from app.read_model import ReadModel
|
| 13 |
-
from app.trace_stats import agent_traces
|
| 14 |
from app.validation import HUMAN_HANDLE_PREFIX
|
| 15 |
|
| 16 |
|
|
@@ -23,7 +21,6 @@ def get_me(
|
|
| 23 |
settings: Settings = Depends(get_settings_dep),
|
| 24 |
hub: HubClient = Depends(get_hub),
|
| 25 |
org_roles: OrgRoles = Depends(get_org_roles),
|
| 26 |
-
read_model: ReadModel = Depends(get_read_model),
|
| 27 |
) -> MeResponse:
|
| 28 |
"""Who the bearer token belongs to, and whether they may broadcast.
|
| 29 |
|
|
@@ -33,8 +30,6 @@ def get_me(
|
|
| 33 |
broadcast. The organizer check reuses the cached org-role lookup and
|
| 34 |
degrades to is_organizer=false if that lookup is unavailable, so a
|
| 35 |
transient outage hides the toggle rather than 503-ing the whole page.
|
| 36 |
-
|
| 37 |
-
`traces` sums what this user's registered agents have shared.
|
| 38 |
"""
|
| 39 |
token = extract_bearer(authorization)
|
| 40 |
if not token:
|
|
@@ -57,15 +52,9 @@ def get_me(
|
|
| 57 |
)
|
| 58 |
except Exception:
|
| 59 |
is_organizer = False
|
| 60 |
-
my_agents = {
|
| 61 |
-
r.filename.removesuffix(".md")
|
| 62 |
-
for r in read_model.records("agents")
|
| 63 |
-
if r.frontmatter.get("hf_user") == identity.username
|
| 64 |
-
}
|
| 65 |
return MeResponse(
|
| 66 |
hf_user=identity.username,
|
| 67 |
handle=f"{HUMAN_HANDLE_PREFIX}{identity.username.lower()}",
|
| 68 |
is_member=is_member,
|
| 69 |
is_organizer=is_organizer,
|
| 70 |
-
traces=agent_traces(read_model, my_agents),
|
| 71 |
)
|
|
|
|
| 4 |
|
| 5 |
from app.auth import extract_bearer
|
| 6 |
from app.config import Settings
|
| 7 |
+
from app.deps import get_hub, get_org_roles, get_settings_dep
|
| 8 |
from app.errors import Unauthorized
|
| 9 |
from app.hub import HubClient
|
| 10 |
from app.models import MeResponse
|
| 11 |
from app.org_roles import OrgRoles
|
|
|
|
|
|
|
| 12 |
from app.validation import HUMAN_HANDLE_PREFIX
|
| 13 |
|
| 14 |
|
|
|
|
| 21 |
settings: Settings = Depends(get_settings_dep),
|
| 22 |
hub: HubClient = Depends(get_hub),
|
| 23 |
org_roles: OrgRoles = Depends(get_org_roles),
|
|
|
|
| 24 |
) -> MeResponse:
|
| 25 |
"""Who the bearer token belongs to, and whether they may broadcast.
|
| 26 |
|
|
|
|
| 30 |
broadcast. The organizer check reuses the cached org-role lookup and
|
| 31 |
degrades to is_organizer=false if that lookup is unavailable, so a
|
| 32 |
transient outage hides the toggle rather than 503-ing the whole page.
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
token = extract_bearer(authorization)
|
| 35 |
if not token:
|
|
|
|
| 52 |
)
|
| 53 |
except Exception:
|
| 54 |
is_organizer = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
return MeResponse(
|
| 56 |
hf_user=identity.username,
|
| 57 |
handle=f"{HUMAN_HANDLE_PREFIX}{identity.username.lower()}",
|
| 58 |
is_member=is_member,
|
| 59 |
is_organizer=is_organizer,
|
|
|
|
| 60 |
)
|
app/routes/messages.py
CHANGED
|
@@ -1,14 +1,13 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import re
|
| 4 |
from datetime import datetime
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, Depends, Header, Query, Request
|
| 7 |
|
| 8 |
from app.audit import AuditLogger
|
| 9 |
from app.auth import extract_bearer
|
| 10 |
from app.config import Settings
|
| 11 |
-
from app.dedup import PromotionLRU,
|
| 12 |
from app.deps import (
|
| 13 |
get_audit,
|
| 14 |
get_bucket_write_limiter,
|
|
@@ -18,7 +17,6 @@ from app.deps import (
|
|
| 18 |
get_org_roles,
|
| 19 |
get_raw_message_limiter,
|
| 20 |
get_read_model,
|
| 21 |
-
get_recent_posts,
|
| 22 |
get_settings_dep,
|
| 23 |
)
|
| 24 |
from app.errors import (
|
|
@@ -39,7 +37,6 @@ from app.hub import HubClient, HubIdentity
|
|
| 39 |
from app.org_roles import OrgRoles
|
| 40 |
from app.listing import list_message_like
|
| 41 |
from app.models import (
|
| 42 |
-
RAW_DUPLICATE_WINDOW_S,
|
| 43 |
MessageListing,
|
| 44 |
MessagePostRequest,
|
| 45 |
MessageRecord,
|
|
@@ -52,7 +49,6 @@ from app.read_model import ReadModel
|
|
| 52 |
from app.validation import (
|
| 53 |
HUMAN_HANDLE_PREFIX,
|
| 54 |
is_human_handle,
|
| 55 |
-
read_source_text,
|
| 56 |
resolve_source,
|
| 57 |
validate_agent_id,
|
| 58 |
validate_channel_name,
|
|
@@ -129,13 +125,6 @@ def require_organizer(
|
|
| 129 |
)
|
| 130 |
|
| 131 |
|
| 132 |
-
def _refs_list(refs: str | list[str]) -> list[str]:
|
| 133 |
-
"""`refs` as a list: a string may hold several comma/space-separated names."""
|
| 134 |
-
if isinstance(refs, str):
|
| 135 |
-
return [r for r in re.split(r"[,\s]+", refs) if r]
|
| 136 |
-
return refs
|
| 137 |
-
|
| 138 |
-
|
| 139 |
def _server_message_fm(agent_id: str, via: str, dt: datetime) -> dict:
|
| 140 |
return {
|
| 141 |
"agent": agent_id,
|
|
@@ -148,13 +137,11 @@ def _server_message_fm(agent_id: str, via: str, dt: datetime) -> dict:
|
|
| 148 |
def post_message(
|
| 149 |
req: MessagePostRequest,
|
| 150 |
request: Request,
|
| 151 |
-
response: Response,
|
| 152 |
authorization: str | None = Header(default=None),
|
| 153 |
settings: Settings = Depends(get_settings_dep),
|
| 154 |
hub: HubClient = Depends(get_hub),
|
| 155 |
audit: AuditLogger = Depends(get_audit),
|
| 156 |
dedup: PromotionLRU = Depends(get_dedup),
|
| 157 |
-
recent: RecentPosts = Depends(get_recent_posts),
|
| 158 |
bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
| 159 |
raw_limiter: CompoundLimiter = Depends(get_raw_message_limiter),
|
| 160 |
read_model: ReadModel = Depends(get_read_model),
|
|
@@ -181,17 +168,13 @@ def post_message(
|
|
| 181 |
)
|
| 182 |
parsed, agent_id = resolve_source(settings, req.source)
|
| 183 |
require_registered(read_model, hub, agent_id)
|
| 184 |
-
key = (agent_id, req.idempotency_key)
|
| 185 |
-
if req.idempotency_key and (replay := recent.get(key)):
|
| 186 |
-
response.status_code = 200
|
| 187 |
-
return replay
|
| 188 |
|
| 189 |
allowed, retry = bucket_limiter.try_consume(parsed.bucket)
|
| 190 |
if not allowed:
|
| 191 |
raise RateLimited(retry)
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
client_fm, source_body = parse(body_text)
|
| 196 |
if "broadcast" in client_fm:
|
| 197 |
raise NotOrganizer(
|
|
@@ -215,7 +198,7 @@ def post_message(
|
|
| 215 |
|
| 216 |
client_fm.setdefault("type", "agent")
|
| 217 |
if req.refs is not None:
|
| 218 |
-
client_fm["refs"] =
|
| 219 |
|
| 220 |
auto_subscribed = (
|
| 221 |
req.channel is not None
|
|
@@ -255,7 +238,7 @@ def post_message(
|
|
| 255 |
extra=audit_extra or None,
|
| 256 |
)
|
| 257 |
|
| 258 |
-
|
| 259 |
filename=filename,
|
| 260 |
via="bucket",
|
| 261 |
path=target,
|
|
@@ -263,9 +246,6 @@ def post_message(
|
|
| 263 |
channel=req.channel,
|
| 264 |
auto_subscribed=auto_subscribed,
|
| 265 |
)
|
| 266 |
-
if req.idempotency_key:
|
| 267 |
-
recent.record(key, result)
|
| 268 |
-
return result
|
| 269 |
|
| 270 |
# raw variant
|
| 271 |
assert req.agent_id is not None and req.body is not None
|
|
@@ -289,25 +269,13 @@ def post_message(
|
|
| 289 |
via = "raw"
|
| 290 |
default_type = "agent"
|
| 291 |
|
| 292 |
-
# A retry returns the first post's response: matched on the idempotency
|
| 293 |
-
# key when given, else on the same body to the same place within the window.
|
| 294 |
-
if req.idempotency_key:
|
| 295 |
-
key, max_age = (req.agent_id, req.idempotency_key), None
|
| 296 |
-
else:
|
| 297 |
-
dest = f"channels/{req.channel}" if req.channel else "message_board"
|
| 298 |
-
key = (req.agent_id, dest, content_hash(req.body.encode("utf-8")))
|
| 299 |
-
max_age = RAW_DUPLICATE_WINDOW_S
|
| 300 |
-
if replay := recent.get(key, max_age_s=max_age):
|
| 301 |
-
response.status_code = 200
|
| 302 |
-
return replay
|
| 303 |
-
|
| 304 |
allowed, retry = raw_limiter.try_consume(req.agent_id)
|
| 305 |
if not allowed:
|
| 306 |
raise RateLimited(retry)
|
| 307 |
|
| 308 |
client_fm: dict = {"type": req.type or default_type}
|
| 309 |
if req.refs is not None:
|
| 310 |
-
client_fm["refs"] =
|
| 311 |
auto_subscribed = (
|
| 312 |
req.channel is not None
|
| 313 |
and req.channel not in read_model.channel_subscriptions(req.agent_id)
|
|
@@ -348,7 +316,7 @@ def post_message(
|
|
| 348 |
extra=audit_extra or None,
|
| 349 |
)
|
| 350 |
|
| 351 |
-
|
| 352 |
filename=filename,
|
| 353 |
via=via,
|
| 354 |
path=target,
|
|
@@ -357,8 +325,6 @@ def post_message(
|
|
| 357 |
channel=req.channel,
|
| 358 |
auto_subscribed=auto_subscribed,
|
| 359 |
)
|
| 360 |
-
recent.record(key, result)
|
| 361 |
-
return result
|
| 362 |
|
| 363 |
|
| 364 |
@router.get("/v1/messages", response_model=MessageListing)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
+
from fastapi import APIRouter, Depends, Header, Query, Request
|
| 6 |
|
| 7 |
from app.audit import AuditLogger
|
| 8 |
from app.auth import extract_bearer
|
| 9 |
from app.config import Settings
|
| 10 |
+
from app.dedup import PromotionLRU, content_hash
|
| 11 |
from app.deps import (
|
| 12 |
get_audit,
|
| 13 |
get_bucket_write_limiter,
|
|
|
|
| 17 |
get_org_roles,
|
| 18 |
get_raw_message_limiter,
|
| 19 |
get_read_model,
|
|
|
|
| 20 |
get_settings_dep,
|
| 21 |
)
|
| 22 |
from app.errors import (
|
|
|
|
| 37 |
from app.org_roles import OrgRoles
|
| 38 |
from app.listing import list_message_like
|
| 39 |
from app.models import (
|
|
|
|
| 40 |
MessageListing,
|
| 41 |
MessagePostRequest,
|
| 42 |
MessageRecord,
|
|
|
|
| 49 |
from app.validation import (
|
| 50 |
HUMAN_HANDLE_PREFIX,
|
| 51 |
is_human_handle,
|
|
|
|
| 52 |
resolve_source,
|
| 53 |
validate_agent_id,
|
| 54 |
validate_channel_name,
|
|
|
|
| 125 |
)
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
def _server_message_fm(agent_id: str, via: str, dt: datetime) -> dict:
|
| 129 |
return {
|
| 130 |
"agent": agent_id,
|
|
|
|
| 137 |
def post_message(
|
| 138 |
req: MessagePostRequest,
|
| 139 |
request: Request,
|
|
|
|
| 140 |
authorization: str | None = Header(default=None),
|
| 141 |
settings: Settings = Depends(get_settings_dep),
|
| 142 |
hub: HubClient = Depends(get_hub),
|
| 143 |
audit: AuditLogger = Depends(get_audit),
|
| 144 |
dedup: PromotionLRU = Depends(get_dedup),
|
|
|
|
| 145 |
bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
| 146 |
raw_limiter: CompoundLimiter = Depends(get_raw_message_limiter),
|
| 147 |
read_model: ReadModel = Depends(get_read_model),
|
|
|
|
| 168 |
)
|
| 169 |
parsed, agent_id = resolve_source(settings, req.source)
|
| 170 |
require_registered(read_model, hub, agent_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
allowed, retry = bucket_limiter.try_consume(parsed.bucket)
|
| 173 |
if not allowed:
|
| 174 |
raise RateLimited(retry)
|
| 175 |
|
| 176 |
+
body_bytes = hub.read_bytes(parsed)
|
| 177 |
+
body_text = body_bytes.decode("utf-8")
|
| 178 |
client_fm, source_body = parse(body_text)
|
| 179 |
if "broadcast" in client_fm:
|
| 180 |
raise NotOrganizer(
|
|
|
|
| 198 |
|
| 199 |
client_fm.setdefault("type", "agent")
|
| 200 |
if req.refs is not None:
|
| 201 |
+
client_fm["refs"] = req.refs
|
| 202 |
|
| 203 |
auto_subscribed = (
|
| 204 |
req.channel is not None
|
|
|
|
| 238 |
extra=audit_extra or None,
|
| 239 |
)
|
| 240 |
|
| 241 |
+
return MessageResponse(
|
| 242 |
filename=filename,
|
| 243 |
via="bucket",
|
| 244 |
path=target,
|
|
|
|
| 246 |
channel=req.channel,
|
| 247 |
auto_subscribed=auto_subscribed,
|
| 248 |
)
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
# raw variant
|
| 251 |
assert req.agent_id is not None and req.body is not None
|
|
|
|
| 269 |
via = "raw"
|
| 270 |
default_type = "agent"
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
allowed, retry = raw_limiter.try_consume(req.agent_id)
|
| 273 |
if not allowed:
|
| 274 |
raise RateLimited(retry)
|
| 275 |
|
| 276 |
client_fm: dict = {"type": req.type or default_type}
|
| 277 |
if req.refs is not None:
|
| 278 |
+
client_fm["refs"] = req.refs
|
| 279 |
auto_subscribed = (
|
| 280 |
req.channel is not None
|
| 281 |
and req.channel not in read_model.channel_subscriptions(req.agent_id)
|
|
|
|
| 316 |
extra=audit_extra or None,
|
| 317 |
)
|
| 318 |
|
| 319 |
+
return MessageResponse(
|
| 320 |
filename=filename,
|
| 321 |
via=via,
|
| 322 |
path=target,
|
|
|
|
| 325 |
channel=req.channel,
|
| 326 |
auto_subscribed=auto_subscribed,
|
| 327 |
)
|
|
|
|
|
|
|
| 328 |
|
| 329 |
|
| 330 |
@router.get("/v1/messages", response_model=MessageListing)
|
app/routes/results.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from datetime import timedelta
|
| 4 |
-
|
| 5 |
from fastapi import APIRouter, Depends, Request
|
| 6 |
|
| 7 |
from app.audit import AuditLogger
|
|
@@ -14,6 +12,7 @@ from app.deps import (
|
|
| 14 |
get_hub,
|
| 15 |
get_read_model,
|
| 16 |
get_settings_dep,
|
|
|
|
| 17 |
get_verifier,
|
| 18 |
)
|
| 19 |
from app.errors import AlreadyPromoted, NotFound, RateLimited
|
|
@@ -31,21 +30,13 @@ from app.naming import result_path, stamp_yaml, utc_now
|
|
| 31 |
from app.rate_limit import CompoundLimiter
|
| 32 |
from app.read_model import ReadModel
|
| 33 |
from app.routes.messages import require_registered
|
| 34 |
-
from app.
|
| 35 |
-
from app.
|
| 36 |
-
from app.verification import PENDING
|
| 37 |
from app.verifier import Verifier
|
| 38 |
|
| 39 |
|
| 40 |
router = APIRouter()
|
| 41 |
|
| 42 |
-
# `$API` stays literal: the README has every agent export it, and
|
| 43 |
-
# share_trace.py reads the same variable to find this backend.
|
| 44 |
-
SHARE_TRACE_HINT = (
|
| 45 |
-
"Share this session's stats so others can learn from how you got here: "
|
| 46 |
-
"curl -fsS $API/v1/share_trace.py -o share_trace.py && python3 share_trace.py"
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
|
| 50 |
@router.post("/v1/results", response_model=ResultResponse, status_code=201)
|
| 51 |
def post_result(
|
|
@@ -56,6 +47,7 @@ def post_result(
|
|
| 56 |
audit: AuditLogger = Depends(get_audit),
|
| 57 |
dedup: PromotionLRU = Depends(get_dedup),
|
| 58 |
bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
|
|
|
| 59 |
read_model: ReadModel = Depends(get_read_model),
|
| 60 |
verifier: Verifier = Depends(get_verifier),
|
| 61 |
) -> ResultResponse:
|
|
@@ -66,8 +58,8 @@ def post_result(
|
|
| 66 |
if not allowed:
|
| 67 |
raise RateLimited(retry)
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
client_fm, source_body = parse(body_text)
|
| 72 |
validate_result_frontmatter(settings, client_fm)
|
| 73 |
|
|
@@ -90,6 +82,9 @@ def post_result(
|
|
| 90 |
read_model.write_through(target, merged, source_body, len(content.encode("utf-8")))
|
| 91 |
filename = target.rsplit("/", 1)[-1]
|
| 92 |
dedup.record(content_hash(body_bytes), dest_folder, filename)
|
|
|
|
|
|
|
|
|
|
| 93 |
# If this claims to beat the verified champion, re-run it on the private
|
| 94 |
# set. Best-effort and async — the POST never fails or waits on it.
|
| 95 |
verifier.maybe_trigger(filename, merged)
|
|
@@ -106,16 +101,7 @@ def post_result(
|
|
| 106 |
user_agent=request.headers.get("user-agent"),
|
| 107 |
)
|
| 108 |
|
| 109 |
-
|
| 110 |
-
# the 24 h cutoff compares lexically.
|
| 111 |
-
last = agent_traces(read_model, {agent_id}).last_shared_at
|
| 112 |
-
shared_recently = last is not None and last >= stamp_yaml(now - timedelta(hours=24))
|
| 113 |
-
return ResultResponse(
|
| 114 |
-
filename=filename,
|
| 115 |
-
via="bucket",
|
| 116 |
-
path=target,
|
| 117 |
-
hint=None if shared_recently else SHARE_TRACE_HINT,
|
| 118 |
-
)
|
| 119 |
|
| 120 |
|
| 121 |
@router.get("/v1/results", response_model=ResultListing)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from fastapi import APIRouter, Depends, Request
|
| 4 |
|
| 5 |
from app.audit import AuditLogger
|
|
|
|
| 12 |
get_hub,
|
| 13 |
get_read_model,
|
| 14 |
get_settings_dep,
|
| 15 |
+
get_verification_status,
|
| 16 |
get_verifier,
|
| 17 |
)
|
| 18 |
from app.errors import AlreadyPromoted, NotFound, RateLimited
|
|
|
|
| 30 |
from app.rate_limit import CompoundLimiter
|
| 31 |
from app.read_model import ReadModel
|
| 32 |
from app.routes.messages import require_registered
|
| 33 |
+
from app.validation import resolve_source, validate_agent_id
|
| 34 |
+
from app.verification import PENDING, VerificationStatusStore
|
|
|
|
| 35 |
from app.verifier import Verifier
|
| 36 |
|
| 37 |
|
| 38 |
router = APIRouter()
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
@router.post("/v1/results", response_model=ResultResponse, status_code=201)
|
| 42 |
def post_result(
|
|
|
|
| 47 |
audit: AuditLogger = Depends(get_audit),
|
| 48 |
dedup: PromotionLRU = Depends(get_dedup),
|
| 49 |
bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
| 50 |
+
verification: VerificationStatusStore = Depends(get_verification_status),
|
| 51 |
read_model: ReadModel = Depends(get_read_model),
|
| 52 |
verifier: Verifier = Depends(get_verifier),
|
| 53 |
) -> ResultResponse:
|
|
|
|
| 58 |
if not allowed:
|
| 59 |
raise RateLimited(retry)
|
| 60 |
|
| 61 |
+
body_bytes = hub.read_bytes(parsed)
|
| 62 |
+
body_text = body_bytes.decode("utf-8")
|
| 63 |
client_fm, source_body = parse(body_text)
|
| 64 |
validate_result_frontmatter(settings, client_fm)
|
| 65 |
|
|
|
|
| 82 |
read_model.write_through(target, merged, source_body, len(content.encode("utf-8")))
|
| 83 |
filename = target.rsplit("/", 1)[-1]
|
| 84 |
dedup.record(content_hash(body_bytes), dest_folder, filename)
|
| 85 |
+
# Track the freshly promoted result as `pending` in the verification index.
|
| 86 |
+
# Best-effort: the result is already written, so a failure here must not 500.
|
| 87 |
+
verification.mark_pending(filename)
|
| 88 |
# If this claims to beat the verified champion, re-run it on the private
|
| 89 |
# set. Best-effort and async — the POST never fails or waits on it.
|
| 90 |
verifier.maybe_trigger(filename, merged)
|
|
|
|
| 101 |
user_agent=request.headers.get("user-agent"),
|
| 102 |
)
|
| 103 |
|
| 104 |
+
return ResultResponse(filename=filename, via="bucket", path=target)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
@router.get("/v1/results", response_model=ResultListing)
|
app/routes/sync.py
CHANGED
|
@@ -8,10 +8,9 @@ from app.deps import (
|
|
| 8 |
get_audit,
|
| 9 |
get_bucket_write_limiter,
|
| 10 |
get_hub,
|
| 11 |
-
get_read_model,
|
| 12 |
get_settings_dep,
|
| 13 |
)
|
| 14 |
-
from app.errors import RateLimited, SyncTooLarge
|
| 15 |
from app.hub import HubClient
|
| 16 |
from app.models import (
|
| 17 |
ArtifactSyncRequest,
|
|
@@ -19,10 +18,8 @@ from app.models import (
|
|
| 19 |
SyncFile,
|
| 20 |
SyncResponse,
|
| 21 |
)
|
| 22 |
-
from app.naming import artifact_dest_dir
|
| 23 |
from app.rate_limit import CompoundLimiter
|
| 24 |
-
from app.read_model import ReadModel
|
| 25 |
-
from app.routes.messages import require_registered
|
| 26 |
from app.validation import (
|
| 27 |
check_dest_not_blocked,
|
| 28 |
resolve_source,
|
|
@@ -34,6 +31,13 @@ from app.validation import (
|
|
| 34 |
router = APIRouter()
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def _check_sync_caps(settings: Settings, files: list) -> int:
|
| 38 |
total_bytes = sum(f.size for f in files)
|
| 39 |
if len(files) > settings.sync_max_files:
|
|
@@ -65,11 +69,10 @@ def artifacts_sync(
|
|
| 65 |
hub: HubClient = Depends(get_hub),
|
| 66 |
audit: AuditLogger = Depends(get_audit),
|
| 67 |
limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
| 68 |
-
read_model: ReadModel = Depends(get_read_model),
|
| 69 |
) -> SyncResponse:
|
| 70 |
validate_slug(req.dest_slug)
|
| 71 |
parsed, agent_id = resolve_source(settings, req.source)
|
| 72 |
-
|
| 73 |
|
| 74 |
allowed, retry = limiter.try_consume(parsed.bucket)
|
| 75 |
if not allowed:
|
|
@@ -111,11 +114,10 @@ def shared_resources_sync(
|
|
| 111 |
hub: HubClient = Depends(get_hub),
|
| 112 |
audit: AuditLogger = Depends(get_audit),
|
| 113 |
limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
| 114 |
-
read_model: ReadModel = Depends(get_read_model),
|
| 115 |
) -> SyncResponse:
|
| 116 |
parsed, agent_id = resolve_source(settings, req.source)
|
| 117 |
validate_shared_dest_path(req.dest_path, agent_id)
|
| 118 |
-
|
| 119 |
|
| 120 |
allowed, retry = limiter.try_consume(parsed.bucket)
|
| 121 |
if not allowed:
|
|
|
|
| 8 |
get_audit,
|
| 9 |
get_bucket_write_limiter,
|
| 10 |
get_hub,
|
|
|
|
| 11 |
get_settings_dep,
|
| 12 |
)
|
| 13 |
+
from app.errors import NotRegistered, RateLimited, SyncTooLarge
|
| 14 |
from app.hub import HubClient
|
| 15 |
from app.models import (
|
| 16 |
ArtifactSyncRequest,
|
|
|
|
| 18 |
SyncFile,
|
| 19 |
SyncResponse,
|
| 20 |
)
|
| 21 |
+
from app.naming import artifact_dest_dir, registration_path
|
| 22 |
from app.rate_limit import CompoundLimiter
|
|
|
|
|
|
|
| 23 |
from app.validation import (
|
| 24 |
check_dest_not_blocked,
|
| 25 |
resolve_source,
|
|
|
|
| 31 |
router = APIRouter()
|
| 32 |
|
| 33 |
|
| 34 |
+
def _require_registered(hub: HubClient, agent_id: str) -> None:
|
| 35 |
+
try:
|
| 36 |
+
hub.read_central_text(registration_path(agent_id))
|
| 37 |
+
except Exception:
|
| 38 |
+
raise NotRegistered(agent_id)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
def _check_sync_caps(settings: Settings, files: list) -> int:
|
| 42 |
total_bytes = sum(f.size for f in files)
|
| 43 |
if len(files) > settings.sync_max_files:
|
|
|
|
| 69 |
hub: HubClient = Depends(get_hub),
|
| 70 |
audit: AuditLogger = Depends(get_audit),
|
| 71 |
limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
|
|
|
| 72 |
) -> SyncResponse:
|
| 73 |
validate_slug(req.dest_slug)
|
| 74 |
parsed, agent_id = resolve_source(settings, req.source)
|
| 75 |
+
_require_registered(hub, agent_id)
|
| 76 |
|
| 77 |
allowed, retry = limiter.try_consume(parsed.bucket)
|
| 78 |
if not allowed:
|
|
|
|
| 114 |
hub: HubClient = Depends(get_hub),
|
| 115 |
audit: AuditLogger = Depends(get_audit),
|
| 116 |
limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
|
|
|
| 117 |
) -> SyncResponse:
|
| 118 |
parsed, agent_id = resolve_source(settings, req.source)
|
| 119 |
validate_shared_dest_path(req.dest_path, agent_id)
|
| 120 |
+
_require_registered(hub, agent_id)
|
| 121 |
|
| 122 |
allowed, retry = limiter.try_consume(parsed.bucket)
|
| 123 |
if not allowed:
|
app/routes/taskforces.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Taskforces (§18): named central-bucket subdirectories for group efforts.
|
| 2 |
+
|
| 3 |
+
A taskforce exists iff ``taskforces/{name}/README.md`` exists — creating one
|
| 4 |
+
IS writing its README, so the "every taskforce has a README" rule is a
|
| 5 |
+
structural invariant, not a policy. All endpoints share one read-model folder
|
| 6 |
+
(the recursive ``taskforces/`` tree listing): browsing any number of
|
| 7 |
+
taskforces costs at most one bucket listing per TTL window, and every write
|
| 8 |
+
is write-through into that same folder.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import mimetypes
|
| 13 |
+
import re
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, Depends, Query, Request, Response
|
| 17 |
+
|
| 18 |
+
from app.audit import AuditLogger
|
| 19 |
+
from app.config import Settings
|
| 20 |
+
from app.dedup import PromotionLRU, content_hash
|
| 21 |
+
from app.deps import (
|
| 22 |
+
get_audit,
|
| 23 |
+
get_bucket_write_limiter,
|
| 24 |
+
get_dedup,
|
| 25 |
+
get_hub,
|
| 26 |
+
get_raw_message_limiter,
|
| 27 |
+
get_read_model,
|
| 28 |
+
get_settings_dep,
|
| 29 |
+
)
|
| 30 |
+
from app.errors import (
|
| 31 |
+
AlreadyPromoted,
|
| 32 |
+
NotFound,
|
| 33 |
+
RateLimited,
|
| 34 |
+
TaskforceExists,
|
| 35 |
+
TaskforceNotFound,
|
| 36 |
+
)
|
| 37 |
+
from app.frontmatter import merge, parse, serialise
|
| 38 |
+
from app.hub import HubClient, ListedFile
|
| 39 |
+
from app.listing import STAMP_LEN, list_message_like
|
| 40 |
+
from app.models import (
|
| 41 |
+
DigestTaskforces,
|
| 42 |
+
MessageListing,
|
| 43 |
+
MessageRecord,
|
| 44 |
+
TaskforceCreateRequest,
|
| 45 |
+
TaskforceCreateResponse,
|
| 46 |
+
TaskforceDetail,
|
| 47 |
+
TaskforceFileInfo,
|
| 48 |
+
TaskforceFileListing,
|
| 49 |
+
TaskforceFilePostRequest,
|
| 50 |
+
TaskforceFileResponse,
|
| 51 |
+
TaskforceListing,
|
| 52 |
+
TaskforceSummary,
|
| 53 |
+
)
|
| 54 |
+
from app.naming import (
|
| 55 |
+
agent_from_filename,
|
| 56 |
+
stamp_yaml,
|
| 57 |
+
taskforce_file_path,
|
| 58 |
+
taskforce_note_path,
|
| 59 |
+
taskforce_readme_path,
|
| 60 |
+
utc_now,
|
| 61 |
+
)
|
| 62 |
+
from app.rate_limit import CompoundLimiter
|
| 63 |
+
from app.read_model import ReadModel, Record
|
| 64 |
+
from app.routes.messages import require_registered
|
| 65 |
+
from app.validation import (
|
| 66 |
+
resolve_source,
|
| 67 |
+
validate_agent_id,
|
| 68 |
+
validate_path_components,
|
| 69 |
+
validate_taskforce_dest_path,
|
| 70 |
+
validate_taskforce_name,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
router = APIRouter()
|
| 75 |
+
|
| 76 |
+
# The single read-model folder shared by every taskforce endpoint (§18.4).
|
| 77 |
+
FOLDER = "taskforces"
|
| 78 |
+
|
| 79 |
+
_STAMPED_RE = re.compile(r"^\d{8}-\d{6}-\d{3}_")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _is_readme(path: str) -> bool:
|
| 83 |
+
return path.rsplit("/", 1)[-1].lower() == "readme.md"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _grouped(read_model: ReadModel) -> dict[str, list[ListedFile]]:
|
| 87 |
+
"""All listed taskforce files grouped by taskforce name; groups without a
|
| 88 |
+
README (e.g. mid-prune leftovers) are not taskforces and are dropped."""
|
| 89 |
+
groups: dict[str, list[ListedFile]] = {}
|
| 90 |
+
for e in read_model.listing(FOLDER):
|
| 91 |
+
rel = e.rel_path.removeprefix("taskforces/")
|
| 92 |
+
name, _, rest = rel.partition("/")
|
| 93 |
+
if not rest:
|
| 94 |
+
continue # stray file directly under taskforces/
|
| 95 |
+
groups.setdefault(name, []).append(e)
|
| 96 |
+
return {
|
| 97 |
+
n: fs
|
| 98 |
+
for n, fs in groups.items()
|
| 99 |
+
if any(f.rel_path == taskforce_readme_path(n) for f in fs)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _require_taskforce(read_model: ReadModel, name: str) -> list[ListedFile]:
|
| 104 |
+
prefix = f"taskforces/{name}/"
|
| 105 |
+
entries = [e for e in read_model.listing(FOLDER) if e.rel_path.startswith(prefix)]
|
| 106 |
+
if not any(e.rel_path == taskforce_readme_path(name) for e in entries):
|
| 107 |
+
raise TaskforceNotFound(name)
|
| 108 |
+
return entries
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _note_records(
|
| 112 |
+
read_model: ReadModel, entries: list[ListedFile]
|
| 113 |
+
) -> list[Record]:
|
| 114 |
+
paths = [
|
| 115 |
+
e.rel_path
|
| 116 |
+
for e in entries
|
| 117 |
+
if e.rel_path.endswith(".md") and not _is_readme(e.rel_path)
|
| 118 |
+
]
|
| 119 |
+
recs = read_model.records_for(FOLDER, paths)
|
| 120 |
+
return sorted(recs.values(), key=lambda r: r.filename)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _contributors(
|
| 124 |
+
entries: list[ListedFile], registered: set[str], creator: str | None
|
| 125 |
+
) -> list[str]:
|
| 126 |
+
"""Derived, not declared (§18.4): stamped note filenames parse to their
|
| 127 |
+
author; named files match a registered agent's ``_{agent_id}`` marker."""
|
| 128 |
+
found: set[str] = set()
|
| 129 |
+
for e in entries:
|
| 130 |
+
if _is_readme(e.rel_path):
|
| 131 |
+
continue
|
| 132 |
+
leaf = e.rel_path.rsplit("/", 1)[-1]
|
| 133 |
+
if leaf.endswith(".md") and _STAMPED_RE.match(leaf):
|
| 134 |
+
author = agent_from_filename(leaf)
|
| 135 |
+
if author:
|
| 136 |
+
found.add(author)
|
| 137 |
+
else:
|
| 138 |
+
for a in registered:
|
| 139 |
+
if re.search(rf"_{re.escape(a)}(?![a-z0-9-])", e.rel_path):
|
| 140 |
+
found.add(a)
|
| 141 |
+
if creator:
|
| 142 |
+
found.discard(creator)
|
| 143 |
+
out = [creator] if creator else []
|
| 144 |
+
out.extend(sorted(found))
|
| 145 |
+
return out
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _excerpt(body: str, limit: int = 160) -> str:
|
| 149 |
+
"""First prose line of the README (headings are usually just the name);
|
| 150 |
+
falls back to the first heading when there is nothing else."""
|
| 151 |
+
heading = ""
|
| 152 |
+
for line in body.splitlines():
|
| 153 |
+
s = line.strip()
|
| 154 |
+
if not s:
|
| 155 |
+
continue
|
| 156 |
+
if s.startswith("#"):
|
| 157 |
+
heading = heading or s.lstrip("#").strip()
|
| 158 |
+
continue
|
| 159 |
+
return s if len(s) <= limit else s[: limit - 1] + "��"
|
| 160 |
+
return heading if len(heading) <= limit else heading[: limit - 1] + "…"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _last_activity(entries: list[ListedFile]) -> str | None:
|
| 164 |
+
stamps = []
|
| 165 |
+
for e in entries:
|
| 166 |
+
leaf = e.rel_path.rsplit("/", 1)[-1]
|
| 167 |
+
if leaf.endswith(".md") and _STAMPED_RE.match(leaf):
|
| 168 |
+
stamps.append(leaf[:STAMP_LEN])
|
| 169 |
+
return max(stamps) if stamps else None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _created_compact(created: str | None) -> str:
|
| 173 |
+
"""The README's human-readable ``created`` stamp as a compact stamp, so it
|
| 174 |
+
can order against note-filename stamps; unparseable → '' (sorts last)."""
|
| 175 |
+
if not created:
|
| 176 |
+
return ""
|
| 177 |
+
try:
|
| 178 |
+
dt = datetime.strptime(created, "%Y-%m-%d %H:%M UTC")
|
| 179 |
+
except ValueError:
|
| 180 |
+
return ""
|
| 181 |
+
return dt.strftime("%Y%m%d-%H%M%S-000")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _fm_str(fm: dict, key: str) -> str | None:
|
| 185 |
+
value = fm.get(key)
|
| 186 |
+
return str(value) if value is not None else None
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def taskforce_digest(read_model: ReadModel) -> DigestTaskforces:
|
| 190 |
+
"""The digest's taskforce block (§16.5, §18.4): count + newest by creation."""
|
| 191 |
+
groups = _grouped(read_model)
|
| 192 |
+
readmes = read_model.records_for(
|
| 193 |
+
FOLDER, [taskforce_readme_path(n) for n in groups]
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
def _key(n: str) -> str:
|
| 197 |
+
rec = readmes.get(taskforce_readme_path(n))
|
| 198 |
+
return _created_compact(_fm_str(rec.frontmatter, "created")) if rec else ""
|
| 199 |
+
|
| 200 |
+
newest = sorted(groups, key=lambda n: (_key(n), n), reverse=True)[:5]
|
| 201 |
+
return DigestTaskforces(count=len(groups), newest=newest)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ───────────────────────── writes ─────────────────────────
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@router.post("/v1/taskforces", response_model=TaskforceCreateResponse, status_code=201)
|
| 208 |
+
def create_taskforce(
|
| 209 |
+
req: TaskforceCreateRequest,
|
| 210 |
+
request: Request,
|
| 211 |
+
response: Response,
|
| 212 |
+
settings: Settings = Depends(get_settings_dep),
|
| 213 |
+
hub: HubClient = Depends(get_hub),
|
| 214 |
+
audit: AuditLogger = Depends(get_audit),
|
| 215 |
+
dedup: PromotionLRU = Depends(get_dedup),
|
| 216 |
+
bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
| 217 |
+
raw_limiter: CompoundLimiter = Depends(get_raw_message_limiter),
|
| 218 |
+
read_model: ReadModel = Depends(get_read_model),
|
| 219 |
+
) -> TaskforceCreateResponse:
|
| 220 |
+
now = utc_now()
|
| 221 |
+
validate_taskforce_name(req.name)
|
| 222 |
+
target = taskforce_readme_path(req.name)
|
| 223 |
+
|
| 224 |
+
raw_bytes: bytes | None = None
|
| 225 |
+
if req.source is not None:
|
| 226 |
+
parsed, agent_id = resolve_source(settings, req.source)
|
| 227 |
+
require_registered(read_model, hub, agent_id)
|
| 228 |
+
allowed, retry = bucket_limiter.try_consume(parsed.bucket)
|
| 229 |
+
if not allowed:
|
| 230 |
+
raise RateLimited(retry)
|
| 231 |
+
raw_bytes = hub.read_bytes(parsed)
|
| 232 |
+
client_fm, body = parse(raw_bytes.decode("utf-8"))
|
| 233 |
+
via = "bucket"
|
| 234 |
+
else:
|
| 235 |
+
assert req.agent_id is not None and req.body is not None
|
| 236 |
+
agent_id = req.agent_id
|
| 237 |
+
validate_agent_id(agent_id)
|
| 238 |
+
require_registered(read_model, hub, agent_id)
|
| 239 |
+
allowed, retry = raw_limiter.try_consume(agent_id)
|
| 240 |
+
if not allowed:
|
| 241 |
+
raise RateLimited(retry)
|
| 242 |
+
client_fm, body = {}, req.body
|
| 243 |
+
via = "raw"
|
| 244 |
+
|
| 245 |
+
existing = read_model.record(FOLDER, f"{req.name}/README.md")
|
| 246 |
+
if existing is not None:
|
| 247 |
+
creator = _fm_str(existing.frontmatter, "creator")
|
| 248 |
+
if creator != agent_id:
|
| 249 |
+
raise TaskforceExists(req.name, creator)
|
| 250 |
+
created = False
|
| 251 |
+
server_fm = {
|
| 252 |
+
"taskforce": req.name,
|
| 253 |
+
"creator": creator,
|
| 254 |
+
"created": existing.frontmatter.get("created"),
|
| 255 |
+
"updated": stamp_yaml(now),
|
| 256 |
+
"via": via,
|
| 257 |
+
}
|
| 258 |
+
else:
|
| 259 |
+
created = True
|
| 260 |
+
server_fm = {
|
| 261 |
+
"taskforce": req.name,
|
| 262 |
+
"creator": agent_id,
|
| 263 |
+
"created": stamp_yaml(now),
|
| 264 |
+
"via": via,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
if raw_bytes is not None:
|
| 268 |
+
dup = dedup.get(content_hash(raw_bytes), f"taskforces/{req.name}")
|
| 269 |
+
if dup:
|
| 270 |
+
raise AlreadyPromoted(dup)
|
| 271 |
+
|
| 272 |
+
merged = merge(client_fm, server_fm)
|
| 273 |
+
content = serialise(merged, body)
|
| 274 |
+
nbytes = len(content.encode("utf-8"))
|
| 275 |
+
hub.write_text_central(target, content)
|
| 276 |
+
read_model.write_through(target, merged, body, nbytes, folder=FOLDER)
|
| 277 |
+
if raw_bytes is not None:
|
| 278 |
+
dedup.record(content_hash(raw_bytes), f"taskforces/{req.name}", "README.md")
|
| 279 |
+
|
| 280 |
+
audit.write(
|
| 281 |
+
agent_id=agent_id,
|
| 282 |
+
route="/v1/taskforces",
|
| 283 |
+
via=via,
|
| 284 |
+
source=req.source,
|
| 285 |
+
target_path=target,
|
| 286 |
+
bytes_count=nbytes,
|
| 287 |
+
status_code=201 if created else 200,
|
| 288 |
+
caller_ip=request.client.host if request.client else None,
|
| 289 |
+
user_agent=request.headers.get("user-agent"),
|
| 290 |
+
extra={"taskforce": req.name, "created": created},
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
if not created:
|
| 294 |
+
response.status_code = 200
|
| 295 |
+
return TaskforceCreateResponse(name=req.name, via=via, path=target, created=created)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@router.post(
|
| 299 |
+
"/v1/taskforces/{name}/files",
|
| 300 |
+
response_model=TaskforceFileResponse,
|
| 301 |
+
status_code=201,
|
| 302 |
+
)
|
| 303 |
+
def post_taskforce_file(
|
| 304 |
+
name: str,
|
| 305 |
+
req: TaskforceFilePostRequest,
|
| 306 |
+
request: Request,
|
| 307 |
+
settings: Settings = Depends(get_settings_dep),
|
| 308 |
+
hub: HubClient = Depends(get_hub),
|
| 309 |
+
audit: AuditLogger = Depends(get_audit),
|
| 310 |
+
dedup: PromotionLRU = Depends(get_dedup),
|
| 311 |
+
bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter),
|
| 312 |
+
raw_limiter: CompoundLimiter = Depends(get_raw_message_limiter),
|
| 313 |
+
read_model: ReadModel = Depends(get_read_model),
|
| 314 |
+
) -> TaskforceFileResponse:
|
| 315 |
+
now = utc_now()
|
| 316 |
+
validate_taskforce_name(name)
|
| 317 |
+
_require_taskforce(read_model, name)
|
| 318 |
+
dest_folder = f"taskforces/{name}"
|
| 319 |
+
route = "/v1/taskforces/{name}/files"
|
| 320 |
+
|
| 321 |
+
data: bytes | None = None
|
| 322 |
+
if req.source is not None:
|
| 323 |
+
parsed, agent_id = resolve_source(settings, req.source)
|
| 324 |
+
require_registered(read_model, hub, agent_id)
|
| 325 |
+
allowed, retry = bucket_limiter.try_consume(parsed.bucket)
|
| 326 |
+
if not allowed:
|
| 327 |
+
raise RateLimited(retry)
|
| 328 |
+
data = hub.read_bytes(parsed)
|
| 329 |
+
|
| 330 |
+
if req.dest_path is not None:
|
| 331 |
+
# Named file: byte-identical copy, attribution by marker (§18.3).
|
| 332 |
+
# No dedup — re-promoting your own path is the update mechanism.
|
| 333 |
+
validate_taskforce_dest_path(req.dest_path, agent_id)
|
| 334 |
+
target = taskforce_file_path(name, req.dest_path)
|
| 335 |
+
hub.write_bytes_central(target, data)
|
| 336 |
+
if target.endswith(".md"):
|
| 337 |
+
try:
|
| 338 |
+
fm, body = parse(data.decode("utf-8"))
|
| 339 |
+
except Exception:
|
| 340 |
+
fm, body = {}, data.decode("utf-8", errors="replace")
|
| 341 |
+
read_model.write_through(target, fm, body, len(data), folder=FOLDER)
|
| 342 |
+
else:
|
| 343 |
+
# Listing freshness only; binaries are never content-cached.
|
| 344 |
+
read_model.write_through(target, {}, "", len(data), folder=FOLDER)
|
| 345 |
+
audit.write(
|
| 346 |
+
agent_id=agent_id,
|
| 347 |
+
route=route,
|
| 348 |
+
via="bucket",
|
| 349 |
+
source=req.source,
|
| 350 |
+
target_path=target,
|
| 351 |
+
bytes_count=len(data),
|
| 352 |
+
status_code=201,
|
| 353 |
+
caller_ip=request.client.host if request.client else None,
|
| 354 |
+
user_agent=request.headers.get("user-agent"),
|
| 355 |
+
extra={"taskforce": name, "kind": "file"},
|
| 356 |
+
)
|
| 357 |
+
return TaskforceFileResponse(
|
| 358 |
+
kind="file", filename=req.dest_path, via="bucket", path=target
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Bucket note: message-shaped promotion into the taskforce.
|
| 362 |
+
client_fm, body = parse(data.decode("utf-8"))
|
| 363 |
+
dup = dedup.get(content_hash(data), dest_folder)
|
| 364 |
+
if dup:
|
| 365 |
+
raise AlreadyPromoted(dup)
|
| 366 |
+
client_fm.setdefault("type", "note")
|
| 367 |
+
via = "bucket"
|
| 368 |
+
else:
|
| 369 |
+
assert req.agent_id is not None and req.body is not None
|
| 370 |
+
agent_id = req.agent_id
|
| 371 |
+
validate_agent_id(agent_id)
|
| 372 |
+
require_registered(read_model, hub, agent_id)
|
| 373 |
+
allowed, retry = raw_limiter.try_consume(agent_id)
|
| 374 |
+
if not allowed:
|
| 375 |
+
raise RateLimited(retry)
|
| 376 |
+
client_fm, body = {"type": req.type or "note"}, req.body
|
| 377 |
+
via = "raw"
|
| 378 |
+
|
| 379 |
+
server_fm = {
|
| 380 |
+
"agent": agent_id,
|
| 381 |
+
"timestamp": stamp_yaml(now),
|
| 382 |
+
"via": via,
|
| 383 |
+
"taskforce": name,
|
| 384 |
+
}
|
| 385 |
+
merged = merge(client_fm, server_fm)
|
| 386 |
+
target = taskforce_note_path(name, agent_id, now)
|
| 387 |
+
filename = target.rsplit("/", 1)[-1]
|
| 388 |
+
content = serialise(merged, body)
|
| 389 |
+
nbytes = len(content.encode("utf-8"))
|
| 390 |
+
hub.write_text_central(target, content)
|
| 391 |
+
read_model.write_through(target, merged, body, nbytes, folder=FOLDER)
|
| 392 |
+
if data is not None:
|
| 393 |
+
dedup.record(content_hash(data), dest_folder, filename)
|
| 394 |
+
|
| 395 |
+
audit.write(
|
| 396 |
+
agent_id=agent_id,
|
| 397 |
+
route=route,
|
| 398 |
+
via=via,
|
| 399 |
+
source=req.source,
|
| 400 |
+
target_path=target,
|
| 401 |
+
bytes_count=nbytes,
|
| 402 |
+
status_code=201,
|
| 403 |
+
caller_ip=request.client.host if request.client else None,
|
| 404 |
+
user_agent=request.headers.get("user-agent"),
|
| 405 |
+
extra={"taskforce": name, "kind": "note"},
|
| 406 |
+
)
|
| 407 |
+
return TaskforceFileResponse(kind="note", filename=filename, via=via, path=target)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# ───────────────────────── discovery ─────────────────────────
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
@router.get("/v1/taskforces", response_model=TaskforceListing)
|
| 414 |
+
def list_taskforces(
|
| 415 |
+
q: str | None = None,
|
| 416 |
+
limit: int | None = None,
|
| 417 |
+
read_model: ReadModel = Depends(get_read_model),
|
| 418 |
+
) -> TaskforceListing:
|
| 419 |
+
groups = _grouped(read_model)
|
| 420 |
+
readmes = read_model.records_for(
|
| 421 |
+
FOLDER, [taskforce_readme_path(n) for n in groups]
|
| 422 |
+
)
|
| 423 |
+
registered = read_model.registered_agents()
|
| 424 |
+
|
| 425 |
+
keyed: list[tuple[str, TaskforceSummary]] = []
|
| 426 |
+
for nm, entries in groups.items():
|
| 427 |
+
readme = readmes.get(taskforce_readme_path(nm))
|
| 428 |
+
fm = readme.frontmatter if readme else {}
|
| 429 |
+
body = readme.body if readme else ""
|
| 430 |
+
if q is not None:
|
| 431 |
+
ql = q.lower()
|
| 432 |
+
if ql not in nm.lower() and ql not in body.lower():
|
| 433 |
+
continue
|
| 434 |
+
creator = _fm_str(fm, "creator")
|
| 435 |
+
created = _fm_str(fm, "created")
|
| 436 |
+
notes = [
|
| 437 |
+
e
|
| 438 |
+
for e in entries
|
| 439 |
+
if e.rel_path.endswith(".md") and not _is_readme(e.rel_path)
|
| 440 |
+
]
|
| 441 |
+
last = _last_activity(entries)
|
| 442 |
+
keyed.append(
|
| 443 |
+
(
|
| 444 |
+
max(last or "", _created_compact(created)),
|
| 445 |
+
TaskforceSummary(
|
| 446 |
+
name=nm,
|
| 447 |
+
creator=creator,
|
| 448 |
+
created=created,
|
| 449 |
+
readme_excerpt=_excerpt(body),
|
| 450 |
+
contributors=_contributors(entries, registered, creator),
|
| 451 |
+
file_count=len(entries),
|
| 452 |
+
note_count=len(notes),
|
| 453 |
+
last_activity=last,
|
| 454 |
+
),
|
| 455 |
+
)
|
| 456 |
+
)
|
| 457 |
+
# Most recently active first (discoverability is the point); name breaks ties.
|
| 458 |
+
keyed.sort(key=lambda t: t[1].name)
|
| 459 |
+
keyed.sort(key=lambda t: t[0], reverse=True)
|
| 460 |
+
items = [s for _, s in keyed]
|
| 461 |
+
matched = len(items)
|
| 462 |
+
if limit is not None and 0 < limit < len(items):
|
| 463 |
+
items = items[:limit]
|
| 464 |
+
return TaskforceListing(count=len(groups), matched=matched, items=items)
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
@router.get("/v1/taskforces/{name}", response_model=TaskforceDetail)
|
| 468 |
+
def get_taskforce(
|
| 469 |
+
name: str,
|
| 470 |
+
read_model: ReadModel = Depends(get_read_model),
|
| 471 |
+
) -> TaskforceDetail:
|
| 472 |
+
validate_taskforce_name(name)
|
| 473 |
+
entries = _require_taskforce(read_model, name)
|
| 474 |
+
readme_path = taskforce_readme_path(name)
|
| 475 |
+
readme = read_model.records_for(FOLDER, [readme_path]).get(readme_path)
|
| 476 |
+
if readme is None:
|
| 477 |
+
raise NotFound(readme_path) # transient content-fetch failure; retry
|
| 478 |
+
fm = readme.frontmatter
|
| 479 |
+
creator = _fm_str(fm, "creator")
|
| 480 |
+
notes = _note_records(read_model, entries)
|
| 481 |
+
recent = list(reversed(notes))[:5]
|
| 482 |
+
return TaskforceDetail(
|
| 483 |
+
name=name,
|
| 484 |
+
creator=creator,
|
| 485 |
+
created=_fm_str(fm, "created"),
|
| 486 |
+
updated=_fm_str(fm, "updated"),
|
| 487 |
+
readme=MessageRecord(filename="README.md", frontmatter=fm, body=readme.body),
|
| 488 |
+
contributors=_contributors(entries, read_model.registered_agents(), creator),
|
| 489 |
+
file_count=len(entries),
|
| 490 |
+
note_count=len(notes),
|
| 491 |
+
recent_notes=[
|
| 492 |
+
MessageRecord(filename=r.filename, frontmatter=r.frontmatter, body=r.body)
|
| 493 |
+
for r in recent
|
| 494 |
+
],
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
@router.get("/v1/taskforces/{name}/notes", response_model=MessageListing)
|
| 499 |
+
def list_taskforce_notes(
|
| 500 |
+
name: str,
|
| 501 |
+
agent: str | None = None,
|
| 502 |
+
since: str | None = None,
|
| 503 |
+
until: str | None = None,
|
| 504 |
+
type_: str | None = Query(None, alias="type"),
|
| 505 |
+
via: str | None = None,
|
| 506 |
+
q: str | None = None,
|
| 507 |
+
expand: bool = False,
|
| 508 |
+
limit: int | None = 10,
|
| 509 |
+
order: str = "desc",
|
| 510 |
+
after: str | None = None,
|
| 511 |
+
before: str | None = None,
|
| 512 |
+
settings: Settings = Depends(get_settings_dep),
|
| 513 |
+
read_model: ReadModel = Depends(get_read_model),
|
| 514 |
+
) -> MessageListing:
|
| 515 |
+
validate_taskforce_name(name)
|
| 516 |
+
entries = _require_taskforce(read_model, name)
|
| 517 |
+
return list_message_like(
|
| 518 |
+
_note_records(read_model, entries),
|
| 519 |
+
agent=agent,
|
| 520 |
+
since=since,
|
| 521 |
+
until=until,
|
| 522 |
+
type_=type_,
|
| 523 |
+
via=via,
|
| 524 |
+
q=q,
|
| 525 |
+
expand=expand,
|
| 526 |
+
limit=limit,
|
| 527 |
+
order=order,
|
| 528 |
+
after=after,
|
| 529 |
+
before=before,
|
| 530 |
+
expand_cap=settings.expand_max_limit,
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
@router.get("/v1/taskforces/{name}/files", response_model=TaskforceFileListing)
|
| 535 |
+
def list_taskforce_files(
|
| 536 |
+
name: str,
|
| 537 |
+
read_model: ReadModel = Depends(get_read_model),
|
| 538 |
+
) -> TaskforceFileListing:
|
| 539 |
+
validate_taskforce_name(name)
|
| 540 |
+
entries = _require_taskforce(read_model, name)
|
| 541 |
+
prefix = f"taskforces/{name}/"
|
| 542 |
+
items = [
|
| 543 |
+
TaskforceFileInfo(path=e.rel_path.removeprefix(prefix), size=e.size)
|
| 544 |
+
for e in sorted(entries, key=lambda e: e.rel_path)
|
| 545 |
+
]
|
| 546 |
+
return TaskforceFileListing(count=len(items), items=items)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@router.get("/v1/taskforces/{name}/files/{file_path:path}")
|
| 550 |
+
def get_taskforce_file(
|
| 551 |
+
name: str,
|
| 552 |
+
file_path: str,
|
| 553 |
+
hub: HubClient = Depends(get_hub),
|
| 554 |
+
read_model: ReadModel = Depends(get_read_model),
|
| 555 |
+
) -> Response:
|
| 556 |
+
validate_taskforce_name(name)
|
| 557 |
+
entries = _require_taskforce(read_model, name)
|
| 558 |
+
validate_path_components(file_path)
|
| 559 |
+
target = taskforce_file_path(name, file_path)
|
| 560 |
+
if not any(e.rel_path == target for e in entries):
|
| 561 |
+
raise NotFound(target)
|
| 562 |
+
data = hub.read_central_bytes_optional(target)
|
| 563 |
+
if data is None:
|
| 564 |
+
raise NotFound(target)
|
| 565 |
+
if target.endswith(".md"):
|
| 566 |
+
media = "text/markdown"
|
| 567 |
+
else:
|
| 568 |
+
media = mimetypes.guess_type(target)[0] or "application/octet-stream"
|
| 569 |
+
return Response(content=data, media_type=media)
|
app/routes/updates.py
CHANGED
|
@@ -99,12 +99,9 @@ async def get_updates(
|
|
| 99 |
)
|
| 100 |
|
| 101 |
if wait <= 0:
|
| 102 |
-
|
| 103 |
-
notifier.note_poll(as_, "updates", parked=False, after=after)
|
| 104 |
-
notifier.note_cursor(as_, page.cursor)
|
| 105 |
-
return page
|
| 106 |
await run_in_threadpool(guard)
|
| 107 |
-
notifier.note_poll(as_, "updates"
|
| 108 |
# Snapshot the keys at park time: the inbox key (always present, so this
|
| 109 |
# stream never degrades to no_streams) plus one per notify: all channel.
|
| 110 |
# Staleness is bounded by one wait window — a level flipped mid-park takes
|
|
@@ -125,7 +122,6 @@ async def get_updates(
|
|
| 125 |
check=check,
|
| 126 |
has_items=lambda listing: bool(listing.items),
|
| 127 |
)
|
| 128 |
-
notifier.note_cursor(as_, page.cursor)
|
| 129 |
return watched(page, status, waited_ms)
|
| 130 |
|
| 131 |
|
|
@@ -145,16 +141,16 @@ def get_watching(
|
|
| 145 |
read model, no bucket listing, nothing to cache — so it is cheap enough for
|
| 146 |
a 30s UI loop, unlike the one-digest-per-agent fan-out it replaces.
|
| 147 |
|
| 148 |
-
`max_wait_s` is the ceiling every `wait=` is clamped to and `fresh_s`
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
return WatchingResponse(
|
| 153 |
max_wait_s=settings.longpoll_max_wait_s,
|
| 154 |
-
fresh_s=settings.
|
| 155 |
watching={
|
| 156 |
-
owner: WatchingEntry(last_poll_age_s=int(
|
| 157 |
-
for owner,
|
| 158 |
},
|
| 159 |
longpoll=notifier.stats(),
|
| 160 |
)
|
|
|
|
| 99 |
)
|
| 100 |
|
| 101 |
if wait <= 0:
|
| 102 |
+
return await run_in_threadpool(check)
|
|
|
|
|
|
|
|
|
|
| 103 |
await run_in_threadpool(guard)
|
| 104 |
+
notifier.note_poll(as_, "updates")
|
| 105 |
# Snapshot the keys at park time: the inbox key (always present, so this
|
| 106 |
# stream never degrades to no_streams) plus one per notify: all channel.
|
| 107 |
# Staleness is bounded by one wait window — a level flipped mid-park takes
|
|
|
|
| 122 |
check=check,
|
| 123 |
has_items=lambda listing: bool(listing.items),
|
| 124 |
)
|
|
|
|
| 125 |
return watched(page, status, waited_ms)
|
| 126 |
|
| 127 |
|
|
|
|
| 141 |
read model, no bucket listing, nothing to cache — so it is cheap enough for
|
| 142 |
a 30s UI loop, unlike the one-digest-per-agent fan-out it replaces.
|
| 143 |
|
| 144 |
+
`max_wait_s` is the ceiling every `wait=` is clamped to and `fresh_s` (2×
|
| 145 |
+
that) the age past which presence should read as stale, published so no
|
| 146 |
+
consumer has to keep its own copy of the knob. The registry counters ride
|
| 147 |
+
along under `longpoll`, identical to `/v1/healthz`."""
|
| 148 |
return WatchingResponse(
|
| 149 |
max_wait_s=settings.longpoll_max_wait_s,
|
| 150 |
+
fresh_s=2 * settings.longpoll_max_wait_s,
|
| 151 |
watching={
|
| 152 |
+
owner: WatchingEntry(last_poll_age_s=int(age_s), mode=mode)
|
| 153 |
+
for owner, (age_s, mode) in notifier.all_last_poll().items()
|
| 154 |
},
|
| 155 |
longpoll=notifier.stats(),
|
| 156 |
)
|
app/trace_stats.py
CHANGED
|
@@ -15,19 +15,17 @@ from datetime import date, datetime
|
|
| 15 |
|
| 16 |
from app.errors import InvalidFrontmatter
|
| 17 |
from app.models import (
|
| 18 |
-
AgentTraces,
|
| 19 |
DigestStats,
|
| 20 |
StatsResponse,
|
| 21 |
TokenTotals,
|
| 22 |
TraceSummary,
|
| 23 |
)
|
| 24 |
-
from app.naming import
|
| 25 |
-
from app.read_model import
|
| 26 |
|
| 27 |
|
| 28 |
# Harnesses whose adapters are expected to deliver the full enforced set
|
| 29 |
# (tokens + tool_calls). Anything else degrades to `partial` (never blocked).
|
| 30 |
-
# Keep in sync with KNOWN_HARNESSES in clients/share_trace.py.
|
| 31 |
KNOWN_FULL_HARNESSES: frozenset[str] = frozenset({"claude-code", "codex"})
|
| 32 |
|
| 33 |
REQUIRED_MANIFEST_FIELDS: tuple[str, ...] = ("schema_version", "harness", "session_id")
|
|
@@ -259,25 +257,6 @@ def list_traces(
|
|
| 259 |
return total, matched, items, nxt
|
| 260 |
|
| 261 |
|
| 262 |
-
# ───────────────────────── per-agent summary ─────────────────────────
|
| 263 |
-
|
| 264 |
-
def agent_traces(read_model: ReadModel, agents: set[str]) -> AgentTraces:
|
| 265 |
-
"""Sessions shared by `agents` and the newest `promoted_at`. Filters the
|
| 266 |
-
cached listing by path first, so only these agents' manifests are read."""
|
| 267 |
-
paths = [
|
| 268 |
-
e.rel_path
|
| 269 |
-
for e in read_model.listing(TRACES_FOLDER)
|
| 270 |
-
if (ids := split_trace_manifest_path(e.rel_path)) and ids[0] in agents
|
| 271 |
-
]
|
| 272 |
-
stamps = [
|
| 273 |
-
str(r.frontmatter["promoted_at"])
|
| 274 |
-
for r in read_model.records_for(TRACES_FOLDER, paths).values()
|
| 275 |
-
if r.frontmatter.get("promoted_at")
|
| 276 |
-
]
|
| 277 |
-
# promoted_at is server-stamped `YYYY-MM-DD HH:MM UTC`: lexical max = newest.
|
| 278 |
-
return AgentTraces(sessions=len(paths), last_shared_at=max(stamps, default=None))
|
| 279 |
-
|
| 280 |
-
|
| 281 |
# ───────────────────────── aggregate ─────────────────────────
|
| 282 |
|
| 283 |
def _accumulate(t: TokenTotals, usage: dict) -> None:
|
|
|
|
| 15 |
|
| 16 |
from app.errors import InvalidFrontmatter
|
| 17 |
from app.models import (
|
|
|
|
| 18 |
DigestStats,
|
| 19 |
StatsResponse,
|
| 20 |
TokenTotals,
|
| 21 |
TraceSummary,
|
| 22 |
)
|
| 23 |
+
from app.naming import split_trace_manifest_path
|
| 24 |
+
from app.read_model import Record
|
| 25 |
|
| 26 |
|
| 27 |
# Harnesses whose adapters are expected to deliver the full enforced set
|
| 28 |
# (tokens + tool_calls). Anything else degrades to `partial` (never blocked).
|
|
|
|
| 29 |
KNOWN_FULL_HARNESSES: frozenset[str] = frozenset({"claude-code", "codex"})
|
| 30 |
|
| 31 |
REQUIRED_MANIFEST_FIELDS: tuple[str, ...] = ("schema_version", "harness", "session_id")
|
|
|
|
| 257 |
return total, matched, items, nxt
|
| 258 |
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
# ───────────────────────── aggregate ─────────────────────────
|
| 261 |
|
| 262 |
def _accumulate(t: TokenTotals, usage: dict) -> None:
|
app/validation.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from app.config import Settings
|
| 4 |
-
from app.errors import InvalidFrontmatter, InvalidPath
|
| 5 |
-
from app.hub import HubClient
|
| 6 |
from app.naming import (
|
| 7 |
AGENT_ID_RE,
|
| 8 |
RESERVED_CHANNEL_NAMES,
|
|
@@ -18,7 +17,7 @@ BLOCKED_TARGETS = {
|
|
| 18 |
"LEADERBOARD.md",
|
| 19 |
"shared_resources/README.md",
|
| 20 |
}
|
| 21 |
-
BLOCKED_PREFIXES = ("audit/", "inbox/", "channels/")
|
| 22 |
|
| 23 |
# The human-* namespace identifies human participants in inbox routing
|
| 24 |
# (§16.4): @human-<name> delivers without a registration check, so no agent
|
|
@@ -140,23 +139,6 @@ def resolve_source(settings: Settings, source: str) -> tuple[SourceURI, str]:
|
|
| 140 |
return parsed, agent_id
|
| 141 |
|
| 142 |
|
| 143 |
-
def read_source_text(hub: HubClient, settings: Settings, parsed: SourceURI) -> str:
|
| 144 |
-
"""Read a promoted source file as UTF-8 text, mapping every way it can be
|
| 145 |
-
unusable to an agent-facing error instead of a bare 500."""
|
| 146 |
-
try:
|
| 147 |
-
data = hub.read_bytes(parsed)
|
| 148 |
-
except FileNotFoundError:
|
| 149 |
-
raise SourceNotFound(str(parsed))
|
| 150 |
-
if len(data) > settings.message_max_bytes:
|
| 151 |
-
raise TooLarge(
|
| 152 |
-
f"source is {len(data)} bytes; the limit is {settings.message_max_bytes}"
|
| 153 |
-
)
|
| 154 |
-
try:
|
| 155 |
-
return data.decode("utf-8")
|
| 156 |
-
except UnicodeDecodeError:
|
| 157 |
-
raise InvalidFrontmatter("source must be UTF-8 text")
|
| 158 |
-
|
| 159 |
-
|
| 160 |
def _validate_agent_marker(dest_path: str, agent_id: str, what: str) -> None:
|
| 161 |
"""Attribution-by-construction: the `_{agent_id}` marker must appear in the
|
| 162 |
dest path, checked against the *resolved* source identity — so only the
|
|
@@ -194,3 +176,29 @@ def validate_channel_name(name: str) -> None:
|
|
| 194 |
f"channel name '{name}' is reserved (it is an API path segment)",
|
| 195 |
hint="pick a different name",
|
| 196 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from app.config import Settings
|
| 4 |
+
from app.errors import InvalidFrontmatter, InvalidPath
|
|
|
|
| 5 |
from app.naming import (
|
| 6 |
AGENT_ID_RE,
|
| 7 |
RESERVED_CHANNEL_NAMES,
|
|
|
|
| 17 |
"LEADERBOARD.md",
|
| 18 |
"shared_resources/README.md",
|
| 19 |
}
|
| 20 |
+
BLOCKED_PREFIXES = ("audit/", "inbox/", "taskforces/", "channels/")
|
| 21 |
|
| 22 |
# The human-* namespace identifies human participants in inbox routing
|
| 23 |
# (§16.4): @human-<name> delivers without a registration check, so no agent
|
|
|
|
| 139 |
return parsed, agent_id
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
def _validate_agent_marker(dest_path: str, agent_id: str, what: str) -> None:
|
| 143 |
"""Attribution-by-construction: the `_{agent_id}` marker must appear in the
|
| 144 |
dest path, checked against the *resolved* source identity — so only the
|
|
|
|
| 176 |
f"channel name '{name}' is reserved (it is an API path segment)",
|
| 177 |
hint="pick a different name",
|
| 178 |
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def validate_taskforce_name(name: str) -> None:
|
| 182 |
+
if name != name.lower():
|
| 183 |
+
raise InvalidPath(
|
| 184 |
+
f"taskforce name must be lowercase: {name!r}",
|
| 185 |
+
hint=f"use '{name.lower()}' instead",
|
| 186 |
+
)
|
| 187 |
+
if not SLUG_RE.match(name):
|
| 188 |
+
raise InvalidPath(
|
| 189 |
+
f"invalid taskforce name: {name!r}",
|
| 190 |
+
hint="kebab-case, 1-40 chars: [a-z0-9] with internal hyphens",
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def validate_taskforce_dest_path(dest_path: str, agent_id: str) -> None:
|
| 195 |
+
"""Named taskforce files (§18.3): shared-resources marker rule, plus the
|
| 196 |
+
README leaf is reserved for the create/update endpoint."""
|
| 197 |
+
validate_path_components(dest_path)
|
| 198 |
+
leaf = dest_path.rsplit("/", 1)[-1]
|
| 199 |
+
if leaf.lower() == "readme.md":
|
| 200 |
+
raise InvalidPath(
|
| 201 |
+
"README.md is reserved: the taskforce README is managed via POST /v1/taskforces",
|
| 202 |
+
hint="pick a different filename for your content",
|
| 203 |
+
)
|
| 204 |
+
_validate_agent_marker(dest_path, agent_id, "taskforce")
|
app/verification.py
CHANGED
|
@@ -1,19 +1,29 @@
|
|
| 1 |
"""Verification-status index for promoted results.
|
| 2 |
|
| 3 |
``results/verification_status.json`` is a flat map of result filename ->
|
| 4 |
-
verification state (``pending`` | ``valid`` | ``invalid``).
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
writes is recorded in the private audit bucket
|
| 18 |
(``verification_runs/<filename>/verdict.json``), and the index is only updated
|
| 19 |
when the current entry is ``pending``/absent **or** equals the verifier's own
|
|
@@ -85,6 +95,28 @@ class VerificationStatusStore:
|
|
| 85 |
return None
|
| 86 |
return data
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
# ───────────────────── automated verdicts (§5.7) ─────────────────────
|
| 89 |
|
| 90 |
def _ledger_path(self, filename: str) -> str:
|
|
@@ -127,7 +159,7 @@ class VerificationStatusStore:
|
|
| 127 |
Compare-and-set against the side-ledger: write the index iff the
|
| 128 |
current entry is ``pending``/absent or equals the verifier's own
|
| 129 |
last-written state. Returns WRITTEN, DEFERRED, or SKIPPED. Serialised
|
| 130 |
-
by
|
| 131 |
"""
|
| 132 |
if new_state not in (VALID, INVALID):
|
| 133 |
raise ValueError(f"verdict must be '{VALID}' or '{INVALID}', got {new_state!r}")
|
|
|
|
| 1 |
"""Verification-status index for promoted results.
|
| 2 |
|
| 3 |
``results/verification_status.json`` is a flat map of result filename ->
|
| 4 |
+
verification state (``pending`` | ``valid`` | ``invalid``). A human (or a
|
| 5 |
+
downstream verifier) flips entries to ``valid`` / ``invalid``; every newly
|
| 6 |
+
promoted result is inserted as ``pending`` so nothing slips through unreviewed.
|
| 7 |
+
|
| 8 |
+
Maintaining it is a read-modify-write of a single shared JSON object, so a
|
| 9 |
+
process-wide lock serialises the update — the Space runs a single uvicorn worker
|
| 10 |
+
and sync endpoints share a threadpool, the same assumption ``DurableJobQuota``
|
| 11 |
+
relies on. The update is **best-effort and non-fatal**: by the time we get here
|
| 12 |
+
the result file is already promoted, so a storage hiccup must not fail the
|
| 13 |
+
request — we log and move on, and the next promotion (or a manual reconcile)
|
| 14 |
+
heals the index.
|
| 15 |
+
|
| 16 |
+
Two safety properties:
|
| 17 |
+
|
| 18 |
+
- **Verdicts are never clobbered.** A filename already present (whatever its
|
| 19 |
+
state) is left untouched — we only ever *insert* a missing entry as
|
| 20 |
+
``pending``, never overwrite an existing ``valid`` / ``invalid``.
|
| 21 |
+
- **Fail SAFE on read.** If the index can't be read (transport error) or parsed
|
| 22 |
+
(corrupt/non-object JSON), we refuse to write — overwriting would erase every
|
| 23 |
+
human verdict. We skip and log loudly so it can be fixed by hand.
|
| 24 |
+
|
| 25 |
+
``set_verdict`` (the automated verifier's write path, §5.7) extends the first
|
| 26 |
+
property with a **side-ledger compare-and-set**: every verdict the verifier
|
| 27 |
writes is recorded in the private audit bucket
|
| 28 |
(``verification_runs/<filename>/verdict.json``), and the index is only updated
|
| 29 |
when the current entry is ``pending``/absent **or** equals the verifier's own
|
|
|
|
| 95 |
return None
|
| 96 |
return data
|
| 97 |
|
| 98 |
+
def mark_pending(self, filename: str) -> None:
|
| 99 |
+
"""Insert ``filename`` as ``pending`` if absent. Best-effort; never raises.
|
| 100 |
+
|
| 101 |
+
Serialised by a process-wide lock so two concurrent promotions cannot
|
| 102 |
+
read-then-write the same index and drop each other's entry. Existing
|
| 103 |
+
entries (including human ``valid`` / ``invalid`` verdicts) are preserved.
|
| 104 |
+
"""
|
| 105 |
+
with self._lock:
|
| 106 |
+
data = self._load()
|
| 107 |
+
if data is None:
|
| 108 |
+
return # read/parse failed — fail safe, leave the index untouched
|
| 109 |
+
if filename in data:
|
| 110 |
+
return # already tracked; don't rewrite or clobber a verdict
|
| 111 |
+
data[filename] = PENDING
|
| 112 |
+
body = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
| 113 |
+
try:
|
| 114 |
+
self._hub.write_text_central(self._path, body)
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
log.warning(
|
| 117 |
+
"verification-status write failed for %s: %s", self._path, exc
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
# ───────────────────── automated verdicts (§5.7) ─────────────────────
|
| 121 |
|
| 122 |
def _ledger_path(self, filename: str) -> str:
|
|
|
|
| 159 |
Compare-and-set against the side-ledger: write the index iff the
|
| 160 |
current entry is ``pending``/absent or equals the verifier's own
|
| 161 |
last-written state. Returns WRITTEN, DEFERRED, or SKIPPED. Serialised
|
| 162 |
+
by the same process-wide lock as ``mark_pending``.
|
| 163 |
"""
|
| 164 |
if new_state not in (VALID, INVALID):
|
| 165 |
raise ValueError(f"verdict must be '{VALID}' or '{INVALID}', got {new_state!r}")
|
clients/collab_watch.sh
CHANGED
|
@@ -22,13 +22,8 @@
|
|
| 22 |
# modes (default: wait):
|
| 23 |
# (none) block until mail, print the page, exit 0
|
| 24 |
# --max-wait N ...but give up cleanly after N seconds with exit 3. N is a
|
| 25 |
-
#
|
| 26 |
-
#
|
| 27 |
-
# 2s idle floor) — size N to fit your shell tool's timeout.
|
| 28 |
-
# A server still unreachable when N is up exits 4, not 3.
|
| 29 |
-
# --after C start from cursor C (a filename, e.g. the digest's
|
| 30 |
-
# watching.last_cursor) instead of the saved one; C is written
|
| 31 |
-
# to the cursor file first. Wait and --exec modes only.
|
| 32 |
# --exec CMD foreground loop: per delivery run CMD (via `sh -c`) with the
|
| 33 |
# page on its stdin; the cursor advances ONLY when CMD exits 0.
|
| 34 |
# CMD also sees COLLAB_WATCH_HANDLE / COLLAB_WATCH_STREAM /
|
|
@@ -45,9 +40,7 @@
|
|
| 45 |
# one you asked about; a watcher on another stream is
|
| 46 |
# NO_WATCHER for this one (exit 11, naming the live stream on
|
| 47 |
# stderr). LAST is the last loop status the watcher recorded.
|
| 48 |
-
# BEHIND outranks every liveness verdict — act on it first
|
| 49 |
-
# stderr then names the command that reads the page now.
|
| 50 |
-
|
| 51 |
# --help this text
|
| 52 |
#
|
| 53 |
# exit codes:
|
|
@@ -59,9 +52,8 @@
|
|
| 59 |
# config, or a server that does not implement the watch API
|
| 60 |
# 2 usage error
|
| 61 |
# 3 --max-wait elapsed with no mail — a CLEAN timeout, not a death
|
| 62 |
-
# 4 gave up after
|
| 63 |
-
#
|
| 64 |
-
# not reach the server); the heartbeat records status=gave_up
|
| 65 |
# 5 another watcher already holds the lock for this handle. The lock is
|
| 66 |
# per-HANDLE, not per-stream: one watcher covers an agent, which is what
|
| 67 |
# the unified `updates` stream is for
|
|
@@ -84,8 +76,6 @@
|
|
| 84 |
# COLLAB_WATCH_BACKOFF initial retry backoff seconds (2; doubles to 60). 0
|
| 85 |
# makes retries instant — a test hook, not a production
|
| 86 |
# setting. It does NOT shorten the idle pacing floor.
|
| 87 |
-
# COLLAB_WATCH_RETRY_BUDGET_S seconds of consecutive request failures before
|
| 88 |
-
# giving up with exit 4 (1200: outlasts a Space rebuild)
|
| 89 |
#
|
| 90 |
# state directory (one per host+handle, so running from another working
|
| 91 |
# directory can never silently re-baseline and skip mail):
|
|
@@ -94,10 +84,8 @@
|
|
| 94 |
# loop pass — including empty timeouts and the give-up —
|
| 95 |
# so a stale heartbeat means exactly "no watcher process
|
| 96 |
# has run recently", nothing else
|
| 97 |
-
# lock/ mkdir-based lock
|
| 98 |
-
#
|
| 99 |
-
# to a process started at another time (PID reuse,
|
| 100 |
-
# another PID namespace), is stale and is reclaimed
|
| 101 |
# delivered.jsonl every delivered page, appended BEFORE it is printed
|
| 102 |
# dead-letter.jsonl pages a --exec handler kept refusing (see --exec below)
|
| 103 |
# The cursor is per-STREAM; the lock and heartbeat are per-HANDLE, because one
|
|
@@ -120,9 +108,6 @@
|
|
| 120 |
# silently skipped page is not.
|
| 121 |
#
|
| 122 |
# harness integration — exit-on-mail composes with anything:
|
| 123 |
-
# * The documented recipe (the collab README) is one synchronous
|
| 124 |
-
# `--max-wait N` run at every pause; the modes below are for harnesses
|
| 125 |
-
# that can hold a process.
|
| 126 |
# * Background-task harness (Claude Code, Codex, ...): launch ONE run with
|
| 127 |
# your harness's own background-task mechanism, react to the JSON when the
|
| 128 |
# task completes, then launch it again.
|
|
@@ -149,15 +134,12 @@
|
|
| 149 |
# filtered. The number of items in the page IS the unread count. A wrapper
|
| 150 |
# that reads `matched` will happily report "up to date" with mail pending.
|
| 151 |
#
|
| 152 |
-
# NOTE (backoff tradeoff): HTTP 5xx
|
| 153 |
-
#
|
| 154 |
-
#
|
| 155 |
-
#
|
| 156 |
-
#
|
| 157 |
-
#
|
| 158 |
-
# --max-wait the ceiling still wins. Folding the normal drops in keeps this
|
| 159 |
-
# simple; the cost is that a Space restart reconnects after ~2s instead of
|
| 160 |
-
# instantly. 3xx and 4xx (a 404 too, even mid-rebuild) do NOT back off — they fail immediately (a 4xx with the server's error body),
|
| 161 |
# because neither a typo'd handle nor a redirecting base URL is a transient
|
| 162 |
# condition. The routine idle path is not a failure at all: when the wait
|
| 163 |
# elapses the server answers 200 with an empty page, so an idle watcher never
|
|
@@ -173,6 +155,7 @@ export LC_ALL
|
|
| 173 |
SELF=collab_watch
|
| 174 |
LIMIT=10 # records per delivered page
|
| 175 |
STATUS_LIMIT=100 # --status unread count saturates here
|
|
|
|
| 176 |
IDLE_FLOOR_S=2 # minimum seconds between two empty answers
|
| 177 |
|
| 178 |
# Server-issued filename shape: <YYYYMMDD>-<HHMMSS>-<mmm>_<agent-id>.md, where
|
|
@@ -182,16 +165,12 @@ FILENAME_RE='[0-9]{8}-[0-9]{6}-[0-9]{3}_[a-z0-9][a-z0-9-]*\.md'
|
|
| 182 |
WAIT="${COLLAB_WATCH_WAIT:-55}"
|
| 183 |
EXEC_RETRIES="${COLLAB_WATCH_EXEC_RETRIES:-3}"
|
| 184 |
BACKOFF_BASE="${COLLAB_WATCH_BACKOFF:-2}"
|
| 185 |
-
RETRY_BUDGET="${COLLAB_WATCH_RETRY_BUDGET_S:-1200}"
|
| 186 |
|
| 187 |
BODY=""
|
| 188 |
-
CODE=""
|
| 189 |
-
CHILD=""
|
| 190 |
LOCK_HELD=""
|
| 191 |
HTTP=000
|
| 192 |
CURL_RC=0
|
| 193 |
STREAK=0
|
| 194 |
-
FAIL_SINCE=0
|
| 195 |
BACKOFF=2
|
| 196 |
CURSOR=""
|
| 197 |
PAGE_CURSOR=""
|
|
@@ -203,7 +182,7 @@ log() { printf '%s: %s\n' "$SELF" "$*" >&2; }
|
|
| 203 |
usage_text() {
|
| 204 |
cat <<'EOF'
|
| 205 |
usage: sh collab_watch.sh <base-url> <handle> [updates|inbox|feed] [flags]
|
| 206 |
-
flags: --max-wait N | --
|
| 207 |
exit: 0 delivered/ok | 1 fatal | 2 usage | 3 clean no-mail timeout |
|
| 208 |
4 gave up | 5 lock held | 10 behind | 11 no watcher | 12 stale heartbeat
|
| 209 |
EOF
|
|
@@ -240,8 +219,6 @@ STREAM=""
|
|
| 240 |
MODE="wait"
|
| 241 |
MODE_FLAG=""
|
| 242 |
MAX_WAIT=""
|
| 243 |
-
AFTER=""
|
| 244 |
-
DEADLINE=""
|
| 245 |
EXEC_CMD=""
|
| 246 |
|
| 247 |
set_mode() {
|
|
@@ -270,13 +247,6 @@ while [ "$#" -gt 0 ]; do
|
|
| 270 |
is_num "$MAX_WAIT" || usage "--max-wait must be a whole number of seconds, got '$MAX_WAIT'"
|
| 271 |
[ "$MAX_WAIT" -gt 0 ] || usage "--max-wait must be greater than 0"
|
| 272 |
;;
|
| 273 |
-
--after)
|
| 274 |
-
shift
|
| 275 |
-
[ "$#" -gt 0 ] || usage "--after needs a cursor (a message filename)"
|
| 276 |
-
AFTER="$1"
|
| 277 |
-
printf '%s\n' "$AFTER" | grep -qxE "$FILENAME_RE" ||
|
| 278 |
-
usage "--after must be a message filename like 20260728-120000-000_agent-b.md, got '$AFTER'"
|
| 279 |
-
;;
|
| 280 |
-h | --help)
|
| 281 |
# Self-documenting: the header comment IS the manual. Falls back to
|
| 282 |
# the short form when $0 is not readable (piped from stdin).
|
|
@@ -323,16 +293,12 @@ esac
|
|
| 323 |
if [ -n "$MAX_WAIT" ] && [ "$MODE" != wait ]; then
|
| 324 |
usage "--max-wait applies to the default wait mode only (not $MODE_FLAG)"
|
| 325 |
fi
|
| 326 |
-
case "$MODE" in
|
| 327 |
-
peek | status) [ -z "$AFTER" ] || usage "--after applies to wait and --exec modes only (not $MODE_FLAG)" ;;
|
| 328 |
-
esac
|
| 329 |
|
| 330 |
# ── configuration ─────────────────────────────────────────────────────
|
| 331 |
|
| 332 |
is_num "$WAIT" || fatal "COLLAB_WATCH_WAIT must be a whole number of seconds, got '$WAIT'"
|
| 333 |
is_num "$EXEC_RETRIES" || fatal "COLLAB_WATCH_EXEC_RETRIES must be a whole number, got '$EXEC_RETRIES'"
|
| 334 |
is_num "$BACKOFF_BASE" || fatal "COLLAB_WATCH_BACKOFF must be a whole number of seconds, got '$BACKOFF_BASE'"
|
| 335 |
-
is_num "$RETRY_BUDGET" || fatal "COLLAB_WATCH_RETRY_BUDGET_S must be a whole number of seconds, got '$RETRY_BUDGET'"
|
| 336 |
BACKOFF="$BACKOFF_BASE"
|
| 337 |
|
| 338 |
BASE="${BASE%/}"
|
|
@@ -380,14 +346,11 @@ case "$STREAM" in
|
|
| 380 |
esac
|
| 381 |
|
| 382 |
BODY=$(mktemp "$DIR/.body.XXXXXX") || fatal "could not create a temp file in '$DIR'"
|
| 383 |
-
CODE=$(mktemp "$DIR/.code.XXXXXX") || fatal "could not create a temp file in '$DIR'"
|
| 384 |
|
| 385 |
cleanup() {
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
kill "$CHILD" 2>/dev/null || :
|
| 389 |
fi
|
| 390 |
-
rm -f "$BODY" "$CODE" 2>/dev/null || :
|
| 391 |
# Release the lock only while it is still OURS: if the pid inside is no
|
| 392 |
# longer $$, another watcher owns the directory (it reclaimed ours as stale)
|
| 393 |
# and removing it would hand a third one the same cursor file.
|
|
@@ -398,30 +361,12 @@ cleanup() {
|
|
| 398 |
:
|
| 399 |
}
|
| 400 |
# The signal traps exit explicitly so the EXIT trap runs and the lock is
|
| 401 |
-
# released: a watcher killed by its harness must not leave a lock behind.
|
| 402 |
-
# fire at once: curl and every sleep run through reap (below); only an --exec
|
| 403 |
-
# handler still runs in the foreground.
|
| 404 |
trap cleanup EXIT
|
| 405 |
trap 'exit 130' INT
|
| 406 |
trap 'exit 143' TERM
|
| 407 |
trap 'exit 129' HUP
|
| 408 |
|
| 409 |
-
# reap <pid>: wait for a background child and return its status. A shell runs
|
| 410 |
-
# a trap only once its FOREGROUND child exits — up to a whole parked request —
|
| 411 |
-
# but a trapped signal interrupts `wait` immediately, and cleanup kills $CHILD.
|
| 412 |
-
reap() {
|
| 413 |
-
CHILD="$1"
|
| 414 |
-
reap_rc=0
|
| 415 |
-
wait "$CHILD" || reap_rc=$?
|
| 416 |
-
CHILD=""
|
| 417 |
-
return "$reap_rc"
|
| 418 |
-
}
|
| 419 |
-
|
| 420 |
-
nap() {
|
| 421 |
-
sleep "$1" &
|
| 422 |
-
reap "$!" || :
|
| 423 |
-
}
|
| 424 |
-
|
| 425 |
# ── state helpers ─────────────────────────────────────────────────────
|
| 426 |
|
| 427 |
# Atomic single-line write: mktemp in the target's directory + mv, so a crash
|
|
@@ -456,38 +401,11 @@ lock_pid() {
|
|
| 456 |
fi
|
| 457 |
}
|
| 458 |
|
| 459 |
-
lock_start() {
|
| 460 |
-
sed -n 2p "$LOCKDIR/pid" 2>/dev/null || :
|
| 461 |
-
}
|
| 462 |
-
|
| 463 |
-
# proc_start <pid>: an opaque start-time stamp, empty when unavailable. Linux
|
| 464 |
-
# /proc field 22 (after the parenthesised comm, which may contain spaces), else
|
| 465 |
-
# `ps -o lstart=` (macOS, procps). Busybox ps has neither -p nor lstart, but
|
| 466 |
-
# busybox systems have /proc.
|
| 467 |
-
proc_start() {
|
| 468 |
-
if [ -r "/proc/$1/stat" ]; then
|
| 469 |
-
sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20
|
| 470 |
-
else
|
| 471 |
-
ps -o lstart= -p "$1" 2>/dev/null || :
|
| 472 |
-
fi
|
| 473 |
-
}
|
| 474 |
-
|
| 475 |
-
# lock_alive <pid> [start]: `kill -0` alone is fooled by PID reuse and by a pid
|
| 476 |
-
# from another PID namespace, so a recorded start time must match too. A lock
|
| 477 |
-
# without one (an older watcher) or a host where neither probe works falls back
|
| 478 |
-
# to the pid alone.
|
| 479 |
lock_alive() {
|
| 480 |
la_pid="${1:-}"
|
| 481 |
is_num "$la_pid" || return 1
|
| 482 |
kill -0 "$la_pid" 2>/dev/null || return 1
|
| 483 |
-
|
| 484 |
-
la_start=$(proc_start "$la_pid")
|
| 485 |
-
[ -z "$la_start" ] || [ "$la_start" = "$2" ]
|
| 486 |
-
}
|
| 487 |
-
|
| 488 |
-
lock_write() {
|
| 489 |
-
LOCK_HELD=1
|
| 490 |
-
printf '%s\n%s\n' "$$" "$(proc_start "$$")" >"$LOCKDIR/pid"
|
| 491 |
}
|
| 492 |
|
| 493 |
# One watcher per HANDLE — not per handle+stream: two watchers under one handle
|
|
@@ -497,7 +415,8 @@ lock_write() {
|
|
| 497 |
# stream is for.
|
| 498 |
lock_acquire() {
|
| 499 |
if mkdir "$LOCKDIR" 2>/dev/null; then
|
| 500 |
-
|
|
|
|
| 501 |
return 0
|
| 502 |
fi
|
| 503 |
lk_pid=$(lock_pid)
|
|
@@ -508,18 +427,19 @@ lock_acquire() {
|
|
| 508 |
# pid by then, while a crash mid-acquire leaves the file empty forever and
|
| 509 |
# is still reclaimed on the second read.
|
| 510 |
if [ -z "$lk_pid" ]; then
|
| 511 |
-
|
| 512 |
lk_pid=$(lock_pid)
|
| 513 |
fi
|
| 514 |
-
if lock_alive "$lk_pid"
|
| 515 |
log "another watcher already holds this handle (pid $lk_pid, lock $LOCKDIR); the lock is per-handle, one watcher covers every stream; exiting 5"
|
| 516 |
exit 5
|
| 517 |
fi
|
| 518 |
-
log "reclaiming stale lock $LOCKDIR (pid ${lk_pid:-unknown} is gone
|
| 519 |
rm -f "$LOCKDIR/pid" 2>/dev/null || :
|
| 520 |
rmdir "$LOCKDIR" 2>/dev/null || :
|
| 521 |
if mkdir "$LOCKDIR" 2>/dev/null; then
|
| 522 |
-
|
|
|
|
| 523 |
return 0
|
| 524 |
fi
|
| 525 |
lk_pid=$(lock_pid)
|
|
@@ -584,9 +504,7 @@ watch_status() {
|
|
| 584 |
# minutes and died with an opaque `curl rc=22`.
|
| 585 |
do_request() {
|
| 586 |
CURL_RC=0
|
| 587 |
-
curl -sS -o "$BODY" -w '%{http_code}' --max-time "$2" "$1"
|
| 588 |
-
reap "$!" || CURL_RC=$?
|
| 589 |
-
HTTP=$(cat "$CODE")
|
| 590 |
[ -n "$HTTP" ] || HTTP=000
|
| 591 |
}
|
| 592 |
|
|
@@ -596,7 +514,7 @@ do_request() {
|
|
| 596 |
# blindly would let the server move a watcher to another host, scheme or handle —
|
| 597 |
# so a redirecting base URL can never succeed, no matter how long we retry:
|
| 598 |
# `http://<org>.hf.space` (which redirects to https) would otherwise walk the
|
| 599 |
-
# whole backoff ladder for
|
| 600 |
# instead of the one-word fix.
|
| 601 |
classify() {
|
| 602 |
[ "$CURL_RC" -eq 0 ] || return 1
|
|
@@ -625,32 +543,20 @@ fail_http() {
|
|
| 625 |
exit 1
|
| 626 |
}
|
| 627 |
|
| 628 |
-
# One shared exponential backoff for every retryable failure.
|
| 629 |
-
#
|
| 630 |
-
#
|
| 631 |
-
# minutes — stamping the heartbeat first so --status can report the give-up
|
| 632 |
-
# after this process is gone.
|
| 633 |
on_retryable() {
|
| 634 |
-
if [ "$STREAK" -eq 0 ]; then
|
| 635 |
-
FAIL_SINCE=$(now)
|
| 636 |
-
fi
|
| 637 |
STREAK=$((STREAK + 1))
|
| 638 |
-
|
| 639 |
-
if [ "$or_failing" -ge "$RETRY_BUDGET" ]; then
|
| 640 |
hb gave_up
|
| 641 |
-
log "giving up after $STREAK consecutive request failures
|
| 642 |
-
exit 4
|
| 643 |
-
fi
|
| 644 |
-
if [ -n "$DEADLINE" ] && [ $((DEADLINE - $(now))) -le "$BACKOFF" ]; then
|
| 645 |
-
hb gave_up
|
| 646 |
-
log "request failed (HTTP $HTTP, curl rc=$CURL_RC) and --max-wait ${MAX_WAIT}s is up; exiting 4"
|
| 647 |
exit 4
|
| 648 |
fi
|
| 649 |
hb retrying
|
| 650 |
-
log "request failed (HTTP $HTTP, curl rc=$CURL_RC); retry $STREAK in ${BACKOFF}s
|
| 651 |
-
|
| 652 |
if [ "$BACKOFF" -gt 0 ]; then
|
| 653 |
-
|
| 654 |
fi
|
| 655 |
BACKOFF=$((BACKOFF * 2))
|
| 656 |
if [ "$BACKOFF" -gt 60 ]; then
|
|
@@ -663,22 +569,6 @@ request_ok_reset() {
|
|
| 663 |
BACKOFF="$BACKOFF_BASE"
|
| 664 |
}
|
| 665 |
|
| 666 |
-
# curl --max-time for a request parked $1 seconds: the wait plus 20s of slack,
|
| 667 |
-
# but never past the --max-wait deadline plus 5s of network latency.
|
| 668 |
-
max_time() {
|
| 669 |
-
mt=$(($1 + 20))
|
| 670 |
-
if [ -n "$DEADLINE" ]; then
|
| 671 |
-
mt_cap=$((DEADLINE - $(now) + 5))
|
| 672 |
-
if [ "$mt_cap" -lt "$mt" ]; then
|
| 673 |
-
mt="$mt_cap"
|
| 674 |
-
fi
|
| 675 |
-
fi
|
| 676 |
-
if [ "$mt" -lt 1 ]; then
|
| 677 |
-
mt=1
|
| 678 |
-
fi
|
| 679 |
-
printf '%s' "$mt"
|
| 680 |
-
}
|
| 681 |
-
|
| 682 |
# Forward-drain URL: $1 = limit, $2 = wait. `after` is omitted when the cursor
|
| 683 |
# is empty — "nothing seen yet" is the absence of a bound, not an empty one.
|
| 684 |
poll_url() {
|
|
@@ -696,7 +586,7 @@ poll_url() {
|
|
| 696 |
# so peeking and watching agree about where "now" is.
|
| 697 |
baseline_cursor() {
|
| 698 |
while :; do
|
| 699 |
-
do_request "$URL${SEP}limit=1&order=desc&expand=true"
|
| 700 |
bc_cls=0
|
| 701 |
classify || bc_cls=$?
|
| 702 |
case "$bc_cls" in
|
|
@@ -729,44 +619,38 @@ journal_page() {
|
|
| 729 |
log "warning: could not append to $1 (delivery continues, recovery does not)"
|
| 730 |
}
|
| 731 |
|
| 732 |
-
# The cursor to resume from: --after if given, else the saved one, else a
|
| 733 |
-
# cold-start baseline.
|
| 734 |
-
load_cursor() {
|
| 735 |
-
if [ -n "$AFTER" ]; then
|
| 736 |
-
write_line "$CURSOR_FILE" "$AFTER"
|
| 737 |
-
log "cursor set to '$AFTER' (--after)"
|
| 738 |
-
fi
|
| 739 |
-
if [ ! -f "$CURSOR_FILE" ]; then
|
| 740 |
-
baseline_cursor
|
| 741 |
-
hb baselined
|
| 742 |
-
else
|
| 743 |
-
CURSOR=$(cursor_read)
|
| 744 |
-
fi
|
| 745 |
-
}
|
| 746 |
-
|
| 747 |
# ── mode: wait / bounded wait ─────────────────────────────────────────
|
| 748 |
|
| 749 |
run_wait() {
|
| 750 |
lock_acquire
|
| 751 |
hb starting
|
| 752 |
|
| 753 |
-
# --max-wait is a
|
| 754 |
-
#
|
| 755 |
-
#
|
| 756 |
-
# --max-
|
| 757 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
if [ -n "$MAX_WAIT" ]; then
|
| 759 |
-
|
| 760 |
fi
|
| 761 |
|
| 762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
|
| 764 |
# Page FORWARD from the cursor (order=asc) so a burst larger than one page
|
| 765 |
# drains oldest-first across consecutive runs with no gaps.
|
| 766 |
while :; do
|
| 767 |
rw_wait="$WAIT"
|
| 768 |
-
if [ -n "$
|
| 769 |
-
rw_left=$((
|
| 770 |
if [ "$rw_left" -le 0 ]; then
|
| 771 |
hb no_mail
|
| 772 |
log "no mail within ${MAX_WAIT}s — clean timeout, exiting 3"
|
|
@@ -779,7 +663,7 @@ run_wait() {
|
|
| 779 |
|
| 780 |
hb waiting
|
| 781 |
rw_t0=$(now)
|
| 782 |
-
do_request "$(poll_url "$LIMIT" "$rw_wait")"
|
| 783 |
rw_cls=0
|
| 784 |
classify || rw_cls=$?
|
| 785 |
case "$rw_cls" in
|
|
@@ -848,7 +732,7 @@ idle_pace() {
|
|
| 848 |
;;
|
| 849 |
esac
|
| 850 |
if [ "$ip_elapsed" -lt "$IDLE_FLOOR_S" ]; then
|
| 851 |
-
|
| 852 |
fi
|
| 853 |
}
|
| 854 |
|
|
@@ -863,7 +747,13 @@ idle_pace() {
|
|
| 863 |
run_exec() {
|
| 864 |
lock_acquire
|
| 865 |
hb starting
|
| 866 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
|
| 868 |
re_page=""
|
| 869 |
re_fails=0
|
|
@@ -949,9 +839,8 @@ run_exec() {
|
|
| 949 |
hb exec_failed
|
| 950 |
log "handler exited $re_rc on the page after '${re_page#start:}'; NOT advancing the cursor, re-delivering in ${re_backoff}s (failure $re_fails/$EXEC_RETRIES)"
|
| 951 |
if [ "$re_backoff" -gt 0 ]; then
|
| 952 |
-
|
| 953 |
fi
|
| 954 |
-
|
| 955 |
re_backoff=$((re_backoff * 2))
|
| 956 |
if [ "$re_backoff" -gt 60 ]; then
|
| 957 |
re_backoff=60
|
|
@@ -1003,8 +892,7 @@ run_peek() {
|
|
| 1003 |
run_status() {
|
| 1004 |
rs_pid=$(lock_pid)
|
| 1005 |
rs_alive=0
|
| 1006 |
-
if lock_alive "$rs_pid"
|
| 1007 |
-
|
| 1008 |
rs_alive=1
|
| 1009 |
else
|
| 1010 |
rs_pid="-"
|
|
@@ -1078,11 +966,6 @@ run_status() {
|
|
| 1078 |
rs_rc=0
|
| 1079 |
fi
|
| 1080 |
|
| 1081 |
-
if [ "$rs_rc" -eq 10 ]; then
|
| 1082 |
-
rs_arg=""
|
| 1083 |
-
[ "$STREAM" = updates ] || rs_arg=" $STREAM"
|
| 1084 |
-
log "read it now: sh $0 $BASE $HANDLE$rs_arg --max-wait 5"
|
| 1085 |
-
fi
|
| 1086 |
if [ "$rs_wrong_stream" -eq 1 ]; then
|
| 1087 |
log "a watcher IS alive for this handle (pid $rs_pid) but it is watching '$rs_stream', not '$STREAM': nothing is advancing cursor.$STREAM"
|
| 1088 |
fi
|
|
|
|
| 22 |
# modes (default: wait):
|
| 23 |
# (none) block until mail, print the page, exit 0
|
| 24 |
# --max-wait N ...but give up cleanly after N seconds with exit 3. N is a
|
| 25 |
+
# floor: exit 3 never fires early, and overshoots by up to the
|
| 26 |
+
# wait window still in flight when N expires.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# --exec CMD foreground loop: per delivery run CMD (via `sh -c`) with the
|
| 28 |
# page on its stdin; the cursor advances ONLY when CMD exits 0.
|
| 29 |
# CMD also sees COLLAB_WATCH_HANDLE / COLLAB_WATCH_STREAM /
|
|
|
|
| 40 |
# one you asked about; a watcher on another stream is
|
| 41 |
# NO_WATCHER for this one (exit 11, naming the live stream on
|
| 42 |
# stderr). LAST is the last loop status the watcher recorded.
|
| 43 |
+
# BEHIND outranks every liveness verdict — act on it first.
|
|
|
|
|
|
|
| 44 |
# --help this text
|
| 45 |
#
|
| 46 |
# exit codes:
|
|
|
|
| 52 |
# config, or a server that does not implement the watch API
|
| 53 |
# 2 usage error
|
| 54 |
# 3 --max-wait elapsed with no mail — a CLEAN timeout, not a death
|
| 55 |
+
# 4 gave up after 10 consecutive request failures (also: --status could not
|
| 56 |
+
# reach the server); the heartbeat records status=gave_up
|
|
|
|
| 57 |
# 5 another watcher already holds the lock for this handle. The lock is
|
| 58 |
# per-HANDLE, not per-stream: one watcher covers an agent, which is what
|
| 59 |
# the unified `updates` stream is for
|
|
|
|
| 76 |
# COLLAB_WATCH_BACKOFF initial retry backoff seconds (2; doubles to 60). 0
|
| 77 |
# makes retries instant — a test hook, not a production
|
| 78 |
# setting. It does NOT shorten the idle pacing floor.
|
|
|
|
|
|
|
| 79 |
#
|
| 80 |
# state directory (one per host+handle, so running from another working
|
| 81 |
# directory can never silently re-baseline and skip mail):
|
|
|
|
| 84 |
# loop pass — including empty timeouts and the give-up —
|
| 85 |
# so a stale heartbeat means exactly "no watcher process
|
| 86 |
# has run recently", nothing else
|
| 87 |
+
# lock/ mkdir-based lock, lock/pid inside; a lock whose pid
|
| 88 |
+
# fails `kill -0` is stale and is reclaimed
|
|
|
|
|
|
|
| 89 |
# delivered.jsonl every delivered page, appended BEFORE it is printed
|
| 90 |
# dead-letter.jsonl pages a --exec handler kept refusing (see --exec below)
|
| 91 |
# The cursor is per-STREAM; the lock and heartbeat are per-HANDLE, because one
|
|
|
|
| 108 |
# silently skipped page is not.
|
| 109 |
#
|
| 110 |
# harness integration — exit-on-mail composes with anything:
|
|
|
|
|
|
|
|
|
|
| 111 |
# * Background-task harness (Claude Code, Codex, ...): launch ONE run with
|
| 112 |
# your harness's own background-task mechanism, react to the JSON when the
|
| 113 |
# task completes, then launch it again.
|
|
|
|
| 134 |
# filtered. The number of items in the page IS the unread count. A wrapper
|
| 135 |
# that reads `matched` will happily report "up to date" with mail pending.
|
| 136 |
#
|
| 137 |
+
# NOTE (backoff tradeoff): HTTP 5xx, refused/unresolved connections, and the
|
| 138 |
+
# expected parked-connection drops when the Space restarts all share ONE
|
| 139 |
+
# small exponential backoff (2,4,8,...,60s) plus a 10-in-a-row streak that
|
| 140 |
+
# exits 4. Folding the normal drops in keeps this simple; the cost is that a
|
| 141 |
+
# Space restart reconnects after ~2s instead of instantly. 3xx and 4xx do NOT
|
| 142 |
+
# back off — they fail immediately (a 4xx with the server's error body),
|
|
|
|
|
|
|
|
|
|
| 143 |
# because neither a typo'd handle nor a redirecting base URL is a transient
|
| 144 |
# condition. The routine idle path is not a failure at all: when the wait
|
| 145 |
# elapses the server answers 200 with an empty page, so an idle watcher never
|
|
|
|
| 155 |
SELF=collab_watch
|
| 156 |
LIMIT=10 # records per delivered page
|
| 157 |
STATUS_LIMIT=100 # --status unread count saturates here
|
| 158 |
+
FAIL_STREAK_MAX=10 # consecutive request failures before exit 4
|
| 159 |
IDLE_FLOOR_S=2 # minimum seconds between two empty answers
|
| 160 |
|
| 161 |
# Server-issued filename shape: <YYYYMMDD>-<HHMMSS>-<mmm>_<agent-id>.md, where
|
|
|
|
| 165 |
WAIT="${COLLAB_WATCH_WAIT:-55}"
|
| 166 |
EXEC_RETRIES="${COLLAB_WATCH_EXEC_RETRIES:-3}"
|
| 167 |
BACKOFF_BASE="${COLLAB_WATCH_BACKOFF:-2}"
|
|
|
|
| 168 |
|
| 169 |
BODY=""
|
|
|
|
|
|
|
| 170 |
LOCK_HELD=""
|
| 171 |
HTTP=000
|
| 172 |
CURL_RC=0
|
| 173 |
STREAK=0
|
|
|
|
| 174 |
BACKOFF=2
|
| 175 |
CURSOR=""
|
| 176 |
PAGE_CURSOR=""
|
|
|
|
| 182 |
usage_text() {
|
| 183 |
cat <<'EOF'
|
| 184 |
usage: sh collab_watch.sh <base-url> <handle> [updates|inbox|feed] [flags]
|
| 185 |
+
flags: --max-wait N | --exec CMD | --peek | --status | --help
|
| 186 |
exit: 0 delivered/ok | 1 fatal | 2 usage | 3 clean no-mail timeout |
|
| 187 |
4 gave up | 5 lock held | 10 behind | 11 no watcher | 12 stale heartbeat
|
| 188 |
EOF
|
|
|
|
| 219 |
MODE="wait"
|
| 220 |
MODE_FLAG=""
|
| 221 |
MAX_WAIT=""
|
|
|
|
|
|
|
| 222 |
EXEC_CMD=""
|
| 223 |
|
| 224 |
set_mode() {
|
|
|
|
| 247 |
is_num "$MAX_WAIT" || usage "--max-wait must be a whole number of seconds, got '$MAX_WAIT'"
|
| 248 |
[ "$MAX_WAIT" -gt 0 ] || usage "--max-wait must be greater than 0"
|
| 249 |
;;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
-h | --help)
|
| 251 |
# Self-documenting: the header comment IS the manual. Falls back to
|
| 252 |
# the short form when $0 is not readable (piped from stdin).
|
|
|
|
| 293 |
if [ -n "$MAX_WAIT" ] && [ "$MODE" != wait ]; then
|
| 294 |
usage "--max-wait applies to the default wait mode only (not $MODE_FLAG)"
|
| 295 |
fi
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
# ── configuration ─────────────────────────────────────────────────────
|
| 298 |
|
| 299 |
is_num "$WAIT" || fatal "COLLAB_WATCH_WAIT must be a whole number of seconds, got '$WAIT'"
|
| 300 |
is_num "$EXEC_RETRIES" || fatal "COLLAB_WATCH_EXEC_RETRIES must be a whole number, got '$EXEC_RETRIES'"
|
| 301 |
is_num "$BACKOFF_BASE" || fatal "COLLAB_WATCH_BACKOFF must be a whole number of seconds, got '$BACKOFF_BASE'"
|
|
|
|
| 302 |
BACKOFF="$BACKOFF_BASE"
|
| 303 |
|
| 304 |
BASE="${BASE%/}"
|
|
|
|
| 346 |
esac
|
| 347 |
|
| 348 |
BODY=$(mktemp "$DIR/.body.XXXXXX") || fatal "could not create a temp file in '$DIR'"
|
|
|
|
| 349 |
|
| 350 |
cleanup() {
|
| 351 |
+
if [ -n "$BODY" ]; then
|
| 352 |
+
rm -f "$BODY" 2>/dev/null || :
|
|
|
|
| 353 |
fi
|
|
|
|
| 354 |
# Release the lock only while it is still OURS: if the pid inside is no
|
| 355 |
# longer $$, another watcher owns the directory (it reclaimed ours as stale)
|
| 356 |
# and removing it would hand a third one the same cursor file.
|
|
|
|
| 361 |
:
|
| 362 |
}
|
| 363 |
# The signal traps exit explicitly so the EXIT trap runs and the lock is
|
| 364 |
+
# released: a watcher killed by its harness must not leave a lock behind.
|
|
|
|
|
|
|
| 365 |
trap cleanup EXIT
|
| 366 |
trap 'exit 130' INT
|
| 367 |
trap 'exit 143' TERM
|
| 368 |
trap 'exit 129' HUP
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
# ── state helpers ─────────────────────────────────────────────────────
|
| 371 |
|
| 372 |
# Atomic single-line write: mktemp in the target's directory + mv, so a crash
|
|
|
|
| 401 |
fi
|
| 402 |
}
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
lock_alive() {
|
| 405 |
la_pid="${1:-}"
|
| 406 |
is_num "$la_pid" || return 1
|
| 407 |
kill -0 "$la_pid" 2>/dev/null || return 1
|
| 408 |
+
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
}
|
| 410 |
|
| 411 |
# One watcher per HANDLE — not per handle+stream: two watchers under one handle
|
|
|
|
| 415 |
# stream is for.
|
| 416 |
lock_acquire() {
|
| 417 |
if mkdir "$LOCKDIR" 2>/dev/null; then
|
| 418 |
+
LOCK_HELD=1
|
| 419 |
+
printf '%s\n' "$$" >"$LOCKDIR/pid"
|
| 420 |
return 0
|
| 421 |
fi
|
| 422 |
lk_pid=$(lock_pid)
|
|
|
|
| 427 |
# pid by then, while a crash mid-acquire leaves the file empty forever and
|
| 428 |
# is still reclaimed on the second read.
|
| 429 |
if [ -z "$lk_pid" ]; then
|
| 430 |
+
sleep 1
|
| 431 |
lk_pid=$(lock_pid)
|
| 432 |
fi
|
| 433 |
+
if lock_alive "$lk_pid"; then
|
| 434 |
log "another watcher already holds this handle (pid $lk_pid, lock $LOCKDIR); the lock is per-handle, one watcher covers every stream; exiting 5"
|
| 435 |
exit 5
|
| 436 |
fi
|
| 437 |
+
log "reclaiming stale lock $LOCKDIR (pid ${lk_pid:-unknown} is gone)"
|
| 438 |
rm -f "$LOCKDIR/pid" 2>/dev/null || :
|
| 439 |
rmdir "$LOCKDIR" 2>/dev/null || :
|
| 440 |
if mkdir "$LOCKDIR" 2>/dev/null; then
|
| 441 |
+
LOCK_HELD=1
|
| 442 |
+
printf '%s\n' "$$" >"$LOCKDIR/pid"
|
| 443 |
return 0
|
| 444 |
fi
|
| 445 |
lk_pid=$(lock_pid)
|
|
|
|
| 504 |
# minutes and died with an opaque `curl rc=22`.
|
| 505 |
do_request() {
|
| 506 |
CURL_RC=0
|
| 507 |
+
HTTP=$(curl -sS -o "$BODY" -w '%{http_code}' --max-time "$2" "$1") || CURL_RC=$?
|
|
|
|
|
|
|
| 508 |
[ -n "$HTTP" ] || HTTP=000
|
| 509 |
}
|
| 510 |
|
|
|
|
| 514 |
# blindly would let the server move a watcher to another host, scheme or handle —
|
| 515 |
# so a redirecting base URL can never succeed, no matter how long we retry:
|
| 516 |
# `http://<org>.hf.space` (which redirects to https) would otherwise walk the
|
| 517 |
+
# whole backoff ladder for ~5 minutes and then exit 4, reporting an outage
|
| 518 |
# instead of the one-word fix.
|
| 519 |
classify() {
|
| 520 |
[ "$CURL_RC" -eq 0 ] || return 1
|
|
|
|
| 543 |
exit 1
|
| 544 |
}
|
| 545 |
|
| 546 |
+
# One shared exponential backoff for every retryable failure. Bumps the streak
|
| 547 |
+
# and gives up (exit 4) at FAIL_STREAK_MAX, stamping the heartbeat first so
|
| 548 |
+
# --status can report the give-up after this process is gone.
|
|
|
|
|
|
|
| 549 |
on_retryable() {
|
|
|
|
|
|
|
|
|
|
| 550 |
STREAK=$((STREAK + 1))
|
| 551 |
+
if [ "$STREAK" -ge "$FAIL_STREAK_MAX" ]; then
|
|
|
|
| 552 |
hb gave_up
|
| 553 |
+
log "giving up after $STREAK consecutive request failures (last: HTTP $HTTP, curl rc=$CURL_RC)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
exit 4
|
| 555 |
fi
|
| 556 |
hb retrying
|
| 557 |
+
log "request failed (HTTP $HTTP, curl rc=$CURL_RC); retry $STREAK/$FAIL_STREAK_MAX in ${BACKOFF}s"
|
|
|
|
| 558 |
if [ "$BACKOFF" -gt 0 ]; then
|
| 559 |
+
sleep "$BACKOFF"
|
| 560 |
fi
|
| 561 |
BACKOFF=$((BACKOFF * 2))
|
| 562 |
if [ "$BACKOFF" -gt 60 ]; then
|
|
|
|
| 569 |
BACKOFF="$BACKOFF_BASE"
|
| 570 |
}
|
| 571 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
# Forward-drain URL: $1 = limit, $2 = wait. `after` is omitted when the cursor
|
| 573 |
# is empty — "nothing seen yet" is the absence of a bound, not an empty one.
|
| 574 |
poll_url() {
|
|
|
|
| 586 |
# so peeking and watching agree about where "now" is.
|
| 587 |
baseline_cursor() {
|
| 588 |
while :; do
|
| 589 |
+
do_request "$URL${SEP}limit=1&order=desc&expand=true" $((WAIT + 20))
|
| 590 |
bc_cls=0
|
| 591 |
classify || bc_cls=$?
|
| 592 |
case "$bc_cls" in
|
|
|
|
| 619 |
log "warning: could not append to $1 (delivery continues, recovery does not)"
|
| 620 |
}
|
| 621 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
# ── mode: wait / bounded wait ─────────────────────────────────────────
|
| 623 |
|
| 624 |
run_wait() {
|
| 625 |
lock_acquire
|
| 626 |
hb starting
|
| 627 |
|
| 628 |
+
# --max-wait is a FLOOR, never a ceiling: exit 3 must not fire before the
|
| 629 |
+
# caller's N seconds are really up. `date +%s` truncates, so the epoch read
|
| 630 |
+
# here is up to a second earlier than the true start instant — without the
|
| 631 |
+
# +1 slack a `--max-wait 2` run could give up after 1.1s and report "no
|
| 632 |
+
# mail" for a window the caller never asked to stop watching. The cost is
|
| 633 |
+
# that the bound overshoots instead: by up to that lost second, plus
|
| 634 |
+
# whatever is left of the request already in flight when it expires (the
|
| 635 |
+
# granularity of a bounded wait is one wait window — say so, don't pretend).
|
| 636 |
+
rw_deadline=""
|
| 637 |
if [ -n "$MAX_WAIT" ]; then
|
| 638 |
+
rw_deadline=$(($(now) + MAX_WAIT + 1))
|
| 639 |
fi
|
| 640 |
|
| 641 |
+
if [ ! -f "$CURSOR_FILE" ]; then
|
| 642 |
+
baseline_cursor
|
| 643 |
+
hb baselined
|
| 644 |
+
else
|
| 645 |
+
CURSOR=$(cursor_read)
|
| 646 |
+
fi
|
| 647 |
|
| 648 |
# Page FORWARD from the cursor (order=asc) so a burst larger than one page
|
| 649 |
# drains oldest-first across consecutive runs with no gaps.
|
| 650 |
while :; do
|
| 651 |
rw_wait="$WAIT"
|
| 652 |
+
if [ -n "$rw_deadline" ]; then
|
| 653 |
+
rw_left=$((rw_deadline - $(now)))
|
| 654 |
if [ "$rw_left" -le 0 ]; then
|
| 655 |
hb no_mail
|
| 656 |
log "no mail within ${MAX_WAIT}s — clean timeout, exiting 3"
|
|
|
|
| 663 |
|
| 664 |
hb waiting
|
| 665 |
rw_t0=$(now)
|
| 666 |
+
do_request "$(poll_url "$LIMIT" "$rw_wait")" $((rw_wait + 20))
|
| 667 |
rw_cls=0
|
| 668 |
classify || rw_cls=$?
|
| 669 |
case "$rw_cls" in
|
|
|
|
| 732 |
;;
|
| 733 |
esac
|
| 734 |
if [ "$ip_elapsed" -lt "$IDLE_FLOOR_S" ]; then
|
| 735 |
+
sleep "$IDLE_FLOOR_S"
|
| 736 |
fi
|
| 737 |
}
|
| 738 |
|
|
|
|
| 747 |
run_exec() {
|
| 748 |
lock_acquire
|
| 749 |
hb starting
|
| 750 |
+
|
| 751 |
+
if [ ! -f "$CURSOR_FILE" ]; then
|
| 752 |
+
baseline_cursor
|
| 753 |
+
hb baselined
|
| 754 |
+
else
|
| 755 |
+
CURSOR=$(cursor_read)
|
| 756 |
+
fi
|
| 757 |
|
| 758 |
re_page=""
|
| 759 |
re_fails=0
|
|
|
|
| 839 |
hb exec_failed
|
| 840 |
log "handler exited $re_rc on the page after '${re_page#start:}'; NOT advancing the cursor, re-delivering in ${re_backoff}s (failure $re_fails/$EXEC_RETRIES)"
|
| 841 |
if [ "$re_backoff" -gt 0 ]; then
|
| 842 |
+
sleep "$re_backoff"
|
| 843 |
fi
|
|
|
|
| 844 |
re_backoff=$((re_backoff * 2))
|
| 845 |
if [ "$re_backoff" -gt 60 ]; then
|
| 846 |
re_backoff=60
|
|
|
|
| 892 |
run_status() {
|
| 893 |
rs_pid=$(lock_pid)
|
| 894 |
rs_alive=0
|
| 895 |
+
if lock_alive "$rs_pid"; then
|
|
|
|
| 896 |
rs_alive=1
|
| 897 |
else
|
| 898 |
rs_pid="-"
|
|
|
|
| 966 |
rs_rc=0
|
| 967 |
fi
|
| 968 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 969 |
if [ "$rs_wrong_stream" -eq 1 ]; then
|
| 970 |
log "a watcher IS alive for this handle (pid $rs_pid) but it is watching '$rs_stream', not '$STREAM': nothing is advancing cursor.$STREAM"
|
| 971 |
fi
|
clients/share_trace.py
CHANGED
|
@@ -22,13 +22,13 @@ WHAT THIS READS AND UPLOADS — for operators, agents, and safety classifiers:
|
|
| 22 |
* `--dry-run` prints the manifest and a typed redaction summary without writing
|
| 23 |
or uploading anything — run it first to verify.
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
|
| 33 |
`full` lets Hugging Face's built-in trace viewer render the native log directly
|
| 34 |
from the bucket (Claude Code & Codex supported out of the box). Redaction parses
|
|
@@ -48,8 +48,8 @@ under ANY `python3` — no `pip install`. The frontmatter is emitted as JSON
|
|
| 48 |
(which is valid YAML, so the backend parses it identically) and the upload
|
| 49 |
shells out to the `hf` CLI (which you already use for `hf auth login`). Org/slug
|
| 50 |
are auto-discovered from the backend's `GET /v1`, so you only need `--backend`
|
| 51 |
-
(or `COLLAB_BACKEND`
|
| 52 |
-
|
| 53 |
cc-codex-trace-metric-extraction).
|
| 54 |
"""
|
| 55 |
from __future__ import annotations
|
|
@@ -69,9 +69,8 @@ import urllib.request
|
|
| 69 |
from pathlib import Path
|
| 70 |
|
| 71 |
|
| 72 |
-
ADAPTER_VERSION =
|
| 73 |
REDACTOR_VERSION = 2
|
| 74 |
-
# Keep in sync with KNOWN_FULL_HARNESSES in backend/app/trace_stats.py.
|
| 75 |
KNOWN_HARNESSES = ("claude-code", "codex")
|
| 76 |
PRIVACY_LEVELS = ("secrets", "balanced", "strict")
|
| 77 |
|
|
@@ -101,14 +100,11 @@ def _int(v) -> int | None:
|
|
| 101 |
|
| 102 |
|
| 103 |
def adapter_claude_code(log_path: Path) -> dict:
|
| 104 |
-
"""
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
API response's message.id and usage — so usage is kept per message.id
|
| 108 |
-
(last write wins) and summed once; tool calls are deduped by tool_use id."""
|
| 109 |
-
responses: dict = {} # message.id (or a per-line key if absent) -> usage
|
| 110 |
tools: dict[str, int] = {}
|
| 111 |
-
|
| 112 |
model = None
|
| 113 |
session_id = log_path.stem
|
| 114 |
first_ts = last_ts = None
|
|
@@ -122,29 +118,21 @@ def adapter_claude_code(log_path: Path) -> dict:
|
|
| 122 |
session_id = rec["sessionId"]
|
| 123 |
if rec.get("type") != "assistant":
|
| 124 |
continue
|
|
|
|
| 125 |
msg = rec.get("message") or {}
|
| 126 |
if msg.get("model"):
|
| 127 |
model = msg["model"]
|
| 128 |
-
|
| 129 |
-
responses[key] = msg.get("usage") or responses.get(key) or {}
|
| 130 |
-
for block in msg.get("content") or []:
|
| 131 |
-
if isinstance(block, dict) and block.get("type") == "tool_use":
|
| 132 |
-
if block.get("id"):
|
| 133 |
-
if block["id"] in seen_tools:
|
| 134 |
-
continue
|
| 135 |
-
seen_tools.add(block["id"])
|
| 136 |
-
name = block.get("name") or "?"
|
| 137 |
-
tools[name] = tools.get(name, 0) + 1
|
| 138 |
-
|
| 139 |
-
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0}
|
| 140 |
-
saw_usage = False
|
| 141 |
-
for u in responses.values():
|
| 142 |
if u:
|
| 143 |
saw_usage = True
|
| 144 |
usage["input_tokens"] += _int(u.get("input_tokens")) or 0
|
| 145 |
usage["output_tokens"] += _int(u.get("output_tokens")) or 0
|
| 146 |
usage["cache_read_tokens"] += _int(u.get("cache_read_input_tokens")) or 0
|
| 147 |
usage["cache_creation_tokens"] += _int(u.get("cache_creation_input_tokens")) or 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
fields: dict = {
|
| 150 |
"harness": "claude-code",
|
|
@@ -153,7 +141,7 @@ def adapter_claude_code(log_path: Path) -> dict:
|
|
| 153 |
"started_at": first_ts,
|
| 154 |
"ended_at": last_ts,
|
| 155 |
"activity": {"tool_calls": sum(tools.values()), "tool_calls_by_name": tools},
|
| 156 |
-
"extensions": {"api_requests":
|
| 157 |
}
|
| 158 |
if saw_usage:
|
| 159 |
usage["total_tokens"] = sum(usage.values())
|
|
@@ -165,7 +153,7 @@ _CODEX_TOOL_TYPES = ("function_call", "custom_tool_call", "local_shell_call", "w
|
|
| 165 |
|
| 166 |
|
| 167 |
def adapter_codex(log_path: Path) -> dict:
|
| 168 |
-
"""
|
| 169 |
(take the last); dedupe tool calls by call_id (MCP appears twice)."""
|
| 170 |
last_usage = None
|
| 171 |
tools: dict[str, int] = {}
|
|
@@ -253,17 +241,9 @@ def build_fields(harness: str, log_path: Path) -> dict:
|
|
| 253 |
return fn(log_path) if fn else adapter_minimal(log_path, harness)
|
| 254 |
|
| 255 |
|
| 256 |
-
def _claude_dir() -> Path:
|
| 257 |
-
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser()
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def _codex_home() -> Path:
|
| 261 |
-
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser()
|
| 262 |
-
|
| 263 |
-
|
| 264 |
def _cc_project_dir(cwd: str) -> Path:
|
| 265 |
slug = re.sub(r"[/._]", "-", os.path.abspath(cwd))
|
| 266 |
-
return
|
| 267 |
|
| 268 |
|
| 269 |
def _latest(paths: list[Path]) -> Path | None:
|
|
@@ -277,7 +257,7 @@ def _detect_claude_code(cwd: str) -> Path | None:
|
|
| 277 |
|
| 278 |
|
| 279 |
def _codex_logs() -> list[Path]:
|
| 280 |
-
codex_root =
|
| 281 |
return sorted(
|
| 282 |
[Path(p) for p in glob.glob(str(codex_root / "**" / "rollout-*.jsonl"), recursive=True)],
|
| 283 |
key=lambda p: p.stat().st_mtime if p.is_file() else 0,
|
|
@@ -288,8 +268,7 @@ def _codex_logs() -> list[Path]:
|
|
| 288 |
def _mentions_cwd(value, cwd: str) -> bool:
|
| 289 |
cwd_abs = os.path.abspath(cwd)
|
| 290 |
if isinstance(value, str):
|
| 291 |
-
|
| 292 |
-
if re.search(re.escape(cwd_abs) + r"(?![\w.-])", value):
|
| 293 |
return True
|
| 294 |
try:
|
| 295 |
return os.path.abspath(os.path.expanduser(value)) == cwd_abs
|
|
@@ -320,12 +299,10 @@ def _detect_codex(cwd: str) -> tuple[Path | None, bool]:
|
|
| 320 |
|
| 321 |
|
| 322 |
def _infer_harness(log_path: Path) -> str | None:
|
| 323 |
-
s = str(log_path
|
| 324 |
-
|
| 325 |
-
claude_root = str(_claude_dir().resolve() / "projects") + os.sep
|
| 326 |
-
if "/.codex/sessions/" in s or s.startswith(codex_root) or log_path.name.startswith("rollout-"):
|
| 327 |
return "codex"
|
| 328 |
-
if "/.claude/projects/" in s
|
| 329 |
return "claude-code"
|
| 330 |
return None
|
| 331 |
|
|
@@ -339,8 +316,6 @@ def _running_harness() -> tuple[str | None, str | None]:
|
|
| 339 |
- Claude Code sets CLAUDE_CODE_SESSION_ID (the exact session) + CLAUDECODE=1.
|
| 340 |
- Codex sets CODEX_SANDBOX* in its (default) sandboxed exec but exposes NO
|
| 341 |
session id — so we know it's Codex, but still locate the rollout by cwd.
|
| 342 |
-
(CODEX_HOME only relocates the logs; it is often exported globally, so
|
| 343 |
-
it is NOT a marker.)
|
| 344 |
"""
|
| 345 |
sid = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
| 346 |
if sid or os.environ.get("CLAUDECODE"):
|
|
@@ -847,23 +822,23 @@ def _confirm_or_exit(
|
|
| 847 |
raw: bool,
|
| 848 |
privacy: str,
|
| 849 |
) -> None:
|
| 850 |
-
# Stats-only shares carry nothing sensitive (just counts): never block them.
|
| 851 |
-
if share != "full" or yes:
|
| 852 |
-
return
|
| 853 |
reasons = []
|
| 854 |
if uncertain:
|
| 855 |
reasons.append("Could not pin the exact invoking session — selection fell back to "
|
| 856 |
"the newest log for this directory; confirm it is the right one.")
|
| 857 |
-
if
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
|
|
|
|
|
|
|
|
|
| 867 |
print("\nconfirmation required:")
|
| 868 |
for reason in reasons:
|
| 869 |
print(f"- {reason}")
|
|
@@ -906,17 +881,10 @@ def _hf_cp(local: str, dest_uri: str) -> None:
|
|
| 906 |
sys.exit(f"`hf buckets cp` failed [{r.returncode}]:\n{(r.stderr or r.stdout).strip()}")
|
| 907 |
|
| 908 |
|
| 909 |
-
def _harness_arg(value: str) -> str:
|
| 910 |
-
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", value):
|
| 911 |
-
raise argparse.ArgumentTypeError("must be a lowercase slug, e.g. claude-code, codex, cursor")
|
| 912 |
-
return value
|
| 913 |
-
|
| 914 |
-
|
| 915 |
def main() -> int:
|
| 916 |
ap = argparse.ArgumentParser(description="Share a session's stats / trace with the collaboration.")
|
| 917 |
-
ap.add_argument("--harness",
|
| 918 |
-
help="
|
| 919 |
-
"stats will be partial). Default: auto-detect from the environment/cwd")
|
| 920 |
ap.add_argument("--transcript", help="explicit native session log path (else: detected)")
|
| 921 |
ap.add_argument("--session-id", help="override the manifest/dest session id")
|
| 922 |
ap.add_argument("--full", action="store_true", help="also upload the redacted native session log")
|
|
@@ -931,11 +899,11 @@ def main() -> int:
|
|
| 931 |
ap.add_argument("--agent-id", default=os.environ.get("AGENT_ID"), help="your registered agent_id")
|
| 932 |
ap.add_argument("--org", default=os.environ.get("ORG"), help="challenge org")
|
| 933 |
ap.add_argument("--slug", default=os.environ.get("COLLAB_SLUG"), help="challenge slug")
|
| 934 |
-
ap.add_argument("--backend", default=os.environ.get("COLLAB_BACKEND")
|
| 935 |
help="the backend Space base URL, e.g. https://<org>-<slug>-bucket-sync.hf.space")
|
| 936 |
ap.add_argument("--upload-only", action="store_true",
|
| 937 |
help="write the bundle to your scratch bucket and skip POST /v1/traces")
|
| 938 |
-
ap.add_argument("--yes", action="store_true", help="confirm --full in non-interactive use")
|
| 939 |
ap.add_argument("--dry-run", action="store_true", help="print the plan + manifest; touch nothing")
|
| 940 |
args = ap.parse_args()
|
| 941 |
if args.full and args.stats_only:
|
|
@@ -966,8 +934,6 @@ def main() -> int:
|
|
| 966 |
harness = args.harness if args.harness != "auto" else _infer_harness(log_path)
|
| 967 |
if harness is None:
|
| 968 |
sys.exit("could not infer harness from --transcript; pass --harness")
|
| 969 |
-
elif args.harness not in (*KNOWN_HARNESSES, "auto"):
|
| 970 |
-
sys.exit(f"no adapter for --harness {args.harness}; pass --transcript <path> to its session log")
|
| 971 |
else:
|
| 972 |
harness, log_path, uncertain = detect(os.getcwd(), args.harness)
|
| 973 |
|
|
@@ -1043,8 +1009,8 @@ def main() -> int:
|
|
| 1043 |
print(f"tool_calls : {activity.get('tool_calls', 'unknown')}")
|
| 1044 |
if harness not in KNOWN_HARNESSES:
|
| 1045 |
print(
|
| 1046 |
-
f"note : '{harness}' has no adapter —
|
| 1047 |
-
"
|
| 1048 |
+ (" + native log" if share == "full" else "")
|
| 1049 |
)
|
| 1050 |
|
|
@@ -1076,7 +1042,7 @@ def main() -> int:
|
|
| 1076 |
(args.slug, "--slug/COLLAB_SLUG"),
|
| 1077 |
]
|
| 1078 |
if promote:
|
| 1079 |
-
required.append((args.backend, "--backend/COLLAB_BACKEND
|
| 1080 |
for req, name in required:
|
| 1081 |
if not req:
|
| 1082 |
sys.exit(f"missing {name}")
|
|
@@ -1124,13 +1090,13 @@ def main() -> int:
|
|
| 1124 |
detail = (e.read().decode(errors="replace") if isinstance(e, urllib.error.HTTPError)
|
| 1125 |
else str(getattr(e, "reason", e)))
|
| 1126 |
# The bundle is already in the bucket — a promote failure is partial,
|
| 1127 |
-
# not total. Make that legible
|
| 1128 |
print(f"\n✓ bundle uploaded to {source}")
|
| 1129 |
print(f"⚠ backend promotion failed [{code}]: {detail.strip()[:200]}")
|
| 1130 |
print(" your trace is safe in your scratch bucket. Re-run with "
|
| 1131 |
"--upload-only to skip promotion, or tell the organizers if "
|
| 1132 |
"POST /v1/traces should be available on this collab.")
|
| 1133 |
-
return
|
| 1134 |
print(f"promoted: {promoted_text}")
|
| 1135 |
try:
|
| 1136 |
promoted = json.loads(promoted_text)
|
|
|
|
| 22 |
* `--dry-run` prints the manifest and a typed redaction summary without writing
|
| 23 |
or uploading anything — run it first to verify.
|
| 24 |
|
| 25 |
+
python share_trace.py # stats only; no content leaves (the floor)
|
| 26 |
+
python share_trace.py --upload-only # write to scratch bucket; skip backend promotion
|
| 27 |
+
python share_trace.py --full --yes # FULL: stats + balanced-redacted log -> library
|
| 28 |
+
python share_trace.py --full --privacy secrets # credentials only; preserve PII
|
| 29 |
+
python share_trace.py --full --privacy strict # also pseudonymize hosts + IPs
|
| 30 |
+
python share_trace.py --full --raw # UNSAFE: full, skip all redaction
|
| 31 |
+
python share_trace.py --dry-run # print the plan + manifest; touch nothing
|
| 32 |
|
| 33 |
`full` lets Hugging Face's built-in trace viewer render the native log directly
|
| 34 |
from the bucket (Claude Code & Codex supported out of the box). Redaction parses
|
|
|
|
| 48 |
(which is valid YAML, so the backend parses it identically) and the upload
|
| 49 |
shells out to the `hf` CLI (which you already use for `hf auth login`). Org/slug
|
| 50 |
are auto-discovered from the backend's `GET /v1`, so you only need `--backend`
|
| 51 |
+
(or `COLLAB_BACKEND`) and your `--agent-id`. The per-harness adapters are inlined
|
| 52 |
+
below; keep them in sync with the verified recipes (memory:
|
| 53 |
cc-codex-trace-metric-extraction).
|
| 54 |
"""
|
| 55 |
from __future__ import annotations
|
|
|
|
| 69 |
from pathlib import Path
|
| 70 |
|
| 71 |
|
| 72 |
+
ADAPTER_VERSION = 1
|
| 73 |
REDACTOR_VERSION = 2
|
|
|
|
| 74 |
KNOWN_HARNESSES = ("claude-code", "codex")
|
| 75 |
PRIVACY_LEVELS = ("secrets", "balanced", "strict")
|
| 76 |
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
def adapter_claude_code(log_path: Path) -> dict:
|
| 103 |
+
"""~/.claude/projects/<slug>/<session_id>.jsonl — per-response usage is SUMMED."""
|
| 104 |
+
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0}
|
| 105 |
+
saw_usage = False
|
|
|
|
|
|
|
|
|
|
| 106 |
tools: dict[str, int] = {}
|
| 107 |
+
api_requests = 0
|
| 108 |
model = None
|
| 109 |
session_id = log_path.stem
|
| 110 |
first_ts = last_ts = None
|
|
|
|
| 118 |
session_id = rec["sessionId"]
|
| 119 |
if rec.get("type") != "assistant":
|
| 120 |
continue
|
| 121 |
+
api_requests += 1
|
| 122 |
msg = rec.get("message") or {}
|
| 123 |
if msg.get("model"):
|
| 124 |
model = msg["model"]
|
| 125 |
+
u = msg.get("usage") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
if u:
|
| 127 |
saw_usage = True
|
| 128 |
usage["input_tokens"] += _int(u.get("input_tokens")) or 0
|
| 129 |
usage["output_tokens"] += _int(u.get("output_tokens")) or 0
|
| 130 |
usage["cache_read_tokens"] += _int(u.get("cache_read_input_tokens")) or 0
|
| 131 |
usage["cache_creation_tokens"] += _int(u.get("cache_creation_input_tokens")) or 0
|
| 132 |
+
for block in msg.get("content") or []:
|
| 133 |
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
| 134 |
+
name = block.get("name") or "?"
|
| 135 |
+
tools[name] = tools.get(name, 0) + 1
|
| 136 |
|
| 137 |
fields: dict = {
|
| 138 |
"harness": "claude-code",
|
|
|
|
| 141 |
"started_at": first_ts,
|
| 142 |
"ended_at": last_ts,
|
| 143 |
"activity": {"tool_calls": sum(tools.values()), "tool_calls_by_name": tools},
|
| 144 |
+
"extensions": {"api_requests": api_requests},
|
| 145 |
}
|
| 146 |
if saw_usage:
|
| 147 |
usage["total_tokens"] = sum(usage.values())
|
|
|
|
| 153 |
|
| 154 |
|
| 155 |
def adapter_codex(log_path: Path) -> dict:
|
| 156 |
+
"""~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — token_count is CUMULATIVE
|
| 157 |
(take the last); dedupe tool calls by call_id (MCP appears twice)."""
|
| 158 |
last_usage = None
|
| 159 |
tools: dict[str, int] = {}
|
|
|
|
| 241 |
return fn(log_path) if fn else adapter_minimal(log_path, harness)
|
| 242 |
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
def _cc_project_dir(cwd: str) -> Path:
|
| 245 |
slug = re.sub(r"[/._]", "-", os.path.abspath(cwd))
|
| 246 |
+
return Path.home() / ".claude" / "projects" / slug
|
| 247 |
|
| 248 |
|
| 249 |
def _latest(paths: list[Path]) -> Path | None:
|
|
|
|
| 257 |
|
| 258 |
|
| 259 |
def _codex_logs() -> list[Path]:
|
| 260 |
+
codex_root = Path.home() / ".codex" / "sessions"
|
| 261 |
return sorted(
|
| 262 |
[Path(p) for p in glob.glob(str(codex_root / "**" / "rollout-*.jsonl"), recursive=True)],
|
| 263 |
key=lambda p: p.stat().st_mtime if p.is_file() else 0,
|
|
|
|
| 268 |
def _mentions_cwd(value, cwd: str) -> bool:
|
| 269 |
cwd_abs = os.path.abspath(cwd)
|
| 270 |
if isinstance(value, str):
|
| 271 |
+
if cwd_abs in value:
|
|
|
|
| 272 |
return True
|
| 273 |
try:
|
| 274 |
return os.path.abspath(os.path.expanduser(value)) == cwd_abs
|
|
|
|
| 299 |
|
| 300 |
|
| 301 |
def _infer_harness(log_path: Path) -> str | None:
|
| 302 |
+
s = str(log_path)
|
| 303 |
+
if "/.codex/sessions/" in s or log_path.name.startswith("rollout-"):
|
|
|
|
|
|
|
| 304 |
return "codex"
|
| 305 |
+
if "/.claude/projects/" in s:
|
| 306 |
return "claude-code"
|
| 307 |
return None
|
| 308 |
|
|
|
|
| 316 |
- Claude Code sets CLAUDE_CODE_SESSION_ID (the exact session) + CLAUDECODE=1.
|
| 317 |
- Codex sets CODEX_SANDBOX* in its (default) sandboxed exec but exposes NO
|
| 318 |
session id — so we know it's Codex, but still locate the rollout by cwd.
|
|
|
|
|
|
|
| 319 |
"""
|
| 320 |
sid = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
| 321 |
if sid or os.environ.get("CLAUDECODE"):
|
|
|
|
| 822 |
raw: bool,
|
| 823 |
privacy: str,
|
| 824 |
) -> None:
|
|
|
|
|
|
|
|
|
|
| 825 |
reasons = []
|
| 826 |
if uncertain:
|
| 827 |
reasons.append("Could not pin the exact invoking session — selection fell back to "
|
| 828 |
"the newest log for this directory; confirm it is the right one.")
|
| 829 |
+
if share == "full":
|
| 830 |
+
if raw:
|
| 831 |
+
reasons.append(
|
| 832 |
+
"Full --raw sharing uploads the UNREDACTED native session log "
|
| 833 |
+
"to your org-readable scratch bucket."
|
| 834 |
+
)
|
| 835 |
+
else:
|
| 836 |
+
reasons.append(
|
| 837 |
+
f"Full sharing uploads the {privacy}-redacted native session log "
|
| 838 |
+
"to your org-readable scratch bucket."
|
| 839 |
+
)
|
| 840 |
+
if not reasons or yes:
|
| 841 |
+
return
|
| 842 |
print("\nconfirmation required:")
|
| 843 |
for reason in reasons:
|
| 844 |
print(f"- {reason}")
|
|
|
|
| 881 |
sys.exit(f"`hf buckets cp` failed [{r.returncode}]:\n{(r.stderr or r.stdout).strip()}")
|
| 882 |
|
| 883 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
def main() -> int:
|
| 885 |
ap = argparse.ArgumentParser(description="Share a session's stats / trace with the collaboration.")
|
| 886 |
+
ap.add_argument("--harness", choices=[*KNOWN_HARNESSES, "auto"], default="auto",
|
| 887 |
+
help="default: auto-detect from this cwd (Claude Code, then Codex)")
|
|
|
|
| 888 |
ap.add_argument("--transcript", help="explicit native session log path (else: detected)")
|
| 889 |
ap.add_argument("--session-id", help="override the manifest/dest session id")
|
| 890 |
ap.add_argument("--full", action="store_true", help="also upload the redacted native session log")
|
|
|
|
| 899 |
ap.add_argument("--agent-id", default=os.environ.get("AGENT_ID"), help="your registered agent_id")
|
| 900 |
ap.add_argument("--org", default=os.environ.get("ORG"), help="challenge org")
|
| 901 |
ap.add_argument("--slug", default=os.environ.get("COLLAB_SLUG"), help="challenge slug")
|
| 902 |
+
ap.add_argument("--backend", default=os.environ.get("COLLAB_BACKEND"),
|
| 903 |
help="the backend Space base URL, e.g. https://<org>-<slug>-bucket-sync.hf.space")
|
| 904 |
ap.add_argument("--upload-only", action="store_true",
|
| 905 |
help="write the bundle to your scratch bucket and skip POST /v1/traces")
|
| 906 |
+
ap.add_argument("--yes", action="store_true", help="confirm --full/global Codex fallback in non-interactive use")
|
| 907 |
ap.add_argument("--dry-run", action="store_true", help="print the plan + manifest; touch nothing")
|
| 908 |
args = ap.parse_args()
|
| 909 |
if args.full and args.stats_only:
|
|
|
|
| 934 |
harness = args.harness if args.harness != "auto" else _infer_harness(log_path)
|
| 935 |
if harness is None:
|
| 936 |
sys.exit("could not infer harness from --transcript; pass --harness")
|
|
|
|
|
|
|
| 937 |
else:
|
| 938 |
harness, log_path, uncertain = detect(os.getcwd(), args.harness)
|
| 939 |
|
|
|
|
| 1009 |
print(f"tool_calls : {activity.get('tool_calls', 'unknown')}")
|
| 1010 |
if harness not in KNOWN_HARNESSES:
|
| 1011 |
print(
|
| 1012 |
+
f"note : '{harness}' has no adapter — shipping a minimal "
|
| 1013 |
+
"manifest (partial)"
|
| 1014 |
+ (" + native log" if share == "full" else "")
|
| 1015 |
)
|
| 1016 |
|
|
|
|
| 1042 |
(args.slug, "--slug/COLLAB_SLUG"),
|
| 1043 |
]
|
| 1044 |
if promote:
|
| 1045 |
+
required.append((args.backend, "--backend/COLLAB_BACKEND"))
|
| 1046 |
for req, name in required:
|
| 1047 |
if not req:
|
| 1048 |
sys.exit(f"missing {name}")
|
|
|
|
| 1090 |
detail = (e.read().decode(errors="replace") if isinstance(e, urllib.error.HTTPError)
|
| 1091 |
else str(getattr(e, "reason", e)))
|
| 1092 |
# The bundle is already in the bucket — a promote failure is partial,
|
| 1093 |
+
# not total. Make that legible and exit 0 (the upload succeeded).
|
| 1094 |
print(f"\n✓ bundle uploaded to {source}")
|
| 1095 |
print(f"⚠ backend promotion failed [{code}]: {detail.strip()[:200]}")
|
| 1096 |
print(" your trace is safe in your scratch bucket. Re-run with "
|
| 1097 |
"--upload-only to skip promotion, or tell the organizers if "
|
| 1098 |
"POST /v1/traces should be available on this collab.")
|
| 1099 |
+
return 0
|
| 1100 |
print(f"promoted: {promoted_text}")
|
| 1101 |
try:
|
| 1102 |
promoted = json.loads(promoted_text)
|
scripts/backfill_inbox.py
CHANGED
|
@@ -71,7 +71,7 @@ def main() -> int:
|
|
| 71 |
)
|
| 72 |
failed_downloads = len(messages) - len(raw_by_path)
|
| 73 |
if failed_downloads:
|
| 74 |
-
print(f"WARNING: {failed_downloads}
|
| 75 |
|
| 76 |
planned: list[tuple[bytes, str]] = []
|
| 77 |
unparseable = 0
|
|
|
|
| 71 |
)
|
| 72 |
failed_downloads = len(messages) - len(raw_by_path)
|
| 73 |
if failed_downloads:
|
| 74 |
+
print(f"WARNING: {failed_downloads} message(s) failed to download; re-run to cover them")
|
| 75 |
|
| 76 |
planned: list[tuple[bytes, str]] = []
|
| 77 |
unparseable = 0
|
scripts/dev_server.py
CHANGED
|
@@ -33,13 +33,11 @@ os.environ.setdefault("RAW_MESSAGE_PER_HOUR", "1000")
|
|
| 33 |
|
| 34 |
from app.audit import AuditLogger # noqa: E402
|
| 35 |
from app.config import Settings # noqa: E402
|
| 36 |
-
from app.dedup import PromotionLRU
|
| 37 |
from app.deps import ( # noqa: E402
|
| 38 |
-
bucket_write_limiter,
|
| 39 |
get_audit,
|
| 40 |
get_bucket_write_limiter,
|
| 41 |
get_dedup,
|
| 42 |
-
get_recent_posts,
|
| 43 |
get_hub,
|
| 44 |
get_notifier,
|
| 45 |
get_org_roles,
|
|
@@ -49,12 +47,11 @@ from app.deps import ( # noqa: E402
|
|
| 49 |
get_settings_dep,
|
| 50 |
get_verification_status,
|
| 51 |
get_verifier,
|
| 52 |
-
raw_message_limiter,
|
| 53 |
)
|
| 54 |
from app.main import app as fastapi_app # noqa: E402
|
| 55 |
from app.notify import Notifier # noqa: E402
|
| 56 |
from app.org_roles import OrgRoles # noqa: E402
|
| 57 |
-
from app.rate_limit import TokenBucket
|
| 58 |
from app.read_model import ReadModel # noqa: E402
|
| 59 |
from app.verification import VerificationStatusStore # noqa: E402
|
| 60 |
from app.verifier import Verifier # noqa: E402
|
|
@@ -137,6 +134,10 @@ class PersistentFakeHub(FakeHub):
|
|
| 137 |
super().write_bytes_to_bucket(bucket, path, data)
|
| 138 |
self._persist(bucket, path)
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
def write_bytes_audit(self, path: str, data: bytes) -> None:
|
| 141 |
super().write_bytes_audit(path, data)
|
| 142 |
self._persist(self._settings.audit_bucket, path)
|
|
@@ -234,7 +235,6 @@ def main() -> None:
|
|
| 234 |
|
| 235 |
read_model = ReadModel(hub, settings)
|
| 236 |
dedup = PromotionLRU(settings.dedup_lru_size)
|
| 237 |
-
recent_posts = RecentPosts(settings.dedup_lru_size)
|
| 238 |
verification = VerificationStatusStore(hub, runs_prefix=settings.verification_runs_prefix)
|
| 239 |
# Every singleton in app/deps.py reads the env-backed settings, so each one
|
| 240 |
# this Settings() must reach needs an override below — the notifier included,
|
|
@@ -244,13 +244,18 @@ def main() -> None:
|
|
| 244 |
max_waiters_total=settings.longpoll_max_waiters_total,
|
| 245 |
wake_spread_s=settings.longpoll_wake_spread_s,
|
| 246 |
wake_spread_threshold=settings.longpoll_wake_spread_threshold,
|
| 247 |
-
parked_window_s=2 * settings.longpoll_max_wait_s,
|
| 248 |
)
|
| 249 |
# The verifier posts verdict messages, so it holds the notifier too — without
|
| 250 |
# it a verdict would land silently and never wake a parked watcher.
|
| 251 |
verifier = Verifier(settings, hub, read_model, verification, FakeJobRunner(),
|
| 252 |
spawn=lambda _name, fn: fn(), notifier=notifier)
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
fastapi_app.dependency_overrides.update({
|
| 255 |
get_settings_dep: lambda: settings,
|
| 256 |
get_hub: lambda: hub,
|
|
@@ -259,13 +264,14 @@ def main() -> None:
|
|
| 259 |
get_org_roles: lambda: OrgRoles(hub, settings),
|
| 260 |
get_audit: lambda: AuditLogger(hub),
|
| 261 |
get_dedup: lambda: dedup,
|
| 262 |
-
get_recent_posts: lambda: recent_posts,
|
| 263 |
get_verification_status: lambda: verification,
|
| 264 |
get_verifier: lambda: verifier,
|
| 265 |
# Real (env-tunable) production limiter shapes — the point of the
|
| 266 |
# test environment is realism, just with dev-friendly defaults.
|
| 267 |
-
get_bucket_write_limiter: lambda:
|
| 268 |
-
|
|
|
|
|
|
|
| 269 |
get_registration_limiter: lambda: TokenBucket(
|
| 270 |
capacity=settings.registration_per_minute,
|
| 271 |
refill_per_minute=settings.registration_per_minute),
|
|
|
|
| 33 |
|
| 34 |
from app.audit import AuditLogger # noqa: E402
|
| 35 |
from app.config import Settings # noqa: E402
|
| 36 |
+
from app.dedup import PromotionLRU # noqa: E402
|
| 37 |
from app.deps import ( # noqa: E402
|
|
|
|
| 38 |
get_audit,
|
| 39 |
get_bucket_write_limiter,
|
| 40 |
get_dedup,
|
|
|
|
| 41 |
get_hub,
|
| 42 |
get_notifier,
|
| 43 |
get_org_roles,
|
|
|
|
| 47 |
get_settings_dep,
|
| 48 |
get_verification_status,
|
| 49 |
get_verifier,
|
|
|
|
| 50 |
)
|
| 51 |
from app.main import app as fastapi_app # noqa: E402
|
| 52 |
from app.notify import Notifier # noqa: E402
|
| 53 |
from app.org_roles import OrgRoles # noqa: E402
|
| 54 |
+
from app.rate_limit import CompoundLimiter, TokenBucket # noqa: E402
|
| 55 |
from app.read_model import ReadModel # noqa: E402
|
| 56 |
from app.verification import VerificationStatusStore # noqa: E402
|
| 57 |
from app.verifier import Verifier # noqa: E402
|
|
|
|
| 134 |
super().write_bytes_to_bucket(bucket, path, data)
|
| 135 |
self._persist(bucket, path)
|
| 136 |
|
| 137 |
+
def append_jsonl_audit(self, path: str, line: str) -> None:
|
| 138 |
+
super().append_jsonl_audit(path, line)
|
| 139 |
+
self._persist(self._settings.audit_bucket, path)
|
| 140 |
+
|
| 141 |
def write_bytes_audit(self, path: str, data: bytes) -> None:
|
| 142 |
super().write_bytes_audit(path, data)
|
| 143 |
self._persist(self._settings.audit_bucket, path)
|
|
|
|
| 235 |
|
| 236 |
read_model = ReadModel(hub, settings)
|
| 237 |
dedup = PromotionLRU(settings.dedup_lru_size)
|
|
|
|
| 238 |
verification = VerificationStatusStore(hub, runs_prefix=settings.verification_runs_prefix)
|
| 239 |
# Every singleton in app/deps.py reads the env-backed settings, so each one
|
| 240 |
# this Settings() must reach needs an override below — the notifier included,
|
|
|
|
| 244 |
max_waiters_total=settings.longpoll_max_waiters_total,
|
| 245 |
wake_spread_s=settings.longpoll_wake_spread_s,
|
| 246 |
wake_spread_threshold=settings.longpoll_wake_spread_threshold,
|
|
|
|
| 247 |
)
|
| 248 |
# The verifier posts verdict messages, so it holds the notifier too — without
|
| 249 |
# it a verdict would land silently and never wake a parked watcher.
|
| 250 |
verifier = Verifier(settings, hub, read_model, verification, FakeJobRunner(),
|
| 251 |
spawn=lambda _name, fn: fn(), notifier=notifier)
|
| 252 |
|
| 253 |
+
def compound(burst: int, sustained: int) -> CompoundLimiter:
|
| 254 |
+
return CompoundLimiter(
|
| 255 |
+
TokenBucket(capacity=burst, refill_per_minute=burst),
|
| 256 |
+
TokenBucket(capacity=sustained, refill_per_minute=sustained),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
fastapi_app.dependency_overrides.update({
|
| 260 |
get_settings_dep: lambda: settings,
|
| 261 |
get_hub: lambda: hub,
|
|
|
|
| 264 |
get_org_roles: lambda: OrgRoles(hub, settings),
|
| 265 |
get_audit: lambda: AuditLogger(hub),
|
| 266 |
get_dedup: lambda: dedup,
|
|
|
|
| 267 |
get_verification_status: lambda: verification,
|
| 268 |
get_verifier: lambda: verifier,
|
| 269 |
# Real (env-tunable) production limiter shapes — the point of the
|
| 270 |
# test environment is realism, just with dev-friendly defaults.
|
| 271 |
+
get_bucket_write_limiter: lambda: compound(
|
| 272 |
+
settings.bucket_write_burst, settings.bucket_write_per_minute),
|
| 273 |
+
get_raw_message_limiter: lambda: compound(
|
| 274 |
+
settings.raw_message_per_minute, settings.raw_message_per_hour),
|
| 275 |
get_registration_limiter: lambda: TokenBucket(
|
| 276 |
capacity=settings.registration_per_minute,
|
| 277 |
refill_per_minute=settings.registration_per_minute),
|
tests/conftest.py
CHANGED
|
@@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
|
|
| 8 |
from app.announce import reset_stamp_guard
|
| 9 |
from app.audit import AuditLogger
|
| 10 |
from app.config import Settings
|
| 11 |
-
from app.dedup import PromotionLRU
|
| 12 |
from app.deps import (
|
| 13 |
get_audit,
|
| 14 |
get_bucket_write_limiter,
|
|
@@ -18,7 +18,6 @@ from app.deps import (
|
|
| 18 |
get_org_roles,
|
| 19 |
get_raw_message_limiter,
|
| 20 |
get_read_model,
|
| 21 |
-
get_recent_posts,
|
| 22 |
get_registration_limiter,
|
| 23 |
get_settings_dep,
|
| 24 |
get_verification_status,
|
|
@@ -57,7 +56,6 @@ def make_env():
|
|
| 57 |
# One LRU per env, like production's process-wide singleton — a
|
| 58 |
# per-request instance would make cross-request dedup untestable.
|
| 59 |
dedup = PromotionLRU(10_000)
|
| 60 |
-
recent_posts = RecentPosts(10_000)
|
| 61 |
verification = VerificationStatusStore(
|
| 62 |
hub, runs_prefix=settings.verification_runs_prefix
|
| 63 |
)
|
|
@@ -68,7 +66,6 @@ def make_env():
|
|
| 68 |
max_waiters_total=settings.longpoll_max_waiters_total,
|
| 69 |
wake_spread_s=settings.longpoll_wake_spread_s,
|
| 70 |
wake_spread_threshold=settings.longpoll_wake_spread_threshold,
|
| 71 |
-
parked_window_s=2 * settings.longpoll_max_wait_s,
|
| 72 |
)
|
| 73 |
runner = FakeJobRunner()
|
| 74 |
# Inline spawn: the verdict watcher runs synchronously inside the POST,
|
|
@@ -89,11 +86,8 @@ def make_env():
|
|
| 89 |
get_read_model: lambda: read_model,
|
| 90 |
get_notifier: lambda: notifier,
|
| 91 |
get_org_roles: lambda: OrgRoles(hub, settings),
|
| 92 |
-
get_audit: lambda: AuditLogger(
|
| 93 |
-
hub, record_client=not settings.audit_bucket_in_org
|
| 94 |
-
),
|
| 95 |
get_dedup: lambda: dedup,
|
| 96 |
-
get_recent_posts: lambda: recent_posts,
|
| 97 |
get_verification_status: lambda: verification,
|
| 98 |
get_verifier: lambda: verifier,
|
| 99 |
get_bucket_write_limiter: generous,
|
|
|
|
| 8 |
from app.announce import reset_stamp_guard
|
| 9 |
from app.audit import AuditLogger
|
| 10 |
from app.config import Settings
|
| 11 |
+
from app.dedup import PromotionLRU
|
| 12 |
from app.deps import (
|
| 13 |
get_audit,
|
| 14 |
get_bucket_write_limiter,
|
|
|
|
| 18 |
get_org_roles,
|
| 19 |
get_raw_message_limiter,
|
| 20 |
get_read_model,
|
|
|
|
| 21 |
get_registration_limiter,
|
| 22 |
get_settings_dep,
|
| 23 |
get_verification_status,
|
|
|
|
| 56 |
# One LRU per env, like production's process-wide singleton — a
|
| 57 |
# per-request instance would make cross-request dedup untestable.
|
| 58 |
dedup = PromotionLRU(10_000)
|
|
|
|
| 59 |
verification = VerificationStatusStore(
|
| 60 |
hub, runs_prefix=settings.verification_runs_prefix
|
| 61 |
)
|
|
|
|
| 66 |
max_waiters_total=settings.longpoll_max_waiters_total,
|
| 67 |
wake_spread_s=settings.longpoll_wake_spread_s,
|
| 68 |
wake_spread_threshold=settings.longpoll_wake_spread_threshold,
|
|
|
|
| 69 |
)
|
| 70 |
runner = FakeJobRunner()
|
| 71 |
# Inline spawn: the verdict watcher runs synchronously inside the POST,
|
|
|
|
| 86 |
get_read_model: lambda: read_model,
|
| 87 |
get_notifier: lambda: notifier,
|
| 88 |
get_org_roles: lambda: OrgRoles(hub, settings),
|
| 89 |
+
get_audit: lambda: AuditLogger(hub),
|
|
|
|
|
|
|
| 90 |
get_dedup: lambda: dedup,
|
|
|
|
| 91 |
get_verification_status: lambda: verification,
|
| 92 |
get_verifier: lambda: verifier,
|
| 93 |
get_bucket_write_limiter: generous,
|
tests/fakes.py
CHANGED
|
@@ -2,31 +2,13 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import hashlib
|
| 5 |
-
import time
|
| 6 |
-
|
| 7 |
-
import httpx
|
| 8 |
-
from huggingface_hub.errors import HfHubHTTPError
|
| 9 |
|
| 10 |
from app.config import Settings
|
| 11 |
from app.frontmatter import serialise
|
| 12 |
-
from app.hub import
|
| 13 |
-
DownloadFailed,
|
| 14 |
-
HubIdentity,
|
| 15 |
-
HubUnreachable,
|
| 16 |
-
ListedFile,
|
| 17 |
-
ListingFailed,
|
| 18 |
-
OrgMemberRole,
|
| 19 |
-
)
|
| 20 |
from app.naming import SourceURI, parse_source_uri
|
| 21 |
|
| 22 |
|
| 23 |
-
def _http_error(message: str = "simulated hub write failure") -> HfHubHTTPError:
|
| 24 |
-
"""An HfHubHTTPError shaped like the one HubClient lets propagate from a
|
| 25 |
-
failed batch_bucket_files call (writes aren't caught in hub.py)."""
|
| 26 |
-
request = httpx.Request("PUT", "https://fake-hub.test/batch")
|
| 27 |
-
return HfHubHTTPError(message, response=httpx.Response(500, request=request))
|
| 28 |
-
|
| 29 |
-
|
| 30 |
class FakeHub:
|
| 31 |
"""In-memory HubClient: buckets are dicts of {path: bytes}; xet_hash is
|
| 32 |
sha256 — content-derived, exactly the property the read model relies on."""
|
|
@@ -42,13 +24,6 @@ class FakeHub:
|
|
| 42 |
self.batch_writes: list[list[str]] = []
|
| 43 |
self.deletes: list[str] = []
|
| 44 |
self.fail_listings = False
|
| 45 |
-
# Chaos toggles (see fail_next_write/fail_next_read/partial_listing/
|
| 46 |
-
# fail_next_listing below): each is one-shot and resets after firing.
|
| 47 |
-
self.latency_s: float = 0.0
|
| 48 |
-
self._fail_write: Exception | None = None
|
| 49 |
-
self._fail_read: tuple[str | None, Exception | None] | None = None
|
| 50 |
-
self._fail_listing_folder: str | None = None
|
| 51 |
-
self._partial_listing: tuple[str, int] | None = None
|
| 52 |
# Scripted whoami identity for token-authenticated paths
|
| 53 |
# (registration handshake, human message posts).
|
| 54 |
self.whoami_user = "test-user"
|
|
@@ -60,6 +35,11 @@ class FakeHub:
|
|
| 60 |
# and only the owner may write (the org ACL). A bucket seeded by a
|
| 61 |
# test without an owner is writable by the caller.
|
| 62 |
self.bucket_owners: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
self.caller_writes: list[tuple[str, str, str]] = [] # (bucket, path, token)
|
| 64 |
self.created_buckets: list[tuple[str, str]] = [] # (bucket, token)
|
| 65 |
# Scripted challenge-org member roles for the organizer-broadcast gate.
|
|
@@ -78,67 +58,11 @@ class FakeHub:
|
|
| 78 |
b = bucket or self._settings.central_bucket
|
| 79 |
self.buckets.setdefault(b, {})[path] = text.encode("utf-8")
|
| 80 |
|
| 81 |
-
# ── chaos toggles ────────────────────────────────────────────────
|
| 82 |
-
def fail_next_write(self, exc: Exception | None = None) -> None:
|
| 83 |
-
"""The next write/batch call raises `exc` (default: an HfHubHTTPError,
|
| 84 |
-
matching what a real batch_bucket_files failure surfaces as)."""
|
| 85 |
-
self._fail_write = exc or _http_error()
|
| 86 |
-
|
| 87 |
-
def fail_next_read(self, path_substring: str | None = None, exc: Exception | None = None) -> None:
|
| 88 |
-
"""The next read whose path contains `path_substring` (any path, if
|
| 89 |
-
omitted) raises `exc` (default: FileNotFoundError; DownloadFailed for
|
| 90 |
-
download_many, as HubClient raises once its retry is spent)."""
|
| 91 |
-
self._fail_read = (path_substring, exc)
|
| 92 |
-
|
| 93 |
-
def partial_listing(self, folder: str, drop: int = 1) -> None:
|
| 94 |
-
"""The next listing of `folder` is interrupted after all but its last
|
| 95 |
-
`drop` entries, as if a list_bucket_tree page failed mid-way. Like
|
| 96 |
-
HubClient._list, the pages already read are discarded and the call
|
| 97 |
-
raises ListingFailed — a partial listing is never returned."""
|
| 98 |
-
self._partial_listing = (folder, drop)
|
| 99 |
-
|
| 100 |
-
def fail_next_listing(self, folder: str) -> None:
|
| 101 |
-
"""The next listing of `folder` raises ListingFailed, as HubClient._list
|
| 102 |
-
does for any list_bucket_tree failure."""
|
| 103 |
-
self._fail_listing_folder = folder
|
| 104 |
-
|
| 105 |
-
def _maybe_sleep(self) -> None:
|
| 106 |
-
if self.latency_s:
|
| 107 |
-
time.sleep(self.latency_s)
|
| 108 |
-
|
| 109 |
-
def _maybe_fail_write(self) -> None:
|
| 110 |
-
self._maybe_sleep()
|
| 111 |
-
if self._fail_write is not None:
|
| 112 |
-
exc, self._fail_write = self._fail_write, None
|
| 113 |
-
raise exc
|
| 114 |
-
|
| 115 |
-
def _maybe_fail_read(self, path: str) -> None:
|
| 116 |
-
self._maybe_sleep()
|
| 117 |
-
if self._fail_read is None:
|
| 118 |
-
return
|
| 119 |
-
substring, exc = self._fail_read
|
| 120 |
-
if substring is not None and substring not in path:
|
| 121 |
-
return
|
| 122 |
-
self._fail_read = None
|
| 123 |
-
raise exc or FileNotFoundError(path)
|
| 124 |
-
|
| 125 |
-
def _apply_listing_toggles(self, prefix: str, files: list[ListedFile]) -> list[ListedFile]:
|
| 126 |
-
self._maybe_sleep()
|
| 127 |
-
if self._fail_listing_folder == prefix:
|
| 128 |
-
self._fail_listing_folder = None
|
| 129 |
-
raise ListingFailed(f"simulated listing failure: {prefix}")
|
| 130 |
-
if self._partial_listing is not None and self._partial_listing[0] == prefix:
|
| 131 |
-
_, drop = self._partial_listing
|
| 132 |
-
self._partial_listing = None
|
| 133 |
-
if drop:
|
| 134 |
-
raise ListingFailed(
|
| 135 |
-
f"simulated failure after {len(files) - drop} entries: {prefix}"
|
| 136 |
-
)
|
| 137 |
-
return files
|
| 138 |
-
|
| 139 |
# ── HubClient surface used by the app ────────────────────────────
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
| 142 |
p = prefix.rstrip("/") + "/" if prefix else ""
|
| 143 |
return [
|
| 144 |
ListedFile(
|
|
@@ -146,59 +70,61 @@ class FakeHub:
|
|
| 146 |
size=len(data),
|
| 147 |
xet_hash=hashlib.sha256(data).hexdigest(),
|
| 148 |
)
|
| 149 |
-
for path, data in
|
| 150 |
if path.startswith(p)
|
| 151 |
]
|
| 152 |
|
| 153 |
-
def list_central_dir(self, prefix: str) -> list[ListedFile]:
|
| 154 |
-
self.list_calls += 1
|
| 155 |
-
if self.fail_listings:
|
| 156 |
-
raise ListingFailed(f"simulated listing outage: {prefix}")
|
| 157 |
-
return self._apply_listing_toggles(prefix, self._listed(self._central(), prefix))
|
| 158 |
-
|
| 159 |
def list_bucket_dir(self, bucket: str, prefix: str) -> list[ListedFile]:
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
def download_many(self, bucket: str, remote_paths: list[str]) -> dict[str, bytes]:
|
| 167 |
self.download_calls += 1
|
| 168 |
-
self._maybe_sleep()
|
| 169 |
-
if self._fail_read is not None:
|
| 170 |
-
substring, exc = self._fail_read
|
| 171 |
-
if substring is None or any(substring in p for p in remote_paths):
|
| 172 |
-
self._fail_read = None
|
| 173 |
-
raise exc or DownloadFailed(f"simulated batch download failure: {bucket}")
|
| 174 |
files = self.buckets.get(bucket, {})
|
| 175 |
return {p: files[p] for p in remote_paths if p in files}
|
| 176 |
|
| 177 |
def read_central_text(self, path: str) -> str:
|
| 178 |
-
self._maybe_fail_read(path)
|
| 179 |
files = self._central()
|
| 180 |
-
|
|
|
|
| 181 |
raise FileNotFoundError(path)
|
| 182 |
return files[path].decode("utf-8")
|
| 183 |
|
| 184 |
def read_central_bytes_optional(self, path: str) -> bytes | None:
|
|
|
|
|
|
|
| 185 |
return self._central().get(path)
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
def write_text_central(self, path: str, text: str) -> None:
|
| 188 |
-
self.
|
| 189 |
|
| 190 |
def write_bytes_central(self, path: str, data: bytes) -> None:
|
| 191 |
-
self._maybe_fail_write()
|
| 192 |
self._central()[path] = data
|
| 193 |
|
| 194 |
def write_many_central(self, items: list[tuple[bytes, str]]) -> None:
|
| 195 |
-
self._maybe_fail_write()
|
| 196 |
self.batch_writes.append([p for _, p in items])
|
| 197 |
for data, p in items:
|
| 198 |
self._central()[p] = data
|
| 199 |
|
| 200 |
def delete_central(self, path: str) -> None:
|
| 201 |
-
self._maybe_fail_write()
|
| 202 |
self.deletes.append(path)
|
| 203 |
self._central().pop(path, None)
|
| 204 |
|
|
@@ -206,24 +132,30 @@ class FakeHub:
|
|
| 206 |
parsed = uri if isinstance(uri, SourceURI) else parse_source_uri(uri)
|
| 207 |
if parsed is None:
|
| 208 |
raise ValueError(f"invalid source URI: {uri}")
|
| 209 |
-
|
| 210 |
-
files = self.buckets.get(
|
| 211 |
-
|
|
|
|
| 212 |
raise FileNotFoundError(str(uri))
|
| 213 |
return files[parsed.path]
|
| 214 |
|
| 215 |
def read_text(self, uri) -> str:
|
| 216 |
return self.read_bytes(uri).decode("utf-8")
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
def read_audit_bytes(self, path: str) -> bytes | None:
|
| 219 |
return self.buckets[self._settings.audit_bucket].get(path)
|
| 220 |
|
| 221 |
def write_bytes_audit(self, path: str, data: bytes) -> None:
|
| 222 |
-
self._maybe_fail_write()
|
| 223 |
self.buckets[self._settings.audit_bucket][path] = data
|
| 224 |
|
| 225 |
def write_bytes_to_bucket(self, bucket: str, path: str, data: bytes) -> None:
|
| 226 |
-
self._maybe_fail_write()
|
| 227 |
self.buckets.setdefault(bucket, {})[path] = data
|
| 228 |
|
| 229 |
def write_text_to_bucket(self, bucket: str, path: str, text: str) -> None:
|
|
@@ -232,7 +164,6 @@ class FakeHub:
|
|
| 232 |
def copy_tree_to_central(self, src_bucket: str, src_prefix: str, dest_prefix: str):
|
| 233 |
"""Mirror of HubClient.copy_tree_to_central: hash-copy a prefix into the
|
| 234 |
central bucket, yielding (src_rel_path, dest_path, size)."""
|
| 235 |
-
self._maybe_fail_write()
|
| 236 |
prefix = src_prefix.rstrip("/")
|
| 237 |
central = self._central()
|
| 238 |
out = []
|
|
@@ -248,7 +179,6 @@ class FakeHub:
|
|
| 248 |
def copy_file_to_central(self, src_bucket: str, src_xet_hash: str, dest_path: str) -> None:
|
| 249 |
"""Mirror of HubClient.copy_file_to_central: copy by xet hash (our fake
|
| 250 |
xet_hash is sha256), no listing."""
|
| 251 |
-
self._maybe_fail_write()
|
| 252 |
for data in self.buckets.get(src_bucket, {}).values():
|
| 253 |
if hashlib.sha256(data).hexdigest() == src_xet_hash:
|
| 254 |
self._central()[dest_path] = data
|
|
@@ -264,7 +194,8 @@ class FakeHub:
|
|
| 264 |
self.bucket_owners.setdefault(bucket, self.whoami_user)
|
| 265 |
|
| 266 |
def write_text_as(self, bucket: str, path: str, text: str, token: str) -> None:
|
| 267 |
-
|
|
|
|
| 268 |
raise PermissionError(f"403: {bucket} is not yours")
|
| 269 |
self.caller_writes.append((bucket, path, token))
|
| 270 |
self.write_text_to_bucket(bucket, path, text)
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import hashlib
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from app.config import Settings
|
| 7 |
from app.frontmatter import serialise
|
| 8 |
+
from app.hub import HubIdentity, HubUnreachable, ListedFile, OrgMemberRole
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from app.naming import SourceURI, parse_source_uri
|
| 10 |
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
class FakeHub:
|
| 13 |
"""In-memory HubClient: buckets are dicts of {path: bytes}; xet_hash is
|
| 14 |
sha256 — content-derived, exactly the property the read model relies on."""
|
|
|
|
| 24 |
self.batch_writes: list[list[str]] = []
|
| 25 |
self.deletes: list[str] = []
|
| 26 |
self.fail_listings = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# Scripted whoami identity for token-authenticated paths
|
| 28 |
# (registration handshake, human message posts).
|
| 29 |
self.whoami_user = "test-user"
|
|
|
|
| 35 |
# and only the owner may write (the org ACL). A bucket seeded by a
|
| 36 |
# test without an owner is writable by the caller.
|
| 37 |
self.bucket_owners: dict[str, str] = {}
|
| 38 |
+
# An org admin's token can write every bucket in the org.
|
| 39 |
+
self.caller_is_org_admin = False
|
| 40 |
+
# Paths ("<bucket>/<path>", or a bare central path) whose optional
|
| 41 |
+
# reads fail with a Hub error instead of answering.
|
| 42 |
+
self.failing_reads: set[str] = set()
|
| 43 |
self.caller_writes: list[tuple[str, str, str]] = [] # (bucket, path, token)
|
| 44 |
self.created_buckets: list[tuple[str, str]] = [] # (bucket, token)
|
| 45 |
# Scripted challenge-org member roles for the organizer-broadcast gate.
|
|
|
|
| 58 |
b = bucket or self._settings.central_bucket
|
| 59 |
self.buckets.setdefault(b, {})[path] = text.encode("utf-8")
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
# ── HubClient surface used by the app ────────────────────────────
|
| 62 |
+
def list_central_dir(self, prefix: str) -> list[ListedFile]:
|
| 63 |
+
self.list_calls += 1
|
| 64 |
+
if self.fail_listings:
|
| 65 |
+
return [] # the real hub flattens listing errors to []
|
| 66 |
p = prefix.rstrip("/") + "/" if prefix else ""
|
| 67 |
return [
|
| 68 |
ListedFile(
|
|
|
|
| 70 |
size=len(data),
|
| 71 |
xet_hash=hashlib.sha256(data).hexdigest(),
|
| 72 |
)
|
| 73 |
+
for path, data in self._central().items()
|
| 74 |
if path.startswith(p)
|
| 75 |
]
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def list_bucket_dir(self, bucket: str, prefix: str) -> list[ListedFile]:
|
| 78 |
+
p = prefix.rstrip("/") + "/" if prefix else ""
|
| 79 |
+
return [
|
| 80 |
+
ListedFile(
|
| 81 |
+
rel_path=path,
|
| 82 |
+
size=len(data),
|
| 83 |
+
xet_hash=hashlib.sha256(data).hexdigest(),
|
| 84 |
+
)
|
| 85 |
+
for path, data in self.buckets.get(bucket, {}).items()
|
| 86 |
+
if path.startswith(p)
|
| 87 |
+
]
|
| 88 |
|
| 89 |
def download_many(self, bucket: str, remote_paths: list[str]) -> dict[str, bytes]:
|
| 90 |
self.download_calls += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
files = self.buckets.get(bucket, {})
|
| 92 |
return {p: files[p] for p in remote_paths if p in files}
|
| 93 |
|
| 94 |
def read_central_text(self, path: str) -> str:
|
|
|
|
| 95 |
files = self._central()
|
| 96 |
+
# Like HubClient._download_one, a failed read surfaces as missing.
|
| 97 |
+
if path not in files or path in self.failing_reads:
|
| 98 |
raise FileNotFoundError(path)
|
| 99 |
return files[path].decode("utf-8")
|
| 100 |
|
| 101 |
def read_central_bytes_optional(self, path: str) -> bytes | None:
|
| 102 |
+
if path in self.failing_reads:
|
| 103 |
+
raise RuntimeError(f"hub read failed: {path}")
|
| 104 |
return self._central().get(path)
|
| 105 |
|
| 106 |
+
def read_text_optional(self, uri) -> str | None:
|
| 107 |
+
parsed = uri if isinstance(uri, SourceURI) else parse_source_uri(uri)
|
| 108 |
+
if parsed is None:
|
| 109 |
+
raise ValueError(f"invalid source URI: {uri}")
|
| 110 |
+
bucket = f"{parsed.org}/{parsed.bucket}"
|
| 111 |
+
if f"{bucket}/{parsed.path}" in self.failing_reads:
|
| 112 |
+
raise RuntimeError(f"hub read failed: {bucket}/{parsed.path}")
|
| 113 |
+
data = self.buckets.get(bucket, {}).get(parsed.path)
|
| 114 |
+
return None if data is None else data.decode("utf-8")
|
| 115 |
+
|
| 116 |
def write_text_central(self, path: str, text: str) -> None:
|
| 117 |
+
self._central()[path] = text.encode("utf-8")
|
| 118 |
|
| 119 |
def write_bytes_central(self, path: str, data: bytes) -> None:
|
|
|
|
| 120 |
self._central()[path] = data
|
| 121 |
|
| 122 |
def write_many_central(self, items: list[tuple[bytes, str]]) -> None:
|
|
|
|
| 123 |
self.batch_writes.append([p for _, p in items])
|
| 124 |
for data, p in items:
|
| 125 |
self._central()[p] = data
|
| 126 |
|
| 127 |
def delete_central(self, path: str) -> None:
|
|
|
|
| 128 |
self.deletes.append(path)
|
| 129 |
self._central().pop(path, None)
|
| 130 |
|
|
|
|
| 132 |
parsed = uri if isinstance(uri, SourceURI) else parse_source_uri(uri)
|
| 133 |
if parsed is None:
|
| 134 |
raise ValueError(f"invalid source URI: {uri}")
|
| 135 |
+
bucket = f"{parsed.org}/{parsed.bucket}"
|
| 136 |
+
files = self.buckets.get(bucket, {})
|
| 137 |
+
# Like HubClient._download_one, a failed read surfaces as missing.
|
| 138 |
+
if parsed.path not in files or f"{bucket}/{parsed.path}" in self.failing_reads:
|
| 139 |
raise FileNotFoundError(str(uri))
|
| 140 |
return files[parsed.path]
|
| 141 |
|
| 142 |
def read_text(self, uri) -> str:
|
| 143 |
return self.read_bytes(uri).decode("utf-8")
|
| 144 |
|
| 145 |
+
def append_jsonl_audit(self, path: str, line: str) -> None:
|
| 146 |
+
b = self.buckets[self._settings.audit_bucket]
|
| 147 |
+
existing = b.get(path, b"")
|
| 148 |
+
if existing and not existing.endswith(b"\n"):
|
| 149 |
+
existing += b"\n"
|
| 150 |
+
b[path] = existing + line.encode("utf-8") + b"\n"
|
| 151 |
+
|
| 152 |
def read_audit_bytes(self, path: str) -> bytes | None:
|
| 153 |
return self.buckets[self._settings.audit_bucket].get(path)
|
| 154 |
|
| 155 |
def write_bytes_audit(self, path: str, data: bytes) -> None:
|
|
|
|
| 156 |
self.buckets[self._settings.audit_bucket][path] = data
|
| 157 |
|
| 158 |
def write_bytes_to_bucket(self, bucket: str, path: str, data: bytes) -> None:
|
|
|
|
| 159 |
self.buckets.setdefault(bucket, {})[path] = data
|
| 160 |
|
| 161 |
def write_text_to_bucket(self, bucket: str, path: str, text: str) -> None:
|
|
|
|
| 164 |
def copy_tree_to_central(self, src_bucket: str, src_prefix: str, dest_prefix: str):
|
| 165 |
"""Mirror of HubClient.copy_tree_to_central: hash-copy a prefix into the
|
| 166 |
central bucket, yielding (src_rel_path, dest_path, size)."""
|
|
|
|
| 167 |
prefix = src_prefix.rstrip("/")
|
| 168 |
central = self._central()
|
| 169 |
out = []
|
|
|
|
| 179 |
def copy_file_to_central(self, src_bucket: str, src_xet_hash: str, dest_path: str) -> None:
|
| 180 |
"""Mirror of HubClient.copy_file_to_central: copy by xet hash (our fake
|
| 181 |
xet_hash is sha256), no listing."""
|
|
|
|
| 182 |
for data in self.buckets.get(src_bucket, {}).values():
|
| 183 |
if hashlib.sha256(data).hexdigest() == src_xet_hash:
|
| 184 |
self._central()[dest_path] = data
|
|
|
|
| 194 |
self.bucket_owners.setdefault(bucket, self.whoami_user)
|
| 195 |
|
| 196 |
def write_text_as(self, bucket: str, path: str, text: str, token: str) -> None:
|
| 197 |
+
owner = self.bucket_owners.get(bucket, self.whoami_user)
|
| 198 |
+
if owner != self.whoami_user and not self.caller_is_org_admin:
|
| 199 |
raise PermissionError(f"403: {bucket} is not yours")
|
| 200 |
self.caller_writes.append((bucket, path, token))
|
| 201 |
self.write_text_to_bucket(bucket, path, text)
|
tests/test_agents_api.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import httpx
|
| 2 |
import pytest
|
| 3 |
-
from huggingface_hub.errors import HfHubHTTPError
|
| 4 |
|
| 5 |
import app.hub as hub_module
|
| 6 |
from app.deps import get_registration_limiter
|
|
@@ -83,10 +83,112 @@ def test_fresh_registration_provisions_bucket_with_caller_token(env):
|
|
| 83 |
assert env.hub.buckets[bucket][".bucket-sync-handshake"] == b"test-user"
|
| 84 |
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
assert _register(env).status_code == 201
|
| 89 |
-
assert env.hub.created_buckets == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
def test_own_bucket_without_handshake_gets_one(env):
|
|
@@ -191,3 +293,22 @@ def test_transient_bucket_check_is_503(env, monkeypatch):
|
|
| 191 |
r = _register(env)
|
| 192 |
assert r.status_code == 503
|
| 193 |
assert r.json()["error"]["code"] == "HUB_UNAVAILABLE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import httpx
|
| 2 |
import pytest
|
| 3 |
+
from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError
|
| 4 |
|
| 5 |
import app.hub as hub_module
|
| 6 |
from app.deps import get_registration_limiter
|
|
|
|
| 83 |
assert env.hub.buckets[bucket][".bucket-sync-handshake"] == b"test-user"
|
| 84 |
|
| 85 |
|
| 86 |
+
BUCKET = "test-org/test-agent-9"
|
| 87 |
+
HANDSHAKE = ".bucket-sync-handshake"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_matching_handshake_is_still_proved_by_a_caller_write(env):
|
| 91 |
+
env.hub.seed(HANDSHAKE, "test-user", bucket=BUCKET)
|
| 92 |
+
env.hub.bucket_owners[BUCKET] = "test-user"
|
| 93 |
assert _register(env).status_code == 201
|
| 94 |
+
assert env.hub.created_buckets == []
|
| 95 |
+
assert env.hub.caller_writes == [(BUCKET, HANDSHAKE, "hf_caller")]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_handshake_planted_in_someone_elses_bucket_is_refused(env):
|
| 99 |
+
# An attacker pre-creates the bucket a victim will register and writes the
|
| 100 |
+
# victim's public username into it. Matching text is not proof: the
|
| 101 |
+
# victim's token cannot write there, so registration must refuse.
|
| 102 |
+
env.hub.seed(HANDSHAKE, "test-user", bucket=BUCKET)
|
| 103 |
+
env.hub.bucket_owners[BUCKET] = "attacker"
|
| 104 |
+
r = _register(env)
|
| 105 |
+
assert r.status_code == 403
|
| 106 |
+
assert r.json()["error"]["code"] == "BUCKET_NOT_YOURS"
|
| 107 |
+
assert env.hub.caller_writes == []
|
| 108 |
+
assert env.client.get("/v1/agents").json()["count"] == 0
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _victim_state(env):
|
| 112 |
+
"""Victim's registration of agent-9 and the handshake in its bucket."""
|
| 113 |
+
seed_agent(env.hub, "agent-9", hf_user="victim")
|
| 114 |
+
env.hub.seed(HANDSHAKE, "victim", bucket=BUCKET)
|
| 115 |
+
env.hub.bucket_owners[BUCKET] = "victim"
|
| 116 |
+
env.hub.caller_is_org_admin = True # the caller could write anywhere
|
| 117 |
+
return dict(env.hub.buckets[env.settings.central_bucket]), dict(env.hub.buckets[BUCKET])
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _assert_untouched(env, before):
|
| 121 |
+
central, bucket = before
|
| 122 |
+
assert env.hub.buckets[env.settings.central_bucket] == central
|
| 123 |
+
assert env.hub.buckets[BUCKET] == bucket
|
| 124 |
+
assert env.hub.caller_writes == [] and env.hub.created_buckets == []
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_registration_read_outage_aborts_before_provisioning(env):
|
| 128 |
+
before = _victim_state(env)
|
| 129 |
+
env.hub.failing_reads.add("agents/agent-9.md")
|
| 130 |
+
r = _register(env, force=True)
|
| 131 |
+
assert r.status_code == 503
|
| 132 |
+
assert r.json()["error"]["code"] == "HUB_UNAVAILABLE"
|
| 133 |
+
_assert_untouched(env, before)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_handshake_read_outage_aborts_before_writing(env):
|
| 137 |
+
before = _victim_state(env)
|
| 138 |
+
env.hub.buckets[env.settings.central_bucket].pop("agents/agent-9.md")
|
| 139 |
+
before = (dict(env.hub.buckets[env.settings.central_bucket]), before[1])
|
| 140 |
+
env.hub.failing_reads.add(f"{BUCKET}/{HANDSHAKE}")
|
| 141 |
+
r = _register(env)
|
| 142 |
+
assert r.status_code == 503
|
| 143 |
+
_assert_untouched(env, before)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_foreign_handshake_blocks_even_a_caller_who_could_write(env):
|
| 147 |
+
# An unregistered bucket whose handshake names another user is theirs; an
|
| 148 |
+
# org admin's token could overwrite it, but registration must not.
|
| 149 |
+
before = _victim_state(env)
|
| 150 |
+
env.hub.buckets[env.settings.central_bucket].pop("agents/agent-9.md")
|
| 151 |
+
before = (dict(env.hub.buckets[env.settings.central_bucket]), before[1])
|
| 152 |
+
r = _register(env)
|
| 153 |
+
assert r.status_code == 403
|
| 154 |
+
assert r.json()["error"]["code"] == "BUCKET_NOT_YOURS"
|
| 155 |
+
assert "victim" in r.json()["error"]["message"]
|
| 156 |
+
_assert_untouched(env, before)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
SECRET = "hf_SECRETtokenDoNotLeak123"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_caller_token_never_reaches_responses_or_logs(env, monkeypatch, caplog):
|
| 163 |
+
caplog.set_level("DEBUG")
|
| 164 |
+
headers = {"authorization": f"Bearer {SECRET}"}
|
| 165 |
+
body = {"agent_id": "agent-9", "model": "m", "harness": "h", "tools": []}
|
| 166 |
+
post = lambda: env.client.post("/v1/agents/register", json=body, headers=headers)
|
| 167 |
+
texts = []
|
| 168 |
+
|
| 169 |
+
env.hub.whoami_fails = True # 401
|
| 170 |
+
texts.append(post()); env.hub.whoami_fails = False
|
| 171 |
+
env.hub.whoami_orgs = set() # 403 NOT_ORG_MEMBER
|
| 172 |
+
texts.append(post()); env.hub.whoami_orgs = {env.settings.org}
|
| 173 |
+
env.hub.failing_reads.add("agents/agent-9.md") # 503 lookup outage
|
| 174 |
+
texts.append(post()); env.hub.failing_reads.clear()
|
| 175 |
+
|
| 176 |
+
def forbid(bucket, token):
|
| 177 |
+
raise PermissionError(f"403 for token {token}")
|
| 178 |
+
monkeypatch.setattr(env.hub, "create_bucket_as", forbid) # 403 BUCKET_CREATE_FORBIDDEN
|
| 179 |
+
texts.append(post()); monkeypatch.undo()
|
| 180 |
+
env.hub.seed(HANDSHAKE, "test-user", bucket=BUCKET) # 403 BUCKET_NOT_YOURS
|
| 181 |
+
env.hub.bucket_owners[BUCKET] = "attacker"
|
| 182 |
+
texts.append(post())
|
| 183 |
+
env.hub.bucket_owners[BUCKET] = "test-user" # 201, audited
|
| 184 |
+
texts.append(post())
|
| 185 |
+
|
| 186 |
+
assert [r.status_code for r in texts] == [401, 403, 503, 403, 403, 201]
|
| 187 |
+
for r in texts:
|
| 188 |
+
assert SECRET not in r.text
|
| 189 |
+
assert SECRET not in caplog.text
|
| 190 |
+
audit = b"".join(env.hub.buckets[env.settings.audit_bucket].values())
|
| 191 |
+
assert SECRET.encode() not in audit
|
| 192 |
|
| 193 |
|
| 194 |
def test_own_bucket_without_handshake_gets_one(env):
|
|
|
|
| 293 |
r = _register(env)
|
| 294 |
assert r.status_code == 503
|
| 295 |
assert r.json()["error"]["code"] == "HUB_UNAVAILABLE"
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_optional_reads_map_only_missing_entries_to_none(env, monkeypatch):
|
| 299 |
+
client = hub_module.HubClient(env.settings)
|
| 300 |
+
|
| 301 |
+
def missing(**k):
|
| 302 |
+
raise EntryNotFoundError("no such file")
|
| 303 |
+
|
| 304 |
+
def outage(**k):
|
| 305 |
+
raise _hub_error(500)
|
| 306 |
+
|
| 307 |
+
monkeypatch.setattr(hub_module, "download_bucket_files", missing)
|
| 308 |
+
assert client.read_text_optional("hf://buckets/test-org/test-agent-9/.bucket-sync-handshake") is None
|
| 309 |
+
assert client.read_central_bytes_optional("agents/agent-9.md") is None
|
| 310 |
+
monkeypatch.setattr(hub_module, "download_bucket_files", outage)
|
| 311 |
+
with pytest.raises(HfHubHTTPError):
|
| 312 |
+
client.read_text_optional("hf://buckets/test-org/test-agent-9/.bucket-sync-handshake")
|
| 313 |
+
with pytest.raises(HfHubHTTPError):
|
| 314 |
+
client.read_central_bytes_optional("agents/agent-9.md")
|
tests/test_audit.py
DELETED
|
@@ -1,93 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import logging
|
| 3 |
-
from datetime import datetime, timezone
|
| 4 |
-
|
| 5 |
-
import pytest
|
| 6 |
-
|
| 7 |
-
import app.audit as audit_mod
|
| 8 |
-
from app.audit import AuditLogger, event_name
|
| 9 |
-
from app.config import Settings
|
| 10 |
-
from fakes import FakeHub, seed_agent
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def audit_objects(env) -> dict[str, dict]:
|
| 14 |
-
return {
|
| 15 |
-
p: json.loads(b)
|
| 16 |
-
for p, b in env.hub.buckets[env.settings.audit_bucket].items()
|
| 17 |
-
if p.startswith("audit/")
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def test_event_name_is_a_filename_safe_route_slug():
|
| 22 |
-
assert event_name("/v1/messages") == "messages"
|
| 23 |
-
assert event_name("/v1/jobs:run") == "jobs-run"
|
| 24 |
-
assert event_name("/v1/channels/{name}/subscribe") == "channels-name-subscribe"
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def test_one_object_per_event_with_distinct_names_even_in_the_same_ms(env, monkeypatch):
|
| 28 |
-
frozen = datetime(2026, 9, 29, 12, 0, 0, 123000, tzinfo=timezone.utc)
|
| 29 |
-
monkeypatch.setattr(audit_mod, "utc_now", lambda: frozen)
|
| 30 |
-
logger = AuditLogger(env.hub)
|
| 31 |
-
for i in range(3):
|
| 32 |
-
logger.write(
|
| 33 |
-
agent_id=f"agent-{i}", route="/v1/messages", via="raw", source=None,
|
| 34 |
-
target_path=None, bytes_count=i, status_code=201,
|
| 35 |
-
)
|
| 36 |
-
objs = audit_objects(env)
|
| 37 |
-
assert sorted(objs) == [
|
| 38 |
-
"audit/202609/20260929-120000-123_messages.json",
|
| 39 |
-
"audit/202609/20260929-120000-124_messages.json",
|
| 40 |
-
"audit/202609/20260929-120000-125_messages.json",
|
| 41 |
-
]
|
| 42 |
-
assert [objs[p]["agent_id"] for p in sorted(objs)] == ["agent-0", "agent-1", "agent-2"]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def test_each_post_writes_its_own_audit_object(env):
|
| 46 |
-
seed_agent(env.hub, "agent-1")
|
| 47 |
-
for body in ("one", "two"):
|
| 48 |
-
r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": body})
|
| 49 |
-
assert r.status_code == 201
|
| 50 |
-
objs = audit_objects(env)
|
| 51 |
-
assert len(objs) == 2
|
| 52 |
-
assert all(p.endswith("_messages.json") for p in objs)
|
| 53 |
-
assert {o["route"] for o in objs.values()} == {"/v1/messages"}
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def test_failed_audit_write_does_not_fail_the_request(env, monkeypatch, caplog):
|
| 57 |
-
seed_agent(env.hub, "agent-1")
|
| 58 |
-
write_audit = env.hub.write_bytes_audit
|
| 59 |
-
|
| 60 |
-
def failing_audit_write(path, data):
|
| 61 |
-
env.hub.fail_next_write() # only the audit write fails, not the message
|
| 62 |
-
return write_audit(path, data)
|
| 63 |
-
|
| 64 |
-
monkeypatch.setattr(env.hub, "write_bytes_audit", failing_audit_write)
|
| 65 |
-
with caplog.at_level(logging.WARNING, logger="app.audit"):
|
| 66 |
-
r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "hi"})
|
| 67 |
-
assert r.status_code == 201
|
| 68 |
-
assert f"message_board/{r.json()['filename']}" in env.hub.buckets[env.settings.central_bucket]
|
| 69 |
-
assert audit_objects(env) == {}
|
| 70 |
-
assert any(
|
| 71 |
-
rec.levelno == logging.WARNING and "messages" in rec.getMessage()
|
| 72 |
-
for rec in caplog.records
|
| 73 |
-
)
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
@pytest.mark.parametrize(
|
| 77 |
-
"audit_bucket, in_org",
|
| 78 |
-
[("test-org/test-audit", True), ("test-org-admin/test-audit", False)],
|
| 79 |
-
)
|
| 80 |
-
def test_caller_ip_omitted_when_audit_bucket_is_in_challenge_org(audit_bucket, in_org):
|
| 81 |
-
settings = Settings(ORG="test-org", COLLAB_SLUG="test", AUDIT_BUCKET=audit_bucket)
|
| 82 |
-
assert settings.audit_bucket_in_org is in_org
|
| 83 |
-
hub = FakeHub(settings)
|
| 84 |
-
AuditLogger(hub, record_client=not settings.audit_bucket_in_org).write(
|
| 85 |
-
agent_id="agent-1", route="/v1/sync", via=None, source=None,
|
| 86 |
-
target_path=None, bytes_count=0, status_code=200,
|
| 87 |
-
caller_ip="203.0.113.7", user_agent="curl/8",
|
| 88 |
-
)
|
| 89 |
-
(log,) = hub.buckets[audit_bucket].values()
|
| 90 |
-
record = json.loads(log)
|
| 91 |
-
assert record["agent_id"] == "agent-1"
|
| 92 |
-
assert ("caller_ip" in record) is not in_org
|
| 93 |
-
assert ("user_agent" in record) is not in_org
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_channels_api.py
CHANGED
|
@@ -284,9 +284,7 @@ def test_channel_and_broadcast_mutually_exclusive(env):
|
|
| 284 |
"/v1/messages",
|
| 285 |
json={"agent_id": "bb", "body": "x", "channel": "evals", "broadcast": True},
|
| 286 |
)
|
| 287 |
-
assert r.status_code ==
|
| 288 |
-
assert r.json()["error"]["code"] == "INVALID_REQUEST"
|
| 289 |
-
assert "mutually exclusive" in r.json()["error"]["message"]
|
| 290 |
|
| 291 |
|
| 292 |
def test_source_channel_frontmatter_rejected(env):
|
|
|
|
| 284 |
"/v1/messages",
|
| 285 |
json={"agent_id": "bb", "body": "x", "channel": "evals", "broadcast": True},
|
| 286 |
)
|
| 287 |
+
assert r.status_code == 422
|
|
|
|
|
|
|
| 288 |
|
| 289 |
|
| 290 |
def test_source_channel_frontmatter_rejected(env):
|
tests/test_client_api.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
"""GET /v1/watch.sh
|
| 2 |
|
| 3 |
-
The
|
| 4 |
packaging: if a redeploy forgets to ship clients/ next to app/, the route 404s
|
| 5 |
and this test is what catches it.
|
| 6 |
"""
|
|
@@ -35,13 +35,3 @@ def test_watch_script_is_served_verbatim(env):
|
|
| 35 |
|
| 36 |
resp = env.client.get("/v1/watch.sh")
|
| 37 |
assert resp.content == _SCRIPT_PATH.read_bytes()
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def test_share_trace_client_is_served_verbatim(env):
|
| 41 |
-
from app.routes.client import _SHARE_TRACE_PATH
|
| 42 |
-
|
| 43 |
-
resp = env.client.get("/v1/share_trace.py")
|
| 44 |
-
assert resp.status_code == 200
|
| 45 |
-
assert "text/x-python" in resp.headers["content-type"]
|
| 46 |
-
assert resp.content == _SHARE_TRACE_PATH.read_bytes()
|
| 47 |
-
compile(resp.text, "share_trace.py", "exec") # agents run it straight away
|
|
|
|
| 1 |
+
"""GET /v1/watch.sh — self-distribution of clients/collab_watch.sh.
|
| 2 |
|
| 3 |
+
The script is served from disk rather than baked in, so this also guards the
|
| 4 |
packaging: if a redeploy forgets to ship clients/ next to app/, the route 404s
|
| 5 |
and this test is what catches it.
|
| 6 |
"""
|
|
|
|
| 35 |
|
| 36 |
resp = env.client.get("/v1/watch.sh")
|
| 37 |
assert resp.content == _SCRIPT_PATH.read_bytes()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_collab_watch.py
CHANGED
|
@@ -7,7 +7,7 @@ top-level ``cursor``, and a ``watch`` block whenever ``wait>0`` was requested.
|
|
| 7 |
Why a stub instead of the FastAPI app (which is how eq2 tested this): the
|
| 8 |
client contract is what is under test here, and half of it only exists in
|
| 9 |
conditions a healthy server will not produce on demand — a 4xx on a typo'd
|
| 10 |
-
handle,
|
| 11 |
record content is deliberately shaped like a cursor. The stub makes each of
|
| 12 |
those a one-line setting, keeps the suite honest about what the *client* does,
|
| 13 |
and leaves it runnable while the server side is still being written.
|
|
@@ -67,8 +67,6 @@ class Stub:
|
|
| 67 |
omit_cursor: bool = False # pretend the server predates §4.4
|
| 68 |
matched_override: int | None = None # lie about `matched`
|
| 69 |
grow_polls: int = 0 # land fresh mail during the next N polls
|
| 70 |
-
fail_first: int = 0 # answer the next N requests with fail_status
|
| 71 |
-
fail_status: int = 503
|
| 72 |
_n: int = 0
|
| 73 |
|
| 74 |
def add(self, body: str = "ping @agent-a", author: str = "agent-b",
|
|
@@ -143,9 +141,6 @@ class _Handler(BaseHTTPRequestHandler):
|
|
| 143 |
with stub.lock:
|
| 144 |
stub.requests.append((parsed.path, query))
|
| 145 |
forced, err = stub.http_status, stub.error_body
|
| 146 |
-
if not forced and stub.fail_first > 0:
|
| 147 |
-
stub.fail_first -= 1
|
| 148 |
-
forced = stub.fail_status
|
| 149 |
grow = stub.grow_polls > 0
|
| 150 |
if grow:
|
| 151 |
stub.grow_polls -= 1
|
|
@@ -258,9 +253,8 @@ def popen(stub: Stub, state: Path | None, *flags: str, handle: str = "agent-a",
|
|
| 258 |
|
| 259 |
def stop(proc: subprocess.Popen, timeout: float = 10) -> tuple[str, str]:
|
| 260 |
"""Terminate a watcher and collect its output. The script traps TERM and
|
| 261 |
-
exits through its cleanup path
|
| 262 |
-
|
| 263 |
-
handler still delays the trap)."""
|
| 264 |
if proc.poll() is None:
|
| 265 |
proc.terminate()
|
| 266 |
try:
|
|
@@ -635,39 +629,18 @@ def test_3xx_fails_fast_with_a_redirect_hint(stub, tmp_path):
|
|
| 635 |
assert heartbeat(state)[1] == "http_301"
|
| 636 |
|
| 637 |
|
| 638 |
-
def
|
| 639 |
-
"""5xx shares the backoff ladder;
|
| 640 |
-
|
| 641 |
-
so --status can report it once the process is gone."""
|
| 642 |
stub.http_status = 503
|
| 643 |
state = fresh(tmp_path, cursor="")
|
| 644 |
-
t0 = time.monotonic()
|
| 645 |
-
|
| 646 |
-
result = run(stub, state, timeout=60, COLLAB_WATCH_RETRY_BUDGET_S="1")
|
| 647 |
-
elapsed = time.monotonic() - t0
|
| 648 |
-
|
| 649 |
-
assert result.returncode == 4, result.stderr
|
| 650 |
-
assert elapsed < 4, f"a 1s budget took {elapsed:.1f}s"
|
| 651 |
-
assert "giving up after" in result.stderr
|
| 652 |
-
assert heartbeat(state)[1] == "gave_up"
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
@pytest.mark.parametrize("status", [502, 503, 504])
|
| 656 |
-
def test_an_edge_outage_is_outlasted_not_counted(stub, tmp_path, status):
|
| 657 |
-
"""A Space rebuild answers 502/503/504 from the edge for longer than any
|
| 658 |
-
fixed retry count survives; the budget is time, so the watcher keeps going
|
| 659 |
-
well past 10 failures and delivers once the Space is back."""
|
| 660 |
-
stub.fail_first = 15
|
| 661 |
-
stub.fail_status = status
|
| 662 |
-
state = fresh(tmp_path, cursor="")
|
| 663 |
-
filename = stub.add()
|
| 664 |
|
| 665 |
result = run(stub, state, timeout=60)
|
| 666 |
|
| 667 |
-
assert result.returncode ==
|
| 668 |
-
assert stub.n_requests() ==
|
| 669 |
-
assert
|
| 670 |
-
|
| 671 |
|
| 672 |
|
| 673 |
def test_backoff_ladder_is_used_when_not_disabled(stub, tmp_path):
|
|
@@ -696,63 +669,26 @@ def test_non_numeric_wait_is_rejected_at_startup(stub, tmp_path):
|
|
| 696 |
|
| 697 |
def test_max_wait_exits_3_on_a_clean_no_mail_timeout(stub, tmp_path):
|
| 698 |
"""Exit 3 exists so a bounded wait that found nothing is distinguishable
|
| 699 |
-
from a watcher that was killed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
state = fresh(tmp_path, cursor="")
|
|
|
|
|
|
|
| 701 |
result = run(stub, state, "--max-wait", "2", timeout=60)
|
|
|
|
| 702 |
|
| 703 |
assert result.returncode == 3
|
| 704 |
assert result.stdout.strip() == ""
|
|
|
|
|
|
|
| 705 |
assert "clean timeout" in result.stderr
|
| 706 |
assert heartbeat(state)[1] == "no_mail"
|
| 707 |
|
| 708 |
|
| 709 |
-
def test_max_wait_is_a_ceiling_not_a_floor(stub, tmp_path):
|
| 710 |
-
"""N is sized to fit a shell tool's timeout, so the run must end within N
|
| 711 |
-
even when the per-request wait window (55s here) is longer than N: each
|
| 712 |
-
request is clamped to the time left instead of overshooting by a window."""
|
| 713 |
-
state = fresh(tmp_path, cursor="")
|
| 714 |
-
t0 = time.monotonic()
|
| 715 |
-
|
| 716 |
-
result = run(stub, state, "--max-wait", "3", wait="55", timeout=60)
|
| 717 |
-
elapsed = time.monotonic() - t0
|
| 718 |
-
|
| 719 |
-
assert result.returncode == 3, result.stderr
|
| 720 |
-
assert elapsed < 3 + 1.5, f"overshot a 3s ceiling ({elapsed:.2f}s)"
|
| 721 |
-
assert elapsed >= 1.5, f"gave up far too early ({elapsed:.2f}s)"
|
| 722 |
-
assert all(float(q["wait"]) <= 3 for _p, q in stub.requests)
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
def test_max_wait_ceiling_holds_while_the_server_is_down(tmp_path):
|
| 726 |
-
"""Retry backoff must not carry a bounded run past N either; running out
|
| 727 |
-
of time mid-retry is exit 4 (unreachable), never a false "no mail"."""
|
| 728 |
-
state = tmp_path / "state"
|
| 729 |
-
t0 = time.monotonic()
|
| 730 |
-
result = subprocess.run(
|
| 731 |
-
["sh", SCRIPT, "http://127.0.0.1:1", "agent-a", "--max-wait", "3"],
|
| 732 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
| 733 |
-
env=script_env(state, backoff="2"), timeout=60,
|
| 734 |
-
)
|
| 735 |
-
elapsed = time.monotonic() - t0
|
| 736 |
-
|
| 737 |
-
assert result.returncode == 4, result.stderr
|
| 738 |
-
assert elapsed < 3 + 1.5, f"overshot a 3s ceiling ({elapsed:.2f}s)"
|
| 739 |
-
assert "--max-wait 3s is up" in result.stderr
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
def test_after_overrides_the_saved_cursor(stub, tmp_path):
|
| 743 |
-
"""--after resumes from a cursor the agent got elsewhere (the digest's
|
| 744 |
-
watching.last_cursor) — e.g. after its state directory was wiped."""
|
| 745 |
-
state = fresh(tmp_path) # no cursor file: would otherwise baseline
|
| 746 |
-
first = stub.add()
|
| 747 |
-
second = stub.add()
|
| 748 |
-
|
| 749 |
-
result = run(stub, state, "--after", first, "--max-wait", "5")
|
| 750 |
-
|
| 751 |
-
assert result.returncode == 0, result.stderr
|
| 752 |
-
assert [i["filename"] for i in json.loads(result.stdout)["items"]] == [second]
|
| 753 |
-
assert (state / "cursor.updates").read_text().strip() == second
|
| 754 |
-
|
| 755 |
-
|
| 756 |
def test_max_wait_still_delivers_when_mail_arrives(stub, tmp_path):
|
| 757 |
state = fresh(tmp_path, cursor="")
|
| 758 |
filename = stub.add()
|
|
@@ -941,7 +877,7 @@ def test_second_watcher_exits_5_naming_the_live_pid(stub, tmp_path):
|
|
| 941 |
try:
|
| 942 |
pid_file = state / "lock" / "pid"
|
| 943 |
assert wait_until(pid_file.exists), "no lock was taken"
|
| 944 |
-
assert pid_file.read_text().
|
| 945 |
|
| 946 |
second = run(stub, state, timeout=20)
|
| 947 |
assert second.returncode == 5
|
|
@@ -953,28 +889,7 @@ def test_second_watcher_exits_5_naming_the_live_pid(stub, tmp_path):
|
|
| 953 |
"the lock must be released on exit"
|
| 954 |
|
| 955 |
|
| 956 |
-
def test_term_while_parked_exits_at_once_and_releases_the_lock(stub, tmp_path):
|
| 957 |
-
"""A TERM must not wait for the parked curl (up to 75s): until the process
|
| 958 |
-
exits, its lock reads as live and a replacement watcher exits 5."""
|
| 959 |
-
state = fresh(tmp_path, cursor="")
|
| 960 |
-
proc = popen(stub, state, wait="55")
|
| 961 |
-
try:
|
| 962 |
-
assert wait_until(lambda: stub.n_requests() >= 1), "never parked"
|
| 963 |
-
time.sleep(0.3) # let curl settle into the parked request
|
| 964 |
-
t0 = time.monotonic()
|
| 965 |
-
proc.terminate()
|
| 966 |
-
proc.communicate(timeout=10)
|
| 967 |
-
elapsed = time.monotonic() - t0
|
| 968 |
-
finally:
|
| 969 |
-
if proc.poll() is None:
|
| 970 |
-
stop(proc)
|
| 971 |
-
assert proc.returncode == 143
|
| 972 |
-
assert elapsed < 2, f"TERM took {elapsed:.1f}s: the trap waited for curl"
|
| 973 |
-
assert not (state / "lock").exists(), "the lock must be released"
|
| 974 |
-
|
| 975 |
-
|
| 976 |
def test_stale_lock_is_reclaimed(stub, tmp_path):
|
| 977 |
-
|
| 978 |
"""A lock whose PID is gone (kill -9, harness reaping) is not a wall."""
|
| 979 |
reaped = subprocess.Popen(["true"])
|
| 980 |
reaped.wait()
|
|
@@ -990,44 +905,7 @@ def test_stale_lock_is_reclaimed(stub, tmp_path):
|
|
| 990 |
assert (state / "cursor.updates").read_text().strip() == filename
|
| 991 |
|
| 992 |
|
| 993 |
-
def test_a_reused_pid_is_a_stale_lock(stub, tmp_path):
|
| 994 |
-
"""`kill -0` succeeds for ANY process with that pid — a reused one, or one
|
| 995 |
-
in another PID namespace sharing the state dir — so lock/pid records the
|
| 996 |
-
owner's start time and a live pid started at another time is stale."""
|
| 997 |
-
impostor = subprocess.Popen(["sleep", "30"])
|
| 998 |
-
try:
|
| 999 |
-
state = fresh(tmp_path, cursor="")
|
| 1000 |
-
(state / "lock").mkdir()
|
| 1001 |
-
(state / "lock" / "pid").write_text(f"{impostor.pid}\nnot-its-start-time\n")
|
| 1002 |
-
filename = stub.add()
|
| 1003 |
-
|
| 1004 |
-
result = run(stub, state)
|
| 1005 |
-
|
| 1006 |
-
assert result.returncode == 0, result.stderr
|
| 1007 |
-
assert "stale lock" in result.stderr
|
| 1008 |
-
assert (state / "cursor.updates").read_text().strip() == filename
|
| 1009 |
-
finally:
|
| 1010 |
-
impostor.terminate()
|
| 1011 |
-
impostor.wait()
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
def test_the_lock_records_its_owners_start_time(stub, tmp_path):
|
| 1015 |
-
state = fresh(tmp_path, cursor="")
|
| 1016 |
-
watcher = popen(stub, state)
|
| 1017 |
-
try:
|
| 1018 |
-
pid_file = state / "lock" / "pid"
|
| 1019 |
-
assert wait_until(lambda: pid_file.exists() and pid_file.read_text().count("\n") == 2)
|
| 1020 |
-
pid, start = pid_file.read_text().splitlines()
|
| 1021 |
-
assert pid == str(watcher.pid)
|
| 1022 |
-
assert start.strip(), "no start time recorded (this host has /proc)"
|
| 1023 |
-
second = run(stub, state, timeout=20)
|
| 1024 |
-
assert second.returncode == 5, "a matching start time is a live lock"
|
| 1025 |
-
finally:
|
| 1026 |
-
stop(watcher)
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
def test_the_lock_is_per_handle_and_status_is_stream_aware(stub, tmp_path):
|
| 1030 |
-
|
| 1031 |
"""The lock and heartbeat are per-HANDLE while cursors are per-stream: one
|
| 1032 |
watcher per agent is the whole point of the unified `updates` stream.
|
| 1033 |
|
|
@@ -1085,8 +963,6 @@ def test_status_10_outranks_a_watcher_on_another_stream(stub, tmp_path):
|
|
| 1085 |
fields = _status_line(result)
|
| 1086 |
assert fields["STATUS"] == "BEHIND" and fields["UNREAD"] == "1"
|
| 1087 |
assert fields["STREAM"] == "updates"
|
| 1088 |
-
assert "agent-a feed --max-wait 5" in result.stderr
|
| 1089 |
-
|
| 1090 |
finally:
|
| 1091 |
alive.terminate()
|
| 1092 |
alive.wait()
|
|
@@ -1145,7 +1021,7 @@ def test_exit_does_not_remove_a_lock_that_is_no_longer_ours(stub, tmp_path):
|
|
| 1145 |
try:
|
| 1146 |
pid_file = state / "lock" / "pid"
|
| 1147 |
assert wait_until(lambda: pid_file.exists()
|
| 1148 |
-
and pid_file.read_text().
|
| 1149 |
"the watcher never took the lock"
|
| 1150 |
pid_file.write_text(f"{other.pid}\n") # another watcher now owns it
|
| 1151 |
|
|
@@ -1235,12 +1111,6 @@ def test_status_10_when_behind_outranks_liveness(stub, tmp_path):
|
|
| 1235 |
assert fields["PID"] == "-"
|
| 1236 |
assert fields["LAST"] == "gave_up", "the give-up must survive the process"
|
| 1237 |
assert int(fields["HEARTBEAT_AGE"].rstrip("s")) >= 412
|
| 1238 |
-
command = f"sh {SCRIPT} {stub.base_url} agent-a --max-wait 5"
|
| 1239 |
-
assert command in result.stderr, "BEHIND must name the command that reads it"
|
| 1240 |
-
t0 = time.monotonic()
|
| 1241 |
-
read = run(stub, state, "--max-wait", "5")
|
| 1242 |
-
assert read.returncode == 0 and len(json.loads(read.stdout)["items"]) == 3
|
| 1243 |
-
assert time.monotonic() - t0 < 3, "pending mail must come back at once"
|
| 1244 |
|
| 1245 |
|
| 1246 |
def test_status_12_when_the_lock_is_live_but_the_heartbeat_is_stale(stub, tmp_path):
|
|
@@ -1353,9 +1223,6 @@ def test_behaviour_is_locale_independent(stub, tmp_path):
|
|
| 1353 |
(["http://127.0.0.1:1", "agent-a", "--max-wait"], "--max-wait needs"),
|
| 1354 |
(["http://127.0.0.1:1", "agent-a", "--exec"], "--exec needs"),
|
| 1355 |
(["http://127.0.0.1:1", "agent-a", "--peek", "--max-wait", "5"], "wait mode only"),
|
| 1356 |
-
(["http://127.0.0.1:1", "agent-a", "--after", "latest"], "--after must be"),
|
| 1357 |
-
(["http://127.0.0.1:1", "agent-a", "--status", "--after",
|
| 1358 |
-
"20260728-120000-000_agent-b.md"], "wait and --exec modes only"),
|
| 1359 |
(["http://127.0.0.1:1", "agent-a", "extra", "updates", "x"], "unexpected argument"),
|
| 1360 |
(["ftp://nope", "agent-a"], "must start with http"),
|
| 1361 |
(["http://127.0.0.1:1", "../etc/passwd"], "characters outside"),
|
|
@@ -1380,7 +1247,7 @@ def test_help_exits_0_on_stdout():
|
|
| 1380 |
stderr=subprocess.PIPE, text=True, timeout=20)
|
| 1381 |
assert result.returncode == 0
|
| 1382 |
assert "exit codes:" in result.stdout
|
| 1383 |
-
for flag in ("--max-wait", "--
|
| 1384 |
assert flag in result.stdout
|
| 1385 |
for warning in ("do NOT wrap", "do NOT detach", "AT-LEAST-ONCE"):
|
| 1386 |
assert warning in result.stdout, f"the header must keep documenting: {warning}"
|
|
|
|
| 7 |
Why a stub instead of the FastAPI app (which is how eq2 tested this): the
|
| 8 |
client contract is what is under test here, and half of it only exists in
|
| 9 |
conditions a healthy server will not produce on demand — a 4xx on a typo'd
|
| 10 |
+
handle, ten 5xx in a row, an instantly-empty *degraded* answer, a page whose
|
| 11 |
record content is deliberately shaped like a cursor. The stub makes each of
|
| 12 |
those a one-line setting, keeps the suite honest about what the *client* does,
|
| 13 |
and leaves it runnable while the server side is still being written.
|
|
|
|
| 67 |
omit_cursor: bool = False # pretend the server predates §4.4
|
| 68 |
matched_override: int | None = None # lie about `matched`
|
| 69 |
grow_polls: int = 0 # land fresh mail during the next N polls
|
|
|
|
|
|
|
| 70 |
_n: int = 0
|
| 71 |
|
| 72 |
def add(self, body: str = "ping @agent-a", author: str = "agent-b",
|
|
|
|
| 141 |
with stub.lock:
|
| 142 |
stub.requests.append((parsed.path, query))
|
| 143 |
forced, err = stub.http_status, stub.error_body
|
|
|
|
|
|
|
|
|
|
| 144 |
grow = stub.grow_polls > 0
|
| 145 |
if grow:
|
| 146 |
stub.grow_polls -= 1
|
|
|
|
| 253 |
|
| 254 |
def stop(proc: subprocess.Popen, timeout: float = 10) -> tuple[str, str]:
|
| 255 |
"""Terminate a watcher and collect its output. The script traps TERM and
|
| 256 |
+
exits through its cleanup path, but a shell only runs the trap once the
|
| 257 |
+
foreground curl returns — hence the tiny waits everywhere."""
|
|
|
|
| 258 |
if proc.poll() is None:
|
| 259 |
proc.terminate()
|
| 260 |
try:
|
|
|
|
| 629 |
assert heartbeat(state)[1] == "http_301"
|
| 630 |
|
| 631 |
|
| 632 |
+
def test_ten_failures_give_up_with_exit_4_and_gave_up_heartbeat(stub, tmp_path):
|
| 633 |
+
"""5xx shares the backoff ladder; a streak of 10 exits 4 and leaves
|
| 634 |
+
status=gave_up behind, so --status can report it once the process is gone."""
|
|
|
|
| 635 |
stub.http_status = 503
|
| 636 |
state = fresh(tmp_path, cursor="")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
|
| 638 |
result = run(stub, state, timeout=60)
|
| 639 |
|
| 640 |
+
assert result.returncode == 4
|
| 641 |
+
assert stub.n_requests() == 10
|
| 642 |
+
assert "giving up after 10" in result.stderr
|
| 643 |
+
assert heartbeat(state)[1] == "gave_up"
|
| 644 |
|
| 645 |
|
| 646 |
def test_backoff_ladder_is_used_when_not_disabled(stub, tmp_path):
|
|
|
|
| 669 |
|
| 670 |
def test_max_wait_exits_3_on_a_clean_no_mail_timeout(stub, tmp_path):
|
| 671 |
"""Exit 3 exists so a bounded wait that found nothing is distinguishable
|
| 672 |
+
from a watcher that was killed.
|
| 673 |
+
|
| 674 |
+
The elapsed assertion is the contract, not a tolerance: N is a FLOOR, so
|
| 675 |
+
exit 3 must never fire before it. `date +%s` truncates to whole seconds, so
|
| 676 |
+
a deadline computed naively from it lands up to a second early — which this
|
| 677 |
+
assertion caught intermittently before the script padded for it."""
|
| 678 |
state = fresh(tmp_path, cursor="")
|
| 679 |
+
t0 = time.monotonic()
|
| 680 |
+
|
| 681 |
result = run(stub, state, "--max-wait", "2", timeout=60)
|
| 682 |
+
elapsed = time.monotonic() - t0
|
| 683 |
|
| 684 |
assert result.returncode == 3
|
| 685 |
assert result.stdout.strip() == ""
|
| 686 |
+
assert elapsed >= 2.0, f"gave up after {elapsed:.2f}s of a 2s floor"
|
| 687 |
+
assert elapsed < 12, f"overshot the bound by too much ({elapsed:.2f}s)"
|
| 688 |
assert "clean timeout" in result.stderr
|
| 689 |
assert heartbeat(state)[1] == "no_mail"
|
| 690 |
|
| 691 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
def test_max_wait_still_delivers_when_mail_arrives(stub, tmp_path):
|
| 693 |
state = fresh(tmp_path, cursor="")
|
| 694 |
filename = stub.add()
|
|
|
|
| 877 |
try:
|
| 878 |
pid_file = state / "lock" / "pid"
|
| 879 |
assert wait_until(pid_file.exists), "no lock was taken"
|
| 880 |
+
assert pid_file.read_text().strip() == str(first.pid)
|
| 881 |
|
| 882 |
second = run(stub, state, timeout=20)
|
| 883 |
assert second.returncode == 5
|
|
|
|
| 889 |
"the lock must be released on exit"
|
| 890 |
|
| 891 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
def test_stale_lock_is_reclaimed(stub, tmp_path):
|
|
|
|
| 893 |
"""A lock whose PID is gone (kill -9, harness reaping) is not a wall."""
|
| 894 |
reaped = subprocess.Popen(["true"])
|
| 895 |
reaped.wait()
|
|
|
|
| 905 |
assert (state / "cursor.updates").read_text().strip() == filename
|
| 906 |
|
| 907 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 908 |
def test_the_lock_is_per_handle_and_status_is_stream_aware(stub, tmp_path):
|
|
|
|
| 909 |
"""The lock and heartbeat are per-HANDLE while cursors are per-stream: one
|
| 910 |
watcher per agent is the whole point of the unified `updates` stream.
|
| 911 |
|
|
|
|
| 963 |
fields = _status_line(result)
|
| 964 |
assert fields["STATUS"] == "BEHIND" and fields["UNREAD"] == "1"
|
| 965 |
assert fields["STREAM"] == "updates"
|
|
|
|
|
|
|
| 966 |
finally:
|
| 967 |
alive.terminate()
|
| 968 |
alive.wait()
|
|
|
|
| 1021 |
try:
|
| 1022 |
pid_file = state / "lock" / "pid"
|
| 1023 |
assert wait_until(lambda: pid_file.exists()
|
| 1024 |
+
and pid_file.read_text().strip() == str(watcher.pid)), \
|
| 1025 |
"the watcher never took the lock"
|
| 1026 |
pid_file.write_text(f"{other.pid}\n") # another watcher now owns it
|
| 1027 |
|
|
|
|
| 1111 |
assert fields["PID"] == "-"
|
| 1112 |
assert fields["LAST"] == "gave_up", "the give-up must survive the process"
|
| 1113 |
assert int(fields["HEARTBEAT_AGE"].rstrip("s")) >= 412
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1114 |
|
| 1115 |
|
| 1116 |
def test_status_12_when_the_lock_is_live_but_the_heartbeat_is_stale(stub, tmp_path):
|
|
|
|
| 1223 |
(["http://127.0.0.1:1", "agent-a", "--max-wait"], "--max-wait needs"),
|
| 1224 |
(["http://127.0.0.1:1", "agent-a", "--exec"], "--exec needs"),
|
| 1225 |
(["http://127.0.0.1:1", "agent-a", "--peek", "--max-wait", "5"], "wait mode only"),
|
|
|
|
|
|
|
|
|
|
| 1226 |
(["http://127.0.0.1:1", "agent-a", "extra", "updates", "x"], "unexpected argument"),
|
| 1227 |
(["ftp://nope", "agent-a"], "must start with http"),
|
| 1228 |
(["http://127.0.0.1:1", "../etc/passwd"], "characters outside"),
|
|
|
|
| 1247 |
stderr=subprocess.PIPE, text=True, timeout=20)
|
| 1248 |
assert result.returncode == 0
|
| 1249 |
assert "exit codes:" in result.stdout
|
| 1250 |
+
for flag in ("--max-wait", "--exec", "--peek", "--status"):
|
| 1251 |
assert flag in result.stdout
|
| 1252 |
for warning in ("do NOT wrap", "do NOT detach", "AT-LEAST-ONCE"):
|
| 1253 |
assert warning in result.stdout, f"the header must keep documenting: {warning}"
|
tests/test_digest_api.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
import json
|
| 2 |
|
| 3 |
-
from app.frontmatter import serialise
|
| 4 |
from fakes import seed_agent, seed_message, seed_result
|
| 5 |
|
| 6 |
|
|
@@ -72,16 +71,6 @@ def test_discovery_root(env):
|
|
| 72 |
assert "mentions" in data["conventions"]
|
| 73 |
|
| 74 |
|
| 75 |
-
def test_discovery_limits_come_from_settings(make_env):
|
| 76 |
-
env = make_env(RAW_MESSAGE_PER_HOUR=7, MESSAGE_MAX_BYTES=1234, MENTION_FANOUT_CAP=3)
|
| 77 |
-
limits = env.client.get("/v1").json()["limits"]
|
| 78 |
-
assert limits["raw_messages_per_hour_per_agent"] == 7
|
| 79 |
-
assert limits["source_max_bytes"] == 1234
|
| 80 |
-
assert limits["mention_fanout_cap"] == 3
|
| 81 |
-
assert limits["raw_body_max_chars"] == 32 * 1024
|
| 82 |
-
assert limits["expand_max_limit"] == env.settings.expand_max_limit
|
| 83 |
-
|
| 84 |
-
|
| 85 |
# ── watch blocks (WATCH_DESIGN.md §4.5) ───────────────────────────────
|
| 86 |
|
| 87 |
import threading
|
|
@@ -147,7 +136,7 @@ def test_digest_updates_is_cursor_aware_via_after(env):
|
|
| 147 |
cursor = env.client.get("/v1/updates?as=watcher").json()["cursor"]
|
| 148 |
env.client.post("/v1/messages", json={"agent_id": "poster", "body": "two @watcher"})
|
| 149 |
|
| 150 |
-
assert env.client.get("/v1/digest?as=watcher
|
| 151 |
caught_up = env.client.get(f"/v1/digest?as=watcher&after={cursor}").json()
|
| 152 |
assert caught_up["updates"]["unread"] == 1
|
| 153 |
# Fully drained.
|
|
@@ -155,18 +144,6 @@ def test_digest_updates_is_cursor_aware_via_after(env):
|
|
| 155 |
assert env.client.get(f"/v1/digest?as=watcher&after={newest}").json()["updates"]["unread"] == 0
|
| 156 |
|
| 157 |
|
| 158 |
-
def test_digest_without_after_counts_from_the_server_last_cursor(env):
|
| 159 |
-
"""An agent that lost its state reads the digest without after=: mail the
|
| 160 |
-
watcher already delivered must not read as unread."""
|
| 161 |
-
seed_agent(env.hub, "watcher")
|
| 162 |
-
seed_agent(env.hub, "poster")
|
| 163 |
-
env.client.post("/v1/messages", json={"agent_id": "poster", "body": "one @watcher"})
|
| 164 |
-
assert env.client.get("/v1/digest?as=watcher").json()["updates"]["unread"] == 1
|
| 165 |
-
assert len(env.client.get("/v1/updates?as=watcher").json()["items"]) == 1
|
| 166 |
-
|
| 167 |
-
assert env.client.get("/v1/digest?as=watcher").json()["updates"]["unread"] == 0
|
| 168 |
-
|
| 169 |
-
|
| 170 |
def test_digest_updates_is_zero_for_a_quiet_handle(env):
|
| 171 |
seed_agent(env.hub, "watcher")
|
| 172 |
data = env.client.get("/v1/digest?as=watcher").json()
|
|
@@ -180,80 +157,31 @@ def test_digest_watching_is_null_until_someone_watches(env):
|
|
| 180 |
assert env.client.get("/v1/digest?as=watcher").json()["watching"] is None
|
| 181 |
|
| 182 |
|
| 183 |
-
def
|
| 184 |
-
"""
|
| 185 |
-
|
| 186 |
seed_agent(env.hub, "watcher")
|
| 187 |
-
assert env.client.get("/v1/digest?as=watcher").json()["watching"] is None
|
| 188 |
-
block = env.client.get("/v1/digest?as=watcher").json()["watching"]
|
| 189 |
-
assert block["mode"] == "poll" and block["stream"] == "digest"
|
| 190 |
-
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
is present between calls — and `mode` says whether it parked."""
|
| 195 |
-
seed_agent(env.hub, "watcher")
|
| 196 |
-
|
| 197 |
-
env.client.get("/v1/updates?as=watcher") # wait=0
|
| 198 |
-
block = env.client.get("/v1/digest?as=watcher").json()["watching"]
|
| 199 |
-
assert (block["mode"], block["stream"]) == ("poll", "updates")
|
| 200 |
|
| 201 |
env.client.get("/v1/updates?as=watcher&wait=0.15")
|
| 202 |
-
|
| 203 |
-
assert
|
| 204 |
-
assert
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
def test_digest_watching_reports_last_cursor(env):
|
| 208 |
-
"""The state-loss safety net: the newest after= cursor the handle sent comes
|
| 209 |
-
back as last_cursor, and a later read without after= does not erase it."""
|
| 210 |
-
seed_agent(env.hub, "watcher")
|
| 211 |
-
cursor = "20260728-120000-000_agent-b.md"
|
| 212 |
-
env.client.get(f"/v1/updates?as=watcher&after={cursor}&wait=0.05")
|
| 213 |
-
env.client.get("/v1/updates?as=watcher") # no cursor: keeps the last one
|
| 214 |
-
|
| 215 |
-
block = env.client.get("/v1/digest?as=watcher").json()["watching"]
|
| 216 |
-
assert block["last_cursor"] == cursor
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
def test_digest_watching_records_the_cursor_handed_out(env):
|
| 220 |
-
"""A watcher that baselined with an empty cursor never SENT one; the cursor
|
| 221 |
-
the server handed it is what it must resume from."""
|
| 222 |
-
seed_agent(env.hub, "watcher")
|
| 223 |
-
seed_agent(env.hub, "poster")
|
| 224 |
-
r = env.client.post("/v1/messages", json={"agent_id": "poster", "body": "ping @watcher"})
|
| 225 |
-
assert r.status_code == 201, r.text
|
| 226 |
-
|
| 227 |
-
page = env.client.get("/v1/updates?as=watcher").json() # wait=0, no after=
|
| 228 |
-
assert len(page["items"]) == 1 and page["cursor"]
|
| 229 |
-
|
| 230 |
-
block = env.client.get("/v1/digest?as=watcher").json()["watching"]
|
| 231 |
-
assert block["last_cursor"] == page["cursor"]
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
def test_a_digest_read_does_not_hide_a_parked_watcher(env):
|
| 235 |
-
"""mode follows the last PARKED poll, not the last read: a digest between
|
| 236 |
-
two parks still reports parked, with the parked poll's stream."""
|
| 237 |
-
seed_agent(env.hub, "watcher")
|
| 238 |
-
env.client.get("/v1/updates?as=watcher&wait=0.05")
|
| 239 |
-
env.client.get("/v1/digest?as=watcher")
|
| 240 |
-
|
| 241 |
-
block = env.client.get("/v1/digest?as=watcher").json()["watching"]
|
| 242 |
-
assert (block["mode"], block["stream"]) == ("parked", "updates")
|
| 243 |
|
| 244 |
|
| 245 |
-
def
|
| 246 |
"""inbox / feed / updates are distinguishable, so an organizer can see WHAT
|
| 247 |
an agent is watching, not just that it is alive."""
|
| 248 |
seed_agent(env.hub, "watcher")
|
| 249 |
env.client.get("/v1/inbox/watcher?wait=0.1")
|
| 250 |
-
|
| 251 |
-
assert (block["mode"], block["stream"]) == ("parked", "inbox")
|
| 252 |
|
| 253 |
_create_channel(env, "room")
|
| 254 |
_subscribe(env, "room", "watcher")
|
| 255 |
env.client.get("/v1/channels/feed?as=watcher&wait=0.1")
|
| 256 |
-
assert env.client.get("/v1/digest?as=watcher").json()["watching"]["
|
| 257 |
|
| 258 |
|
| 259 |
def test_digest_watching_is_visible_while_a_poll_is_parked(env):
|
|
@@ -278,7 +206,7 @@ def test_digest_watching_is_visible_while_a_poll_is_parked(env):
|
|
| 278 |
else:
|
| 279 |
time.sleep(0.01)
|
| 280 |
assert seen is not None, "watch presence never became visible"
|
| 281 |
-
assert
|
| 282 |
finally:
|
| 283 |
t.join(timeout=5)
|
| 284 |
|
|
@@ -307,24 +235,3 @@ def test_discovery_documents_watching(env):
|
|
| 307 |
assert "wait=" in polling and "watch.sh" in polling
|
| 308 |
# The matched-vs-len(items) trap that produced a false "up to date".
|
| 309 |
assert "len(items)" in polling
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def test_digest_stats_present_with_zeros_before_any_trace(env):
|
| 313 |
-
seed_collab(env.hub)
|
| 314 |
-
data = env.client.get("/v1/digest").json()
|
| 315 |
-
assert data["stats"] == {"total_tokens": 0, "sessions_counted": 0, "agents_reporting": 0}
|
| 316 |
-
assert data["you"] is None
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
def test_digest_you_reports_the_callers_traces(env):
|
| 320 |
-
seed_collab(env.hub)
|
| 321 |
-
env.hub.seed(
|
| 322 |
-
"traces/agent-1/s1/manifest.md",
|
| 323 |
-
serialise({"promoted_at": "2026-06-05 10:00 UTC", "usage": {"total_tokens": 7}}, ""),
|
| 324 |
-
)
|
| 325 |
-
data = env.client.get("/v1/digest?as=agent-1").json()
|
| 326 |
-
assert data["you"]["traces"] == {"sessions": 1, "last_shared_at": "2026-06-05 10:00 UTC"}
|
| 327 |
-
assert data["stats"] == {"total_tokens": 7, "sessions_counted": 1, "agents_reporting": 1}
|
| 328 |
-
assert env.client.get("/v1/digest?as=agent-2").json()["you"] == {
|
| 329 |
-
"traces": {"sessions": 0, "last_shared_at": None}
|
| 330 |
-
}
|
|
|
|
| 1 |
import json
|
| 2 |
|
|
|
|
| 3 |
from fakes import seed_agent, seed_message, seed_result
|
| 4 |
|
| 5 |
|
|
|
|
| 71 |
assert "mentions" in data["conventions"]
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# ── watch blocks (WATCH_DESIGN.md §4.5) ───────────────────────────────
|
| 75 |
|
| 76 |
import threading
|
|
|
|
| 136 |
cursor = env.client.get("/v1/updates?as=watcher").json()["cursor"]
|
| 137 |
env.client.post("/v1/messages", json={"agent_id": "poster", "body": "two @watcher"})
|
| 138 |
|
| 139 |
+
assert env.client.get("/v1/digest?as=watcher").json()["updates"]["unread"] == 2
|
| 140 |
caught_up = env.client.get(f"/v1/digest?as=watcher&after={cursor}").json()
|
| 141 |
assert caught_up["updates"]["unread"] == 1
|
| 142 |
# Fully drained.
|
|
|
|
| 144 |
assert env.client.get(f"/v1/digest?as=watcher&after={newest}").json()["updates"]["unread"] == 0
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def test_digest_updates_is_zero_for_a_quiet_handle(env):
|
| 148 |
seed_agent(env.hub, "watcher")
|
| 149 |
data = env.client.get("/v1/digest?as=watcher").json()
|
|
|
|
| 157 |
assert env.client.get("/v1/digest?as=watcher").json()["watching"] is None
|
| 158 |
|
| 159 |
|
| 160 |
+
def test_digest_watching_appears_after_a_wait_poll(env):
|
| 161 |
+
"""A wait>0 poll stamps the handle's presence; a plain wait=0 poll does
|
| 162 |
+
not — polling is not watching."""
|
| 163 |
seed_agent(env.hub, "watcher")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
env.client.get("/v1/updates?as=watcher") # wait=0: not a watch
|
| 166 |
+
assert env.client.get("/v1/digest?as=watcher").json()["watching"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
env.client.get("/v1/updates?as=watcher&wait=0.15")
|
| 169 |
+
data = env.client.get("/v1/digest?as=watcher").json()
|
| 170 |
+
assert data["watching"]["mode"] == "updates"
|
| 171 |
+
assert data["watching"]["last_poll_age_s"] >= 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
+
def test_digest_watching_reports_the_mode(env):
|
| 175 |
"""inbox / feed / updates are distinguishable, so an organizer can see WHAT
|
| 176 |
an agent is watching, not just that it is alive."""
|
| 177 |
seed_agent(env.hub, "watcher")
|
| 178 |
env.client.get("/v1/inbox/watcher?wait=0.1")
|
| 179 |
+
assert env.client.get("/v1/digest?as=watcher").json()["watching"]["mode"] == "inbox"
|
|
|
|
| 180 |
|
| 181 |
_create_channel(env, "room")
|
| 182 |
_subscribe(env, "room", "watcher")
|
| 183 |
env.client.get("/v1/channels/feed?as=watcher&wait=0.1")
|
| 184 |
+
assert env.client.get("/v1/digest?as=watcher").json()["watching"]["mode"] == "feed"
|
| 185 |
|
| 186 |
|
| 187 |
def test_digest_watching_is_visible_while_a_poll_is_parked(env):
|
|
|
|
| 206 |
else:
|
| 207 |
time.sleep(0.01)
|
| 208 |
assert seen is not None, "watch presence never became visible"
|
| 209 |
+
assert seen["mode"] == "updates"
|
| 210 |
finally:
|
| 211 |
t.join(timeout=5)
|
| 212 |
|
|
|
|
| 235 |
assert "wait=" in polling and "watch.sh" in polling
|
| 236 |
# The matched-vs-len(items) trap that produced a false "up to date".
|
| 237 |
assert "len(items)" in polling
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_fakes.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
| 1 |
-
"""Tests for FakeHub's chaos-injection toggles: each one fires once, on the
|
| 2 |
-
next matching call, then resets to normal behaviour."""
|
| 3 |
-
import pytest
|
| 4 |
-
from huggingface_hub.errors import HfHubHTTPError
|
| 5 |
-
|
| 6 |
-
from app.config import Settings
|
| 7 |
-
from app.hub import ListingFailed
|
| 8 |
-
from fakes import FakeHub, seed_message
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def make_hub(**overrides) -> FakeHub:
|
| 12 |
-
settings = Settings(
|
| 13 |
-
HF_TOKEN="test-token",
|
| 14 |
-
ORG="test-org",
|
| 15 |
-
COLLAB_SLUG="test",
|
| 16 |
-
AUDIT_BUCKET="auditor/test-audit",
|
| 17 |
-
**overrides,
|
| 18 |
-
)
|
| 19 |
-
return FakeHub(settings)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def test_fail_next_write_fires_once_then_resets():
|
| 23 |
-
hub = make_hub()
|
| 24 |
-
hub.fail_next_write()
|
| 25 |
-
with pytest.raises(HfHubHTTPError):
|
| 26 |
-
hub.write_text_central("agents/a.md", "hi")
|
| 27 |
-
assert "agents/a.md" not in hub._central()
|
| 28 |
-
|
| 29 |
-
hub.write_text_central("agents/a.md", "hi") # toggle reset: this succeeds
|
| 30 |
-
assert hub._central()["agents/a.md"] == b"hi"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def test_fail_next_read_matches_substring_then_resets():
|
| 34 |
-
hub = make_hub()
|
| 35 |
-
seed_message(hub, "20260601-100000-000", "agent-1", "hello")
|
| 36 |
-
path = "message_board/20260601-100000-000_agent-1.md"
|
| 37 |
-
|
| 38 |
-
hub.fail_next_read(path_substring="results/") # doesn't match this path
|
| 39 |
-
assert hub.read_central_text(path) # passes through untouched
|
| 40 |
-
|
| 41 |
-
hub.fail_next_read(path_substring="message_board/")
|
| 42 |
-
with pytest.raises(FileNotFoundError):
|
| 43 |
-
hub.read_central_text(path)
|
| 44 |
-
|
| 45 |
-
assert hub.read_central_text(path) # toggle reset: this succeeds
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def test_partial_listing_raises_instead_of_returning_a_prefix_then_resets():
|
| 49 |
-
hub = make_hub()
|
| 50 |
-
for i in range(3):
|
| 51 |
-
seed_message(hub, f"2026060{i + 1}-100000-000", "agent-1", f"msg {i}")
|
| 52 |
-
|
| 53 |
-
hub.partial_listing("message_board", drop=1)
|
| 54 |
-
with pytest.raises(ListingFailed):
|
| 55 |
-
hub.list_central_dir("message_board")
|
| 56 |
-
|
| 57 |
-
assert len(hub.list_central_dir("message_board")) == 3 # toggle reset
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def test_fail_next_listing_raises_then_resets():
|
| 61 |
-
hub = make_hub()
|
| 62 |
-
seed_message(hub, "20260601-100000-000", "agent-1", "hello")
|
| 63 |
-
|
| 64 |
-
hub.fail_next_listing("message_board")
|
| 65 |
-
with pytest.raises(ListingFailed):
|
| 66 |
-
hub.list_central_dir("message_board")
|
| 67 |
-
|
| 68 |
-
assert len(hub.list_central_dir("message_board")) == 1 # toggle reset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_health_api.py
DELETED
|
@@ -1,64 +0,0 @@
|
|
| 1 |
-
"""/v1/healthz: the warm flag and the read-model gauges."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import time
|
| 5 |
-
|
| 6 |
-
from fastapi.testclient import TestClient
|
| 7 |
-
|
| 8 |
-
from app.main import WARM_FOLDERS, app
|
| 9 |
-
from fakes import seed_agent, seed_message
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def test_healthz_is_200_and_cold_until_warm_up_completes(env):
|
| 13 |
-
seed_agent(env.hub, "agent-1")
|
| 14 |
-
r = env.client.get("/v1/healthz")
|
| 15 |
-
assert r.status_code == 200 and r.json()["warm"] is False
|
| 16 |
-
env.read_model.warm_up(WARM_FOLDERS)
|
| 17 |
-
assert env.client.get("/v1/healthz").json()["warm"] is True
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def test_warm_up_fills_the_caches(env):
|
| 21 |
-
seed_agent(env.hub, "agent-1")
|
| 22 |
-
seed_message(env.hub, "20260601-100000-000", "agent-1", "hi")
|
| 23 |
-
env.read_model.warm_up(WARM_FOLDERS)
|
| 24 |
-
listed, downloaded = env.hub.list_calls, env.hub.download_calls
|
| 25 |
-
assert env.client.get("/v1/messages?expand=true").json()["count"] == 1
|
| 26 |
-
assert (env.hub.list_calls, env.hub.download_calls) == (listed, downloaded)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def test_warm_up_retries_a_failing_folder_before_reporting_warm(env):
|
| 30 |
-
seed_agent(env.hub, "agent-1")
|
| 31 |
-
env.hub.fail_next_listing("agents")
|
| 32 |
-
env.read_model.warm_up(WARM_FOLDERS, retry_s=0)
|
| 33 |
-
assert env.read_model.warm is True
|
| 34 |
-
assert env.read_model.registered_agents() == {"agent-1"}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def test_lifespan_warms_the_read_model_in_the_background(env):
|
| 38 |
-
seed_agent(env.hub, "agent-1")
|
| 39 |
-
with TestClient(app) as client: # runs the lifespan
|
| 40 |
-
deadline = time.monotonic() + 5
|
| 41 |
-
while not client.get("/v1/healthz").json()["warm"]:
|
| 42 |
-
assert time.monotonic() < deadline, "warm-up never completed"
|
| 43 |
-
time.sleep(0.01)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def test_healthz_read_model_gauges(make_env):
|
| 47 |
-
env = make_env(LISTING_TTL_S=0)
|
| 48 |
-
seed_message(env.hub, "20260601-100000-000", "agent-1", "hi")
|
| 49 |
-
env.client.get("/v1/messages?expand=true")
|
| 50 |
-
gauges = env.client.get("/v1/healthz").json()["read_model"]
|
| 51 |
-
assert gauges["folders"] >= 1
|
| 52 |
-
assert gauges["content_cache_bytes"] > 0
|
| 53 |
-
assert gauges["listing_errors"] == {}
|
| 54 |
-
|
| 55 |
-
# A warm folder whose listing fails is served from cache — and named here.
|
| 56 |
-
env.hub.fail_next_listing("message_board")
|
| 57 |
-
assert env.client.get("/v1/messages").json()["count"] == 1
|
| 58 |
-
errors = env.client.get("/v1/healthz").json()["read_model"]["listing_errors"]
|
| 59 |
-
assert set(errors) == {"message_board"}
|
| 60 |
-
assert "simulated" in errors["message_board"]["error"]
|
| 61 |
-
assert errors["message_board"]["age_s"] >= 0
|
| 62 |
-
|
| 63 |
-
env.client.get("/v1/messages") # the next listing succeeds and clears it
|
| 64 |
-
assert env.client.get("/v1/healthz").json()["read_model"]["listing_errors"] == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_hub_client.py
DELETED
|
@@ -1,79 +0,0 @@
|
|
| 1 |
-
"""HubClient's read contract against a stubbed huggingface_hub: a listing or
|
| 2 |
-
batch download either completes or raises — never a silent subset."""
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from types import SimpleNamespace
|
| 7 |
-
|
| 8 |
-
import httpx
|
| 9 |
-
import pytest
|
| 10 |
-
from huggingface_hub.errors import HfHubHTTPError
|
| 11 |
-
|
| 12 |
-
from app import hub as hub_mod
|
| 13 |
-
from app.config import Settings
|
| 14 |
-
from app.hub import DownloadFailed, HubClient, ListingFailed
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def _client() -> HubClient:
|
| 18 |
-
return HubClient(
|
| 19 |
-
Settings(
|
| 20 |
-
HF_TOKEN="test-token",
|
| 21 |
-
ORG="test-org",
|
| 22 |
-
COLLAB_SLUG="test",
|
| 23 |
-
AUDIT_BUCKET="auditor/test-audit",
|
| 24 |
-
)
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def _http_error() -> HfHubHTTPError:
|
| 29 |
-
request = httpx.Request("GET", "https://fake-hub.test/tree")
|
| 30 |
-
return HfHubHTTPError("page 3 failed", response=httpx.Response(500, request=request))
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _entry(path: str):
|
| 34 |
-
return SimpleNamespace(type="file", path=path, size=1, xet_hash=path)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def test_listing_interrupted_mid_pagination_raises(monkeypatch):
|
| 38 |
-
def tree(**_kw):
|
| 39 |
-
yield _entry("message_board/a.md")
|
| 40 |
-
yield _entry("message_board/b.md")
|
| 41 |
-
raise _http_error()
|
| 42 |
-
|
| 43 |
-
monkeypatch.setattr(hub_mod, "list_bucket_tree", tree)
|
| 44 |
-
with pytest.raises(ListingFailed):
|
| 45 |
-
_client().list_central_dir("message_board")
|
| 46 |
-
# Scratch-bucket callers keep their [] contract, but never get the prefix.
|
| 47 |
-
assert _client().list_bucket_dir("test-org/test-agent-1", "message_board") == []
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def test_download_many_skips_only_genuinely_missing_files(monkeypatch):
|
| 51 |
-
def download(bucket_id, files, raise_on_missing_files, token):
|
| 52 |
-
assert raise_on_missing_files is False # missing files are skipped, not raised
|
| 53 |
-
for remote, local in files:
|
| 54 |
-
if remote != "gone.md":
|
| 55 |
-
Path(local).write_bytes(remote.encode())
|
| 56 |
-
|
| 57 |
-
monkeypatch.setattr(hub_mod, "download_bucket_files", download)
|
| 58 |
-
assert _client().download_many("b", ["a.md", "gone.md"]) == {"a.md": b"a.md"}
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def test_download_many_retries_once_then_raises(monkeypatch):
|
| 62 |
-
calls = []
|
| 63 |
-
|
| 64 |
-
def flaky(bucket_id, files, raise_on_missing_files, token):
|
| 65 |
-
calls.append(1)
|
| 66 |
-
if len(calls) == 1:
|
| 67 |
-
raise _http_error()
|
| 68 |
-
for remote, local in files:
|
| 69 |
-
Path(local).write_bytes(b"x")
|
| 70 |
-
|
| 71 |
-
monkeypatch.setattr(hub_mod, "download_bucket_files", flaky)
|
| 72 |
-
assert _client().download_many("b", ["a.md"]) == {"a.md": b"x"}
|
| 73 |
-
|
| 74 |
-
def down(**_kw):
|
| 75 |
-
raise _http_error()
|
| 76 |
-
|
| 77 |
-
monkeypatch.setattr(hub_mod, "download_bucket_files", down)
|
| 78 |
-
with pytest.raises(DownloadFailed):
|
| 79 |
-
_client().download_many("b", ["a.md"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_hub_errors.py
DELETED
|
@@ -1,111 +0,0 @@
|
|
| 1 |
-
"""Agent-facing errors at the hub boundary: every failure is the standard
|
| 2 |
-
{"error": {code, message, hint?}} body, never a bare 500 or a FastAPI 422."""
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
from fakes import seed_agent
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
SOURCE = "hf://buckets/test-org/test-agent-1/drafts/note.md"
|
| 9 |
-
RESULT = "hf://buckets/test-org/test-agent-1/results/run1.md"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def _error(r, status: int, code: str) -> dict:
|
| 13 |
-
assert r.status_code == status, r.text
|
| 14 |
-
err = r.json()["error"]
|
| 15 |
-
assert err["code"] == code and err["message"]
|
| 16 |
-
return err
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def test_missing_message_source_is_404_with_upload_hint(env):
|
| 20 |
-
seed_agent(env.hub, "agent-1")
|
| 21 |
-
r = env.client.post("/v1/messages", json={"source": SOURCE})
|
| 22 |
-
err = _error(r, 404, "SOURCE_NOT_FOUND")
|
| 23 |
-
assert SOURCE in err["message"]
|
| 24 |
-
assert "hf buckets cp" in err["hint"]
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def test_failed_source_read_is_404(env):
|
| 28 |
-
seed_agent(env.hub, "agent-1")
|
| 29 |
-
env.hub.seed("results/run1.md", "---\nscore: 1\n---\nx", bucket="test-org/test-agent-1")
|
| 30 |
-
env.hub.fail_next_read("results/run1.md")
|
| 31 |
-
_error(env.client.post("/v1/results", json={"source": RESULT}), 404, "SOURCE_NOT_FOUND")
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def test_binary_source_is_400(env):
|
| 35 |
-
seed_agent(env.hub, "agent-1")
|
| 36 |
-
env.hub.buckets["test-org/test-agent-1"] = {"drafts/note.md": b"\xff\xfe\x00binary"}
|
| 37 |
-
for route, uri in [("/v1/messages", SOURCE), ("/v1/results", SOURCE)]:
|
| 38 |
-
err = _error(env.client.post(route, json={"source": uri}), 400, "INVALID_FRONTMATTER")
|
| 39 |
-
assert "UTF-8" in err["message"]
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def test_oversized_source_is_413_naming_the_limit(make_env):
|
| 43 |
-
env = make_env(MESSAGE_MAX_BYTES=100)
|
| 44 |
-
seed_agent(env.hub, "agent-1")
|
| 45 |
-
env.hub.seed("drafts/note.md", "x" * 101, bucket="test-org/test-agent-1")
|
| 46 |
-
err = _error(env.client.post("/v1/messages", json={"source": SOURCE}), 413, "TOO_LARGE")
|
| 47 |
-
assert "100" in err["message"]
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def test_hub_write_failure_is_503_with_retry_after(env):
|
| 51 |
-
seed_agent(env.hub, "agent-1")
|
| 52 |
-
env.hub.fail_next_write()
|
| 53 |
-
r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "hi"})
|
| 54 |
-
err = _error(r, 503, "STORAGE_UNAVAILABLE")
|
| 55 |
-
assert "nothing was written" in err["message"]
|
| 56 |
-
assert r.headers["retry-after"] == "5"
|
| 57 |
-
assert env.client.get("/v1/messages").json()["count"] == 0
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def test_missing_required_fields_is_400_listing_each(env):
|
| 61 |
-
r = env.client.post("/v1/agents/register", json={"agent_id": "agent-1"})
|
| 62 |
-
err = _error(r, 400, "INVALID_REQUEST")
|
| 63 |
-
lines = err["message"].splitlines()
|
| 64 |
-
assert "body.model: Field required" in lines
|
| 65 |
-
assert "body.harness: Field required" in lines
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def test_unknown_field_is_400_naming_it(env):
|
| 69 |
-
seed_agent(env.hub, "agent-1")
|
| 70 |
-
r = env.client.post(
|
| 71 |
-
"/v1/messages", json={"agent_id": "agent-1", "body": "hi", "mentions": ["agent-2"]}
|
| 72 |
-
)
|
| 73 |
-
err = _error(r, 400, "INVALID_REQUEST")
|
| 74 |
-
assert "body.mentions: Extra inputs are not permitted" in err["message"]
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def test_oversized_raw_body_is_413(env):
|
| 78 |
-
seed_agent(env.hub, "agent-1")
|
| 79 |
-
r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "x" * 40_000})
|
| 80 |
-
err = _error(r, 413, "TOO_LARGE")
|
| 81 |
-
assert "32768" in err["message"]
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def test_raw_refs_accepts_a_list(env):
|
| 85 |
-
seed_agent(env.hub, "agent-1")
|
| 86 |
-
seed_agent(env.hub, "agent-2")
|
| 87 |
-
refs = ["20260601-100000-000_agent-2.md"]
|
| 88 |
-
r = env.client.post(
|
| 89 |
-
"/v1/messages", json={"agent_id": "agent-1", "body": "building on it", "refs": refs}
|
| 90 |
-
)
|
| 91 |
-
assert r.status_code == 201, r.text
|
| 92 |
-
assert r.json()["mentions_delivered"] == ["agent-2"]
|
| 93 |
-
fm = env.client.get(f"/v1/messages/{r.json()['filename']}").json()["frontmatter"]
|
| 94 |
-
assert fm["refs"] == refs
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def test_cold_listing_failure_is_503_not_an_empty_folder(env):
|
| 98 |
-
env.hub.seed("message_board/20260601-100000-000_agent-1.md", "---\nagent: agent-1\n---\nhi")
|
| 99 |
-
env.hub.fail_next_listing("message_board")
|
| 100 |
-
r = env.client.get("/v1/messages")
|
| 101 |
-
_error(r, 503, "STORAGE_UNAVAILABLE")
|
| 102 |
-
assert r.headers["retry-after"] == "5"
|
| 103 |
-
assert env.client.get("/v1/messages").json()["count"] == 1
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def test_failed_batch_download_is_503_not_404(env):
|
| 107 |
-
fn = "20260601-100000-000_agent-1.md"
|
| 108 |
-
env.hub.seed(f"message_board/{fn}", "---\nagent: agent-1\n---\nhi")
|
| 109 |
-
env.hub.fail_next_read("message_board/")
|
| 110 |
-
_error(env.client.get(f"/v1/messages/{fn}"), 503, "STORAGE_UNAVAILABLE")
|
| 111 |
-
assert env.client.get(f"/v1/messages/{fn}").status_code == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_me_api.py
CHANGED
|
@@ -2,9 +2,6 @@
|
|
| 2 |
hint for whether to show the broadcast toggle. Not the security boundary:
|
| 3 |
POST /v1/messages re-verifies on every broadcast."""
|
| 4 |
|
| 5 |
-
from app.frontmatter import serialise
|
| 6 |
-
from fakes import seed_agent
|
| 7 |
-
|
| 8 |
|
| 9 |
def _me(env, token="user-token"):
|
| 10 |
return env.client.get("/v1/me", headers={"Authorization": f"Bearer {token}"})
|
|
@@ -24,7 +21,6 @@ def test_me_reports_organizer_via_email_lookup(env):
|
|
| 24 |
"handle": "human-test-user",
|
| 25 |
"is_member": True,
|
| 26 |
"is_organizer": True,
|
| 27 |
-
"traces": {"sessions": 0, "last_shared_at": None},
|
| 28 |
}
|
| 29 |
# the targeted lookup answered; no full-org scan needed
|
| 30 |
assert env.hub.org_member_roles_calls == 0
|
|
@@ -58,19 +54,3 @@ def test_me_degrades_to_not_organizer_on_lookup_failure(env):
|
|
| 58 |
def test_me_handle_is_lowercased(env):
|
| 59 |
env.hub.whoami_user = "Test-User"
|
| 60 |
assert _me(env).json()["handle"] == "human-test-user"
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def test_me_sums_traces_of_the_callers_agents(env):
|
| 64 |
-
seed_agent(env.hub, "mine-1")
|
| 65 |
-
seed_agent(env.hub, "mine-2")
|
| 66 |
-
seed_agent(env.hub, "theirs", hf_user="someone-else")
|
| 67 |
-
for agent, at in [
|
| 68 |
-
("mine-1", "2026-06-01 10:00 UTC"),
|
| 69 |
-
("mine-2", "2026-06-02 10:00 UTC"),
|
| 70 |
-
("theirs", "2026-06-03 10:00 UTC"),
|
| 71 |
-
]:
|
| 72 |
-
env.hub.seed(f"traces/{agent}/s/manifest.md", serialise({"promoted_at": at}, ""))
|
| 73 |
-
assert _me(env).json()["traces"] == {
|
| 74 |
-
"sessions": 2,
|
| 75 |
-
"last_shared_at": "2026-06-02 10:00 UTC",
|
| 76 |
-
}
|
|
|
|
| 2 |
hint for whether to show the broadcast toggle. Not the security boundary:
|
| 3 |
POST /v1/messages re-verifies on every broadcast."""
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def _me(env, token="user-token"):
|
| 7 |
return env.client.get("/v1/me", headers={"Authorization": f"Bearer {token}"})
|
|
|
|
| 21 |
"handle": "human-test-user",
|
| 22 |
"is_member": True,
|
| 23 |
"is_organizer": True,
|
|
|
|
| 24 |
}
|
| 25 |
# the targeted lookup answered; no full-org scan needed
|
| 26 |
assert env.hub.org_member_roles_calls == 0
|
|
|
|
| 54 |
def test_me_handle_is_lowercased(env):
|
| 55 |
env.hub.whoami_user = "Test-User"
|
| 56 |
assert _me(env).json()["handle"] == "human-test-user"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_messages_api.py
CHANGED
|
@@ -364,72 +364,3 @@ def test_raw_variant_needs_no_allowlist(env):
|
|
| 364 |
json={"agent_id": "agent-1", "body": "hello", "type": "note"},
|
| 365 |
)
|
| 366 |
assert r.status_code == 201
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
# ── retries (DESIGN.md §5 "Message retries") ─────────────────────────
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
def _board_files(env) -> list[str]:
|
| 373 |
-
return [p for p in env.hub.buckets[env.settings.central_bucket] if p.startswith("message_board/")]
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
def test_raw_retry_with_idempotency_key_returns_original_with_200(env):
|
| 377 |
-
seed_agent(env.hub, "agent-1")
|
| 378 |
-
seed_agent(env.hub, "agent-2")
|
| 379 |
-
post = {"agent_id": "agent-1", "body": "ping @agent-2", "idempotency_key": "k-1"}
|
| 380 |
-
first = env.client.post("/v1/messages", json=post)
|
| 381 |
-
assert first.status_code == 201
|
| 382 |
-
# Same key, even with a changed body: the key identifies the post.
|
| 383 |
-
again = env.client.post("/v1/messages", json={**post, "body": "ping again @agent-2"})
|
| 384 |
-
assert again.status_code == 200
|
| 385 |
-
assert again.json() == first.json()
|
| 386 |
-
assert len(_board_files(env)) == 1
|
| 387 |
-
assert len(env.hub.batch_writes) == 1 # no second post, no second fan-out
|
| 388 |
-
# A new key is a new message, even with the same body.
|
| 389 |
-
assert env.client.post("/v1/messages", json={**post, "idempotency_key": "k-2"}).status_code == 201
|
| 390 |
-
assert len(_board_files(env)) == 2
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
def test_idempotency_key_is_scoped_per_agent_and_length_capped(env):
|
| 394 |
-
seed_agent(env.hub, "agent-1")
|
| 395 |
-
seed_agent(env.hub, "agent-2")
|
| 396 |
-
for agent in ("agent-1", "agent-2"):
|
| 397 |
-
r = env.client.post("/v1/messages", json={"agent_id": agent, "body": "hi", "idempotency_key": "k"})
|
| 398 |
-
assert r.status_code == 201
|
| 399 |
-
r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "x", "idempotency_key": "k" * 65})
|
| 400 |
-
assert r.json()["error"]["code"] == "TOO_LARGE"
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
def test_bucket_retry_with_idempotency_key_returns_original_with_200(env):
|
| 404 |
-
seed_agent(env.hub, "agent-1")
|
| 405 |
-
uri = _seed_source(env, "agent-1", "drafts/m.md", "---\ntype: note\n---\nbody")
|
| 406 |
-
first = env.client.post("/v1/messages", json={"source": uri, "idempotency_key": "b-1"})
|
| 407 |
-
assert first.status_code == 201
|
| 408 |
-
again = env.client.post("/v1/messages", json={"source": uri, "idempotency_key": "b-1"})
|
| 409 |
-
assert again.status_code == 200
|
| 410 |
-
assert again.json()["filename"] == first.json()["filename"]
|
| 411 |
-
assert len(_board_files(env)) == 1
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def test_raw_repeat_body_without_key_is_a_duplicate_within_60s(make_env):
|
| 415 |
-
from app.dedup import RecentPosts
|
| 416 |
-
from app.deps import get_recent_posts
|
| 417 |
-
from app.main import app as fastapi_app
|
| 418 |
-
|
| 419 |
-
env = make_env()
|
| 420 |
-
now = [1000.0]
|
| 421 |
-
recent = RecentPosts(100, clock=lambda: now[0])
|
| 422 |
-
fastapi_app.dependency_overrides[get_recent_posts] = lambda: recent
|
| 423 |
-
seed_agent(env.hub, "agent-1")
|
| 424 |
-
post = {"agent_id": "agent-1", "body": "done with the sweep"}
|
| 425 |
-
first = env.client.post("/v1/messages", json=post)
|
| 426 |
-
assert first.status_code == 201
|
| 427 |
-
now[0] += 59
|
| 428 |
-
again = env.client.post("/v1/messages", json=post)
|
| 429 |
-
assert again.status_code == 200
|
| 430 |
-
assert again.json()["filename"] == first.json()["filename"]
|
| 431 |
-
assert len(_board_files(env)) == 1
|
| 432 |
-
# Past the window the same body is a new message.
|
| 433 |
-
now[0] += 2
|
| 434 |
-
assert env.client.post("/v1/messages", json=post).status_code == 201
|
| 435 |
-
assert len(_board_files(env)) == 2
|
|
|
|
| 364 |
json={"agent_id": "agent-1", "body": "hello", "type": "note"},
|
| 365 |
)
|
| 366 |
assert r.status_code == 201
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_notify_unit.py
CHANGED
|
@@ -93,33 +93,3 @@ def test_wake_from_foreign_thread():
|
|
| 93 |
return result
|
| 94 |
|
| 95 |
assert asyncio.run(scenario()) is True
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def test_mode_is_parked_within_the_window_then_poll():
|
| 99 |
-
"""A plain read after a park keeps reporting parked until the parked stamp
|
| 100 |
-
is older than parked_window_s; stream is the parked poll's until then,
|
| 101 |
-
the latest read's after."""
|
| 102 |
-
now = [0.0]
|
| 103 |
-
n = Notifier(
|
| 104 |
-
max_waiters_per_owner=4,
|
| 105 |
-
max_waiters_total=256,
|
| 106 |
-
wake_spread_s=8.0,
|
| 107 |
-
wake_spread_threshold=20,
|
| 108 |
-
parked_window_s=110.0,
|
| 109 |
-
clock=lambda: now[0],
|
| 110 |
-
)
|
| 111 |
-
n.note_poll("a", "updates", parked=True)
|
| 112 |
-
now[0] = 50.0
|
| 113 |
-
n.note_poll("a", "digest", parked=False)
|
| 114 |
-
assert n.last_poll("a")[1:3] == ("parked", "updates")
|
| 115 |
-
now[0] = 111.0
|
| 116 |
-
assert n.last_poll("a")[1:3] == ("poll", "digest")
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def test_last_cursor_keeps_the_newest_of_sent_and_handed_out():
|
| 120 |
-
n = _notifier()
|
| 121 |
-
n.note_poll("a", "updates", parked=False, after="20260101-000000-000_x.md")
|
| 122 |
-
n.note_cursor("a", "20260102-000000-000_y.md")
|
| 123 |
-
n.note_poll("a", "updates", parked=False, after="20260101-000000-000_x.md")
|
| 124 |
-
n.note_cursor("a", None)
|
| 125 |
-
assert n.last_poll("a").last_cursor == "20260102-000000-000_y.md"
|
|
|
|
| 93 |
return result
|
| 94 |
|
| 95 |
assert asyncio.run(scenario()) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_rate_limit.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
"""TokenBucket / CompoundLimiter unit tests."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
from app.config import Settings
|
| 5 |
-
from app.deps import raw_message_limiter
|
| 6 |
from app.rate_limit import CompoundLimiter, TokenBucket
|
| 7 |
|
| 8 |
|
|
@@ -43,17 +41,3 @@ def test_compound_keys_are_independent():
|
|
| 43 |
assert limiter.try_consume("a")[0] is True
|
| 44 |
assert limiter.try_consume("a")[0] is False
|
| 45 |
assert limiter.try_consume("b")[0] is True
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def test_raw_message_hourly_refill_is_fractional():
|
| 49 |
-
"""30/h refills one token every 2 minutes, not every minute (60/h)."""
|
| 50 |
-
settings = Settings(
|
| 51 |
-
ORG="o", COLLAB_SLUG="s", AUDIT_BUCKET="a/b",
|
| 52 |
-
RAW_MESSAGE_PER_MINUTE=1000, RAW_MESSAGE_PER_HOUR=30,
|
| 53 |
-
)
|
| 54 |
-
limiter = raw_message_limiter(settings)
|
| 55 |
-
for _ in range(30):
|
| 56 |
-
assert limiter.try_consume("k") == (True, 0)
|
| 57 |
-
allowed, retry = limiter.try_consume("k")
|
| 58 |
-
assert allowed is False
|
| 59 |
-
assert 115 <= retry <= 121
|
|
|
|
| 1 |
"""TokenBucket / CompoundLimiter unit tests."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from app.rate_limit import CompoundLimiter, TokenBucket
|
| 5 |
|
| 6 |
|
|
|
|
| 41 |
assert limiter.try_consume("a")[0] is True
|
| 42 |
assert limiter.try_consume("a")[0] is False
|
| 43 |
assert limiter.try_consume("b")[0] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_read_model.py
CHANGED
|
@@ -1,11 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
-
import threading
|
| 3 |
-
|
| 4 |
-
import pytest
|
| 5 |
|
| 6 |
from app.config import Settings
|
| 7 |
from app.frontmatter import serialise
|
| 8 |
-
from app.hub import DownloadFailed, ListingFailed
|
| 9 |
from app.read_model import ReadModel
|
| 10 |
from fakes import FakeHub, seed_message
|
| 11 |
|
|
@@ -74,100 +70,13 @@ def test_write_through_is_visible_without_a_new_listing():
|
|
| 74 |
assert hub.list_calls == listed # TTL untouched — served from the overlay
|
| 75 |
|
| 76 |
|
| 77 |
-
def
|
| 78 |
rm, hub, clock, s = make_rm()
|
| 79 |
seed_message(hub, "20260601-120000-000", "agent-1", "hello")
|
| 80 |
assert len(rm.records("message_board")) == 1
|
| 81 |
hub.fail_listings = True
|
| 82 |
clock.t += s.listing_ttl_s + 1
|
| 83 |
-
assert len(rm.records("message_board")) == 1 #
|
| 84 |
-
assert rm._folder("message_board").last_error is not None
|
| 85 |
-
hub.fail_listings = False
|
| 86 |
-
clock.t += s.listing_ttl_s + 1
|
| 87 |
-
rm.records("message_board")
|
| 88 |
-
assert rm._folder("message_board").last_error is None # cleared on success
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def test_failed_listing_backs_off_one_ttl():
|
| 92 |
-
rm, hub, clock, s = make_rm()
|
| 93 |
-
rm.records("message_board")
|
| 94 |
-
hub.fail_listings = True
|
| 95 |
-
clock.t += s.listing_ttl_s + 1
|
| 96 |
-
rm.records("message_board")
|
| 97 |
-
listed = hub.list_calls
|
| 98 |
-
rm.records("message_board")
|
| 99 |
-
assert hub.list_calls == listed # an outage is not one listing per read
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def test_failed_listing_on_a_cold_folder_raises():
|
| 103 |
-
rm, hub, _clock, _s = make_rm()
|
| 104 |
-
seed_message(hub, "20260601-120000-000", "agent-1", "hello")
|
| 105 |
-
hub.fail_next_listing("message_board")
|
| 106 |
-
with pytest.raises(ListingFailed):
|
| 107 |
-
rm.records("message_board")
|
| 108 |
-
assert len(rm.records("message_board")) == 1 # the next read retries
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def test_empty_listing_is_the_truth_once_failures_are_explicit():
|
| 112 |
-
rm, hub, clock, s = make_rm()
|
| 113 |
-
fn = seed_message(hub, "20260601-120000-000", "agent-1", "hello")
|
| 114 |
-
assert len(rm.records("message_board")) == 1
|
| 115 |
-
del hub.buckets[s.central_bucket][f"message_board/{fn}"] # admin removal
|
| 116 |
-
clock.t += s.listing_ttl_s + 1
|
| 117 |
-
assert rm.records("message_board") == []
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def test_partial_listing_is_not_cached_and_a_cursor_does_not_skip():
|
| 121 |
-
"""A listing interrupted mid-way must not replace the folder: here it
|
| 122 |
-
would have shown m3 without m2, a watcher would advance its after= cursor
|
| 123 |
-
to m3, and m2 would be lost to it for good once the full listing
|
| 124 |
-
returned."""
|
| 125 |
-
rm, hub, clock, s = make_rm()
|
| 126 |
-
m1 = seed_message(hub, "20260601-120000-000", "agent-1", "one")
|
| 127 |
-
assert [r.filename for r in rm.records("message_board")] == [m1]
|
| 128 |
-
cached = dict(rm._folder("message_board").files)
|
| 129 |
-
|
| 130 |
-
seed_message(hub, "20260603-120000-000", "agent-1", "three")
|
| 131 |
-
m2 = seed_message(hub, "20260602-120000-000", "agent-1", "two") # listed last
|
| 132 |
-
hub.partial_listing("message_board", drop=1) # the page holding m2 fails
|
| 133 |
-
clock.t += s.listing_ttl_s + 1
|
| 134 |
-
|
| 135 |
-
def after(cursor):
|
| 136 |
-
return [r.filename for r in rm.records("message_board") if r.filename > cursor]
|
| 137 |
-
|
| 138 |
-
assert after(m1) == [] # the cached truth, not "m3 but no m2"
|
| 139 |
-
assert rm._folder("message_board").files == cached
|
| 140 |
-
clock.t += s.listing_ttl_s + 1
|
| 141 |
-
assert after(m1)[0] == m2 # nothing skipped once the listing completes
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
def test_failed_batch_download_raises_rather_than_dropping_records():
|
| 145 |
-
rm, hub, _clock, _s = make_rm()
|
| 146 |
-
fn = seed_message(hub, "20260601-120000-000", "agent-1", "hello")
|
| 147 |
-
hub.fail_next_read("message_board/")
|
| 148 |
-
with pytest.raises(DownloadFailed):
|
| 149 |
-
rm.record("message_board", fn) # not None, which would be a false 404
|
| 150 |
-
assert rm.record("message_board", fn).body.strip() == "hello"
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
def test_concurrent_cold_reads_share_one_batch_download():
|
| 154 |
-
"""After a restart every watcher reconnects at once; N cold readers of a
|
| 155 |
-
folder must cost one batch download, not N."""
|
| 156 |
-
rm, hub, _clock, _s = make_rm()
|
| 157 |
-
for i in range(3):
|
| 158 |
-
seed_message(hub, f"2026060{i + 1}-120000-000", "agent-1", f"msg {i}")
|
| 159 |
-
hub.latency_s = 0.2
|
| 160 |
-
results = []
|
| 161 |
-
threads = [
|
| 162 |
-
threading.Thread(target=lambda: results.append(rm.records("message_board")))
|
| 163 |
-
for _ in range(2)
|
| 164 |
-
]
|
| 165 |
-
for t in threads:
|
| 166 |
-
t.start()
|
| 167 |
-
for t in threads:
|
| 168 |
-
t.join()
|
| 169 |
-
assert [len(r) for r in results] == [3, 3]
|
| 170 |
-
assert hub.list_calls == 1 and hub.download_calls == 1
|
| 171 |
|
| 172 |
|
| 173 |
def test_lru_eviction_bounds_memory_but_never_drops_results():
|
|
|
|
| 1 |
import json
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from app.config import Settings
|
| 4 |
from app.frontmatter import serialise
|
|
|
|
| 5 |
from app.read_model import ReadModel
|
| 6 |
from fakes import FakeHub, seed_message
|
| 7 |
|
|
|
|
| 70 |
assert hub.list_calls == listed # TTL untouched — served from the overlay
|
| 71 |
|
| 72 |
|
| 73 |
+
def test_transient_empty_listing_keeps_cached_entries():
|
| 74 |
rm, hub, clock, s = make_rm()
|
| 75 |
seed_message(hub, "20260601-120000-000", "agent-1", "hello")
|
| 76 |
assert len(rm.records("message_board")) == 1
|
| 77 |
hub.fail_listings = True
|
| 78 |
clock.t += s.listing_ttl_s + 1
|
| 79 |
+
assert len(rm.records("message_board")) == 1 # nothing is ever deleted
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
def test_lru_eviction_bounds_memory_but_never_drops_results():
|