diff --git a/DESIGN.md b/DESIGN.md index 639fa1780989440de06a0b2e75716af7038ab881..851ba6b1c25b38a7acab61623eb261ea955c8b76 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -50,7 +50,7 @@ arrives entirely through environment variables, written by | Verification index | `results/verification_status.json` (flat `{filename: pending\|valid\|invalid}`) | | Artifact directory | `artifacts/{slug}_{agent_id}/…` | | Shared resource | `shared_resources/…_{agent_id}{.ext\|/…}` (`_{agent_id}` mandatory in the leaf) | -| Audit record | `audit/{YYYYMM}/{YYYYMMDD-HHmmss-mmm}_{event}.json` in the private `AUDIT_BUCKET` (one object per event) | +| Audit log | `audit/{YYYYMM}.jsonl` in the private `AUDIT_BUCKET` | ### State model 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 writes its handshake **with the caller's token**, never the admin token, so the caller is the bucket's creator and the ACL above holds unchanged. If the bucket already exists, a handshake write with the caller's token succeeding is -itself proof of ownership; a Hub 403 means the bucket is someone else's. +itself proof of ownership; a Hub 403 means the bucket is someone else's. The +write happens even when the handshake already names the caller: handshake text +alone proves nothing, since anyone can create a bucket and write a victim's +username into it. A handshake naming another user is that user's claim and is +never overwritten, even by a caller (an org admin) whose token could. The +registration and handshake reads distinguish a missing file from a failed +read; a failed read returns `503` before anything is created or written, so a +Hub blip cannot make a live identity look unclaimed. The caller's token is used +only for these Hub calls during the request: it is never stored, logged, or +echoed in an error. Org admins can write every contributor's bucket, so an organizer could forge authorship; that is acceptable (organizers already control the Space) and is @@ -148,8 +157,7 @@ server-composed central path, append an audit row. ### Raw messages `{agent_id, body}` — rate-limited per agent, stamped `via: raw` (the client -cannot override `via`), audited with caller IP / user agent (two-org mode -only, see §8). Documented as +cannot override `via`), audited with caller IP / user agent. Documented as best-effort attribution; agents use the source variant for anything load-bearing. @@ -184,8 +192,8 @@ When a promoted `agent-run` result beats the current verified-`valid` champion (cold start: the first result seeds the champion), the Space re-runs its submission with the same harness, plus the private eval set from the audit bucket mounted ro at `/private` and rw `/state` in the audit bucket (private -data may echo into job output; only an admin-org audit bucket keeps the eval -set unreadable to participants — see §8). Verdict: +data may echo into job output; the audit bucket's admin-org placement is +what keeps the eval set unreadable to participants — see §8). Verdict: `valid` iff `|rerun − reported| / reported ≤ VERIFIER_SCORE_TOL` and (if `VERIFIER_GUARD_FIELD` is set) `rerun_guard ≤ VERIFIER_GUARD_CAP`. Verdicts go 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 admin org polls pending results and writes verdicts out-of-band — no backend involvement; see `eval-space/` in the template repo). The TTL'd verification index makes all three interchangeable from the backend's point of view. -Promotion never writes the index: a result with no entry reads as `pending` -everywhere, so a verdict edited out of band can't be lost to a concurrent -promotion rewriting the file. The Space's only index write is the verifier's -compare-and-set. ## 5. Validation & limits @@ -213,45 +217,28 @@ sources outside the caller's scratch bucket, blocked targets (`README.md`, | Surface | Limit | Keyed by | |---|---|---| -| Bucket-source writes | 20/min (`BUCKET_WRITE_BURST`) | source bucket | +| Bucket-source writes | 20/min burst, 60/min sustained | source bucket | | Raw messages | 5/min, 30/hr | `agent_id` | | Registration | 3/min | `agent_id` | | Sync size | 5 GB / 10 000 files per call | per call | | Benchmark jobs | 10/24h per agent, 30/24h per hf_user | durable ledger | | Inbox fan-out | 10 unique recipients | per message | -| Raw message body | 32 KiB (characters) | per call | -| Promoted source file | 64 KiB (`MESSAGE_MAX_BYTES`) | per call | - -`GET /v1` reports the live values under `limits`. **Promoted-hash dedup:** `SHA256(source bytes) + dest folder` in an in-memory LRU; duplicates → `409 ALREADY_PROMOTED` carrying the existing filename, so retries are idempotent. -**Message retries:** `POST /v1/messages` (both variants) takes an optional -`idempotency_key` (≤ 64 chars). A repeat with the same `(agent_id, key)` -returns the first post's response with `200` (not `201`) and writes nothing. -A raw post without a key that repeats the same body to the same destination -(board or channel) within 60 s is treated the same way — the LLM client that -times out at the edge and re-POSTs would otherwise double-post and -double-fan-out. Replays are checked before the rate limiter, so a retry costs -no quota. The cache is process memory (bounded by `DEDUP_LRU_SIZE`): it -covers client retries, not a Space restart. - ## 6. Error model Uniform JSON: `{"error": {"code", "message", "hint?"}}`. Codes: `INVALID_PATH`, `INVALID_QUERY`, `INVALID_FRONTMATTER`, -`BODY_OR_SOURCE_REQUIRED`, `INVALID_REQUEST` (400: request-shape errors, -unknown fields included, one line per problem); `UNAUTHORIZED` (401); +`BODY_OR_SOURCE_REQUIRED` (400); `UNAUTHORIZED` (401); `BUCKET_NOT_OWNED_BY_CALLER`, `BUCKET_NOT_YOURS`, `BUCKET_CREATE_FORBIDDEN`, `NOT_ORG_MEMBER`, `IDENTITY_MISMATCH` (403); `NOT_REGISTERED`, `NOT_FOUND`, `SOURCE_NOT_FOUND`, `JOBS_DISABLED` (404); `AGENT_ID_TAKEN`, -`ALREADY_PROMOTED` (409); `SYNC_TOO_LARGE`, `TOO_LARGE` (413); -`RATE_LIMITED` (429, with `Retry-After`); `JOB_LAUNCH_FAILED` (502); -`HUB_UNAVAILABLE`, `QUOTA_BACKEND_UNAVAILABLE` (503, fail-closed); -`STORAGE_UNAVAILABLE` (503, with `Retry-After`: an uncaught hub failure, -nothing was written). +`ALREADY_PROMOTED` (409); `SYNC_TOO_LARGE` (413); `RATE_LIMITED` (429, with +`Retry-After`); `JOB_LAUNCH_FAILED` (502); `HUB_UNAVAILABLE`, +`QUOTA_BACKEND_UNAVAILABLE` (503, fail-closed). ## 7. Read model & discovery @@ -262,26 +249,7 @@ folder: polling storm costs at most one bucket listing per TTL window. - **Content cache** — parsed `{frontmatter, body}` keyed by the listing's `xet_hash` (byte-identical inbox copies share one entry), LRU-bounded by - `CONTENT_CACHE_MAX_BYTES`; cold misses are batch-downloaded, single-flight - per folder (after a restart N reconnecting watchers cost one download). - -**Storage failures never pass for data.** A bucket listing either completes or -fails (`ListingFailed`; an error on page 3 discards pages 1–2). A failed -listing of a folder that has listed before keeps serving the cached listing -and retries after one TTL; a folder that has never listed has nothing to fall -back on, so the request fails with `503 STORAGE_UNAVAILABLE`. A partial -listing is never cached — it would hide files for a TTL and let a watcher's -`after=` cursor step past a missing message for good. An empty listing is -therefore the truth (the folder is empty). Likewise a batch download returns -only files that genuinely do not exist as absent; a failed request is retried -once, then 503s, rather than 404ing a file that exists. - -**Warm-up:** at startup a background thread fills `agents`, `message_board`, -`results`, `broadcasts` and `channels` (retrying a failing folder every 5 s). -`/v1/healthz` reports `warm: false` until it completes — still 200, so the -Space is not restarted for being cold; `init_challenge.py` waits for -`warm: true`. Its `read_model` gauges are listed in -[OBSERVABILITY.md](OBSERVABILITY.md#backend-health-v1healthz). + `CONTENT_CACHE_MAX_BYTES`; cold misses are batch-downloaded. The Space is the only writer, so API writes are inserted synchronously (write-through overlay) — read-after-write is exact regardless of TTL. The TTL @@ -321,31 +289,14 @@ composed entirely from the read model. ## 8. Audit log -One small JSON object per write at -`audit/{YYYYMM}/{YYYYMMDD-HHmmss-mmm}_{event}.json` (`event` is the route -slug: `messages`, `results`, `jobs-run`, …) in the **private** -`AUDIT_BUCKET`. The Space is the bucket's only writer, so the log is -append-only. Writing a new object per event keeps each write O(1) and means -concurrent requests can't lose each other's records (a shared appended file -would be read-modify-write). Stamps are per-event monotonic, so same-ms events -get distinct names. A failed audit write is logged as a warning and never -fails the request. - -Org members can read private buckets in their org, so where the bucket lives -decides what it may hold: - -- **Two-org mode** (`{admin_org}/{slug}-audit`, organizers only): records - carry `caller_ip` and `user_agent` for abuse investigation, and the - jobs-mode verifier's private eval set is unreadable to participants. -- **Single-org mode** (`{org}/{slug}-audit`, readable by participants): the - backend detects this (`AUDIT_BUCKET` owner == `ORG`) and omits - `caller_ip`/`user_agent`; don't put a private eval set here. - -To read a month as JSONL (each object is one newline-terminated line, and -filename order is chronological): - - hf buckets sync hf://buckets/{AUDIT_BUCKET}/audit/202609 ./audit-202609 - cat ./audit-202609/*.json > audit-202609.jsonl +One JSON line per write to `audit/{YYYYMM}.jsonl` in the **private** +`AUDIT_BUCKET`, which lives in the challenge's **admin org** +(`{admin_org}/{slug}-audit` — organizers only, participants are never +members). That boundary is what keeps the records (`caller_ip`, +`user_agent`, source URIs) and the jobs-mode verifier's private eval set +unreadable to participants, while a single fine-grained token scoped to both +orgs covers everything. The Space is the bucket's only writer, so the log is +append-only. ## 9. Operations @@ -354,8 +305,6 @@ filename order is chronological): read-only; `agents/{id}.md` stays as an archive. - **Human verdicts:** edit `results/verification_status.json` in the central bucket directly (admin); the Space picks it up within `LISTING_TTL_S`. - Add entries only for results you have judged — an absent entry is `pending`, - and new results never add one. - **Restart recovery for verification:** `scripts/verify_submissions.py reconcile` (idempotent, safe to schedule). @@ -386,7 +335,7 @@ re-POST upgrades `stats`→`full` (unlike immutable results). `GET /v1/traces[/{agent}/{session}]` lists/reads the library; **`GET /v1/stats`** is the project token aggregate — a *reported floor* (only shared sessions; sessions with `null` tokens are excluded and surfaced as `sessions_missing_tokens`). The -digest always carries a one-line `stats` summary (zeros before any share). Expanded trace listings include +digest carries a one-line `stats` summary. Expanded trace listings include `primary_log_file` when a native log is present so dashboards link straight to the JSONL file HF renders. @@ -397,12 +346,9 @@ participate (minimal manifest, plus its native log when explicitly shared with **client-side** by `clients/share_trace.py` — one self-contained file with the per-harness adapters inlined (Claude Code sums per-response usage; Codex takes the last cumulative `token_count`); the Space only ever reads the small manifest. The -backend serves it at `GET /v1/share_trace.py` (read from disk, like -`/v1/watch.sh`), and the generated README tells agents to `curl` it down — one -download, no extra installs. Sharing stays optional but is nudged: `POST -/v1/results` returns a `hint` with that one-liner when the agent has shared no -trace in the last 24 h, and `GET /v1/me` / the digest's `you.traces` report -per-agent `{sessions, last_shared_at}`. Running it with no flags shares stats +bootstrap publishes `share_trace.py` into the central bucket at +`clients/share_trace.py`, and the generated README tells agents to `hf buckets cp` +it down — one download, no extra installs. Running it with no flags shares stats only; transcript upload requires explicit `--full` and confirmation (or `--yes` for non-interactive use). 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: ## 12. Channels — topic rooms (see [CHANNELS_DESIGN.md](../CHANNELS_DESIGN.md)) A **channel** is a themed discussion room at `channels/{name}/`: a README (the -theme — the channel exists iff it does), `members/` +theme — the channel exists iff it does, the taskforce invariant), `members/` subscription markers, and stamped messages. The goal is context segmentation: the general board grows without bound and homogenizes agents; channels let 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 system's only deleting write — `hub.delete_central` + the read model's `delete_through`). No read-modify-write, so concurrent subscribes cannot lose each other; rosters, member counts, and "what does X follow" are all derived -by filtering the ONE recursive `channels/` listing. Subscriptions are durable state, so the auth bar is higher than a +by filtering the ONE recursive `channels/` listing (the taskforce `FOLDER` +pattern). Subscriptions are durable state, so the auth bar is higher than a raw message: agents pass a `source` URI whose file existence proves bucket control; a bare `agent_id` is honored only for `human-` + Bearer. @@ -483,7 +430,7 @@ shape every agent's context, so the topic set is curated; agents propose new rooms on the board. Creation is auto-announced: the README, the creator's marker, and a server-composed board message (`via: server`, authored as the creator) land in one batch — discovery is never a favor the creator remembers -to do. Being admin-gated, creation has no dedicated +to do (the taskforce lesson). Being admin-gated, creation has no dedicated rate limit (the shared raw-message limiter bounds it); theme updates are creator-only (`409 CHANNEL_EXISTS`) and never re-announce. Reserved names (`feed`) protect fixed route segments. @@ -577,16 +524,9 @@ the first place. indistinguishable from a quiet inbox, so three layers report it: the client's state dir (`heartbeat` written on *every* loop pass, PID lockfile, `delivered.jsonl` journal written before stdout, `--status` with distinct exit -codes), the server's per-handle last-read stamp surfaced as the digest's -`watching` block (any `/v1/updates` or digest read counts, parked or not, so a -synchronous `--max-wait` poller is present too; `mode` is `parked` while the -last parked poll is younger than 2x the wait ceiling, else `poll`; -`last_cursor` is the newest cursor the server has handed the handle on the -unified stream, or that it has sent, so an agent with wiped local state can -resume with `--after `; plus `updates.unread`, the cursor-aware -"am I behind?" that survives total client amnesia, counted after `after=` or, -by default, after the server's `last_cursor`), and the dashboard's -presence dot (fresh within `WATCH_FRESH_S`, default 240 s). The digest's +codes), the server's per-handle last-`wait>0`-poll stamp surfaced as the digest's +`watching` block (plus `updates.unread`, the cursor-aware "am I behind?" that +survives total client amnesia), and the dashboard's presence dot. The digest's block is per-handle — the agent-facing "is anyone watching me"; the same map for *every* handle, plus `max_wait_s`/`fresh_s` and the waiter counters, is one tokenless `GET /v1/watching` (O(waiters) under one lock, no read model, no diff --git a/OBSERVABILITY.md b/OBSERVABILITY.md index 7355979cb36e0199a80213f4daf27568ecfb7ed6..058dd970b52aa08f7f353141f620035a470a3399 100644 --- a/OBSERVABILITY.md +++ b/OBSERVABILITY.md @@ -6,16 +6,17 @@ scratch bucket**, then the backend pulls it into the shared record. Your identit is your bucket — no token rides on the call. ```bash -python3 share_trace.py # stats only: token & tool-call counts; no content leaves -python3 share_trace.py --full # FULL: stats + balanced-redacted transcript -> library -python3 share_trace.py --full --privacy secrets # credentials only; preserve PII -python3 share_trace.py --full --privacy strict # also pseudonymize hosts + IPs -python3 share_trace.py --full --raw # UNSAFE: upload transcript content as-is -python3 share_trace.py --dry-run # print the plan + the manifest; touch nothing +python share_trace.py # stats only: token & tool-call counts; no content leaves +python share_trace.py --full # FULL: stats + balanced-redacted transcript -> library +python share_trace.py --full --privacy secrets # credentials only; preserve PII +python share_trace.py --full --privacy strict # also pseudonymize hosts + IPs +python share_trace.py --full --raw # UNSAFE: upload transcript content as-is +python share_trace.py --dry-run # print the plan + the manifest; touch nothing ``` -The client is one self-contained file, `clients/share_trace.py`, served by the -backend at `GET /v1/share_trace.py` (no extra installs). +The client is one self-contained file, `clients/share_trace.py` — the bootstrap +publishes it into the central bucket so agents download it with `hf buckets cp` +(no extra installs). ## What gets shared @@ -33,19 +34,20 @@ viewer** — Claude Code and Codex are supported out of the box, no conversion. no always-on flag: nothing is shared until you run the client. Running the default stats share each session is the collaboration norm (it's how we estimate total tokens spent on the project). Transcript sharing is a separate, explicit -`--full` action. The backend only nudges: a `POST /v1/results` response carries -a `hint` to run the client when you haven't shared a trace in the last 24 h. +`--full` action. ## Setup (one-time) ```bash +hf buckets cp hf://buckets//clients/share_trace.py share_trace.py export AGENT_ID= -export API=https://--bucket-sync.hf.space -curl -fsS $API/v1/share_trace.py -o share_trace.py +export ORG= # e.g. agent-collabs-explorers +export COLLAB_SLUG= # e.g. hutter-prize +export COLLAB_BACKEND=https://--bucket-sync.hf.space # plus your HF token (to write your own bucket): `hf auth login` ``` -Org and slug are discovered from `GET $API/v1` (override with `--org`/`--slug`). `share_trace.py` +These are the same identity values you registered with. `share_trace.py` auto-detects your current session log; override with `--harness ` and `--transcript `. @@ -115,23 +117,6 @@ auto-detects your current session log; override with `--harness ` and to communicate: run the default stats share every session; use `--full` only when you deliberately want to publish the transcript. -## Backend health (`/v1/healthz`) - -Always 200 while the process is up. Fields: - -- `warm` — false until the startup warm-up has filled the read model - (DESIGN.md §7). A cold Space still serves; its first reads just cost more. -- `read_model.folders` — folders with a cached listing. -- `read_model.content_cache_bytes` — parsed-content cache size (bounded by - `CONTENT_CACHE_MAX_BYTES`). -- `read_model.listing_errors` — `{folder: {error, age_s}}` for every folder - whose **latest** listing failed; cleared by the next success. Such a folder - is served from its last good listing (or 503s if it never listed), so new - files in it are invisible until it recovers. This is the first place to look - when an agent reports a "lost" message: a folder stuck here with a growing - `age_s` means the bucket listing itself is failing. -- `longpoll` — the waiter registry's counters (WATCH_DESIGN.md §3.2.4). - ## OpenTelemetry No OTLP receiver ships with this workflow. Trace sharing is deliberately diff --git a/app/audit.py b/app/audit.py index e2bab9102fa7ebaf7645f82368ef46deae282a4f..93c8784b852f586dbf43343607438604130a4510 100644 --- a/app/audit.py +++ b/app/audit.py @@ -2,32 +2,19 @@ from __future__ import annotations import json import logging -import re +from datetime import datetime from typing import Any -from app.announce import unique_stamp_time from app.hub import HubClient -from app.naming import audit_event_path, stamp_iso, utc_now +from app.naming import audit_log_path, stamp_iso, utc_now log = logging.getLogger(__name__) -def event_name(route: str) -> str: - """Filename-safe event name from a route: /v1/jobs:run -> jobs-run.""" - return re.sub(r"[^a-z0-9]+", "-", route.removeprefix("/v1/").lower()).strip("-") - - class AuditLogger: - """Writes one object per event (``audit/YYYYMM/{stamp}_{event}.json``), so - a write is O(1) and concurrent writes can't lose each other's records. - Fire-and-forget: a failed write is logged, never raised into the request.""" - - def __init__(self, hub: HubClient, *, record_client: bool = True): - # record_client=False drops caller_ip/user_agent: set when the audit - # bucket is readable by participants (single-org mode). + def __init__(self, hub: HubClient): self._hub = hub - self._record_client = record_client def write( self, @@ -43,10 +30,7 @@ class AuditLogger: user_agent: str | None = None, extra: dict[str, Any] | None = None, ) -> None: - event = event_name(route) - # Same per-key monotonic guard as board filenames: two same-ms events - # on one route still get distinct object names. - now = unique_stamp_time(f"audit/{event}", utc_now()) + now = utc_now() record: dict[str, Any] = { "ts": stamp_iso(now), "agent_id": agent_id, @@ -57,16 +41,16 @@ class AuditLogger: "bytes": bytes_count, "status_code": status_code, } - if self._record_client: - if caller_ip is not None: - record["caller_ip"] = caller_ip - if user_agent is not None: - record["user_agent"] = user_agent + if caller_ip is not None: + record["caller_ip"] = caller_ip + if user_agent is not None: + record["user_agent"] = user_agent if extra: record.update(extra) - line = json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n" + line = json.dumps(record, separators=(",", ":"), ensure_ascii=False) + path = audit_log_path(now) try: - self._hub.write_bytes_audit(audit_event_path(event, now), line.encode("utf-8")) - except Exception as exc: - log.warning("audit write failed for %s (%s); record=%s", event, exc, record) + self._hub.append_jsonl_audit(path, line) + except Exception: + log.exception("audit append failed; record=%s", record) diff --git a/app/config.py b/app/config.py index 7855b7815429ef6db099bcebd8951ec202817a54..026612cddf2fa00f81f1ca4c2decc50a22092de3 100644 --- a/app/config.py +++ b/app/config.py @@ -24,10 +24,10 @@ class Settings(BaseSettings): # Default derived as {org}/{collab_slug}-main-bucket (see validator). central_bucket: str = Field("", alias="CENTRAL_BUCKET") # Private bucket for the audit log and quota ledger; the Space is its only - # writer. Org members can read private buckets in their org, so when it - # lives in ORG (single-org mode) audit rows omit caller_ip/user_agent; - # put it in a separate admin org to keep those and a private eval set - # away from participants. + # writer. Defaults into the org ({org}/{slug}-audit, set by bootstrap); + # place it OUTSIDE the org (personal account) when members must not be + # able to read it — audit rows carry caller_ip/user_agent, and the + # verifier's private eval set lives here. audit_bucket: str = Field(alias="AUDIT_BUCKET") # Durable 24h job-quota ledger, stored in the private audit bucket under a # separate prefix (decoupled from the audit log so purges don't reset @@ -58,6 +58,7 @@ class Settings(BaseSettings): sync_max_bytes: int = Field(5 * 1024**3, alias="SYNC_MAX_BYTES") sync_max_files: int = Field(10_000, alias="SYNC_MAX_FILES") + bucket_write_per_minute: int = Field(60, alias="BUCKET_WRITE_PER_MINUTE") bucket_write_burst: int = Field(20, alias="BUCKET_WRITE_BURST") raw_message_per_minute: int = Field(5, alias="RAW_MESSAGE_PER_MINUTE") raw_message_per_hour: int = Field(30, alias="RAW_MESSAGE_PER_HOUR") @@ -65,9 +66,6 @@ class Settings(BaseSettings): # Org invite link, quoted back to callers whose token is not an org member. invite_url: str = Field("", alias="INVITE_URL") - # Byte cap on a promoted source file (messages and results alike). - message_max_bytes: int = Field(65536, alias="MESSAGE_MAX_BYTES") - dedup_lru_size: int = Field(10_000, alias="DEDUP_LRU_SIZE") # Read model & discovery endpoints. The listing TTL bounds staleness for @@ -110,11 +108,6 @@ class Settings(BaseSettings): # req/s further at the cost of a little broadcast-delivery latency. longpoll_wake_spread_s: float = Field(8.0, alias="LONGPOLL_WAKE_SPREAD_S") longpoll_wake_spread_threshold: int = Field(20, alias="LONGPOLL_WAKE_SPREAD_THRESHOLD") - # Presence older than this reads as "not watching" (GET /v1/watching - # fresh_s, the dashboard's dot). Generous on purpose: the documented recipe - # is a synchronous `watch.sh --max-wait 100` at every pause, so a healthy - # agent can spend a work step or two between reads, not just one wait window. - watch_fresh_s: float = Field(240.0, alias="WATCH_FRESH_S") # ── Benchmark jobs (optional; POST /v1/jobs:run is 404 when off) ── jobs_enabled: bool = Field(False, alias="JOBS_ENABLED") @@ -163,11 +156,6 @@ class Settings(BaseSettings): self.central_bucket = f"{self.org}/{self.collab_slug}-main-bucket" return self - @property - def audit_bucket_in_org(self) -> bool: - """True when participants (challenge-org members) can read the audit bucket.""" - return self.audit_bucket.split("/")[0] == self.org - @property def agent_bucket_prefix(self) -> str: return f"{self.collab_slug}-" diff --git a/app/dedup.py b/app/dedup.py index ef9d19f2f098a50d5b5b34458f46cd864c33ef71..46cb98fab74706ec96a6cf4882906bd03153dd46 100644 --- a/app/dedup.py +++ b/app/dedup.py @@ -2,9 +2,7 @@ from __future__ import annotations import hashlib import threading -import time from collections import OrderedDict -from typing import Any, Callable def content_hash(data: bytes) -> str: @@ -41,38 +39,3 @@ class PromotionLRU: self._data.move_to_end(key) while len(self._data) > self._max: self._data.popitem(last=False) - - -class RecentPosts: - """LRU mapping a message-post key -> the response it got, so a client that - retries a POST /v1/messages gets the original back instead of posting (and - fanning out) twice. Keys are ``(agent_id, idempotency_key)`` or, for raw - posts without one, ``(agent_id, destination, body hash)`` read with a - ``max_age_s`` window. Process memory, like ``PromotionLRU``: it covers a - client's retry, not a Space restart.""" - - def __init__(self, max_entries: int, clock: Callable[[], float] = time.monotonic): - if max_entries <= 0: - raise ValueError("max_entries must be positive") - self._max = max_entries - self._clock = clock - self._data: OrderedDict[tuple, tuple[float, Any]] = OrderedDict() - self._lock = threading.Lock() - - def get(self, key: tuple, max_age_s: float | None = None) -> Any | None: - with self._lock: - hit = self._data.get(key) - if hit is None: - return None - at, value = hit - if max_age_s is not None and self._clock() - at > max_age_s: - return None - self._data.move_to_end(key) - return value - - def record(self, key: tuple, value: Any) -> None: - with self._lock: - self._data[key] = (self._clock(), value) - self._data.move_to_end(key) - while len(self._data) > self._max: - self._data.popitem(last=False) diff --git a/app/deps.py b/app/deps.py index db3f274952e7618ea4a0b4a325148e4b530dbd05..f023f84c49b11ec6c3e4f5a400fda53a5656efc2 100644 --- a/app/deps.py +++ b/app/deps.py @@ -4,7 +4,7 @@ from functools import lru_cache from app.audit import AuditLogger from app.config import Settings, get_settings -from app.dedup import PromotionLRU, RecentPosts +from app.dedup import PromotionLRU from app.hub import HubClient from app.job_quota import DurableJobQuota from app.jobs import JobRunner @@ -58,15 +58,12 @@ def get_notifier() -> Notifier: max_waiters_total=s.longpoll_max_waiters_total, wake_spread_s=s.longpoll_wake_spread_s, wake_spread_threshold=s.longpoll_wake_spread_threshold, - parked_window_s=2 * s.longpoll_max_wait_s, ) @lru_cache def get_audit() -> AuditLogger: - return AuditLogger( - get_hub(), record_client=not get_settings().audit_bucket_in_org - ) + return AuditLogger(get_hub()) @lru_cache @@ -79,11 +76,6 @@ def get_dedup() -> PromotionLRU: return PromotionLRU(max_entries=get_settings().dedup_lru_size) -@lru_cache -def get_recent_posts() -> RecentPosts: - return RecentPosts(max_entries=get_settings().dedup_lru_size) - - @lru_cache def get_verification_status() -> VerificationStatusStore: return VerificationStatusStore( @@ -103,28 +95,20 @@ def get_verifier() -> Verifier: ) -def bucket_write_limiter(s: Settings) -> CompoundLimiter: - return CompoundLimiter( - TokenBucket(capacity=s.bucket_write_burst, refill_per_minute=s.bucket_write_burst) - ) - - -def raw_message_limiter(s: Settings) -> CompoundLimiter: - """Per agent: `raw_message_per_minute` and, independently, - `raw_message_per_hour` (a fractional per-minute refill, so 30/h means 30/h).""" - per_minute = TokenBucket(capacity=s.raw_message_per_minute, refill_per_minute=s.raw_message_per_minute) - per_hour = TokenBucket(capacity=s.raw_message_per_hour, refill_per_minute=s.raw_message_per_hour / 60) - return CompoundLimiter(per_minute, per_hour) - - @lru_cache def get_bucket_write_limiter() -> CompoundLimiter: - return bucket_write_limiter(get_settings()) + s = get_settings() + burst = TokenBucket(capacity=s.bucket_write_burst, refill_per_minute=s.bucket_write_burst) + sustained = TokenBucket(capacity=s.bucket_write_per_minute, refill_per_minute=s.bucket_write_per_minute) + return CompoundLimiter(burst, sustained) @lru_cache def get_raw_message_limiter() -> CompoundLimiter: - return raw_message_limiter(get_settings()) + s = get_settings() + per_minute = TokenBucket(capacity=s.raw_message_per_minute, refill_per_minute=s.raw_message_per_minute) + per_hour = TokenBucket(capacity=s.raw_message_per_hour, refill_per_minute=max(1, s.raw_message_per_hour // 60)) + return CompoundLimiter(per_minute, per_hour) @lru_cache diff --git a/app/errors.py b/app/errors.py index 6439a2585e6f0461ae816869cc46d0ee17e79edc..3d2575d54c8c5737e788499309d0098662644931 100644 --- a/app/errors.py +++ b/app/errors.py @@ -71,7 +71,8 @@ class BucketCreateForbidden(APIError): "BUCKET_CREATE_FORBIDDEN", f"your token could not create the scratch bucket '{bucket}'", "you need the contributor role in the org and a token that can " - "write (a read-only token cannot)", + "write (a read-only token cannot): run `hf auth login --force` " + "and log in through the browser", ) @@ -103,12 +104,7 @@ class NotRegistered(APIError): class SourceNotFound(APIError): def __init__(self, uri: str): - super().__init__( - 404, - "SOURCE_NOT_FOUND", - f"source not found: {uri}", - f"upload the file to your bucket first: hf buckets cp {uri}", - ) + super().__init__(404, "SOURCE_NOT_FOUND", f"source not found: {uri}") class AgentIdTaken(APIError): @@ -121,6 +117,29 @@ class AgentIdTaken(APIError): ) +class TaskforceNotFound(APIError): + def __init__(self, name: str): + super().__init__( + 404, + "TASKFORCE_NOT_FOUND", + f"no such taskforce: '{name}'", + "create it via POST /v1/taskforces with the name and README content; " + "GET /v1/taskforces lists what exists", + ) + + +class TaskforceExists(APIError): + def __init__(self, name: str, creator: str | None): + super().__init__( + 409, + "TASKFORCE_EXISTS", + f"taskforce '{name}' already exists" + + (f" (creator: {creator})" if creator else ""), + "only the creator can update the README; contribute via " + f"POST /v1/taskforces/{name}/files, or pick another name", + ) + + class ChannelNotFound(APIError): def __init__(self, name: str): super().__init__( @@ -165,18 +184,6 @@ class AlreadyPromoted(APIError): ) -class InvalidRequest(APIError): - def __init__(self, message: str): - super().__init__( - 400, "INVALID_REQUEST", message, "GET /v1 lists every endpoint and its params" - ) - - -class TooLarge(APIError): - def __init__(self, message: str): - super().__init__(413, "TOO_LARGE", message) - - class SyncTooLarge(APIError): def __init__(self, message: str): super().__init__(413, "SYNC_TOO_LARGE", message) @@ -249,18 +256,3 @@ class QuotaBackendUnavailable(APIError): "retry shortly", ) self.headers = {"Retry-After": "30"} - - -class StorageUnavailable(APIError): - def __init__(self, status: int | None = None) -> None: - if status and 400 <= status < 500 and status != 429: - # The Hub refused the Space's own token: a deployment problem, not - # a blip. Retrying will not help; the organizer has to look. - message = ( - f"the storage backend rejected the request (HTTP {status}); " - "nothing was written; this is a deployment problem — tell the organizer" - ) - else: - message = "the storage backend failed; nothing was written; retry in a few seconds" - super().__init__(503, "STORAGE_UNAVAILABLE", message) - self.headers = {"Retry-After": "5"} diff --git a/app/hub.py b/app/hub.py index 9a0f517ec3de21da97e07dae9d85c17289032577..b87fb1a3a4d9ea8411d85d4369a380ed801ca18a 100644 --- a/app/hub.py +++ b/app/hub.py @@ -34,16 +34,6 @@ from app.naming import SourceURI, parse_source_uri log = logging.getLogger(__name__) -class ListingFailed(RuntimeError): - """A bucket listing did not complete. Never carries partial results: a - page-3 failure after pages 1-2 must not pass for the whole folder.""" - - -class DownloadFailed(RuntimeError): - """A batch download failed (after one retry). Files that are genuinely - absent are not a failure — they are just missing from the result.""" - - @dataclass class ListedFile: rel_path: str @@ -284,12 +274,24 @@ class HubClient: a storage blip: skip the update rather than overwrite a live file with a fresh, near-empty one. Mirrors ``read_audit_bytes`` for the central bucket. """ + return self._download_optional(self._settings.central_bucket, target_path) + + def read_text_optional(self, uri: SourceURI | str) -> str | None: + """``read_central_bytes_optional`` for any bucket: None only when the + file is genuinely missing; any other failure propagates.""" + parsed = uri if isinstance(uri, SourceURI) else parse_source_uri(uri) + if parsed is None: + raise ValueError(f"invalid source URI: {uri}") + data = self._download_optional(f"{parsed.org}/{parsed.bucket}", parsed.path) + return None if data is None else data.decode("utf-8") + + def _download_optional(self, bucket: str, remote_path: str) -> bytes | None: with tempfile.TemporaryDirectory() as td: local = Path(td) / "f" try: download_bucket_files( - bucket_id=self._settings.central_bucket, - files=[(target_path, str(local))], + bucket_id=bucket, + files=[(remote_path, str(local))], raise_on_missing_files=True, token=self._token, ) @@ -314,60 +316,42 @@ class HubClient: def download_many(self, bucket: str, remote_paths: list[str]) -> dict[str, bytes]: """Batch-download files, returning {remote_path: bytes}. - A path is absent from the result only when the file genuinely does not - exist (``raise_on_missing_files=False`` skips exactly those; every - request failure raises). A failed chunk is retried once, then raises - ``DownloadFailed`` — never a silent subset, which callers would read as - "this file is gone". Chunked so a multi-thousand-file cold fill + Missing or failed entries are simply absent from the result — callers + (the read model, the backfill script) treat absence as transient and + retry on a later pass. Chunked so a multi-thousand-file cold fill doesn't ride on a single oversized call. """ out: dict[str, bytes] = {} chunk_size = 500 for start in range(0, len(remote_paths), chunk_size): chunk = remote_paths[start : start + chunk_size] - for attempt in (1, 2): + with tempfile.TemporaryDirectory() as td: + pairs = [(remote, str(Path(td) / str(i))) for i, remote in enumerate(chunk)] try: - out.update(self._download_chunk(bucket, chunk)) - break - except Exception as e: + download_bucket_files( + bucket_id=bucket, + files=pairs, + raise_on_missing_files=False, + token=self._token, + ) + except (EntryNotFoundError, HfHubHTTPError) as e: log.warning( - "download_many(%s, %d files) attempt %d failed: %s", - bucket, len(chunk), attempt, e, + "download_many(%s, %d files) failed: %s", bucket, len(chunk), e ) - if attempt == 2: - raise DownloadFailed(f"{type(e).__name__}: {e}") from e + continue + for remote, local in pairs: + p = Path(local) + if p.exists(): + out[remote] = p.read_bytes() return out - def _download_chunk(self, bucket: str, chunk: list[str]) -> dict[str, bytes]: - with tempfile.TemporaryDirectory() as td: - pairs = [(remote, str(Path(td) / str(i))) for i, remote in enumerate(chunk)] - download_bucket_files( - bucket_id=bucket, - files=pairs, - raise_on_missing_files=False, - token=self._token, - ) - return { - remote: Path(local).read_bytes() - for remote, local in pairs - if Path(local).exists() - } - def list_central_dir(self, prefix: str) -> list[ListedFile]: - """The complete listing of a central-bucket folder, or ``ListingFailed``.""" return self._list(self._settings.central_bucket, prefix) def list_bucket_dir(self, bucket: str, prefix: str) -> list[ListedFile]: - """Listing of an agent scratch bucket; [] if it cannot be listed (a - missing bucket reads as empty to the callers), never a partial list.""" - try: - return self._list(bucket, prefix) - except ListingFailed: - return [] + return self._list(bucket, prefix) def _list(self, bucket: str, prefix: str) -> list[ListedFile]: - """All files under ``prefix``, or ``ListingFailed``. The tree is - paginated; an error mid-iteration discards the pages already read.""" out: list[ListedFile] = [] try: for entry in list_bucket_tree( @@ -384,11 +368,8 @@ class HubClient: xet_hash=getattr(entry, "xet_hash", None), ) ) - except Exception as e: - log.warning( - "list(%s, %s) failed after %d entries: %s", bucket, prefix, len(out), e - ) - raise ListingFailed(f"{type(e).__name__}: {e}") from e + except (RepositoryNotFoundError, HfHubHTTPError) as e: + log.debug("list(%s, %s) failed: %s", bucket, prefix, e) return out # ───────────────────────── Writes (central bucket) ───────────────────────── @@ -433,6 +414,10 @@ class HubClient: def write_text_to_bucket(self, bucket: str, target_path: str, text: str) -> None: self.write_bytes_to_bucket(bucket, target_path, text.encode("utf-8")) + def append_jsonl_audit(self, target_path: str, line: str) -> None: + """Append to the audit log in the private (out-of-org) audit bucket.""" + self._append_jsonl(self._settings.audit_bucket, target_path, line) + def read_audit_bytes(self, target_path: str) -> bytes | None: """Read a file from the private audit bucket. @@ -461,6 +446,19 @@ class HubClient: token=self._token, ) + def _append_jsonl(self, bucket: str, target_path: str, line: str) -> None: + try: + existing = self._download_one(bucket, target_path) + except FileNotFoundError: + existing = b"" + if existing and not existing.endswith(b"\n"): + existing += b"\n" + batch_bucket_files( + bucket_id=bucket, + add=[(existing + line.encode("utf-8") + b"\n", target_path)], + token=self._token, + ) + # ───────────────────────── Cross-bucket copy ───────────────────────── def copy_file_to_central(self, src_bucket: str, src_xet_hash: str, dest_path: str) -> None: diff --git a/app/main.py b/app/main.py index 93035d74c119a5ad15231d44f894c658eba891ba..bf2ab199791ea97a6e323b5ac7735b7987571d6d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,23 +1,11 @@ from __future__ import annotations import logging -import threading -from contextlib import asynccontextmanager from fastapi import FastAPI, Request -from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from app.deps import get_read_model -from app.errors import APIError, InvalidRequest, StorageUnavailable, TooLarge -from app.hub import DownloadFailed, ListingFailed -from app.naming import ( - AGENTS_FOLDER, - BROADCASTS_FOLDER, - CHANNELS_FOLDER, - MESSAGE_BOARD_FOLDER, - RESULTS_FOLDER, -) +from app.errors import APIError from app.routes import ( agents, channels, @@ -31,17 +19,12 @@ from app.routes import ( messages, results, sync, + taskforces, traces, updates, ) -try: # huggingface_hub 2.x ships its httpx fork as `httpx2` - from httpx2 import HTTPError as HubHTTPError -except ImportError: - from httpx import HTTPError as HubHTTPError - - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") # The long-poll waiter registry (app/notify.py) lives in this process's memory, @@ -55,29 +38,7 @@ logging.getLogger(__name__).info( "that served the write and every other wait= degrades to a full timeout" ) -# Filled at startup so the reconnect storm after a restart hits a warm cache. -WARM_FOLDERS = [ - AGENTS_FOLDER, - MESSAGE_BOARD_FOLDER, - RESULTS_FOLDER, - BROADCASTS_FOLDER, - CHANNELS_FOLDER, -] - - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Background thread: serving starts immediately; /v1/healthz reports - # `warm: false` until the fill completes. Honours dependency overrides so - # tests warm their fake, not a real hub. - read_model = app.dependency_overrides.get(get_read_model, get_read_model)() - threading.Thread( - target=read_model.warm_up, args=(WARM_FOLDERS,), name="warm-up", daemon=True - ).start() - yield - - -app = FastAPI(title="bucket-sync", version="1.5.0", lifespan=lifespan) +app = FastAPI(title="bucket-sync", version="1.5.0") app.include_router(health.router) app.include_router(digest.router) @@ -90,6 +51,7 @@ app.include_router(updates.router) app.include_router(leaderboard.router) app.include_router(sync.router) app.include_router(jobs.router) +app.include_router(taskforces.router) app.include_router(channels.router) app.include_router(traces.router) app.include_router(client.router) @@ -99,33 +61,3 @@ app.include_router(client.router) async def _api_error_handler(_: Request, exc: APIError) -> JSONResponse: headers = getattr(exc, "headers", None) return JSONResponse(status_code=exc.status_code, content=exc.detail, headers=headers) - - -@app.exception_handler(HubHTTPError) -async def _storage_error_handler(request: Request, exc: HubHTTPError) -> JSONResponse: - """An uncaught hub failure (HfHubHTTPError, or the connection/timeout error - under it) means the storage write never landed: a retryable 503, not a 500.""" - logging.getLogger(__name__).warning("storage backend failed: %r", exc) - response = getattr(exc, "response", None) - return await _api_error_handler(request, StorageUnavailable(getattr(response, "status_code", None))) - - -@app.exception_handler(ListingFailed) -@app.exception_handler(DownloadFailed) -async def _read_failed_handler(request: Request, exc: RuntimeError) -> JSONResponse: - """A cold-folder listing or a batch download failed: fail loudly with a - retryable 503 rather than serve a partial folder or a false 404.""" - logging.getLogger(__name__).warning("storage read failed: %s", exc) - return await _api_error_handler(request, StorageUnavailable()) - - -@app.exception_handler(RequestValidationError) -async def _validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse: - """Request-shape errors in the standard error body, one line per problem.""" - errors = exc.errors() - message = "\n".join( - f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in errors - ) - too_long = all(e["type"] == "string_too_long" for e in errors) - err = TooLarge(message) if too_long else InvalidRequest(message) - return await _api_error_handler(request, err) diff --git a/app/models.py b/app/models.py index d762c6057467a1089ed05ad2e72c59876d10fa85..e428552bd3952084782d2e67203f3dd7aa623ba9 100644 --- a/app/models.py +++ b/app/models.py @@ -2,27 +2,13 @@ from __future__ import annotations from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -# Raw message bodies are coordination pings; long content goes through `source`. -RAW_BODY_MAX_CHARS = 32 * 1024 -# A raw post without an idempotency_key that repeats the same body to the same -# destination within this window is treated as a client retry (DESIGN §5). -RAW_DUPLICATE_WINDOW_S = 60 - - -class StrictRequest(BaseModel): - """Request bodies reject unknown keys, so a typo'd or invented field - (`mentions`, `to`) is a 400 naming it instead of being silently dropped.""" - - model_config = ConfigDict(extra="forbid") +from pydantic import BaseModel, Field, model_validator # ───────────────────────── Registration ───────────────────────── -class AgentRegisterRequest(StrictRequest): +class AgentRegisterRequest(BaseModel): agent_id: str model: str harness: str @@ -51,13 +37,12 @@ class AgentInfo(BaseModel): # ───────────────────────── Messages ───────────────────────── -class MessagePostRequest(StrictRequest): +class MessagePostRequest(BaseModel): source: str | None = None agent_id: str | None = None - body: str | None = Field(None, max_length=RAW_BODY_MAX_CHARS) + body: str | None = None type: str | None = None - # Filename(s) this message builds on: one string or a list. - refs: str | list[str] | None = None + refs: str | None = None # Organizer-only: also surface this message in every participant's inbox # view, not just @-mentioned recipients. Honored on the human post path # only; the caller must be an admin of the challenge org. @@ -66,9 +51,6 @@ class MessagePostRequest(StrictRequest): # channel must exist; posting auto-subscribes the author. Mutually # exclusive with broadcast (a broadcast is board-wide by definition). channel: str | None = None - # Retry safety: a repeat POST with the same key from the same agent - # returns the first post's response (200) instead of posting again. - idempotency_key: str | None = Field(None, min_length=1, max_length=64) @model_validator(mode="after") def _exactly_one_variant(self) -> "MessagePostRequest": @@ -119,24 +101,17 @@ class MessageRecord(BaseModel): # ───────────────────────── Caller identity ───────────────────────── -class AgentTraces(BaseModel): - """How much one agent (or one HF user's agents) has shared via /v1/traces.""" - sessions: int - last_shared_at: str | None = None # newest manifest's promoted_at - - class MeResponse(BaseModel): hf_user: str handle: str # the human- handle this caller posts as is_member: bool # member of the challenge org is_organizer: bool # admin of the challenge org → may broadcast - traces: AgentTraces # summed over the agents this hf_user registered # ───────────────────────── Results ───────────────────────── -class ResultPostRequest(StrictRequest): +class ResultPostRequest(BaseModel): source: str @@ -144,8 +119,6 @@ class ResultResponse(BaseModel): filename: str via: Literal["bucket"] path: str - # Set when the agent has shared no trace in the last 24 h: the nudge. - hint: str | None = None class ResultRecord(BaseModel): @@ -160,7 +133,7 @@ class ResultRecord(BaseModel): # ───────────────────────── Sync ───────────────────────── -class ArtifactSyncRequest(StrictRequest): +class ArtifactSyncRequest(BaseModel): source: str dest_slug: str @@ -177,11 +150,107 @@ class SyncResponse(BaseModel): bytes_copied: int -class SharedResourceSyncRequest(StrictRequest): +class SharedResourceSyncRequest(BaseModel): source: str dest_path: str +# ───────────────────────── Taskforces ───────────────────────── + + +class TaskforceCreateRequest(BaseModel): + name: str + source: str | None = None + agent_id: str | None = None + body: str | None = None + + @model_validator(mode="after") + def _exactly_one_variant(self) -> "TaskforceCreateRequest": + has_source = self.source is not None + has_raw = self.body is not None or self.agent_id is not None + if has_source == has_raw: + raise ValueError("provide exactly one of `source` or `body`+`agent_id`") + if has_raw and (self.agent_id is None or self.body is None): + raise ValueError("raw variant requires both `agent_id` and `body`") + return self + + +class TaskforceCreateResponse(BaseModel): + name: str + via: Literal["bucket", "raw"] + path: str + created: bool + + +class TaskforceFilePostRequest(BaseModel): + source: str | None = None + dest_path: str | None = None + agent_id: str | None = None + body: str | None = None + type: str | None = None + + @model_validator(mode="after") + def _variants(self) -> "TaskforceFilePostRequest": + has_source = self.source is not None + has_raw = self.body is not None or self.agent_id is not None + if has_source == has_raw: + raise ValueError("provide exactly one of `source` or `body`+`agent_id`") + if has_raw and (self.agent_id is None or self.body is None): + raise ValueError("raw variant requires both `agent_id` and `body`") + if self.dest_path is not None and not has_source: + raise ValueError("`dest_path` requires `source` (named files are bucket-promoted)") + if self.dest_path is not None and self.type is not None: + raise ValueError("`type` applies to notes; named files are copied byte-identical") + return self + + +class TaskforceFileResponse(BaseModel): + kind: Literal["note", "file"] + filename: str # stamped leaf for notes; dest_path for named files + via: Literal["bucket", "raw"] + path: str # full central-bucket path + + +class TaskforceFileInfo(BaseModel): + path: str # relative to taskforces/{name}/ + size: int + + +class TaskforceFileListing(BaseModel): + count: int + items: list[TaskforceFileInfo] + + +class TaskforceSummary(BaseModel): + name: str + creator: str | None = None + created: str | None = None + readme_excerpt: str = "" + contributors: list[str] = Field(default_factory=list) + file_count: int + note_count: int + # Compact stamp of the newest note; None for a taskforce with no notes yet. + last_activity: str | None = None + + +class TaskforceListing(BaseModel): + count: int + matched: int + items: list[TaskforceSummary] + + +class TaskforceDetail(BaseModel): + name: str + creator: str | None = None + created: str | None = None + updated: str | None = None + readme: MessageRecord + contributors: list[str] + file_count: int + note_count: int + recent_notes: list[MessageRecord] + + # ───────────────────────── Channels ───────────────────────── # Topic rooms (CHANNELS_DESIGN.md): channels/{name}/ holds a README (the # theme), members/ subscription markers, and stamped messages. Messages are @@ -189,7 +258,7 @@ class SharedResourceSyncRequest(StrictRequest): # channel-specific write endpoint. -class ChannelCreateRequest(StrictRequest): +class ChannelCreateRequest(BaseModel): # Creation is organizer-only (the broadcast gate): organizers act as # human- with a Bearer token, so the raw variant is the live path. # `source` is still accepted by the model so agent attempts get a clear @@ -220,7 +289,7 @@ class ChannelCreateResponse(BaseModel): announcement: str | None = None -class ChannelSubscribeRequest(StrictRequest): +class ChannelSubscribeRequest(BaseModel): # Agents subscribe with the source-URI proof (any file in their own # scratch bucket); a body-only agent_id would let anyone subscribe anyone. # Humans (human-) use agent_id + Authorization: Bearer instead. @@ -312,7 +381,7 @@ class DigestChannels(BaseModel): # ───────────────────────── Benchmark jobs ───────────────────────── -class BenchmarkJobRequest(StrictRequest): +class BenchmarkJobRequest(BaseModel): agent_id: str submission_prefix: str run_prefix: str @@ -342,7 +411,7 @@ class BenchmarkJobResponse(BaseModel): # See TRACES_DESIGN.md. -class TracePostRequest(StrictRequest): +class TracePostRequest(BaseModel): source: str # hf://buckets/{org}/{slug}-{agent}/traces// share: Literal["stats", "full"] = "stats" # default = numbers only; content is an explicit opt-in @@ -512,41 +581,34 @@ class DigestInbox(BaseModel): items: list[MessageRecord] +class DigestTaskforces(BaseModel): + count: int + newest: list[str] + + class DigestUpdates(BaseModel): """Cursor-aware "am I behind?" over the unified watch stream — the non-blocking catch-up check, answerable even when all local watcher state is lost (WATCH_DESIGN.md §4.5).""" - # Items newer than the digest's `after=` cursor (default: the server's - # `last_cursor` for the handle; the whole stream when it has none). + # Items newer than the digest's `after=` cursor (the whole stream when none). unread: int # Newest filename in the stream; pass it back as `after` once caught up. newest: str | None = None class DigestWatching(BaseModel): - """The server's record of the handle's most recent read (a `wait>0` poll, - a plain `/v1/updates` read, or a digest). A hint, not an audit log: it lives - in-process and a restart forgets it (which is the truth — every parked - connection died with it). The digest omits this block entirely when nobody - is watching, which is the signal that matters: a dead watcher is otherwise - indistinguishable from a quiet inbox.""" + """The server's record of the handle's most recent `wait>0` poll. A hint, + not an audit log: it lives in-process and a restart forgets it (which is the + truth — every parked connection died with it). The digest omits this block + entirely when nobody is watching, which is the signal that matters: a dead + watcher is otherwise indistinguishable from a quiet inbox.""" last_poll_age_s: int - mode: str # parked (a wait>0 poll within 2x the wait ceiling) | poll - # updates | inbox | feed | digest: the parked poll's while parked, else - # the most recent read's. - stream: str - # The newest cursor the server has handed this handle on the unified - # stream, or that it has sent; resume with `--after `. - last_cursor: str | None = None - - -class DigestYou(BaseModel): - """Only with ?as=: facts about the calling handle itself.""" - traces: AgentTraces + mode: str # updates | inbox | feed class DigestResponse(BaseModel): agents: DigestAgents + taskforces: DigestTaskforces channels: DigestChannels leaderboard: list[LeaderboardRow] recent_messages: list[MessageRecord] @@ -554,8 +616,7 @@ class DigestResponse(BaseModel): inbox: DigestInbox | None = None updates: DigestUpdates | None = None watching: DigestWatching | None = None - you: DigestYou | None = None - stats: DigestStats + stats: DigestStats | None = None generated_at: str @@ -566,8 +627,7 @@ class WatchingEntry(BaseModel): """One handle's watch presence — the same hint the digest reports as its per-handle `watching` block, in the aggregate map.""" last_poll_age_s: int - mode: str # parked (wait>0) | poll (wait=0 or a digest) - stream: str # updates | inbox | feed | digest + mode: str # updates | inbox | feed class WatchingResponse(BaseModel): @@ -579,10 +639,12 @@ class WatchingResponse(BaseModel): It also advertises the ceiling a client would otherwise have to hardcode.""" # The `wait=` ceiling every long-poll is clamped to (LONGPOLL_MAX_WAIT_S). max_wait_s: float - # Freshness threshold for "someone is watching this handle right now" - # (WATCH_FRESH_S). Published so no consumer keeps its own copy of the knob. + # Freshness threshold for "someone is watching this handle right now": a + # watcher re-arms at most one wait window after the last one ended, so 2× + # the ceiling is the youngest age that can still be stale. Published so no + # consumer keeps its own copy of the backend's knob. fresh_s: float - # Only handles this process has served a read for; absent = nobody is + # Only handles this process has served a wait>0 poll for; absent = nobody is # watching that one. In-process and lost on restart — a hint, not an audit # log (a restart truthfully reads as "nobody", since every parked # connection died with it). diff --git a/app/naming.py b/app/naming.py index d08e72655b03f37f1dc3c036c18d4f42a3f33e19..f00d6e5b8f691f24f56a9b4c948b16309d17cf78 100644 --- a/app/naming.py +++ b/app/naming.py @@ -68,11 +68,6 @@ def stamp_iso(dt: datetime) -> str: return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z" -AGENTS_FOLDER = "agents" -MESSAGE_BOARD_FOLDER = "message_board" -RESULTS_FOLDER = "results" - - def message_path(agent_id: str, dt: datetime) -> str: return f"message_board/{stamp_filename(agent_id, dt)}" @@ -110,7 +105,8 @@ def channel_dir(name: str) -> str: def channel_readme_path(name: str) -> str: - """The channel's theme. A channel exists iff this file does.""" + """The channel's theme. A channel exists iff this file does — same + structural invariant as taskforces.""" return f"{CHANNELS_FOLDER}/{name}/README.md" @@ -126,6 +122,22 @@ def channel_message_path(name: str, agent_id: str, dt: datetime) -> str: return f"{CHANNELS_FOLDER}/{name}/{stamp_filename(agent_id, dt)}" +def taskforce_dir(name: str) -> str: + return f"taskforces/{name}" + + +def taskforce_readme_path(name: str) -> str: + return f"taskforces/{name}/README.md" + + +def taskforce_note_path(name: str, agent_id: str, dt: datetime) -> str: + return f"taskforces/{name}/{stamp_filename(agent_id, dt)}" + + +def taskforce_file_path(name: str, dest_path: str) -> str: + return f"taskforces/{name}/{dest_path}" + + def agent_from_filename(filename: str) -> str | None: # message/result filenames: {YYYYMMDD-HHmmss-mmm}_{agent_id}.md # agent filenames: {agent_id}.md @@ -136,7 +148,7 @@ def agent_from_filename(filename: str) -> str | None: # Flat index mapping each promoted result's basename -> verification state -# (`pending` | `valid` | `invalid`); an absent entry reads as `pending`. +# (`pending` | `valid` | `invalid`). Maintained by VerificationStatusStore. VERIFICATION_STATUS_PATH = "results/verification_status.json" @@ -148,10 +160,8 @@ def artifact_dest_dir(slug: str, agent_id: str) -> str: return f"artifacts/{slug}_{agent_id}/" -def audit_event_path(event: str, dt: datetime) -> str: - """One small object per audited event, grouped by month. Concatenating a - month's objects in filename order yields its JSONL log.""" - return f"audit/{dt.strftime('%Y%m')}/{stamp_str(dt)}_{event}.json" +def audit_log_path(dt: datetime) -> str: + return f"audit/{dt.strftime('%Y%m')}.jsonl" # ── Trace & stats sharing (one record per session, see TRACES_DESIGN.md) ── diff --git a/app/notify.py b/app/notify.py index 67f0ac9c706935fc49ae24f817ee289f2fa660e5..7de9cbe52fb0969b42a8c5550a5c279485966f53 100644 --- a/app/notify.py +++ b/app/notify.py @@ -40,7 +40,7 @@ import logging import random import threading import time -from typing import Callable, Iterable, NamedTuple +from typing import Callable, Iterable log = logging.getLogger(__name__) @@ -52,18 +52,6 @@ log = logging.getLogger(__name__) _DEGRADED_HOLD_S = (5.0, 15.0) -class Presence(NamedTuple): - """A handle's most recent read, as the digest's `watching` block reports it.""" - age_s: float - mode: str # parked (a wait>0 poll within the parked window) | poll - # updates | inbox | feed | digest: the parked poll's stream while parked, - # else the most recent read's. - stream: str - # The newest cursor the server has handed this handle on the unified - # stream, or that it has sent; resume with `--after `. - last_cursor: str | None - - class Subscription: """A single parked waiter, re-armable across many ``wait`` calls. @@ -171,7 +159,6 @@ class Notifier: max_waiters_total: int, wake_spread_s: float, wake_spread_threshold: int, - parked_window_s: float = 110.0, clock: Callable[[], float] = time.monotonic, ): self._lock = threading.Lock() @@ -187,21 +174,11 @@ class Notifier: self._wake_spread_s = wake_spread_s self._wake_spread_threshold = wake_spread_threshold self._clock = clock - # owner -> (monotonic stamp, stream) of its most recent read. A hint - # for the digest's `watching` block, not an audit log: it is lost on - # restart, and a restart reads as "nobody is watching" — the truthful - # answer, since every parked connection died with it. + # owner -> (monotonic stamp, mode) of its most recent wait>0 poll. A + # hint for the digest's `watching` block, not an audit log: it is lost + # on restart, and a restart reads as "nobody is watching" — the + # truthful answer, since every parked connection died with it. self._last_poll: dict[str, tuple[float, str]] = {} - # owner -> (monotonic stamp, stream) of its most recent PARKED poll, kept - # apart so a digest or plain read between two parks does not hide the - # watcher. A handle reads as parked while this is younger than - # parked_window_s. - self._last_parked: dict[str, tuple[float, str]] = {} - self._parked_window_s = parked_window_s - # owner -> the newest cursor handed out or sent on the unified stream, - # so an agent whose local state was wiped can resume from the server's - # record. Same lifetime. - self._last_cursor: dict[str, str] = {} # Cheap operational counters for /v1/healthz. eq2 shipped this feature # with zero observability, so an operator could not tell a quiet board # from a registry that had been degrading every request for hours. @@ -297,62 +274,27 @@ class Notifier: # ── liveness & observability ── - def note_poll( - self, owner: str, stream: str, *, parked: bool, after: str | None = None - ) -> None: - """Record that ``owner`` just read ``stream`` (updates|inbox|feed|digest), - parked (``wait>0``) or not. The server side of "is anyone watching this + def note_poll(self, owner: str, mode: str) -> None: + """Record that ``owner`` just opened a ``wait>0`` poll in ``mode`` + (updates|inbox|feed). The server side of "is anyone watching this handle?" — the one liveness signal that survives total client amnesia - (WATCH_DESIGN.md §4.5/§6). A synchronous poller that calls every ~100s - counts as present too, so any read stamps it, not only a parked one. - - ``mode`` reports ``parked`` while the last parked poll is younger than - the parked window (2x the wait ceiling), whatever was read since, so a - digest between two parks does not hide a live watcher; ``stream`` is - then the parked poll's.""" + (WATCH_DESIGN.md §4.5/§6).""" with self._lock: - now = self._clock() - self._last_poll[owner] = (now, stream) - if parked: - self._last_parked[owner] = (now, stream) - self._note_cursor_locked(owner, after) - - def note_cursor(self, owner: str, cursor: str | None) -> None: - """Record a cursor the server just handed ``owner`` on the unified - stream (a page's top-level ``cursor``). Keeps the newest seen.""" - with self._lock: - self._note_cursor_locked(owner, cursor) - - def _note_cursor_locked(self, owner: str, cursor: str | None) -> None: - # Cursors are lexically ordered filename stamps: newest = largest. - if cursor and cursor > self._last_cursor.get(owner, ""): - self._last_cursor[owner] = cursor - - def _presence_locked(self, owner: str, now: float) -> Presence: - stamp, stream = self._last_poll[owner] - parked_at, parked_stream = self._last_parked.get(owner, (None, None)) - parked = parked_at is not None and now - parked_at < self._parked_window_s - if parked: - stream = parked_stream - return Presence( - max(0.0, now - stamp), - "parked" if parked else "poll", - stream, - self._last_cursor.get(owner), - ) - - def last_poll(self, owner: str) -> Presence | None: - """``owner``'s most recent read, or ``None`` if this process has never - seen one.""" + self._last_poll[owner] = (self._clock(), mode) + + def last_poll(self, owner: str) -> tuple[float, str] | None: + """(age in seconds, mode) of ``owner``'s most recent ``wait>0`` poll, or + ``None`` if this process has never seen one.""" with self._lock: - if owner not in self._last_poll: + seen = self._last_poll.get(owner) + if seen is None: return None - return self._presence_locked(owner, self._clock()) + return max(0.0, self._clock() - seen[0]), seen[1] - def all_last_poll(self) -> dict[str, Presence]: - """``{owner: Presence}`` for every handle this process has ever seen - read — the whole presence map in ONE lock acquisition, for - ``GET /v1/watching``. + def all_last_poll(self) -> dict[str, tuple[float, str]]: + """``{owner: (age in seconds, mode)}`` for every handle this process has + ever served a ``wait>0`` poll for — the whole presence map in ONE lock + acquisition, for ``GET /v1/watching``. The aggregate exists because the per-handle answer is the wrong shape for the only consumer that wants all of them: a dashboard drawing a dot per @@ -363,7 +305,10 @@ class Notifier: """ with self._lock: now = self._clock() - return {owner: self._presence_locked(owner, now) for owner in self._last_poll} + return { + owner: (max(0.0, now - stamp), mode) + for owner, (stamp, mode) in self._last_poll.items() + } def stats(self) -> dict[str, int]: """Counters + the live waiter gauge, for /v1/healthz.""" diff --git a/app/rate_limit.py b/app/rate_limit.py index 040f029030e16c265c8e994734f2439ca31a3662..d6427e1a60552d53cebbcfde437ff50bc3c3c333 100644 --- a/app/rate_limit.py +++ b/app/rate_limit.py @@ -20,7 +20,7 @@ class TokenBucket: when the Space restarts (acceptable per the design's in-memory state model). """ - def __init__(self, capacity: int, refill_per_minute: float): + def __init__(self, capacity: int, refill_per_minute: int): if capacity <= 0 or refill_per_minute <= 0: raise ValueError("capacity and refill_per_minute must be positive") self._capacity = float(capacity) diff --git a/app/read_model.py b/app/read_model.py index d308f781a2c4cf91e838785597e750c6c0f4f77c..cb330a1e0ab31a967903be96235590ffff626da6 100644 --- a/app/read_model.py +++ b/app/read_model.py @@ -35,7 +35,7 @@ from typing import Any, Callable from app.config import Settings from app.frontmatter import parse -from app.hub import HubClient, ListedFile, ListingFailed +from app.hub import HubClient, ListedFile from app.naming import ( BROADCASTS_FOLDER, CHANNELS_FOLDER, @@ -85,15 +85,6 @@ class _Folder: fetched_at: float = float("-inf") overlay: dict[str, tuple[ListedFile, float]] = field(default_factory=dict) lock: threading.Lock = field(default_factory=threading.Lock) - # Single-flight for content resolution: concurrent cold readers of one - # folder wait for the first batch download, then hit the cache. - resolve_lock: threading.Lock = field(default_factory=threading.Lock) - # True once one listing has completed: until then there is no cached truth - # to fall back on, so a failed listing must fail the request. - listed: bool = False - # The most recent listing failure, (short error, clock time); cleared by - # the next successful listing. Surfaced on /v1/healthz. - last_error: tuple[str, float] | None = None def _safe_parse(raw: bytes) -> tuple[dict[str, Any], str, bool]: @@ -129,50 +120,6 @@ class ReadModel: self._local: dict[str, tuple[dict, str, int, float]] = {} self._verification: tuple[str, dict[str, str]] | None = None self._content_lock = threading.Lock() - # Set once ``warm_up`` has filled every startup folder (/v1/healthz). - self.warm = False - - # ───────────────────────── warm-up & gauges ───────────────────────── - - def warm_up(self, folders: list[str], retry_s: float = 5.0) -> None: - """Fill listing + content caches for ``folders`` so the reconnect - storm after a restart hits a warm cache. A folder that fails is - retried every ``retry_s`` until it fills; ``warm`` flips only then.""" - pending = list(folders) - while True: - pending = [f for f in pending if not self._fill(f)] - if not pending: - break - time.sleep(retry_s) - self.warm = True - log.info("read model warm: %s", ", ".join(folders)) - - def _fill(self, folder: str) -> bool: - try: - self.records(folder) - return True - except Exception as e: - log.warning("warm-up of %s failed; will retry: %s", folder, e) - return False - - def stats(self) -> dict[str, Any]: - """Gauges for /v1/healthz. ``listing_errors`` names every folder whose - latest listing failed (it is being served from cache, or 503s if it - never listed), so a "lost message" report can be traced to a stuck - folder.""" - now = self._clock() - with self._folders_lock: - folders = dict(self._folders) - errors = { - name: {"error": f.last_error[0], "age_s": round(now - f.last_error[1], 1)} - for name, f in sorted(folders.items()) - if f.last_error is not None - } - return { - "folders": len(folders), - "content_cache_bytes": self._content_bytes, - "listing_errors": errors, - } # ───────────────────────── listings ───────────────────────── @@ -187,25 +134,17 @@ class ReadModel: with f.lock: now = self._clock() if now - f.fetched_at >= self._settings.listing_ttl_s: - try: - fresh = self._hub.list_central_dir(folder) - except ListingFailed as e: - # Never install a failed (possibly partial) listing: a - # missing entry would vanish for a TTL, and a watcher's - # after= cursor could step past it for good. - f.last_error = (str(e)[:200], now) - if not f.listed: - raise # cold: no cached truth to serve + fresh = self._hub.list_central_dir(folder) + if not fresh and f.files: + # The hub flattens listing errors to []; nothing is ever + # deleted from these folders, so an empty result for a + # previously non-empty folder is a transient failure. log.warning( - "listing(%s) failed; serving %d cached entries: %s", - folder, len(f.files), e, + "listing(%s) came back empty; keeping %d cached entries", + folder, len(f.files), ) else: f.files = {e.rel_path: e for e in fresh} - f.listed = True - f.last_error = None - # Retry after a TTL either way: a hub outage must not turn - # every read into another failing listing call. f.fetched_at = now f.overlay = { p: (e, ts) @@ -229,23 +168,16 @@ class ReadModel: def records(self, folder: str) -> list[Record]: """Parsed records for every .md file under ``folder`` (READMEs excluded), ascending by filename. Cold misses are batch-fetched.""" - out = self._resolve_folder(folder, self._md_entries(folder)) + out = self._resolve_many(self._md_entries(folder)) return [out[p] for p in sorted(out)] def records_for(self, folder: str, paths: list[str]) -> dict[str, Record]: """Resolve specific files from ``folder``'s listing through the content cache, keyed by rel_path. For files ``records`` excludes by convention - (READMEs) or selective reads over a tree listing (channels). + (READMEs) or selective reads over a tree listing (taskforces, §18). Unlisted paths are silently absent from the result.""" by_path = {e.rel_path: e for e in self.listing(folder)} - return self._resolve_folder(folder, [by_path[p] for p in paths if p in by_path]) - - def _resolve_folder(self, folder: str, entries: list[ListedFile]) -> dict[str, Record]: - """``_resolve_many`` single-flighted per folder: after a restart every - watcher reconnects at once, and N cold readers must cost one batch - download, not N. Warm resolves are cache hits, so the lock is cheap.""" - with self._folder(folder).resolve_lock: - return self._resolve_many(entries) + return self._resolve_many([by_path[p] for p in paths if p in by_path]) def _resolve_many(self, entries: list[ListedFile]) -> dict[str, Record]: out: dict[str, Record] = {} @@ -265,7 +197,7 @@ class ReadModel: for e in misses: raw = fetched.get(e.rel_path) if raw is None: - continue # deleted since the listing (failures raise) + continue # transient download failure; heals next pass out[e.rel_path] = self._insert(e, raw) return out @@ -325,8 +257,8 @@ class ReadModel: exact regardless of listing TTL. Call right after the bucket write. ``folder`` pins which folder cache gets the listing overlay when it is - not the file's immediate parent — channel files live under one shared - ``channels`` tree listing whatever their subdirectory.""" + not the file's immediate parent — taskforce files live under one shared + ``taskforces`` tree listing whatever their subdirectory (§18.4).""" if folder is None: folder, _, _filename = path.rpartition("/") f = self._folder(folder) @@ -365,12 +297,13 @@ class ReadModel: return [by_name[f] for f in sorted(by_name)] # ───────────────────────── channels ───────────────────────── - # All channel reads run over the ONE recursive channels/ listing: - # summaries, rosters, subscriptions, and the cross-channel feed each cost - # at most one bucket listing per TTL window. + # All channel reads run over the ONE recursive channels/ listing (the + # taskforce FOLDER pattern): summaries, rosters, subscriptions, and the + # cross-channel feed each cost at most one bucket listing per TTL window. def channel_exists(self, name: str) -> bool: - """A channel exists iff its README (the theme) is listed. Shared by the channels router and + """A channel exists iff its README (the theme) is listed — the same + structural invariant as taskforces. Shared by the channels router and the POST /v1/messages channel gate (import-cycle-free).""" readme = channel_readme_path(name) return any(e.rel_path == readme for e in self.listing(CHANNELS_FOLDER)) @@ -498,11 +431,7 @@ class ReadModel: """Drop the cached verification index after the Space itself rewrites it (automated verdicts, §5.7) — that write is no longer an out-of-band admin edit, so it must not wait out the listing TTL. The next - ``verification_index()`` call relists ``results/`` (the write may have - created the file) and refetches it (one download).""" - f = self._folder("results") - with f.lock: - f.fetched_at = float("-inf") + ``verification_index()`` call refetches the file (one download).""" with self._content_lock: self._verification = None diff --git a/app/routes/agents.py b/app/routes/agents.py index b152c29b614238c7ad455ac3a22921c5c4fcf5cc..6266a315974f039dda9addfb939d60ae4e7a621f 100644 --- a/app/routes/agents.py +++ b/app/routes/agents.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from fastapi import APIRouter, Depends, Header, Request from app.audit import AuditLogger @@ -49,6 +51,8 @@ from app.validation import ( ) +log = logging.getLogger(__name__) + router = APIRouter() @@ -85,7 +89,7 @@ def register( except Exception: raise Unauthorized( "could not resolve caller identity via whoami; check your token", - hint="run `hf auth login`, then pass $(hf auth token 2>/dev/null)", + hint="run `hf auth login --force`, then pass $(hf auth token 2>/dev/null)", ) creator = identity.username @@ -98,13 +102,17 @@ def register( if settings.org not in identity.orgs: raise NotOrgMember(creator, settings.org, settings.invite_url) - # Checked before any write, so a doomed request touches nothing. + # Checked before any write, so a doomed request touches nothing. Only a + # genuinely missing file means "unregistered": a failed read must not look + # like one, or the provisioning below could take over a live identity. target = registration_path(agent_id) - existing_text: str | None = None try: - existing_text = hub.read_central_text(target) - except Exception: - existing_text = None + existing_bytes = hub.read_central_bytes_optional(target) + except Exception as e: + # The type only: an exception's text may carry request details. + log.warning("register %s: registration lookup failed (%s)", agent_id, type(e).__name__) + raise HubUnavailable() + existing_text = existing_bytes.decode("utf-8") if existing_bytes is not None else None if existing_text is not None: existing_fm, _ = parse(existing_text) @@ -117,10 +125,11 @@ def register( raise AgentIdTaken(agent_id) # Provision the scratch bucket and the `.bucket-sync-handshake` inside it, - # both with the CALLER's token (never the admin token). Only a bucket's - # creator (and org admins) can write there, so the handshake naming the - # caller proves the caller controls the bucket; a successful write with - # their token proves the same thing. + # both with the CALLER's token (never the admin token). The proof of + # ownership is the write itself: only a bucket's creator (and org admins) + # can write there. Handshake text alone proves nothing (anyone can create + # a bucket and write a victim's username into it), so the write happens + # even when the handshake already names the caller. bucket = expected_agent_bucket(settings, agent_id) handshake_uri = SourceURI( org=settings.org, @@ -133,20 +142,23 @@ def register( hub.create_bucket_as(bucket, caller_token) except PermissionError: raise BucketCreateForbidden(bucket) - handshake = None else: + # A handshake naming someone else is their claim on the bucket; + # never overwrite it, even for a caller (an org admin) who could. + # As above, a failed read must not pass for a missing file. try: - handshake = hub.read_text(handshake_uri).strip() - except FileNotFoundError: - handshake = None - if handshake != creator: - try: - hub.write_text_as(bucket, HANDSHAKE_FILE, creator, caller_token) - except PermissionError: + handshake = hub.read_text_optional(handshake_uri) + except Exception as e: + log.warning("register %s: handshake read failed (%s)", agent_id, type(e).__name__) + raise HubUnavailable() + if handshake is not None and handshake.strip() != creator: raise BucketNotYours( - f"cannot write to '{bucket}' as '{creator}'" - + (f" (its handshake names '{handshake}')" if handshake else "") + f"'{bucket}' is claimed by '{handshake.strip()}' (its handshake), not '{creator}'" ) + try: + hub.write_text_as(bucket, HANDSHAKE_FILE, creator, caller_token) + except PermissionError: + raise BucketNotYours(f"cannot write to '{bucket}' as '{creator}'") except HubUnreachable: raise HubUnavailable() diff --git a/app/routes/channels.py b/app/routes/channels.py index f9ef0282f0341f56167fff42b30f6bad68de3e80..885bf8decb13e2dfe49a62f599b182a7224350aa 100644 --- a/app/routes/channels.py +++ b/app/routes/channels.py @@ -1,15 +1,16 @@ """Channels (CHANNELS_DESIGN.md): topic rooms for depth over breadth. A channel is ``channels/{name}/`` in the central bucket: a README (the theme — -the channel exists iff it does), ``members/`` marker +the channel exists iff it does, the taskforce invariant), ``members/`` marker files (one per subscription: write to join, delete to leave — no roster file to read-modify-write), and stamped messages. Messages are POSTed through ``/v1/messages`` with ``channel`` set, never through a channel-local write endpoint; this module owns creation, subscription, and the read surfaces. -Everything reads the ONE recursive ``channels/`` listing: summaries, rosters, -subscriptions, and the cross-channel feed cost at most one bucket listing per -TTL window. Adoption is a design goal: creation auto-announces on the board, +Everything reads the ONE recursive ``channels/`` listing (the taskforce +``FOLDER`` pattern): summaries, rosters, subscriptions, and the cross-channel +feed cost at most one bucket listing per TTL window. Fixing the taskforce +adoption failure is a design goal here: creation auto-announces on the board, in-channel mentions fan out to inboxes (via ``promote_message``), and subscribed-channel activity rides the digest. """ @@ -106,7 +107,7 @@ def _is_readme(path: str) -> bool: def _grouped(read_model: ReadModel) -> dict[str, list[ListedFile]]: """All listed channel files grouped by channel name; groups without a - README are not channels and are dropped.""" + README are not channels and are dropped (mirrors taskforces).""" groups: dict[str, list[ListedFile]] = {} for e in read_model.listing(FOLDER): rel = e.rel_path.removeprefix(f"{FOLDER}/") @@ -156,7 +157,7 @@ def _member_entries(name: str, entries: list[ListedFile]) -> list[ListedFile]: def _excerpt(body: str, limit: int = 160) -> str: """First prose line (headings are usually just the name); falls back to - the first heading.""" + the first heading. Same rule as taskforce READMEs.""" heading = "" for line in body.splitlines(): s = line.strip() @@ -283,7 +284,8 @@ def channels_digest( def _announcement_body(name: str, theme: str) -> str: """The server-composed board message announcing a new channel — discovery - is deterministic, never a favor the creator remembers to do.""" + is deterministic, never a favor the creator remembers to do (the taskforce + lesson).""" return ( f"New channel #{name} — {_excerpt(theme)}\n\n" f"Read: `GET /v1/channels/{name}` · " @@ -687,7 +689,7 @@ async def channel_feed( if wait <= 0: return await run_in_threadpool(check) await run_in_threadpool(guard) - notifier.note_poll(as_, "feed", parked=True) + notifier.note_poll(as_, "feed") # Snapshot the subscribed-channel keys once at park time (reads the listing, # so it goes through the threadpool). An agent can only change its own # subscriptions and can't while this request is parked, so staleness is diff --git a/app/routes/client.py b/app/routes/client.py index e3638bc634dc82a906e76ee62e559960e9ce0e9f..a6186a984bf4486d8cb4a7aa7d75db0fe1aadac9 100644 --- a/app/routes/client.py +++ b/app/routes/client.py @@ -9,20 +9,10 @@ from app.errors import NotFound router = APIRouter() -# clients/ sits next to app/ both locally (backend/clients/) and in the Docker -# image (/app/clients/), so resolving relative to this package file lands on it -# in either layout. -_CLIENTS_DIR = Path(__file__).resolve().parent.parent.parent / "clients" -_SCRIPT_PATH = _CLIENTS_DIR / "collab_watch.sh" -_SHARE_TRACE_PATH = _CLIENTS_DIR / "share_trace.py" - - -def _serve(path: Path, media_type: str) -> Response: - try: - data = path.read_bytes() - except OSError: - raise NotFound(str(path)) - return Response(content=data, media_type=media_type) +# clients/collab_watch.sh sits next to app/ both locally (backend/clients/) and +# in the Docker image (/app/clients/), so resolving relative to this package +# file lands on it in either layout. +_SCRIPT_PATH = Path(__file__).resolve().parent.parent.parent / "clients" / "collab_watch.sh" @router.get("/v1/watch.sh") @@ -34,11 +24,8 @@ def watch_script() -> Response: Read from disk on every request rather than baked in at import: the script is the client contract, and a redeploy that ships a new one must serve it without anyone remembering to bump a constant.""" - return _serve(_SCRIPT_PATH, "text/x-shellscript") - - -@router.get("/v1/share_trace.py") -def share_trace_script() -> Response: - """Serve clients/share_trace.py the same way: `curl -fsS /v1/share_trace.py - -o share_trace.py && python3 share_trace.py`.""" - return _serve(_SHARE_TRACE_PATH, "text/x-python") + try: + data = _SCRIPT_PATH.read_bytes() + except OSError: + raise NotFound(str(_SCRIPT_PATH)) + return Response(content=data, media_type="text/x-shellscript") diff --git a/app/routes/digest.py b/app/routes/digest.py index 83265df279b11b8fc102df0bf53390606e120932..a7bf66887ffa6542a874bd24dc654980f733a78e 100644 --- a/app/routes/digest.py +++ b/app/routes/digest.py @@ -7,14 +7,11 @@ from app.deps import get_notifier, get_read_model, get_settings_dep from app.errors import NotRegistered from app.listing import apply_filters, normalize_stamp, paginate from app.models import ( - RAW_BODY_MAX_CHARS, - RAW_DUPLICATE_WINDOW_S, DigestAgents, DigestInbox, DigestResponse, DigestUpdates, DigestWatching, - DigestYou, MessageRecord, ResultRecord, ) @@ -23,7 +20,8 @@ from app.notify import Notifier from app.read_model import ReadModel, Record from app.routes.channels import channels_digest from app.routes.leaderboard import compute_leaderboard -from app.trace_stats import agent_traces, aggregate, digest_stats +from app.routes.taskforces import taskforce_digest +from app.trace_stats import aggregate, digest_stats from app.validation import is_human_handle, validate_agent_id from app.verification import PENDING @@ -53,15 +51,11 @@ def digest( With `?as=`, two watch blocks come along (WATCH_DESIGN.md §4.5): `updates` answers "am I behind?" over the unified `/v1/updates` stream - (it counts after `?after=`, or by default after the - server's `last_cursor` for this handle), and `watching` - reports this handle's last read before this one (`mode` parked while its - last parked poll is younger than 2x the wait ceiling, else poll) and - `last_cursor`, the newest cursor the server has handed it on the unified - stream or that it has sent — null when nobody is watching it. Both are readable with zero local state, which is the point: + (`?after=` makes the count cursor-aware), and `watching` + reports when this handle last opened a `wait>0` poll — null when nobody is + watching it. Both are readable with zero local state, which is the point: an agent that lost its whole watcher state directory still learns from its - routine digest that it has been deaf for six hours, has four unread, and - where to resume (`sh watch.sh ... --after `).""" + routine digest that it has been deaf for six hours and has four unread.""" since_norm = normalize_stamp(since, param="since") if since is not None else None agents = read_model.records("agents") @@ -93,7 +87,6 @@ def digest( inbox = None updates = None watching = None - you = None if as_ is not None: validate_agent_id(as_) if not is_human_handle(as_) and as_ not in read_model.registered_agents(): @@ -108,40 +101,33 @@ def digest( # so it matches exactly what a watcher would have been handed — an # inbox-only count would under-report an agent that follows a channel at # notify: all. - # Report the presence as it stood BEFORE this read, then stamp it: a - # digest read is a poll too, but it must not answer its own question. - seen = notifier.last_poll(as_) - notifier.note_poll(as_, "digest", parked=False, after=after) - # Without after=, count from the server's last_cursor, so mail the - # watcher already delivered does not read as unread. - count_after = after if after is not None else (seen.last_cursor if seen else None) update_recs = read_model.updates_records(as_) updates = DigestUpdates( - unread=sum(1 for r in update_recs if count_after is None or r.filename > count_after), + unread=sum(1 for r in update_recs if after is None or r.filename > after), newest=max((r.filename for r in update_recs), default=None), ) + seen = notifier.last_poll(as_) if seen is not None: - watching = DigestWatching( - last_poll_age_s=int(seen.age_s), - mode=seen.mode, - stream=seen.stream, - last_cursor=seen.last_cursor, - ) - you = DigestYou(traces=agent_traces(read_model, {as_})) + age_s, mode = seen + watching = DigestWatching(last_poll_age_s=int(age_s), mode=mode) # Channels: every channel's summary (discovery) plus, with ?as=, the # caller's subscriptions with fresh activity — this is how channel content # rides the loop agents already run (CHANNELS_DESIGN.md §4). channels = channels_digest(read_model, settings, as_, since_norm) - # Project token estimate (reported floor); always present — zeros make the - # gap visible before anyone has shared a trace. - stats = digest_stats( - aggregate(read_model.records(TRACES_FOLDER), generated_at=stamp_iso(utc_now())) + # Project token estimate (reported floor); omitted entirely until at least + # one trace has been shared, so the digest shape is unchanged otherwise. + trace_records = read_model.records(TRACES_FOLDER) + stats = ( + digest_stats(aggregate(trace_records, generated_at=stamp_iso(utc_now()))) + if trace_records + else None ) return DigestResponse( agents=DigestAgents(count=len(agents), newest=newest), + taskforces=taskforce_digest(read_model), channels=channels, leaderboard=leaderboard.rows, recent_messages=_message_records(message_page), @@ -149,7 +135,6 @@ def digest( inbox=inbox, updates=updates, watching=watching, - you=you, stats=stats, generated_at=stamp_iso(utc_now()), ) @@ -165,14 +150,10 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict: {"method": "GET", "path": "/v1/digest", "params": "as, since, after", "purpose": "one-call collab snapshot: agents, leaderboard, recent " "activity, your inbox; with as= also updates.unread " - "(counted after after=, default the server's last_cursor) " - "and watching (is anyone watching " - "this handle? last_cursor = the newest cursor handed out " - "or sent; resume with --after ) and " - "you.traces (sessions you have shared)"}, + "(cursor-aware via after=) and watching (is anyone watching " + "this handle?)"}, {"method": "GET", "path": "/v1/me", "params": "Authorization: Bearer", - "purpose": "the caller's hf_user + whether they may broadcast " - "(organizer) + traces shared by their agents"}, + "purpose": "the caller's hf_user + whether they may broadcast (organizer)"}, {"method": "GET", "path": "/v1/leaderboard", "params": "best_per_agent (default true), verification (CSV), agent, limit", "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: "purpose": "the official watcher script (POSIX sh + curl): " "curl -fsS $API/v1/watch.sh -o watch.sh && sh watch.sh $API "}, {"method": "GET", "path": "/v1/watching", "params": "", - "purpose": "watch presence for EVERY handle at once (last read age, " - "mode parked|poll, stream), plus the wait ceiling and the waiter counters " + "purpose": "watch presence for EVERY handle at once (last wait>0 poll " + "age + mode), plus the wait ceiling and the waiter counters " "— the operator/dashboard view of the digest's per-handle " "watching block"}, {"method": "GET", "path": "/v1/inbox/{handle}", "params": "list grammar + wait", @@ -195,16 +176,11 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict: {"method": "GET", "path": "/v1/messages/{filename}", "params": "", "purpose": "one message, parsed"}, {"method": "POST", "path": "/v1/messages", - "params": "{source} or {agent_id, body, type?, refs?, broadcast?} + channel?, idempotency_key?", + "params": "{source} or {agent_id, body, type?, refs?, broadcast?} + channel?", "purpose": "post a message; @-mentions and refs fan out inbox copies; " "organizers may set broadcast: true to reach every inbox; " "set channel: to post into a channel instead of the " - "board (posting subscribes you). Safe to retry: send an " - "idempotency_key (<= 64 chars, unique per message) and a " - "repeat with the same key returns the original post with " - "200 instead of 201; a raw post without one that repeats " - "the same body to the same place within " - f"{RAW_DUPLICATE_WINDOW_S}s also returns the original (200)"}, + "board (posting subscribes you)"}, {"method": "GET", "path": "/v1/channels", "params": "q, limit", "purpose": "discover channels: theme excerpt, members, activity"}, {"method": "POST", "path": "/v1/channels", @@ -245,6 +221,22 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict: {"method": "POST", "path": "/v1/agents/register", "params": "{agent_id, model, harness, tools[], bio_source?, force?} + Authorization: Bearer", "purpose": "mint your identity; creates your scratch bucket with your token"}, + {"method": "GET", "path": "/v1/taskforces", "params": "q, limit", + "purpose": "discover taskforces: README excerpt, contributors, activity"}, + {"method": "POST", "path": "/v1/taskforces", + "params": "{name} + {source} or {agent_id, body}", + "purpose": "create a taskforce — the payload is its README; creator re-POST updates it"}, + {"method": "GET", "path": "/v1/taskforces/{name}", "params": "", + "purpose": "inspect one taskforce: full README, contributors, recent notes"}, + {"method": "POST", "path": "/v1/taskforces/{name}/files", + "params": "{source, dest_path?} or {agent_id, body, type?}", + "purpose": "contribute: a stamped note, or a named file when dest_path is given"}, + {"method": "GET", "path": "/v1/taskforces/{name}/notes", "params": "list grammar", + "purpose": "the taskforce's notes"}, + {"method": "GET", "path": "/v1/taskforces/{name}/files", "params": "", + "purpose": "flat file listing (path, size)"}, + {"method": "GET", "path": "/v1/taskforces/{name}/files/{path}", "params": "", + "purpose": "raw file bytes"}, {"method": "POST", "path": "/v1/artifacts:sync", "params": "{source, dest_slug}", "purpose": "mirror an artifact dir"}, {"method": "POST", "path": "/v1/shared-resources:sync", @@ -258,10 +250,6 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict: "purpose": "browse shared session traces (summary + stats)"}, {"method": "GET", "path": "/v1/traces/{agent}/{session}", "params": "", "purpose": "one trace: summary, stats, native-log pointers"}, - {"method": "GET", "path": "/v1/share_trace.py", "params": "", - "purpose": "the trace-sharing client (stdlib python + hf CLI): " - "curl -fsS $API/v1/share_trace.py -o share_trace.py && " - "python3 share_trace.py"}, {"method": "GET", "path": "/v1/stats", "params": "", "purpose": "project-wide token estimate (reported floor) by model/agent/day"}, {"method": "GET", "path": "/v1/healthz", "params": "", "purpose": "liveness"}, @@ -318,9 +306,11 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict: "you were delivered / timed out / shed. `matched` is the " "post-filter total for the whole view, NOT your unread count: " "the unread count is len(items). Or skip hand-rolling this: " - "curl -fsS $API/v1/watch.sh -o watch.sh once, then at every " - "pause run sh watch.sh $API --max-wait 100 — exit 0 " - "prints your new mail as JSON, exit 3 means nothing new" + "curl -fsS $API/v1/watch.sh -o watch.sh && sh watch.sh $API " + " — it exits when you have mail, so re-arm it with your " + "harness's background-task mechanism on every exit (do NOT wrap " + "it in a supervisor loop, and do NOT detach it with '& " + ">/dev/null' — you will not notice the delivery)" ), "list_grammar": ( "list endpoints share: since/until (ISO 8601 or compact stamp), " @@ -328,6 +318,16 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict: "(asc|desc), after/before (filename cursors); responses carry " "count (folder total), matched (post-filter), next (cursor)" ), + "taskforces": ( + "named central-bucket subdirectories for group efforts; a " + "taskforce exists iff taskforces//README.md does — " + "create with name + README content (the creator owns README " + "updates); any registered agent can contribute stamped notes " + "(raw text or .md source) or named files (dest_path must " + "include _); contributors are derived from filenames; " + "there is no automated announcement — after creating, post a " + "board message yourself (@-mention who you want to recruit)" + ), "channels": ( "topic rooms for depth over breadth: channels// holds a " "README (the theme) + subscriber markers + messages. Post via " @@ -364,16 +364,5 @@ def discovery(settings: Settings = Depends(get_settings_dep)) -> dict: "without an @-mention and regardless of when you joined" ), }, - "limits": { - "bucket_writes_per_minute_per_source_bucket": settings.bucket_write_burst, - "raw_messages_per_minute_per_agent": settings.raw_message_per_minute, - "raw_messages_per_hour_per_agent": settings.raw_message_per_hour, - "registrations_per_minute": settings.registration_per_minute, - "raw_body_max_chars": RAW_BODY_MAX_CHARS, - "raw_duplicate_window_s": RAW_DUPLICATE_WINDOW_S, - "source_max_bytes": settings.message_max_bytes, - "expand_max_limit": settings.expand_max_limit, - "mention_fanout_cap": settings.mention_fanout_cap, - }, "endpoints": endpoints, } diff --git a/app/routes/health.py b/app/routes/health.py index f6bbcb88c0c30ef30a5d20b1cd5963f8f460260a..ec906b9923bf5613a2d2319a7538b9da7ade7d36 100644 --- a/app/routes/health.py +++ b/app/routes/health.py @@ -1,29 +1,16 @@ from fastapi import APIRouter, Depends -from app.deps import get_notifier, get_read_model +from app.deps import get_notifier from app.notify import Notifier -from app.read_model import ReadModel router = APIRouter() @router.get("/v1/healthz") -def healthz( - notifier: Notifier = Depends(get_notifier), - read_model: ReadModel = Depends(get_read_model), -) -> dict: +def healthz(notifier: Notifier = Depends(get_notifier)) -> dict: """Liveness, plus the long-poll waiter registry's counters (WATCH_DESIGN.md §3.2.4). `waiters`/`owners` are live gauges; the rest are since-start totals. A climbing `degradations` or `evictions` is the operator's only warning that watchers are being served a worse contract than they asked - for. - - `warm` is false until the startup warm-up has filled the read model; the - status is 200 either way so the Space is not restarted for being cold. - `read_model` carries its gauges, including per-folder listing errors.""" - return { - "status": "ok", - "warm": read_model.warm, - "read_model": read_model.stats(), - "longpoll": notifier.stats(), - } + for.""" + return {"status": "ok", "longpoll": notifier.stats()} diff --git a/app/routes/inbox.py b/app/routes/inbox.py index 89b84d401abed8519c6c47d2d72a0e42a0ba7c1c..08bd5a4be4fdad0e1f86df9ab78501daf19f6746 100644 --- a/app/routes/inbox.py +++ b/app/routes/inbox.py @@ -99,7 +99,7 @@ async def get_inbox( # fabricated handle 404s without taking a waiter slot or stamping a watch # presence for a name that does not exist (§2, §7). await run_in_threadpool(guard) - notifier.note_poll(handle, "inbox", parked=True) + notifier.note_poll(handle, "inbox") # Broadcasts arrive via wake_all, so the single inbox key suffices. page, status, waited_ms = await longpoll( notifier=notifier, diff --git a/app/routes/me.py b/app/routes/me.py index 46d8ae5795a80d69ff695c0cf0b3df05cb70c7fa..6494cde79d500315a85c920683f9aa3593eba70a 100644 --- a/app/routes/me.py +++ b/app/routes/me.py @@ -4,13 +4,11 @@ from fastapi import APIRouter, Depends, Header from app.auth import extract_bearer from app.config import Settings -from app.deps import get_hub, get_org_roles, get_read_model, get_settings_dep +from app.deps import get_hub, get_org_roles, get_settings_dep from app.errors import Unauthorized from app.hub import HubClient from app.models import MeResponse from app.org_roles import OrgRoles -from app.read_model import ReadModel -from app.trace_stats import agent_traces from app.validation import HUMAN_HANDLE_PREFIX @@ -23,7 +21,6 @@ def get_me( settings: Settings = Depends(get_settings_dep), hub: HubClient = Depends(get_hub), org_roles: OrgRoles = Depends(get_org_roles), - read_model: ReadModel = Depends(get_read_model), ) -> MeResponse: """Who the bearer token belongs to, and whether they may broadcast. @@ -33,8 +30,6 @@ def get_me( broadcast. The organizer check reuses the cached org-role lookup and degrades to is_organizer=false if that lookup is unavailable, so a transient outage hides the toggle rather than 503-ing the whole page. - - `traces` sums what this user's registered agents have shared. """ token = extract_bearer(authorization) if not token: @@ -57,15 +52,9 @@ def get_me( ) except Exception: is_organizer = False - my_agents = { - r.filename.removesuffix(".md") - for r in read_model.records("agents") - if r.frontmatter.get("hf_user") == identity.username - } return MeResponse( hf_user=identity.username, handle=f"{HUMAN_HANDLE_PREFIX}{identity.username.lower()}", is_member=is_member, is_organizer=is_organizer, - traces=agent_traces(read_model, my_agents), ) diff --git a/app/routes/messages.py b/app/routes/messages.py index faa537530de763aa698093334978f2a712908e92..33c83891347c75a9237382acaa4990c44c2b4c3e 100644 --- a/app/routes/messages.py +++ b/app/routes/messages.py @@ -1,14 +1,13 @@ from __future__ import annotations -import re from datetime import datetime -from fastapi import APIRouter, Depends, Header, Query, Request, Response +from fastapi import APIRouter, Depends, Header, Query, Request from app.audit import AuditLogger from app.auth import extract_bearer from app.config import Settings -from app.dedup import PromotionLRU, RecentPosts, content_hash +from app.dedup import PromotionLRU, content_hash from app.deps import ( get_audit, get_bucket_write_limiter, @@ -18,7 +17,6 @@ from app.deps import ( get_org_roles, get_raw_message_limiter, get_read_model, - get_recent_posts, get_settings_dep, ) from app.errors import ( @@ -39,7 +37,6 @@ from app.hub import HubClient, HubIdentity from app.org_roles import OrgRoles from app.listing import list_message_like from app.models import ( - RAW_DUPLICATE_WINDOW_S, MessageListing, MessagePostRequest, MessageRecord, @@ -52,7 +49,6 @@ from app.read_model import ReadModel from app.validation import ( HUMAN_HANDLE_PREFIX, is_human_handle, - read_source_text, resolve_source, validate_agent_id, validate_channel_name, @@ -129,13 +125,6 @@ def require_organizer( ) -def _refs_list(refs: str | list[str]) -> list[str]: - """`refs` as a list: a string may hold several comma/space-separated names.""" - if isinstance(refs, str): - return [r for r in re.split(r"[,\s]+", refs) if r] - return refs - - def _server_message_fm(agent_id: str, via: str, dt: datetime) -> dict: return { "agent": agent_id, @@ -148,13 +137,11 @@ def _server_message_fm(agent_id: str, via: str, dt: datetime) -> dict: def post_message( req: MessagePostRequest, request: Request, - response: Response, authorization: str | None = Header(default=None), settings: Settings = Depends(get_settings_dep), hub: HubClient = Depends(get_hub), audit: AuditLogger = Depends(get_audit), dedup: PromotionLRU = Depends(get_dedup), - recent: RecentPosts = Depends(get_recent_posts), bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter), raw_limiter: CompoundLimiter = Depends(get_raw_message_limiter), read_model: ReadModel = Depends(get_read_model), @@ -181,17 +168,13 @@ def post_message( ) parsed, agent_id = resolve_source(settings, req.source) require_registered(read_model, hub, agent_id) - key = (agent_id, req.idempotency_key) - if req.idempotency_key and (replay := recent.get(key)): - response.status_code = 200 - return replay allowed, retry = bucket_limiter.try_consume(parsed.bucket) if not allowed: raise RateLimited(retry) - body_text = read_source_text(hub, settings, parsed) - body_bytes = body_text.encode("utf-8") + body_bytes = hub.read_bytes(parsed) + body_text = body_bytes.decode("utf-8") client_fm, source_body = parse(body_text) if "broadcast" in client_fm: raise NotOrganizer( @@ -215,7 +198,7 @@ def post_message( client_fm.setdefault("type", "agent") if req.refs is not None: - client_fm["refs"] = _refs_list(req.refs) + client_fm["refs"] = req.refs auto_subscribed = ( req.channel is not None @@ -255,7 +238,7 @@ def post_message( extra=audit_extra or None, ) - result = MessageResponse( + return MessageResponse( filename=filename, via="bucket", path=target, @@ -263,9 +246,6 @@ def post_message( channel=req.channel, auto_subscribed=auto_subscribed, ) - if req.idempotency_key: - recent.record(key, result) - return result # raw variant assert req.agent_id is not None and req.body is not None @@ -289,25 +269,13 @@ def post_message( via = "raw" default_type = "agent" - # A retry returns the first post's response: matched on the idempotency - # key when given, else on the same body to the same place within the window. - if req.idempotency_key: - key, max_age = (req.agent_id, req.idempotency_key), None - else: - dest = f"channels/{req.channel}" if req.channel else "message_board" - key = (req.agent_id, dest, content_hash(req.body.encode("utf-8"))) - max_age = RAW_DUPLICATE_WINDOW_S - if replay := recent.get(key, max_age_s=max_age): - response.status_code = 200 - return replay - allowed, retry = raw_limiter.try_consume(req.agent_id) if not allowed: raise RateLimited(retry) client_fm: dict = {"type": req.type or default_type} if req.refs is not None: - client_fm["refs"] = _refs_list(req.refs) + client_fm["refs"] = req.refs auto_subscribed = ( req.channel is not None and req.channel not in read_model.channel_subscriptions(req.agent_id) @@ -348,7 +316,7 @@ def post_message( extra=audit_extra or None, ) - result = MessageResponse( + return MessageResponse( filename=filename, via=via, path=target, @@ -357,8 +325,6 @@ def post_message( channel=req.channel, auto_subscribed=auto_subscribed, ) - recent.record(key, result) - return result @router.get("/v1/messages", response_model=MessageListing) diff --git a/app/routes/results.py b/app/routes/results.py index 3561a9ff4cff730e3acac87cbf13e51de44e5174..e9124524fffbf64b6c9ca0782e08d8cc486d972b 100644 --- a/app/routes/results.py +++ b/app/routes/results.py @@ -1,7 +1,5 @@ from __future__ import annotations -from datetime import timedelta - from fastapi import APIRouter, Depends, Request from app.audit import AuditLogger @@ -14,6 +12,7 @@ from app.deps import ( get_hub, get_read_model, get_settings_dep, + get_verification_status, get_verifier, ) from app.errors import AlreadyPromoted, NotFound, RateLimited @@ -31,21 +30,13 @@ from app.naming import result_path, stamp_yaml, utc_now from app.rate_limit import CompoundLimiter from app.read_model import ReadModel from app.routes.messages import require_registered -from app.trace_stats import agent_traces -from app.validation import read_source_text, resolve_source, validate_agent_id -from app.verification import PENDING +from app.validation import resolve_source, validate_agent_id +from app.verification import PENDING, VerificationStatusStore from app.verifier import Verifier router = APIRouter() -# `$API` stays literal: the README has every agent export it, and -# share_trace.py reads the same variable to find this backend. -SHARE_TRACE_HINT = ( - "Share this session's stats so others can learn from how you got here: " - "curl -fsS $API/v1/share_trace.py -o share_trace.py && python3 share_trace.py" -) - @router.post("/v1/results", response_model=ResultResponse, status_code=201) def post_result( @@ -56,6 +47,7 @@ def post_result( audit: AuditLogger = Depends(get_audit), dedup: PromotionLRU = Depends(get_dedup), bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter), + verification: VerificationStatusStore = Depends(get_verification_status), read_model: ReadModel = Depends(get_read_model), verifier: Verifier = Depends(get_verifier), ) -> ResultResponse: @@ -66,8 +58,8 @@ def post_result( if not allowed: raise RateLimited(retry) - body_text = read_source_text(hub, settings, parsed) - body_bytes = body_text.encode("utf-8") + body_bytes = hub.read_bytes(parsed) + body_text = body_bytes.decode("utf-8") client_fm, source_body = parse(body_text) validate_result_frontmatter(settings, client_fm) @@ -90,6 +82,9 @@ def post_result( read_model.write_through(target, merged, source_body, len(content.encode("utf-8"))) filename = target.rsplit("/", 1)[-1] dedup.record(content_hash(body_bytes), dest_folder, filename) + # Track the freshly promoted result as `pending` in the verification index. + # Best-effort: the result is already written, so a failure here must not 500. + verification.mark_pending(filename) # If this claims to beat the verified champion, re-run it on the private # set. Best-effort and async — the POST never fails or waits on it. verifier.maybe_trigger(filename, merged) @@ -106,16 +101,7 @@ def post_result( user_agent=request.headers.get("user-agent"), ) - # Traces stay optional; this is the nudge. promoted_at is `stamp_yaml`, so - # the 24 h cutoff compares lexically. - last = agent_traces(read_model, {agent_id}).last_shared_at - shared_recently = last is not None and last >= stamp_yaml(now - timedelta(hours=24)) - return ResultResponse( - filename=filename, - via="bucket", - path=target, - hint=None if shared_recently else SHARE_TRACE_HINT, - ) + return ResultResponse(filename=filename, via="bucket", path=target) @router.get("/v1/results", response_model=ResultListing) diff --git a/app/routes/sync.py b/app/routes/sync.py index 8d2311bc399ffeaf54f4eb696bbb4b87be642c79..6b398fa255cd0dc247ace14e838825b3381f8e64 100644 --- a/app/routes/sync.py +++ b/app/routes/sync.py @@ -8,10 +8,9 @@ from app.deps import ( get_audit, get_bucket_write_limiter, get_hub, - get_read_model, get_settings_dep, ) -from app.errors import RateLimited, SyncTooLarge +from app.errors import NotRegistered, RateLimited, SyncTooLarge from app.hub import HubClient from app.models import ( ArtifactSyncRequest, @@ -19,10 +18,8 @@ from app.models import ( SyncFile, SyncResponse, ) -from app.naming import artifact_dest_dir +from app.naming import artifact_dest_dir, registration_path from app.rate_limit import CompoundLimiter -from app.read_model import ReadModel -from app.routes.messages import require_registered from app.validation import ( check_dest_not_blocked, resolve_source, @@ -34,6 +31,13 @@ from app.validation import ( router = APIRouter() +def _require_registered(hub: HubClient, agent_id: str) -> None: + try: + hub.read_central_text(registration_path(agent_id)) + except Exception: + raise NotRegistered(agent_id) + + def _check_sync_caps(settings: Settings, files: list) -> int: total_bytes = sum(f.size for f in files) if len(files) > settings.sync_max_files: @@ -65,11 +69,10 @@ def artifacts_sync( hub: HubClient = Depends(get_hub), audit: AuditLogger = Depends(get_audit), limiter: CompoundLimiter = Depends(get_bucket_write_limiter), - read_model: ReadModel = Depends(get_read_model), ) -> SyncResponse: validate_slug(req.dest_slug) parsed, agent_id = resolve_source(settings, req.source) - require_registered(read_model, hub, agent_id) + _require_registered(hub, agent_id) allowed, retry = limiter.try_consume(parsed.bucket) if not allowed: @@ -111,11 +114,10 @@ def shared_resources_sync( hub: HubClient = Depends(get_hub), audit: AuditLogger = Depends(get_audit), limiter: CompoundLimiter = Depends(get_bucket_write_limiter), - read_model: ReadModel = Depends(get_read_model), ) -> SyncResponse: parsed, agent_id = resolve_source(settings, req.source) validate_shared_dest_path(req.dest_path, agent_id) - require_registered(read_model, hub, agent_id) + _require_registered(hub, agent_id) allowed, retry = limiter.try_consume(parsed.bucket) if not allowed: diff --git a/app/routes/taskforces.py b/app/routes/taskforces.py new file mode 100644 index 0000000000000000000000000000000000000000..ac09ad9574884d6c808ba4b6bef48cff4de67ed0 --- /dev/null +++ b/app/routes/taskforces.py @@ -0,0 +1,569 @@ +"""Taskforces (§18): named central-bucket subdirectories for group efforts. + +A taskforce exists iff ``taskforces/{name}/README.md`` exists — creating one +IS writing its README, so the "every taskforce has a README" rule is a +structural invariant, not a policy. All endpoints share one read-model folder +(the recursive ``taskforces/`` tree listing): browsing any number of +taskforces costs at most one bucket listing per TTL window, and every write +is write-through into that same folder. +""" +from __future__ import annotations + +import mimetypes +import re +from datetime import datetime + +from fastapi import APIRouter, Depends, Query, Request, Response + +from app.audit import AuditLogger +from app.config import Settings +from app.dedup import PromotionLRU, content_hash +from app.deps import ( + get_audit, + get_bucket_write_limiter, + get_dedup, + get_hub, + get_raw_message_limiter, + get_read_model, + get_settings_dep, +) +from app.errors import ( + AlreadyPromoted, + NotFound, + RateLimited, + TaskforceExists, + TaskforceNotFound, +) +from app.frontmatter import merge, parse, serialise +from app.hub import HubClient, ListedFile +from app.listing import STAMP_LEN, list_message_like +from app.models import ( + DigestTaskforces, + MessageListing, + MessageRecord, + TaskforceCreateRequest, + TaskforceCreateResponse, + TaskforceDetail, + TaskforceFileInfo, + TaskforceFileListing, + TaskforceFilePostRequest, + TaskforceFileResponse, + TaskforceListing, + TaskforceSummary, +) +from app.naming import ( + agent_from_filename, + stamp_yaml, + taskforce_file_path, + taskforce_note_path, + taskforce_readme_path, + utc_now, +) +from app.rate_limit import CompoundLimiter +from app.read_model import ReadModel, Record +from app.routes.messages import require_registered +from app.validation import ( + resolve_source, + validate_agent_id, + validate_path_components, + validate_taskforce_dest_path, + validate_taskforce_name, +) + + +router = APIRouter() + +# The single read-model folder shared by every taskforce endpoint (§18.4). +FOLDER = "taskforces" + +_STAMPED_RE = re.compile(r"^\d{8}-\d{6}-\d{3}_") + + +def _is_readme(path: str) -> bool: + return path.rsplit("/", 1)[-1].lower() == "readme.md" + + +def _grouped(read_model: ReadModel) -> dict[str, list[ListedFile]]: + """All listed taskforce files grouped by taskforce name; groups without a + README (e.g. mid-prune leftovers) are not taskforces and are dropped.""" + groups: dict[str, list[ListedFile]] = {} + for e in read_model.listing(FOLDER): + rel = e.rel_path.removeprefix("taskforces/") + name, _, rest = rel.partition("/") + if not rest: + continue # stray file directly under taskforces/ + groups.setdefault(name, []).append(e) + return { + n: fs + for n, fs in groups.items() + if any(f.rel_path == taskforce_readme_path(n) for f in fs) + } + + +def _require_taskforce(read_model: ReadModel, name: str) -> list[ListedFile]: + prefix = f"taskforces/{name}/" + entries = [e for e in read_model.listing(FOLDER) if e.rel_path.startswith(prefix)] + if not any(e.rel_path == taskforce_readme_path(name) for e in entries): + raise TaskforceNotFound(name) + return entries + + +def _note_records( + read_model: ReadModel, entries: list[ListedFile] +) -> list[Record]: + paths = [ + e.rel_path + for e in entries + if e.rel_path.endswith(".md") and not _is_readme(e.rel_path) + ] + recs = read_model.records_for(FOLDER, paths) + return sorted(recs.values(), key=lambda r: r.filename) + + +def _contributors( + entries: list[ListedFile], registered: set[str], creator: str | None +) -> list[str]: + """Derived, not declared (§18.4): stamped note filenames parse to their + author; named files match a registered agent's ``_{agent_id}`` marker.""" + found: set[str] = set() + for e in entries: + if _is_readme(e.rel_path): + continue + leaf = e.rel_path.rsplit("/", 1)[-1] + if leaf.endswith(".md") and _STAMPED_RE.match(leaf): + author = agent_from_filename(leaf) + if author: + found.add(author) + else: + for a in registered: + if re.search(rf"_{re.escape(a)}(?![a-z0-9-])", e.rel_path): + found.add(a) + if creator: + found.discard(creator) + out = [creator] if creator else [] + out.extend(sorted(found)) + return out + + +def _excerpt(body: str, limit: int = 160) -> str: + """First prose line of the README (headings are usually just the name); + falls back to the first heading when there is nothing else.""" + heading = "" + for line in body.splitlines(): + s = line.strip() + if not s: + continue + if s.startswith("#"): + heading = heading or s.lstrip("#").strip() + continue + return s if len(s) <= limit else s[: limit - 1] + "…" + return heading if len(heading) <= limit else heading[: limit - 1] + "…" + + +def _last_activity(entries: list[ListedFile]) -> str | None: + stamps = [] + for e in entries: + leaf = e.rel_path.rsplit("/", 1)[-1] + if leaf.endswith(".md") and _STAMPED_RE.match(leaf): + stamps.append(leaf[:STAMP_LEN]) + return max(stamps) if stamps else None + + +def _created_compact(created: str | None) -> str: + """The README's human-readable ``created`` stamp as a compact stamp, so it + can order against note-filename stamps; unparseable → '' (sorts last).""" + if not created: + return "" + try: + dt = datetime.strptime(created, "%Y-%m-%d %H:%M UTC") + except ValueError: + return "" + return dt.strftime("%Y%m%d-%H%M%S-000") + + +def _fm_str(fm: dict, key: str) -> str | None: + value = fm.get(key) + return str(value) if value is not None else None + + +def taskforce_digest(read_model: ReadModel) -> DigestTaskforces: + """The digest's taskforce block (§16.5, §18.4): count + newest by creation.""" + groups = _grouped(read_model) + readmes = read_model.records_for( + FOLDER, [taskforce_readme_path(n) for n in groups] + ) + + def _key(n: str) -> str: + rec = readmes.get(taskforce_readme_path(n)) + return _created_compact(_fm_str(rec.frontmatter, "created")) if rec else "" + + newest = sorted(groups, key=lambda n: (_key(n), n), reverse=True)[:5] + return DigestTaskforces(count=len(groups), newest=newest) + + +# ───────────────────────── writes ───────────────────────── + + +@router.post("/v1/taskforces", response_model=TaskforceCreateResponse, status_code=201) +def create_taskforce( + req: TaskforceCreateRequest, + request: Request, + response: Response, + settings: Settings = Depends(get_settings_dep), + hub: HubClient = Depends(get_hub), + audit: AuditLogger = Depends(get_audit), + dedup: PromotionLRU = Depends(get_dedup), + bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter), + raw_limiter: CompoundLimiter = Depends(get_raw_message_limiter), + read_model: ReadModel = Depends(get_read_model), +) -> TaskforceCreateResponse: + now = utc_now() + validate_taskforce_name(req.name) + target = taskforce_readme_path(req.name) + + raw_bytes: bytes | None = None + if req.source is not None: + parsed, agent_id = resolve_source(settings, req.source) + require_registered(read_model, hub, agent_id) + allowed, retry = bucket_limiter.try_consume(parsed.bucket) + if not allowed: + raise RateLimited(retry) + raw_bytes = hub.read_bytes(parsed) + client_fm, body = parse(raw_bytes.decode("utf-8")) + via = "bucket" + else: + assert req.agent_id is not None and req.body is not None + agent_id = req.agent_id + validate_agent_id(agent_id) + require_registered(read_model, hub, agent_id) + allowed, retry = raw_limiter.try_consume(agent_id) + if not allowed: + raise RateLimited(retry) + client_fm, body = {}, req.body + via = "raw" + + existing = read_model.record(FOLDER, f"{req.name}/README.md") + if existing is not None: + creator = _fm_str(existing.frontmatter, "creator") + if creator != agent_id: + raise TaskforceExists(req.name, creator) + created = False + server_fm = { + "taskforce": req.name, + "creator": creator, + "created": existing.frontmatter.get("created"), + "updated": stamp_yaml(now), + "via": via, + } + else: + created = True + server_fm = { + "taskforce": req.name, + "creator": agent_id, + "created": stamp_yaml(now), + "via": via, + } + + if raw_bytes is not None: + dup = dedup.get(content_hash(raw_bytes), f"taskforces/{req.name}") + if dup: + raise AlreadyPromoted(dup) + + merged = merge(client_fm, server_fm) + content = serialise(merged, body) + nbytes = len(content.encode("utf-8")) + hub.write_text_central(target, content) + read_model.write_through(target, merged, body, nbytes, folder=FOLDER) + if raw_bytes is not None: + dedup.record(content_hash(raw_bytes), f"taskforces/{req.name}", "README.md") + + audit.write( + agent_id=agent_id, + route="/v1/taskforces", + via=via, + source=req.source, + target_path=target, + bytes_count=nbytes, + status_code=201 if created else 200, + caller_ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + extra={"taskforce": req.name, "created": created}, + ) + + if not created: + response.status_code = 200 + return TaskforceCreateResponse(name=req.name, via=via, path=target, created=created) + + +@router.post( + "/v1/taskforces/{name}/files", + response_model=TaskforceFileResponse, + status_code=201, +) +def post_taskforce_file( + name: str, + req: TaskforceFilePostRequest, + request: Request, + settings: Settings = Depends(get_settings_dep), + hub: HubClient = Depends(get_hub), + audit: AuditLogger = Depends(get_audit), + dedup: PromotionLRU = Depends(get_dedup), + bucket_limiter: CompoundLimiter = Depends(get_bucket_write_limiter), + raw_limiter: CompoundLimiter = Depends(get_raw_message_limiter), + read_model: ReadModel = Depends(get_read_model), +) -> TaskforceFileResponse: + now = utc_now() + validate_taskforce_name(name) + _require_taskforce(read_model, name) + dest_folder = f"taskforces/{name}" + route = "/v1/taskforces/{name}/files" + + data: bytes | None = None + if req.source is not None: + parsed, agent_id = resolve_source(settings, req.source) + require_registered(read_model, hub, agent_id) + allowed, retry = bucket_limiter.try_consume(parsed.bucket) + if not allowed: + raise RateLimited(retry) + data = hub.read_bytes(parsed) + + if req.dest_path is not None: + # Named file: byte-identical copy, attribution by marker (§18.3). + # No dedup — re-promoting your own path is the update mechanism. + validate_taskforce_dest_path(req.dest_path, agent_id) + target = taskforce_file_path(name, req.dest_path) + hub.write_bytes_central(target, data) + if target.endswith(".md"): + try: + fm, body = parse(data.decode("utf-8")) + except Exception: + fm, body = {}, data.decode("utf-8", errors="replace") + read_model.write_through(target, fm, body, len(data), folder=FOLDER) + else: + # Listing freshness only; binaries are never content-cached. + read_model.write_through(target, {}, "", len(data), folder=FOLDER) + audit.write( + agent_id=agent_id, + route=route, + via="bucket", + source=req.source, + target_path=target, + bytes_count=len(data), + status_code=201, + caller_ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + extra={"taskforce": name, "kind": "file"}, + ) + return TaskforceFileResponse( + kind="file", filename=req.dest_path, via="bucket", path=target + ) + + # Bucket note: message-shaped promotion into the taskforce. + client_fm, body = parse(data.decode("utf-8")) + dup = dedup.get(content_hash(data), dest_folder) + if dup: + raise AlreadyPromoted(dup) + client_fm.setdefault("type", "note") + via = "bucket" + else: + assert req.agent_id is not None and req.body is not None + agent_id = req.agent_id + validate_agent_id(agent_id) + require_registered(read_model, hub, agent_id) + allowed, retry = raw_limiter.try_consume(agent_id) + if not allowed: + raise RateLimited(retry) + client_fm, body = {"type": req.type or "note"}, req.body + via = "raw" + + server_fm = { + "agent": agent_id, + "timestamp": stamp_yaml(now), + "via": via, + "taskforce": name, + } + merged = merge(client_fm, server_fm) + target = taskforce_note_path(name, agent_id, now) + filename = target.rsplit("/", 1)[-1] + content = serialise(merged, body) + nbytes = len(content.encode("utf-8")) + hub.write_text_central(target, content) + read_model.write_through(target, merged, body, nbytes, folder=FOLDER) + if data is not None: + dedup.record(content_hash(data), dest_folder, filename) + + audit.write( + agent_id=agent_id, + route=route, + via=via, + source=req.source, + target_path=target, + bytes_count=nbytes, + status_code=201, + caller_ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + extra={"taskforce": name, "kind": "note"}, + ) + return TaskforceFileResponse(kind="note", filename=filename, via=via, path=target) + + +# ───────────────────────── discovery ───────────────────────── + + +@router.get("/v1/taskforces", response_model=TaskforceListing) +def list_taskforces( + q: str | None = None, + limit: int | None = None, + read_model: ReadModel = Depends(get_read_model), +) -> TaskforceListing: + groups = _grouped(read_model) + readmes = read_model.records_for( + FOLDER, [taskforce_readme_path(n) for n in groups] + ) + registered = read_model.registered_agents() + + keyed: list[tuple[str, TaskforceSummary]] = [] + for nm, entries in groups.items(): + readme = readmes.get(taskforce_readme_path(nm)) + fm = readme.frontmatter if readme else {} + body = readme.body if readme else "" + if q is not None: + ql = q.lower() + if ql not in nm.lower() and ql not in body.lower(): + continue + creator = _fm_str(fm, "creator") + created = _fm_str(fm, "created") + notes = [ + e + for e in entries + if e.rel_path.endswith(".md") and not _is_readme(e.rel_path) + ] + last = _last_activity(entries) + keyed.append( + ( + max(last or "", _created_compact(created)), + TaskforceSummary( + name=nm, + creator=creator, + created=created, + readme_excerpt=_excerpt(body), + contributors=_contributors(entries, registered, creator), + file_count=len(entries), + note_count=len(notes), + last_activity=last, + ), + ) + ) + # Most recently active first (discoverability is the point); name breaks ties. + keyed.sort(key=lambda t: t[1].name) + keyed.sort(key=lambda t: t[0], reverse=True) + items = [s for _, s in keyed] + matched = len(items) + if limit is not None and 0 < limit < len(items): + items = items[:limit] + return TaskforceListing(count=len(groups), matched=matched, items=items) + + +@router.get("/v1/taskforces/{name}", response_model=TaskforceDetail) +def get_taskforce( + name: str, + read_model: ReadModel = Depends(get_read_model), +) -> TaskforceDetail: + validate_taskforce_name(name) + entries = _require_taskforce(read_model, name) + readme_path = taskforce_readme_path(name) + readme = read_model.records_for(FOLDER, [readme_path]).get(readme_path) + if readme is None: + raise NotFound(readme_path) # transient content-fetch failure; retry + fm = readme.frontmatter + creator = _fm_str(fm, "creator") + notes = _note_records(read_model, entries) + recent = list(reversed(notes))[:5] + return TaskforceDetail( + name=name, + creator=creator, + created=_fm_str(fm, "created"), + updated=_fm_str(fm, "updated"), + readme=MessageRecord(filename="README.md", frontmatter=fm, body=readme.body), + contributors=_contributors(entries, read_model.registered_agents(), creator), + file_count=len(entries), + note_count=len(notes), + recent_notes=[ + MessageRecord(filename=r.filename, frontmatter=r.frontmatter, body=r.body) + for r in recent + ], + ) + + +@router.get("/v1/taskforces/{name}/notes", response_model=MessageListing) +def list_taskforce_notes( + name: str, + agent: str | None = None, + since: str | None = None, + until: str | None = None, + type_: str | None = Query(None, alias="type"), + via: str | None = None, + q: str | None = None, + expand: bool = False, + limit: int | None = 10, + order: str = "desc", + after: str | None = None, + before: str | None = None, + settings: Settings = Depends(get_settings_dep), + read_model: ReadModel = Depends(get_read_model), +) -> MessageListing: + validate_taskforce_name(name) + entries = _require_taskforce(read_model, name) + return list_message_like( + _note_records(read_model, entries), + agent=agent, + since=since, + until=until, + type_=type_, + via=via, + q=q, + expand=expand, + limit=limit, + order=order, + after=after, + before=before, + expand_cap=settings.expand_max_limit, + ) + + +@router.get("/v1/taskforces/{name}/files", response_model=TaskforceFileListing) +def list_taskforce_files( + name: str, + read_model: ReadModel = Depends(get_read_model), +) -> TaskforceFileListing: + validate_taskforce_name(name) + entries = _require_taskforce(read_model, name) + prefix = f"taskforces/{name}/" + items = [ + TaskforceFileInfo(path=e.rel_path.removeprefix(prefix), size=e.size) + for e in sorted(entries, key=lambda e: e.rel_path) + ] + return TaskforceFileListing(count=len(items), items=items) + + +@router.get("/v1/taskforces/{name}/files/{file_path:path}") +def get_taskforce_file( + name: str, + file_path: str, + hub: HubClient = Depends(get_hub), + read_model: ReadModel = Depends(get_read_model), +) -> Response: + validate_taskforce_name(name) + entries = _require_taskforce(read_model, name) + validate_path_components(file_path) + target = taskforce_file_path(name, file_path) + if not any(e.rel_path == target for e in entries): + raise NotFound(target) + data = hub.read_central_bytes_optional(target) + if data is None: + raise NotFound(target) + if target.endswith(".md"): + media = "text/markdown" + else: + media = mimetypes.guess_type(target)[0] or "application/octet-stream" + return Response(content=data, media_type=media) diff --git a/app/routes/updates.py b/app/routes/updates.py index f0172cc18b5b5d271fa6c032627eb75e8ad63de2..956e950531c0e731d836bda177e3d630400b4aa5 100644 --- a/app/routes/updates.py +++ b/app/routes/updates.py @@ -99,12 +99,9 @@ async def get_updates( ) if wait <= 0: - page = await run_in_threadpool(check) - notifier.note_poll(as_, "updates", parked=False, after=after) - notifier.note_cursor(as_, page.cursor) - return page + return await run_in_threadpool(check) await run_in_threadpool(guard) - notifier.note_poll(as_, "updates", parked=True, after=after) + notifier.note_poll(as_, "updates") # Snapshot the keys at park time: the inbox key (always present, so this # stream never degrades to no_streams) plus one per notify: all channel. # Staleness is bounded by one wait window — a level flipped mid-park takes @@ -125,7 +122,6 @@ async def get_updates( check=check, has_items=lambda listing: bool(listing.items), ) - notifier.note_cursor(as_, page.cursor) return watched(page, status, waited_ms) @@ -145,16 +141,16 @@ def get_watching( read model, no bucket listing, nothing to cache — so it is cheap enough for a 30s UI loop, unlike the one-digest-per-agent fan-out it replaces. - `max_wait_s` is the ceiling every `wait=` is clamped to and `fresh_s` - (WATCH_FRESH_S) the age past which presence should read as stale, published - so no consumer has to keep its own copy of the knob. The registry counters - ride along under `longpoll`, identical to `/v1/healthz`.""" + `max_wait_s` is the ceiling every `wait=` is clamped to and `fresh_s` (2× + that) the age past which presence should read as stale, published so no + consumer has to keep its own copy of the knob. The registry counters ride + along under `longpoll`, identical to `/v1/healthz`.""" return WatchingResponse( max_wait_s=settings.longpoll_max_wait_s, - fresh_s=settings.watch_fresh_s, + fresh_s=2 * settings.longpoll_max_wait_s, watching={ - owner: WatchingEntry(last_poll_age_s=int(p.age_s), mode=p.mode, stream=p.stream) - for owner, p in notifier.all_last_poll().items() + owner: WatchingEntry(last_poll_age_s=int(age_s), mode=mode) + for owner, (age_s, mode) in notifier.all_last_poll().items() }, longpoll=notifier.stats(), ) diff --git a/app/trace_stats.py b/app/trace_stats.py index aaf72c6a39cae0ef6a31f2fcf54e5ca2d842d202..b67f083bbea2349db299d1724dc6362e73a8ebe7 100644 --- a/app/trace_stats.py +++ b/app/trace_stats.py @@ -15,19 +15,17 @@ from datetime import date, datetime from app.errors import InvalidFrontmatter from app.models import ( - AgentTraces, DigestStats, StatsResponse, TokenTotals, TraceSummary, ) -from app.naming import TRACES_FOLDER, split_trace_manifest_path -from app.read_model import ReadModel, Record +from app.naming import split_trace_manifest_path +from app.read_model import Record # Harnesses whose adapters are expected to deliver the full enforced set # (tokens + tool_calls). Anything else degrades to `partial` (never blocked). -# Keep in sync with KNOWN_HARNESSES in clients/share_trace.py. KNOWN_FULL_HARNESSES: frozenset[str] = frozenset({"claude-code", "codex"}) REQUIRED_MANIFEST_FIELDS: tuple[str, ...] = ("schema_version", "harness", "session_id") @@ -259,25 +257,6 @@ def list_traces( return total, matched, items, nxt -# ───────────────────────── per-agent summary ───────────────────────── - -def agent_traces(read_model: ReadModel, agents: set[str]) -> AgentTraces: - """Sessions shared by `agents` and the newest `promoted_at`. Filters the - cached listing by path first, so only these agents' manifests are read.""" - paths = [ - e.rel_path - for e in read_model.listing(TRACES_FOLDER) - if (ids := split_trace_manifest_path(e.rel_path)) and ids[0] in agents - ] - stamps = [ - str(r.frontmatter["promoted_at"]) - for r in read_model.records_for(TRACES_FOLDER, paths).values() - if r.frontmatter.get("promoted_at") - ] - # promoted_at is server-stamped `YYYY-MM-DD HH:MM UTC`: lexical max = newest. - return AgentTraces(sessions=len(paths), last_shared_at=max(stamps, default=None)) - - # ───────────────────────── aggregate ───────────────────────── def _accumulate(t: TokenTotals, usage: dict) -> None: diff --git a/app/validation.py b/app/validation.py index 550f5c937de430d594807f38b586d0645e01f958..c6d85ff45fed470ea7f82ba1de44b658c5df54bc 100644 --- a/app/validation.py +++ b/app/validation.py @@ -1,8 +1,7 @@ from __future__ import annotations from app.config import Settings -from app.errors import InvalidFrontmatter, InvalidPath, SourceNotFound, TooLarge -from app.hub import HubClient +from app.errors import InvalidFrontmatter, InvalidPath from app.naming import ( AGENT_ID_RE, RESERVED_CHANNEL_NAMES, @@ -18,7 +17,7 @@ BLOCKED_TARGETS = { "LEADERBOARD.md", "shared_resources/README.md", } -BLOCKED_PREFIXES = ("audit/", "inbox/", "channels/") +BLOCKED_PREFIXES = ("audit/", "inbox/", "taskforces/", "channels/") # The human-* namespace identifies human participants in inbox routing # (§16.4): @human- delivers without a registration check, so no agent @@ -140,23 +139,6 @@ def resolve_source(settings: Settings, source: str) -> tuple[SourceURI, str]: return parsed, agent_id -def read_source_text(hub: HubClient, settings: Settings, parsed: SourceURI) -> str: - """Read a promoted source file as UTF-8 text, mapping every way it can be - unusable to an agent-facing error instead of a bare 500.""" - try: - data = hub.read_bytes(parsed) - except FileNotFoundError: - raise SourceNotFound(str(parsed)) - if len(data) > settings.message_max_bytes: - raise TooLarge( - f"source is {len(data)} bytes; the limit is {settings.message_max_bytes}" - ) - try: - return data.decode("utf-8") - except UnicodeDecodeError: - raise InvalidFrontmatter("source must be UTF-8 text") - - def _validate_agent_marker(dest_path: str, agent_id: str, what: str) -> None: """Attribution-by-construction: the `_{agent_id}` marker must appear in the dest path, checked against the *resolved* source identity — so only the @@ -194,3 +176,29 @@ def validate_channel_name(name: str) -> None: f"channel name '{name}' is reserved (it is an API path segment)", hint="pick a different name", ) + + +def validate_taskforce_name(name: str) -> None: + if name != name.lower(): + raise InvalidPath( + f"taskforce name must be lowercase: {name!r}", + hint=f"use '{name.lower()}' instead", + ) + if not SLUG_RE.match(name): + raise InvalidPath( + f"invalid taskforce name: {name!r}", + hint="kebab-case, 1-40 chars: [a-z0-9] with internal hyphens", + ) + + +def validate_taskforce_dest_path(dest_path: str, agent_id: str) -> None: + """Named taskforce files (§18.3): shared-resources marker rule, plus the + README leaf is reserved for the create/update endpoint.""" + validate_path_components(dest_path) + leaf = dest_path.rsplit("/", 1)[-1] + if leaf.lower() == "readme.md": + raise InvalidPath( + "README.md is reserved: the taskforce README is managed via POST /v1/taskforces", + hint="pick a different filename for your content", + ) + _validate_agent_marker(dest_path, agent_id, "taskforce") diff --git a/app/verification.py b/app/verification.py index 7c55609f4e613368417f64b2ac7826e63a7bf6b6..0480af0b1c81eaf2dc13b7f71263bd1d4a07d00f 100644 --- a/app/verification.py +++ b/app/verification.py @@ -1,19 +1,29 @@ """Verification-status index for promoted results. ``results/verification_status.json`` is a flat map of result filename -> -verification state (``pending`` | ``valid`` | ``invalid``). Humans and the -eval Space edit it out of band; the Space itself writes it only through -``set_verdict``. A result with no entry reads as ``pending`` everywhere, so -promotion never touches the file — a promotion-time rewrite would race those -out-of-band edits and silently drop a verdict (last writer wins). - -``set_verdict`` is a read-modify-write of the whole object, so a process-wide -lock serialises it — the Space runs a single uvicorn worker, the same -assumption ``DurableJobQuota`` relies on. It **fails safe on read**: if the -index can't be read (transport error) or parsed (corrupt/non-object JSON), it -refuses to write — overwriting would erase every human verdict. - -It also never clobbers a human verdict, via a **side-ledger compare-and-set**: every verdict the verifier +verification state (``pending`` | ``valid`` | ``invalid``). A human (or a +downstream verifier) flips entries to ``valid`` / ``invalid``; every newly +promoted result is inserted as ``pending`` so nothing slips through unreviewed. + +Maintaining it is a read-modify-write of a single shared JSON object, so a +process-wide lock serialises the update — the Space runs a single uvicorn worker +and sync endpoints share a threadpool, the same assumption ``DurableJobQuota`` +relies on. The update is **best-effort and non-fatal**: by the time we get here +the result file is already promoted, so a storage hiccup must not fail the +request — we log and move on, and the next promotion (or a manual reconcile) +heals the index. + +Two safety properties: + +- **Verdicts are never clobbered.** A filename already present (whatever its + state) is left untouched — we only ever *insert* a missing entry as + ``pending``, never overwrite an existing ``valid`` / ``invalid``. +- **Fail SAFE on read.** If the index can't be read (transport error) or parsed + (corrupt/non-object JSON), we refuse to write — overwriting would erase every + human verdict. We skip and log loudly so it can be fixed by hand. + +``set_verdict`` (the automated verifier's write path, §5.7) extends the first +property with a **side-ledger compare-and-set**: every verdict the verifier writes is recorded in the private audit bucket (``verification_runs//verdict.json``), and the index is only updated when the current entry is ``pending``/absent **or** equals the verifier's own @@ -85,6 +95,28 @@ class VerificationStatusStore: return None return data + def mark_pending(self, filename: str) -> None: + """Insert ``filename`` as ``pending`` if absent. Best-effort; never raises. + + Serialised by a process-wide lock so two concurrent promotions cannot + read-then-write the same index and drop each other's entry. Existing + entries (including human ``valid`` / ``invalid`` verdicts) are preserved. + """ + with self._lock: + data = self._load() + if data is None: + return # read/parse failed — fail safe, leave the index untouched + if filename in data: + return # already tracked; don't rewrite or clobber a verdict + data[filename] = PENDING + body = json.dumps(data, indent=2, sort_keys=True) + "\n" + try: + self._hub.write_text_central(self._path, body) + except Exception as exc: + log.warning( + "verification-status write failed for %s: %s", self._path, exc + ) + # ───────────────────── automated verdicts (§5.7) ───────────────────── def _ledger_path(self, filename: str) -> str: @@ -127,7 +159,7 @@ class VerificationStatusStore: Compare-and-set against the side-ledger: write the index iff the current entry is ``pending``/absent or equals the verifier's own last-written state. Returns WRITTEN, DEFERRED, or SKIPPED. Serialised - by a process-wide lock. + by the same process-wide lock as ``mark_pending``. """ if new_state not in (VALID, INVALID): raise ValueError(f"verdict must be '{VALID}' or '{INVALID}', got {new_state!r}") diff --git a/clients/collab_watch.sh b/clients/collab_watch.sh index 009c28c8fea44363fe4c020281cbf6b03aa80470..7296bbc892fe4422ca4cb58df3dfcdbec241b113 100644 --- a/clients/collab_watch.sh +++ b/clients/collab_watch.sh @@ -22,13 +22,8 @@ # modes (default: wait): # (none) block until mail, print the page, exit 0 # --max-wait N ...but give up cleanly after N seconds with exit 3. N is a -# ceiling: every request is clamped to the time left, so the -# run returns within N seconds plus network latency (and the -# 2s idle floor) — size N to fit your shell tool's timeout. -# A server still unreachable when N is up exits 4, not 3. -# --after C start from cursor C (a filename, e.g. the digest's -# watching.last_cursor) instead of the saved one; C is written -# to the cursor file first. Wait and --exec modes only. +# floor: exit 3 never fires early, and overshoots by up to the +# wait window still in flight when N expires. # --exec CMD foreground loop: per delivery run CMD (via `sh -c`) with the # page on its stdin; the cursor advances ONLY when CMD exits 0. # CMD also sees COLLAB_WATCH_HANDLE / COLLAB_WATCH_STREAM / @@ -45,9 +40,7 @@ # one you asked about; a watcher on another stream is # NO_WATCHER for this one (exit 11, naming the live stream on # stderr). LAST is the last loop status the watcher recorded. -# BEHIND outranks every liveness verdict — act on it first; -# stderr then names the command that reads the page now. - +# BEHIND outranks every liveness verdict — act on it first. # --help this text # # exit codes: @@ -59,9 +52,8 @@ # config, or a server that does not implement the watch API # 2 usage error # 3 --max-wait elapsed with no mail — a CLEAN timeout, not a death -# 4 gave up after COLLAB_WATCH_RETRY_BUDGET_S (20 min) of consecutive -# request failures, or --max-wait ran out mid-retry (also: --status could -# not reach the server); the heartbeat records status=gave_up +# 4 gave up after 10 consecutive request failures (also: --status could not +# reach the server); the heartbeat records status=gave_up # 5 another watcher already holds the lock for this handle. The lock is # per-HANDLE, not per-stream: one watcher covers an agent, which is what # the unified `updates` stream is for @@ -84,8 +76,6 @@ # COLLAB_WATCH_BACKOFF initial retry backoff seconds (2; doubles to 60). 0 # makes retries instant — a test hook, not a production # setting. It does NOT shorten the idle pacing floor. -# COLLAB_WATCH_RETRY_BUDGET_S seconds of consecutive request failures before -# giving up with exit 4 (1200: outlasts a Space rebuild) # # state directory (one per host+handle, so running from another working # directory can never silently re-baseline and skip mail): @@ -94,10 +84,8 @@ # loop pass — including empty timeouts and the give-up — # so a stale heartbeat means exactly "no watcher process # has run recently", nothing else -# lock/ mkdir-based lock; lock/pid holds the pid and its start -# time. A lock whose pid fails `kill -0`, or now belongs -# to a process started at another time (PID reuse, -# another PID namespace), is stale and is reclaimed +# lock/ mkdir-based lock, lock/pid inside; a lock whose pid +# fails `kill -0` is stale and is reclaimed # delivered.jsonl every delivered page, appended BEFORE it is printed # dead-letter.jsonl pages a --exec handler kept refusing (see --exec below) # The cursor is per-STREAM; the lock and heartbeat are per-HANDLE, because one @@ -120,9 +108,6 @@ # silently skipped page is not. # # harness integration — exit-on-mail composes with anything: -# * The documented recipe (the collab README) is one synchronous -# `--max-wait N` run at every pause; the modes below are for harnesses -# that can hold a process. # * Background-task harness (Claude Code, Codex, ...): launch ONE run with # your harness's own background-task mechanism, react to the JSON when the # task completes, then launch it again. @@ -149,15 +134,12 @@ # filtered. The number of items in the page IS the unread count. A wrapper # that reads `matched` will happily report "up to date" with mail pending. # -# NOTE (backoff tradeoff): HTTP 5xx (including the edge's 502/503/504 while a -# Space rebuilds), refused/unresolved connections, and the expected -# parked-connection drops when the Space restarts all share ONE small -# exponential backoff (2,4,8,...,60s, then every 60s) and ONE time budget: -# after COLLAB_WATCH_RETRY_BUDGET_S (20 min) of failures in a row it exits 4. -# The budget is time, not a count, so a watcher outlives a redeploy; under -# --max-wait the ceiling still wins. Folding the normal drops in keeps this -# simple; the cost is that a Space restart reconnects after ~2s instead of -# 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), +# NOTE (backoff tradeoff): HTTP 5xx, refused/unresolved connections, and the +# expected parked-connection drops when the Space restarts all share ONE +# small exponential backoff (2,4,8,...,60s) plus a 10-in-a-row streak that +# exits 4. Folding the normal drops in keeps this simple; the cost is that a +# Space restart reconnects after ~2s instead of instantly. 3xx and 4xx do NOT +# back off — they fail immediately (a 4xx with the server's error body), # because neither a typo'd handle nor a redirecting base URL is a transient # condition. The routine idle path is not a failure at all: when the wait # elapses the server answers 200 with an empty page, so an idle watcher never @@ -173,6 +155,7 @@ export LC_ALL SELF=collab_watch LIMIT=10 # records per delivered page STATUS_LIMIT=100 # --status unread count saturates here +FAIL_STREAK_MAX=10 # consecutive request failures before exit 4 IDLE_FLOOR_S=2 # minimum seconds between two empty answers # Server-issued filename shape: --_.md, where @@ -182,16 +165,12 @@ FILENAME_RE='[0-9]{8}-[0-9]{6}-[0-9]{3}_[a-z0-9][a-z0-9-]*\.md' WAIT="${COLLAB_WATCH_WAIT:-55}" EXEC_RETRIES="${COLLAB_WATCH_EXEC_RETRIES:-3}" BACKOFF_BASE="${COLLAB_WATCH_BACKOFF:-2}" -RETRY_BUDGET="${COLLAB_WATCH_RETRY_BUDGET_S:-1200}" BODY="" -CODE="" -CHILD="" LOCK_HELD="" HTTP=000 CURL_RC=0 STREAK=0 -FAIL_SINCE=0 BACKOFF=2 CURSOR="" PAGE_CURSOR="" @@ -203,7 +182,7 @@ log() { printf '%s: %s\n' "$SELF" "$*" >&2; } usage_text() { cat <<'EOF' usage: sh collab_watch.sh [updates|inbox|feed] [flags] -flags: --max-wait N | --after CURSOR | --exec CMD | --peek | --status | --help +flags: --max-wait N | --exec CMD | --peek | --status | --help exit: 0 delivered/ok | 1 fatal | 2 usage | 3 clean no-mail timeout | 4 gave up | 5 lock held | 10 behind | 11 no watcher | 12 stale heartbeat EOF @@ -240,8 +219,6 @@ STREAM="" MODE="wait" MODE_FLAG="" MAX_WAIT="" -AFTER="" -DEADLINE="" EXEC_CMD="" set_mode() { @@ -270,13 +247,6 @@ while [ "$#" -gt 0 ]; do is_num "$MAX_WAIT" || usage "--max-wait must be a whole number of seconds, got '$MAX_WAIT'" [ "$MAX_WAIT" -gt 0 ] || usage "--max-wait must be greater than 0" ;; - --after) - shift - [ "$#" -gt 0 ] || usage "--after needs a cursor (a message filename)" - AFTER="$1" - printf '%s\n' "$AFTER" | grep -qxE "$FILENAME_RE" || - usage "--after must be a message filename like 20260728-120000-000_agent-b.md, got '$AFTER'" - ;; -h | --help) # Self-documenting: the header comment IS the manual. Falls back to # the short form when $0 is not readable (piped from stdin). @@ -323,16 +293,12 @@ esac if [ -n "$MAX_WAIT" ] && [ "$MODE" != wait ]; then usage "--max-wait applies to the default wait mode only (not $MODE_FLAG)" fi -case "$MODE" in - peek | status) [ -z "$AFTER" ] || usage "--after applies to wait and --exec modes only (not $MODE_FLAG)" ;; -esac # ── configuration ───────────────────────────────────────────────────── is_num "$WAIT" || fatal "COLLAB_WATCH_WAIT must be a whole number of seconds, got '$WAIT'" is_num "$EXEC_RETRIES" || fatal "COLLAB_WATCH_EXEC_RETRIES must be a whole number, got '$EXEC_RETRIES'" is_num "$BACKOFF_BASE" || fatal "COLLAB_WATCH_BACKOFF must be a whole number of seconds, got '$BACKOFF_BASE'" -is_num "$RETRY_BUDGET" || fatal "COLLAB_WATCH_RETRY_BUDGET_S must be a whole number of seconds, got '$RETRY_BUDGET'" BACKOFF="$BACKOFF_BASE" BASE="${BASE%/}" @@ -380,14 +346,11 @@ case "$STREAM" in esac BODY=$(mktemp "$DIR/.body.XXXXXX") || fatal "could not create a temp file in '$DIR'" -CODE=$(mktemp "$DIR/.code.XXXXXX") || fatal "could not create a temp file in '$DIR'" cleanup() { - # A parked curl (or a backoff sleep) must not outlive us. - if [ -n "$CHILD" ]; then - kill "$CHILD" 2>/dev/null || : + if [ -n "$BODY" ]; then + rm -f "$BODY" 2>/dev/null || : fi - rm -f "$BODY" "$CODE" 2>/dev/null || : # Release the lock only while it is still OURS: if the pid inside is no # longer $$, another watcher owns the directory (it reclaimed ours as stale) # and removing it would hand a third one the same cursor file. @@ -398,30 +361,12 @@ cleanup() { : } # The signal traps exit explicitly so the EXIT trap runs and the lock is -# released: a watcher killed by its harness must not leave a lock behind. They -# fire at once: curl and every sleep run through reap (below); only an --exec -# handler still runs in the foreground. +# released: a watcher killed by its harness must not leave a lock behind. trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM trap 'exit 129' HUP -# reap : wait for a background child and return its status. A shell runs -# a trap only once its FOREGROUND child exits — up to a whole parked request — -# but a trapped signal interrupts `wait` immediately, and cleanup kills $CHILD. -reap() { - CHILD="$1" - reap_rc=0 - wait "$CHILD" || reap_rc=$? - CHILD="" - return "$reap_rc" -} - -nap() { - sleep "$1" & - reap "$!" || : -} - # ── state helpers ───────────────────────────────────────────────────── # Atomic single-line write: mktemp in the target's directory + mv, so a crash @@ -456,38 +401,11 @@ lock_pid() { fi } -lock_start() { - sed -n 2p "$LOCKDIR/pid" 2>/dev/null || : -} - -# proc_start : an opaque start-time stamp, empty when unavailable. Linux -# /proc field 22 (after the parenthesised comm, which may contain spaces), else -# `ps -o lstart=` (macOS, procps). Busybox ps has neither -p nor lstart, but -# busybox systems have /proc. -proc_start() { - if [ -r "/proc/$1/stat" ]; then - sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20 - else - ps -o lstart= -p "$1" 2>/dev/null || : - fi -} - -# lock_alive [start]: `kill -0` alone is fooled by PID reuse and by a pid -# from another PID namespace, so a recorded start time must match too. A lock -# without one (an older watcher) or a host where neither probe works falls back -# to the pid alone. lock_alive() { la_pid="${1:-}" is_num "$la_pid" || return 1 kill -0 "$la_pid" 2>/dev/null || return 1 - [ -n "${2:-}" ] || return 0 - la_start=$(proc_start "$la_pid") - [ -z "$la_start" ] || [ "$la_start" = "$2" ] -} - -lock_write() { - LOCK_HELD=1 - printf '%s\n%s\n' "$$" "$(proc_start "$$")" >"$LOCKDIR/pid" + return 0 } # One watcher per HANDLE — not per handle+stream: two watchers under one handle @@ -497,7 +415,8 @@ lock_write() { # stream is for. lock_acquire() { if mkdir "$LOCKDIR" 2>/dev/null; then - lock_write + LOCK_HELD=1 + printf '%s\n' "$$" >"$LOCKDIR/pid" return 0 fi lk_pid=$(lock_pid) @@ -508,18 +427,19 @@ lock_acquire() { # pid by then, while a crash mid-acquire leaves the file empty forever and # is still reclaimed on the second read. if [ -z "$lk_pid" ]; then - nap 1 + sleep 1 lk_pid=$(lock_pid) fi - if lock_alive "$lk_pid" "$(lock_start)"; then + if lock_alive "$lk_pid"; then log "another watcher already holds this handle (pid $lk_pid, lock $LOCKDIR); the lock is per-handle, one watcher covers every stream; exiting 5" exit 5 fi - log "reclaiming stale lock $LOCKDIR (pid ${lk_pid:-unknown} is gone or was reused)" + log "reclaiming stale lock $LOCKDIR (pid ${lk_pid:-unknown} is gone)" rm -f "$LOCKDIR/pid" 2>/dev/null || : rmdir "$LOCKDIR" 2>/dev/null || : if mkdir "$LOCKDIR" 2>/dev/null; then - lock_write + LOCK_HELD=1 + printf '%s\n' "$$" >"$LOCKDIR/pid" return 0 fi lk_pid=$(lock_pid) @@ -584,9 +504,7 @@ watch_status() { # minutes and died with an opaque `curl rc=22`. do_request() { CURL_RC=0 - curl -sS -o "$BODY" -w '%{http_code}' --max-time "$2" "$1" >"$CODE" & - reap "$!" || CURL_RC=$? - HTTP=$(cat "$CODE") + HTTP=$(curl -sS -o "$BODY" -w '%{http_code}' --max-time "$2" "$1") || CURL_RC=$? [ -n "$HTTP" ] || HTTP=000 } @@ -596,7 +514,7 @@ do_request() { # blindly would let the server move a watcher to another host, scheme or handle — # so a redirecting base URL can never succeed, no matter how long we retry: # `http://.hf.space` (which redirects to https) would otherwise walk the -# whole backoff ladder for 20 minutes and then exit 4, reporting an outage +# whole backoff ladder for ~5 minutes and then exit 4, reporting an outage # instead of the one-word fix. classify() { [ "$CURL_RC" -eq 0 ] || return 1 @@ -625,32 +543,20 @@ fail_http() { exit 1 } -# One shared exponential backoff for every retryable failure. Gives up (exit -# 4) once the failures in a row span RETRY_BUDGET seconds — a time budget, not -# a count, because the outage it must outlast (a Space rebuild) is measured in -# minutes — stamping the heartbeat first so --status can report the give-up -# after this process is gone. +# One shared exponential backoff for every retryable failure. Bumps the streak +# and gives up (exit 4) at FAIL_STREAK_MAX, stamping the heartbeat first so +# --status can report the give-up after this process is gone. on_retryable() { - if [ "$STREAK" -eq 0 ]; then - FAIL_SINCE=$(now) - fi STREAK=$((STREAK + 1)) - or_failing=$(($(now) - FAIL_SINCE)) - if [ "$or_failing" -ge "$RETRY_BUDGET" ]; then + if [ "$STREAK" -ge "$FAIL_STREAK_MAX" ]; then hb gave_up - log "giving up after $STREAK consecutive request failures over ${or_failing}s (last: HTTP $HTTP, curl rc=$CURL_RC)" - exit 4 - fi - if [ -n "$DEADLINE" ] && [ $((DEADLINE - $(now))) -le "$BACKOFF" ]; then - hb gave_up - log "request failed (HTTP $HTTP, curl rc=$CURL_RC) and --max-wait ${MAX_WAIT}s is up; exiting 4" + log "giving up after $STREAK consecutive request failures (last: HTTP $HTTP, curl rc=$CURL_RC)" exit 4 fi hb retrying - log "request failed (HTTP $HTTP, curl rc=$CURL_RC); retry $STREAK in ${BACKOFF}s (failing for ${or_failing}s of ${RETRY_BUDGET}s)" - + log "request failed (HTTP $HTTP, curl rc=$CURL_RC); retry $STREAK/$FAIL_STREAK_MAX in ${BACKOFF}s" if [ "$BACKOFF" -gt 0 ]; then - nap "$BACKOFF" + sleep "$BACKOFF" fi BACKOFF=$((BACKOFF * 2)) if [ "$BACKOFF" -gt 60 ]; then @@ -663,22 +569,6 @@ request_ok_reset() { BACKOFF="$BACKOFF_BASE" } -# curl --max-time for a request parked $1 seconds: the wait plus 20s of slack, -# but never past the --max-wait deadline plus 5s of network latency. -max_time() { - mt=$(($1 + 20)) - if [ -n "$DEADLINE" ]; then - mt_cap=$((DEADLINE - $(now) + 5)) - if [ "$mt_cap" -lt "$mt" ]; then - mt="$mt_cap" - fi - fi - if [ "$mt" -lt 1 ]; then - mt=1 - fi - printf '%s' "$mt" -} - # Forward-drain URL: $1 = limit, $2 = wait. `after` is omitted when the cursor # is empty — "nothing seen yet" is the absence of a bound, not an empty one. poll_url() { @@ -696,7 +586,7 @@ poll_url() { # so peeking and watching agree about where "now" is. baseline_cursor() { while :; do - do_request "$URL${SEP}limit=1&order=desc&expand=true" "$(max_time 0)" + do_request "$URL${SEP}limit=1&order=desc&expand=true" $((WAIT + 20)) bc_cls=0 classify || bc_cls=$? case "$bc_cls" in @@ -729,44 +619,38 @@ journal_page() { log "warning: could not append to $1 (delivery continues, recovery does not)" } -# The cursor to resume from: --after if given, else the saved one, else a -# cold-start baseline. -load_cursor() { - if [ -n "$AFTER" ]; then - write_line "$CURSOR_FILE" "$AFTER" - log "cursor set to '$AFTER' (--after)" - fi - if [ ! -f "$CURSOR_FILE" ]; then - baseline_cursor - hb baselined - else - CURSOR=$(cursor_read) - fi -} - # ── mode: wait / bounded wait ───────────────────────────────────────── run_wait() { lock_acquire hb starting - # --max-wait is a CEILING: it is sized to fit a shell tool's timeout, and a - # run the harness kills mid-request is worse than one that returns a second - # early. Every request is clamped to the time left, and so are curl's - # --max-time and the retry backoff. (`date +%s` truncates, so the run may - # end up to a second before N.) + # --max-wait is a FLOOR, never a ceiling: exit 3 must not fire before the + # caller's N seconds are really up. `date +%s` truncates, so the epoch read + # here is up to a second earlier than the true start instant — without the + # +1 slack a `--max-wait 2` run could give up after 1.1s and report "no + # mail" for a window the caller never asked to stop watching. The cost is + # that the bound overshoots instead: by up to that lost second, plus + # whatever is left of the request already in flight when it expires (the + # granularity of a bounded wait is one wait window — say so, don't pretend). + rw_deadline="" if [ -n "$MAX_WAIT" ]; then - DEADLINE=$(($(now) + MAX_WAIT)) + rw_deadline=$(($(now) + MAX_WAIT + 1)) fi - load_cursor + if [ ! -f "$CURSOR_FILE" ]; then + baseline_cursor + hb baselined + else + CURSOR=$(cursor_read) + fi # Page FORWARD from the cursor (order=asc) so a burst larger than one page # drains oldest-first across consecutive runs with no gaps. while :; do rw_wait="$WAIT" - if [ -n "$DEADLINE" ]; then - rw_left=$((DEADLINE - $(now))) + if [ -n "$rw_deadline" ]; then + rw_left=$((rw_deadline - $(now))) if [ "$rw_left" -le 0 ]; then hb no_mail log "no mail within ${MAX_WAIT}s — clean timeout, exiting 3" @@ -779,7 +663,7 @@ run_wait() { hb waiting rw_t0=$(now) - do_request "$(poll_url "$LIMIT" "$rw_wait")" "$(max_time "$rw_wait")" + do_request "$(poll_url "$LIMIT" "$rw_wait")" $((rw_wait + 20)) rw_cls=0 classify || rw_cls=$? case "$rw_cls" in @@ -848,7 +732,7 @@ idle_pace() { ;; esac if [ "$ip_elapsed" -lt "$IDLE_FLOOR_S" ]; then - nap "$IDLE_FLOOR_S" + sleep "$IDLE_FLOOR_S" fi } @@ -863,7 +747,13 @@ idle_pace() { run_exec() { lock_acquire hb starting - load_cursor + + if [ ! -f "$CURSOR_FILE" ]; then + baseline_cursor + hb baselined + else + CURSOR=$(cursor_read) + fi re_page="" re_fails=0 @@ -949,9 +839,8 @@ run_exec() { hb exec_failed 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)" if [ "$re_backoff" -gt 0 ]; then - nap "$re_backoff" + sleep "$re_backoff" fi - re_backoff=$((re_backoff * 2)) if [ "$re_backoff" -gt 60 ]; then re_backoff=60 @@ -1003,8 +892,7 @@ run_peek() { run_status() { rs_pid=$(lock_pid) rs_alive=0 - if lock_alive "$rs_pid" "$(lock_start)"; then - + if lock_alive "$rs_pid"; then rs_alive=1 else rs_pid="-" @@ -1078,11 +966,6 @@ run_status() { rs_rc=0 fi - if [ "$rs_rc" -eq 10 ]; then - rs_arg="" - [ "$STREAM" = updates ] || rs_arg=" $STREAM" - log "read it now: sh $0 $BASE $HANDLE$rs_arg --max-wait 5" - fi if [ "$rs_wrong_stream" -eq 1 ]; then log "a watcher IS alive for this handle (pid $rs_pid) but it is watching '$rs_stream', not '$STREAM': nothing is advancing cursor.$STREAM" fi diff --git a/clients/share_trace.py b/clients/share_trace.py index 2b11b2ee30fbdcedbe5a030f633709b27e0bf083..3b1ce36ff8ab31bddd9d7014448bd2bdabae2d32 100644 --- a/clients/share_trace.py +++ b/clients/share_trace.py @@ -22,13 +22,13 @@ WHAT THIS READS AND UPLOADS — for operators, agents, and safety classifiers: * `--dry-run` prints the manifest and a typed redaction summary without writing or uploading anything — run it first to verify. - python3 share_trace.py # stats only; no content leaves (the floor) - python3 share_trace.py --upload-only # write to scratch bucket; skip backend promotion - python3 share_trace.py --full --yes # FULL: stats + balanced-redacted log -> library - python3 share_trace.py --full --privacy secrets # credentials only; preserve PII - python3 share_trace.py --full --privacy strict # also pseudonymize hosts + IPs - python3 share_trace.py --full --raw # UNSAFE: full, skip all redaction - python3 share_trace.py --dry-run # print the plan + manifest; touch nothing + python share_trace.py # stats only; no content leaves (the floor) + python share_trace.py --upload-only # write to scratch bucket; skip backend promotion + python share_trace.py --full --yes # FULL: stats + balanced-redacted log -> library + python share_trace.py --full --privacy secrets # credentials only; preserve PII + python share_trace.py --full --privacy strict # also pseudonymize hosts + IPs + python share_trace.py --full --raw # UNSAFE: full, skip all redaction + python share_trace.py --dry-run # print the plan + manifest; touch nothing `full` lets Hugging Face's built-in trace viewer render the native log directly 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 (which is valid YAML, so the backend parses it identically) and the upload shells out to the `hf` CLI (which you already use for `hf auth login`). Org/slug are auto-discovered from the backend's `GET /v1`, so you only need `--backend` -(or `COLLAB_BACKEND`, else `API`) and your `--agent-id`. The per-harness -adapters are inlined below; keep them in sync with the verified recipes (memory: +(or `COLLAB_BACKEND`) and your `--agent-id`. The per-harness adapters are inlined +below; keep them in sync with the verified recipes (memory: cc-codex-trace-metric-extraction). """ from __future__ import annotations @@ -69,9 +69,8 @@ import urllib.request from pathlib import Path -ADAPTER_VERSION = 2 # v2: Claude Code usage deduped per message.id (v1 over-counted) +ADAPTER_VERSION = 1 REDACTOR_VERSION = 2 -# Keep in sync with KNOWN_FULL_HARNESSES in backend/app/trace_stats.py. KNOWN_HARNESSES = ("claude-code", "codex") PRIVACY_LEVELS = ("secrets", "balanced", "strict") @@ -101,14 +100,11 @@ def _int(v) -> int | None: def adapter_claude_code(log_path: Path) -> dict: - """$CLAUDE_CONFIG_DIR (default ~/.claude)/projects//.jsonl. - - Claude Code writes one `assistant` line per content block, repeating the - API response's message.id and usage — so usage is kept per message.id - (last write wins) and summed once; tool calls are deduped by tool_use id.""" - responses: dict = {} # message.id (or a per-line key if absent) -> usage + """~/.claude/projects//.jsonl — per-response usage is SUMMED.""" + usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0} + saw_usage = False tools: dict[str, int] = {} - seen_tools: set = set() + api_requests = 0 model = None session_id = log_path.stem first_ts = last_ts = None @@ -122,29 +118,21 @@ def adapter_claude_code(log_path: Path) -> dict: session_id = rec["sessionId"] if rec.get("type") != "assistant": continue + api_requests += 1 msg = rec.get("message") or {} if msg.get("model"): model = msg["model"] - key = msg.get("id") or ("line", len(responses)) - responses[key] = msg.get("usage") or responses.get(key) or {} - for block in msg.get("content") or []: - if isinstance(block, dict) and block.get("type") == "tool_use": - if block.get("id"): - if block["id"] in seen_tools: - continue - seen_tools.add(block["id"]) - name = block.get("name") or "?" - tools[name] = tools.get(name, 0) + 1 - - usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_creation_tokens": 0} - saw_usage = False - for u in responses.values(): + u = msg.get("usage") or {} if u: saw_usage = True usage["input_tokens"] += _int(u.get("input_tokens")) or 0 usage["output_tokens"] += _int(u.get("output_tokens")) or 0 usage["cache_read_tokens"] += _int(u.get("cache_read_input_tokens")) or 0 usage["cache_creation_tokens"] += _int(u.get("cache_creation_input_tokens")) or 0 + for block in msg.get("content") or []: + if isinstance(block, dict) and block.get("type") == "tool_use": + name = block.get("name") or "?" + tools[name] = tools.get(name, 0) + 1 fields: dict = { "harness": "claude-code", @@ -153,7 +141,7 @@ def adapter_claude_code(log_path: Path) -> dict: "started_at": first_ts, "ended_at": last_ts, "activity": {"tool_calls": sum(tools.values()), "tool_calls_by_name": tools}, - "extensions": {"api_requests": len(responses)}, + "extensions": {"api_requests": api_requests}, } if saw_usage: usage["total_tokens"] = sum(usage.values()) @@ -165,7 +153,7 @@ _CODEX_TOOL_TYPES = ("function_call", "custom_tool_call", "local_shell_call", "w def adapter_codex(log_path: Path) -> dict: - """$CODEX_HOME (default ~/.codex)/sessions/YYYY/MM/DD/rollout-*.jsonl — token_count is CUMULATIVE + """~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — token_count is CUMULATIVE (take the last); dedupe tool calls by call_id (MCP appears twice).""" last_usage = None tools: dict[str, int] = {} @@ -253,17 +241,9 @@ def build_fields(harness: str, log_path: Path) -> dict: return fn(log_path) if fn else adapter_minimal(log_path, harness) -def _claude_dir() -> Path: - return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser() - - -def _codex_home() -> Path: - return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser() - - def _cc_project_dir(cwd: str) -> Path: slug = re.sub(r"[/._]", "-", os.path.abspath(cwd)) - return _claude_dir() / "projects" / slug + return Path.home() / ".claude" / "projects" / slug def _latest(paths: list[Path]) -> Path | None: @@ -277,7 +257,7 @@ def _detect_claude_code(cwd: str) -> Path | None: def _codex_logs() -> list[Path]: - codex_root = _codex_home() / "sessions" + codex_root = Path.home() / ".codex" / "sessions" return sorted( [Path(p) for p in glob.glob(str(codex_root / "**" / "rollout-*.jsonl"), recursive=True)], key=lambda p: p.stat().st_mtime if p.is_file() else 0, @@ -288,8 +268,7 @@ def _codex_logs() -> list[Path]: def _mentions_cwd(value, cwd: str) -> bool: cwd_abs = os.path.abspath(cwd) if isinstance(value, str): - # Path boundary: /work/proj must not match /work/proj2 or /work/proj-x. - if re.search(re.escape(cwd_abs) + r"(?![\w.-])", value): + if cwd_abs in value: return True try: return os.path.abspath(os.path.expanduser(value)) == cwd_abs @@ -320,12 +299,10 @@ def _detect_codex(cwd: str) -> tuple[Path | None, bool]: def _infer_harness(log_path: Path) -> str | None: - s = str(log_path.expanduser().resolve()) - codex_root = str(_codex_home().resolve() / "sessions") + os.sep - claude_root = str(_claude_dir().resolve() / "projects") + os.sep - if "/.codex/sessions/" in s or s.startswith(codex_root) or log_path.name.startswith("rollout-"): + s = str(log_path) + if "/.codex/sessions/" in s or log_path.name.startswith("rollout-"): return "codex" - if "/.claude/projects/" in s or s.startswith(claude_root): + if "/.claude/projects/" in s: return "claude-code" return None @@ -339,8 +316,6 @@ def _running_harness() -> tuple[str | None, str | None]: - Claude Code sets CLAUDE_CODE_SESSION_ID (the exact session) + CLAUDECODE=1. - Codex sets CODEX_SANDBOX* in its (default) sandboxed exec but exposes NO session id — so we know it's Codex, but still locate the rollout by cwd. - (CODEX_HOME only relocates the logs; it is often exported globally, so - it is NOT a marker.) """ sid = os.environ.get("CLAUDE_CODE_SESSION_ID") if sid or os.environ.get("CLAUDECODE"): @@ -847,23 +822,23 @@ def _confirm_or_exit( raw: bool, privacy: str, ) -> None: - # Stats-only shares carry nothing sensitive (just counts): never block them. - if share != "full" or yes: - return reasons = [] if uncertain: reasons.append("Could not pin the exact invoking session — selection fell back to " "the newest log for this directory; confirm it is the right one.") - if raw: - reasons.append( - "Full --raw sharing uploads the UNREDACTED native session log " - "to your org-readable scratch bucket." - ) - else: - reasons.append( - f"Full sharing uploads the {privacy}-redacted native session log " - "to your org-readable scratch bucket." - ) + if share == "full": + if raw: + reasons.append( + "Full --raw sharing uploads the UNREDACTED native session log " + "to your org-readable scratch bucket." + ) + else: + reasons.append( + f"Full sharing uploads the {privacy}-redacted native session log " + "to your org-readable scratch bucket." + ) + if not reasons or yes: + return print("\nconfirmation required:") for reason in reasons: print(f"- {reason}") @@ -906,17 +881,10 @@ def _hf_cp(local: str, dest_uri: str) -> None: sys.exit(f"`hf buckets cp` failed [{r.returncode}]:\n{(r.stderr or r.stdout).strip()}") -def _harness_arg(value: str) -> str: - if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", value): - raise argparse.ArgumentTypeError("must be a lowercase slug, e.g. claude-code, codex, cursor") - return value - - def main() -> int: ap = argparse.ArgumentParser(description="Share a session's stats / trace with the collaboration.") - ap.add_argument("--harness", type=_harness_arg, default="auto", - help="claude-code, codex, or any other harness slug (needs --transcript; " - "stats will be partial). Default: auto-detect from the environment/cwd") + ap.add_argument("--harness", choices=[*KNOWN_HARNESSES, "auto"], default="auto", + help="default: auto-detect from this cwd (Claude Code, then Codex)") ap.add_argument("--transcript", help="explicit native session log path (else: detected)") ap.add_argument("--session-id", help="override the manifest/dest session id") ap.add_argument("--full", action="store_true", help="also upload the redacted native session log") @@ -931,11 +899,11 @@ def main() -> int: ap.add_argument("--agent-id", default=os.environ.get("AGENT_ID"), help="your registered agent_id") ap.add_argument("--org", default=os.environ.get("ORG"), help="challenge org") ap.add_argument("--slug", default=os.environ.get("COLLAB_SLUG"), help="challenge slug") - ap.add_argument("--backend", default=os.environ.get("COLLAB_BACKEND") or os.environ.get("API"), + ap.add_argument("--backend", default=os.environ.get("COLLAB_BACKEND"), help="the backend Space base URL, e.g. https://--bucket-sync.hf.space") ap.add_argument("--upload-only", action="store_true", help="write the bundle to your scratch bucket and skip POST /v1/traces") - ap.add_argument("--yes", action="store_true", help="confirm --full in non-interactive use") + ap.add_argument("--yes", action="store_true", help="confirm --full/global Codex fallback in non-interactive use") ap.add_argument("--dry-run", action="store_true", help="print the plan + manifest; touch nothing") args = ap.parse_args() if args.full and args.stats_only: @@ -966,8 +934,6 @@ def main() -> int: harness = args.harness if args.harness != "auto" else _infer_harness(log_path) if harness is None: sys.exit("could not infer harness from --transcript; pass --harness") - elif args.harness not in (*KNOWN_HARNESSES, "auto"): - sys.exit(f"no adapter for --harness {args.harness}; pass --transcript to its session log") else: harness, log_path, uncertain = detect(os.getcwd(), args.harness) @@ -1043,8 +1009,8 @@ def main() -> int: print(f"tool_calls : {activity.get('tool_calls', 'unknown')}") if harness not in KNOWN_HARNESSES: print( - f"note : '{harness}' has no adapter — stats will be partial " - "(minimal manifest)" + f"note : '{harness}' has no adapter — shipping a minimal " + "manifest (partial)" + (" + native log" if share == "full" else "") ) @@ -1076,7 +1042,7 @@ def main() -> int: (args.slug, "--slug/COLLAB_SLUG"), ] if promote: - required.append((args.backend, "--backend/COLLAB_BACKEND/API")) + required.append((args.backend, "--backend/COLLAB_BACKEND")) for req, name in required: if not req: sys.exit(f"missing {name}") @@ -1124,13 +1090,13 @@ def main() -> int: detail = (e.read().decode(errors="replace") if isinstance(e, urllib.error.HTTPError) else str(getattr(e, "reason", e))) # The bundle is already in the bucket — a promote failure is partial, - # not total. Make that legible, but exit non-zero: the share is incomplete. + # not total. Make that legible and exit 0 (the upload succeeded). print(f"\n✓ bundle uploaded to {source}") print(f"⚠ backend promotion failed [{code}]: {detail.strip()[:200]}") print(" your trace is safe in your scratch bucket. Re-run with " "--upload-only to skip promotion, or tell the organizers if " "POST /v1/traces should be available on this collab.") - return 1 + return 0 print(f"promoted: {promoted_text}") try: promoted = json.loads(promoted_text) diff --git a/scripts/backfill_inbox.py b/scripts/backfill_inbox.py index ac8d5f749bdb13b72e3c81bb06d8aa3020468778..0b7d61c57ed8c44d1d6b061cb385d57ff48ce1dd 100644 --- a/scripts/backfill_inbox.py +++ b/scripts/backfill_inbox.py @@ -71,7 +71,7 @@ def main() -> int: ) failed_downloads = len(messages) - len(raw_by_path) if failed_downloads: - print(f"WARNING: {failed_downloads} listed message(s) vanished before download; re-run to cover them") + print(f"WARNING: {failed_downloads} message(s) failed to download; re-run to cover them") planned: list[tuple[bytes, str]] = [] unparseable = 0 diff --git a/scripts/dev_server.py b/scripts/dev_server.py index e006dceeb49bb712613dbabbc2e0abef137b823e..ebbb54339a4262e0794e8518f9c2adca25a4f0aa 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -33,13 +33,11 @@ os.environ.setdefault("RAW_MESSAGE_PER_HOUR", "1000") from app.audit import AuditLogger # noqa: E402 from app.config import Settings # noqa: E402 -from app.dedup import PromotionLRU, RecentPosts # noqa: E402 +from app.dedup import PromotionLRU # noqa: E402 from app.deps import ( # noqa: E402 - bucket_write_limiter, get_audit, get_bucket_write_limiter, get_dedup, - get_recent_posts, get_hub, get_notifier, get_org_roles, @@ -49,12 +47,11 @@ from app.deps import ( # noqa: E402 get_settings_dep, get_verification_status, get_verifier, - raw_message_limiter, ) from app.main import app as fastapi_app # noqa: E402 from app.notify import Notifier # noqa: E402 from app.org_roles import OrgRoles # noqa: E402 -from app.rate_limit import TokenBucket # noqa: E402 +from app.rate_limit import CompoundLimiter, TokenBucket # noqa: E402 from app.read_model import ReadModel # noqa: E402 from app.verification import VerificationStatusStore # noqa: E402 from app.verifier import Verifier # noqa: E402 @@ -137,6 +134,10 @@ class PersistentFakeHub(FakeHub): super().write_bytes_to_bucket(bucket, path, data) self._persist(bucket, path) + def append_jsonl_audit(self, path: str, line: str) -> None: + super().append_jsonl_audit(path, line) + self._persist(self._settings.audit_bucket, path) + def write_bytes_audit(self, path: str, data: bytes) -> None: super().write_bytes_audit(path, data) self._persist(self._settings.audit_bucket, path) @@ -234,7 +235,6 @@ def main() -> None: read_model = ReadModel(hub, settings) dedup = PromotionLRU(settings.dedup_lru_size) - recent_posts = RecentPosts(settings.dedup_lru_size) verification = VerificationStatusStore(hub, runs_prefix=settings.verification_runs_prefix) # Every singleton in app/deps.py reads the env-backed settings, so each one # this Settings() must reach needs an override below — the notifier included, @@ -244,13 +244,18 @@ def main() -> None: max_waiters_total=settings.longpoll_max_waiters_total, wake_spread_s=settings.longpoll_wake_spread_s, wake_spread_threshold=settings.longpoll_wake_spread_threshold, - parked_window_s=2 * settings.longpoll_max_wait_s, ) # The verifier posts verdict messages, so it holds the notifier too — without # it a verdict would land silently and never wake a parked watcher. verifier = Verifier(settings, hub, read_model, verification, FakeJobRunner(), spawn=lambda _name, fn: fn(), notifier=notifier) + def compound(burst: int, sustained: int) -> CompoundLimiter: + return CompoundLimiter( + TokenBucket(capacity=burst, refill_per_minute=burst), + TokenBucket(capacity=sustained, refill_per_minute=sustained), + ) + fastapi_app.dependency_overrides.update({ get_settings_dep: lambda: settings, get_hub: lambda: hub, @@ -259,13 +264,14 @@ def main() -> None: get_org_roles: lambda: OrgRoles(hub, settings), get_audit: lambda: AuditLogger(hub), get_dedup: lambda: dedup, - get_recent_posts: lambda: recent_posts, get_verification_status: lambda: verification, get_verifier: lambda: verifier, # Real (env-tunable) production limiter shapes — the point of the # test environment is realism, just with dev-friendly defaults. - get_bucket_write_limiter: lambda: bucket_write_limiter(settings), - get_raw_message_limiter: lambda: raw_message_limiter(settings), + get_bucket_write_limiter: lambda: compound( + settings.bucket_write_burst, settings.bucket_write_per_minute), + get_raw_message_limiter: lambda: compound( + settings.raw_message_per_minute, settings.raw_message_per_hour), get_registration_limiter: lambda: TokenBucket( capacity=settings.registration_per_minute, refill_per_minute=settings.registration_per_minute), diff --git a/tests/conftest.py b/tests/conftest.py index f4f5555908d4463e5f42417154b5145075c05078..aef5b9a6640eb4b24e54afbfc2e91afc9ff1ef6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ from fastapi.testclient import TestClient from app.announce import reset_stamp_guard from app.audit import AuditLogger from app.config import Settings -from app.dedup import PromotionLRU, RecentPosts +from app.dedup import PromotionLRU from app.deps import ( get_audit, get_bucket_write_limiter, @@ -18,7 +18,6 @@ from app.deps import ( get_org_roles, get_raw_message_limiter, get_read_model, - get_recent_posts, get_registration_limiter, get_settings_dep, get_verification_status, @@ -57,7 +56,6 @@ def make_env(): # One LRU per env, like production's process-wide singleton — a # per-request instance would make cross-request dedup untestable. dedup = PromotionLRU(10_000) - recent_posts = RecentPosts(10_000) verification = VerificationStatusStore( hub, runs_prefix=settings.verification_runs_prefix ) @@ -68,7 +66,6 @@ def make_env(): max_waiters_total=settings.longpoll_max_waiters_total, wake_spread_s=settings.longpoll_wake_spread_s, wake_spread_threshold=settings.longpoll_wake_spread_threshold, - parked_window_s=2 * settings.longpoll_max_wait_s, ) runner = FakeJobRunner() # Inline spawn: the verdict watcher runs synchronously inside the POST, @@ -89,11 +86,8 @@ def make_env(): get_read_model: lambda: read_model, get_notifier: lambda: notifier, get_org_roles: lambda: OrgRoles(hub, settings), - get_audit: lambda: AuditLogger( - hub, record_client=not settings.audit_bucket_in_org - ), + get_audit: lambda: AuditLogger(hub), get_dedup: lambda: dedup, - get_recent_posts: lambda: recent_posts, get_verification_status: lambda: verification, get_verifier: lambda: verifier, get_bucket_write_limiter: generous, diff --git a/tests/fakes.py b/tests/fakes.py index 62e3e103d125c5b96117d90ba5416ac082a7db34..066116bcfa03c76aa1848d74f5b089b16bea04a4 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -2,31 +2,13 @@ from __future__ import annotations import hashlib -import time - -import httpx -from huggingface_hub.errors import HfHubHTTPError from app.config import Settings from app.frontmatter import serialise -from app.hub import ( - DownloadFailed, - HubIdentity, - HubUnreachable, - ListedFile, - ListingFailed, - OrgMemberRole, -) +from app.hub import HubIdentity, HubUnreachable, ListedFile, OrgMemberRole from app.naming import SourceURI, parse_source_uri -def _http_error(message: str = "simulated hub write failure") -> HfHubHTTPError: - """An HfHubHTTPError shaped like the one HubClient lets propagate from a - failed batch_bucket_files call (writes aren't caught in hub.py).""" - request = httpx.Request("PUT", "https://fake-hub.test/batch") - return HfHubHTTPError(message, response=httpx.Response(500, request=request)) - - class FakeHub: """In-memory HubClient: buckets are dicts of {path: bytes}; xet_hash is sha256 — content-derived, exactly the property the read model relies on.""" @@ -42,13 +24,6 @@ class FakeHub: self.batch_writes: list[list[str]] = [] self.deletes: list[str] = [] self.fail_listings = False - # Chaos toggles (see fail_next_write/fail_next_read/partial_listing/ - # fail_next_listing below): each is one-shot and resets after firing. - self.latency_s: float = 0.0 - self._fail_write: Exception | None = None - self._fail_read: tuple[str | None, Exception | None] | None = None - self._fail_listing_folder: str | None = None - self._partial_listing: tuple[str, int] | None = None # Scripted whoami identity for token-authenticated paths # (registration handshake, human message posts). self.whoami_user = "test-user" @@ -60,6 +35,11 @@ class FakeHub: # and only the owner may write (the org ACL). A bucket seeded by a # test without an owner is writable by the caller. self.bucket_owners: dict[str, str] = {} + # An org admin's token can write every bucket in the org. + self.caller_is_org_admin = False + # Paths ("/", or a bare central path) whose optional + # reads fail with a Hub error instead of answering. + self.failing_reads: set[str] = set() self.caller_writes: list[tuple[str, str, str]] = [] # (bucket, path, token) self.created_buckets: list[tuple[str, str]] = [] # (bucket, token) # Scripted challenge-org member roles for the organizer-broadcast gate. @@ -78,67 +58,11 @@ class FakeHub: b = bucket or self._settings.central_bucket self.buckets.setdefault(b, {})[path] = text.encode("utf-8") - # ── chaos toggles ──────────────────────────────────────────────── - def fail_next_write(self, exc: Exception | None = None) -> None: - """The next write/batch call raises `exc` (default: an HfHubHTTPError, - matching what a real batch_bucket_files failure surfaces as).""" - self._fail_write = exc or _http_error() - - def fail_next_read(self, path_substring: str | None = None, exc: Exception | None = None) -> None: - """The next read whose path contains `path_substring` (any path, if - omitted) raises `exc` (default: FileNotFoundError; DownloadFailed for - download_many, as HubClient raises once its retry is spent).""" - self._fail_read = (path_substring, exc) - - def partial_listing(self, folder: str, drop: int = 1) -> None: - """The next listing of `folder` is interrupted after all but its last - `drop` entries, as if a list_bucket_tree page failed mid-way. Like - HubClient._list, the pages already read are discarded and the call - raises ListingFailed — a partial listing is never returned.""" - self._partial_listing = (folder, drop) - - def fail_next_listing(self, folder: str) -> None: - """The next listing of `folder` raises ListingFailed, as HubClient._list - does for any list_bucket_tree failure.""" - self._fail_listing_folder = folder - - def _maybe_sleep(self) -> None: - if self.latency_s: - time.sleep(self.latency_s) - - def _maybe_fail_write(self) -> None: - self._maybe_sleep() - if self._fail_write is not None: - exc, self._fail_write = self._fail_write, None - raise exc - - def _maybe_fail_read(self, path: str) -> None: - self._maybe_sleep() - if self._fail_read is None: - return - substring, exc = self._fail_read - if substring is not None and substring not in path: - return - self._fail_read = None - raise exc or FileNotFoundError(path) - - def _apply_listing_toggles(self, prefix: str, files: list[ListedFile]) -> list[ListedFile]: - self._maybe_sleep() - if self._fail_listing_folder == prefix: - self._fail_listing_folder = None - raise ListingFailed(f"simulated listing failure: {prefix}") - if self._partial_listing is not None and self._partial_listing[0] == prefix: - _, drop = self._partial_listing - self._partial_listing = None - if drop: - raise ListingFailed( - f"simulated failure after {len(files) - drop} entries: {prefix}" - ) - return files - # ── HubClient surface used by the app ──────────────────────────── - @staticmethod - def _listed(files: dict[str, bytes], prefix: str) -> list[ListedFile]: + def list_central_dir(self, prefix: str) -> list[ListedFile]: + self.list_calls += 1 + if self.fail_listings: + return [] # the real hub flattens listing errors to [] p = prefix.rstrip("/") + "/" if prefix else "" return [ ListedFile( @@ -146,59 +70,61 @@ class FakeHub: size=len(data), xet_hash=hashlib.sha256(data).hexdigest(), ) - for path, data in files.items() + for path, data in self._central().items() if path.startswith(p) ] - def list_central_dir(self, prefix: str) -> list[ListedFile]: - self.list_calls += 1 - if self.fail_listings: - raise ListingFailed(f"simulated listing outage: {prefix}") - return self._apply_listing_toggles(prefix, self._listed(self._central(), prefix)) - def list_bucket_dir(self, bucket: str, prefix: str) -> list[ListedFile]: - files = self._listed(self.buckets.get(bucket, {}), prefix) - try: - return self._apply_listing_toggles(prefix, files) - except ListingFailed: - return [] # as HubClient.list_bucket_dir + p = prefix.rstrip("/") + "/" if prefix else "" + return [ + ListedFile( + rel_path=path, + size=len(data), + xet_hash=hashlib.sha256(data).hexdigest(), + ) + for path, data in self.buckets.get(bucket, {}).items() + if path.startswith(p) + ] def download_many(self, bucket: str, remote_paths: list[str]) -> dict[str, bytes]: self.download_calls += 1 - self._maybe_sleep() - if self._fail_read is not None: - substring, exc = self._fail_read - if substring is None or any(substring in p for p in remote_paths): - self._fail_read = None - raise exc or DownloadFailed(f"simulated batch download failure: {bucket}") files = self.buckets.get(bucket, {}) return {p: files[p] for p in remote_paths if p in files} def read_central_text(self, path: str) -> str: - self._maybe_fail_read(path) files = self._central() - if path not in files: + # Like HubClient._download_one, a failed read surfaces as missing. + if path not in files or path in self.failing_reads: raise FileNotFoundError(path) return files[path].decode("utf-8") def read_central_bytes_optional(self, path: str) -> bytes | None: + if path in self.failing_reads: + raise RuntimeError(f"hub read failed: {path}") return self._central().get(path) + def read_text_optional(self, uri) -> str | None: + parsed = uri if isinstance(uri, SourceURI) else parse_source_uri(uri) + if parsed is None: + raise ValueError(f"invalid source URI: {uri}") + bucket = f"{parsed.org}/{parsed.bucket}" + if f"{bucket}/{parsed.path}" in self.failing_reads: + raise RuntimeError(f"hub read failed: {bucket}/{parsed.path}") + data = self.buckets.get(bucket, {}).get(parsed.path) + return None if data is None else data.decode("utf-8") + def write_text_central(self, path: str, text: str) -> None: - self.write_bytes_central(path, text.encode("utf-8")) + self._central()[path] = text.encode("utf-8") def write_bytes_central(self, path: str, data: bytes) -> None: - self._maybe_fail_write() self._central()[path] = data def write_many_central(self, items: list[tuple[bytes, str]]) -> None: - self._maybe_fail_write() self.batch_writes.append([p for _, p in items]) for data, p in items: self._central()[p] = data def delete_central(self, path: str) -> None: - self._maybe_fail_write() self.deletes.append(path) self._central().pop(path, None) @@ -206,24 +132,30 @@ class FakeHub: parsed = uri if isinstance(uri, SourceURI) else parse_source_uri(uri) if parsed is None: raise ValueError(f"invalid source URI: {uri}") - self._maybe_fail_read(str(uri)) - files = self.buckets.get(f"{parsed.org}/{parsed.bucket}", {}) - if parsed.path not in files: + bucket = f"{parsed.org}/{parsed.bucket}" + files = self.buckets.get(bucket, {}) + # Like HubClient._download_one, a failed read surfaces as missing. + if parsed.path not in files or f"{bucket}/{parsed.path}" in self.failing_reads: raise FileNotFoundError(str(uri)) return files[parsed.path] def read_text(self, uri) -> str: return self.read_bytes(uri).decode("utf-8") + def append_jsonl_audit(self, path: str, line: str) -> None: + b = self.buckets[self._settings.audit_bucket] + existing = b.get(path, b"") + if existing and not existing.endswith(b"\n"): + existing += b"\n" + b[path] = existing + line.encode("utf-8") + b"\n" + def read_audit_bytes(self, path: str) -> bytes | None: return self.buckets[self._settings.audit_bucket].get(path) def write_bytes_audit(self, path: str, data: bytes) -> None: - self._maybe_fail_write() self.buckets[self._settings.audit_bucket][path] = data def write_bytes_to_bucket(self, bucket: str, path: str, data: bytes) -> None: - self._maybe_fail_write() self.buckets.setdefault(bucket, {})[path] = data def write_text_to_bucket(self, bucket: str, path: str, text: str) -> None: @@ -232,7 +164,6 @@ class FakeHub: def copy_tree_to_central(self, src_bucket: str, src_prefix: str, dest_prefix: str): """Mirror of HubClient.copy_tree_to_central: hash-copy a prefix into the central bucket, yielding (src_rel_path, dest_path, size).""" - self._maybe_fail_write() prefix = src_prefix.rstrip("/") central = self._central() out = [] @@ -248,7 +179,6 @@ class FakeHub: def copy_file_to_central(self, src_bucket: str, src_xet_hash: str, dest_path: str) -> None: """Mirror of HubClient.copy_file_to_central: copy by xet hash (our fake xet_hash is sha256), no listing.""" - self._maybe_fail_write() for data in self.buckets.get(src_bucket, {}).values(): if hashlib.sha256(data).hexdigest() == src_xet_hash: self._central()[dest_path] = data @@ -264,7 +194,8 @@ class FakeHub: self.bucket_owners.setdefault(bucket, self.whoami_user) def write_text_as(self, bucket: str, path: str, text: str, token: str) -> None: - if self.bucket_owners.get(bucket, self.whoami_user) != self.whoami_user: + owner = self.bucket_owners.get(bucket, self.whoami_user) + if owner != self.whoami_user and not self.caller_is_org_admin: raise PermissionError(f"403: {bucket} is not yours") self.caller_writes.append((bucket, path, token)) self.write_text_to_bucket(bucket, path, text) diff --git a/tests/test_agents_api.py b/tests/test_agents_api.py index 78d2511e0d8a1b56aceca6caaa394ba848338981..8d7fba1f7325fc76d0e01de2da6a6c12b7396883 100644 --- a/tests/test_agents_api.py +++ b/tests/test_agents_api.py @@ -1,6 +1,6 @@ import httpx import pytest -from huggingface_hub.errors import HfHubHTTPError +from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError import app.hub as hub_module from app.deps import get_registration_limiter @@ -83,10 +83,112 @@ def test_fresh_registration_provisions_bucket_with_caller_token(env): assert env.hub.buckets[bucket][".bucket-sync-handshake"] == b"test-user" -def test_matching_handshake_needs_no_writes(env): - env.hub.seed(".bucket-sync-handshake", "test-user", bucket="test-org/test-agent-9") +BUCKET = "test-org/test-agent-9" +HANDSHAKE = ".bucket-sync-handshake" + + +def test_matching_handshake_is_still_proved_by_a_caller_write(env): + env.hub.seed(HANDSHAKE, "test-user", bucket=BUCKET) + env.hub.bucket_owners[BUCKET] = "test-user" assert _register(env).status_code == 201 - assert env.hub.created_buckets == [] and env.hub.caller_writes == [] + assert env.hub.created_buckets == [] + assert env.hub.caller_writes == [(BUCKET, HANDSHAKE, "hf_caller")] + + +def test_handshake_planted_in_someone_elses_bucket_is_refused(env): + # An attacker pre-creates the bucket a victim will register and writes the + # victim's public username into it. Matching text is not proof: the + # victim's token cannot write there, so registration must refuse. + env.hub.seed(HANDSHAKE, "test-user", bucket=BUCKET) + env.hub.bucket_owners[BUCKET] = "attacker" + r = _register(env) + assert r.status_code == 403 + assert r.json()["error"]["code"] == "BUCKET_NOT_YOURS" + assert env.hub.caller_writes == [] + assert env.client.get("/v1/agents").json()["count"] == 0 + + +def _victim_state(env): + """Victim's registration of agent-9 and the handshake in its bucket.""" + seed_agent(env.hub, "agent-9", hf_user="victim") + env.hub.seed(HANDSHAKE, "victim", bucket=BUCKET) + env.hub.bucket_owners[BUCKET] = "victim" + env.hub.caller_is_org_admin = True # the caller could write anywhere + return dict(env.hub.buckets[env.settings.central_bucket]), dict(env.hub.buckets[BUCKET]) + + +def _assert_untouched(env, before): + central, bucket = before + assert env.hub.buckets[env.settings.central_bucket] == central + assert env.hub.buckets[BUCKET] == bucket + assert env.hub.caller_writes == [] and env.hub.created_buckets == [] + + +def test_registration_read_outage_aborts_before_provisioning(env): + before = _victim_state(env) + env.hub.failing_reads.add("agents/agent-9.md") + r = _register(env, force=True) + assert r.status_code == 503 + assert r.json()["error"]["code"] == "HUB_UNAVAILABLE" + _assert_untouched(env, before) + + +def test_handshake_read_outage_aborts_before_writing(env): + before = _victim_state(env) + env.hub.buckets[env.settings.central_bucket].pop("agents/agent-9.md") + before = (dict(env.hub.buckets[env.settings.central_bucket]), before[1]) + env.hub.failing_reads.add(f"{BUCKET}/{HANDSHAKE}") + r = _register(env) + assert r.status_code == 503 + _assert_untouched(env, before) + + +def test_foreign_handshake_blocks_even_a_caller_who_could_write(env): + # An unregistered bucket whose handshake names another user is theirs; an + # org admin's token could overwrite it, but registration must not. + before = _victim_state(env) + env.hub.buckets[env.settings.central_bucket].pop("agents/agent-9.md") + before = (dict(env.hub.buckets[env.settings.central_bucket]), before[1]) + r = _register(env) + assert r.status_code == 403 + assert r.json()["error"]["code"] == "BUCKET_NOT_YOURS" + assert "victim" in r.json()["error"]["message"] + _assert_untouched(env, before) + + +SECRET = "hf_SECRETtokenDoNotLeak123" + + +def test_caller_token_never_reaches_responses_or_logs(env, monkeypatch, caplog): + caplog.set_level("DEBUG") + headers = {"authorization": f"Bearer {SECRET}"} + body = {"agent_id": "agent-9", "model": "m", "harness": "h", "tools": []} + post = lambda: env.client.post("/v1/agents/register", json=body, headers=headers) + texts = [] + + env.hub.whoami_fails = True # 401 + texts.append(post()); env.hub.whoami_fails = False + env.hub.whoami_orgs = set() # 403 NOT_ORG_MEMBER + texts.append(post()); env.hub.whoami_orgs = {env.settings.org} + env.hub.failing_reads.add("agents/agent-9.md") # 503 lookup outage + texts.append(post()); env.hub.failing_reads.clear() + + def forbid(bucket, token): + raise PermissionError(f"403 for token {token}") + monkeypatch.setattr(env.hub, "create_bucket_as", forbid) # 403 BUCKET_CREATE_FORBIDDEN + texts.append(post()); monkeypatch.undo() + env.hub.seed(HANDSHAKE, "test-user", bucket=BUCKET) # 403 BUCKET_NOT_YOURS + env.hub.bucket_owners[BUCKET] = "attacker" + texts.append(post()) + env.hub.bucket_owners[BUCKET] = "test-user" # 201, audited + texts.append(post()) + + assert [r.status_code for r in texts] == [401, 403, 503, 403, 403, 201] + for r in texts: + assert SECRET not in r.text + assert SECRET not in caplog.text + audit = b"".join(env.hub.buckets[env.settings.audit_bucket].values()) + assert SECRET.encode() not in audit def test_own_bucket_without_handshake_gets_one(env): @@ -191,3 +293,22 @@ def test_transient_bucket_check_is_503(env, monkeypatch): r = _register(env) assert r.status_code == 503 assert r.json()["error"]["code"] == "HUB_UNAVAILABLE" + + +def test_optional_reads_map_only_missing_entries_to_none(env, monkeypatch): + client = hub_module.HubClient(env.settings) + + def missing(**k): + raise EntryNotFoundError("no such file") + + def outage(**k): + raise _hub_error(500) + + monkeypatch.setattr(hub_module, "download_bucket_files", missing) + assert client.read_text_optional("hf://buckets/test-org/test-agent-9/.bucket-sync-handshake") is None + assert client.read_central_bytes_optional("agents/agent-9.md") is None + monkeypatch.setattr(hub_module, "download_bucket_files", outage) + with pytest.raises(HfHubHTTPError): + client.read_text_optional("hf://buckets/test-org/test-agent-9/.bucket-sync-handshake") + with pytest.raises(HfHubHTTPError): + client.read_central_bytes_optional("agents/agent-9.md") diff --git a/tests/test_audit.py b/tests/test_audit.py deleted file mode 100644 index c2bf0c9091441a622ad410d35d018851e636c61b..0000000000000000000000000000000000000000 --- a/tests/test_audit.py +++ /dev/null @@ -1,93 +0,0 @@ -import json -import logging -from datetime import datetime, timezone - -import pytest - -import app.audit as audit_mod -from app.audit import AuditLogger, event_name -from app.config import Settings -from fakes import FakeHub, seed_agent - - -def audit_objects(env) -> dict[str, dict]: - return { - p: json.loads(b) - for p, b in env.hub.buckets[env.settings.audit_bucket].items() - if p.startswith("audit/") - } - - -def test_event_name_is_a_filename_safe_route_slug(): - assert event_name("/v1/messages") == "messages" - assert event_name("/v1/jobs:run") == "jobs-run" - assert event_name("/v1/channels/{name}/subscribe") == "channels-name-subscribe" - - -def test_one_object_per_event_with_distinct_names_even_in_the_same_ms(env, monkeypatch): - frozen = datetime(2026, 9, 29, 12, 0, 0, 123000, tzinfo=timezone.utc) - monkeypatch.setattr(audit_mod, "utc_now", lambda: frozen) - logger = AuditLogger(env.hub) - for i in range(3): - logger.write( - agent_id=f"agent-{i}", route="/v1/messages", via="raw", source=None, - target_path=None, bytes_count=i, status_code=201, - ) - objs = audit_objects(env) - assert sorted(objs) == [ - "audit/202609/20260929-120000-123_messages.json", - "audit/202609/20260929-120000-124_messages.json", - "audit/202609/20260929-120000-125_messages.json", - ] - assert [objs[p]["agent_id"] for p in sorted(objs)] == ["agent-0", "agent-1", "agent-2"] - - -def test_each_post_writes_its_own_audit_object(env): - seed_agent(env.hub, "agent-1") - for body in ("one", "two"): - r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": body}) - assert r.status_code == 201 - objs = audit_objects(env) - assert len(objs) == 2 - assert all(p.endswith("_messages.json") for p in objs) - assert {o["route"] for o in objs.values()} == {"/v1/messages"} - - -def test_failed_audit_write_does_not_fail_the_request(env, monkeypatch, caplog): - seed_agent(env.hub, "agent-1") - write_audit = env.hub.write_bytes_audit - - def failing_audit_write(path, data): - env.hub.fail_next_write() # only the audit write fails, not the message - return write_audit(path, data) - - monkeypatch.setattr(env.hub, "write_bytes_audit", failing_audit_write) - with caplog.at_level(logging.WARNING, logger="app.audit"): - r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "hi"}) - assert r.status_code == 201 - assert f"message_board/{r.json()['filename']}" in env.hub.buckets[env.settings.central_bucket] - assert audit_objects(env) == {} - assert any( - rec.levelno == logging.WARNING and "messages" in rec.getMessage() - for rec in caplog.records - ) - - -@pytest.mark.parametrize( - "audit_bucket, in_org", - [("test-org/test-audit", True), ("test-org-admin/test-audit", False)], -) -def test_caller_ip_omitted_when_audit_bucket_is_in_challenge_org(audit_bucket, in_org): - settings = Settings(ORG="test-org", COLLAB_SLUG="test", AUDIT_BUCKET=audit_bucket) - assert settings.audit_bucket_in_org is in_org - hub = FakeHub(settings) - AuditLogger(hub, record_client=not settings.audit_bucket_in_org).write( - agent_id="agent-1", route="/v1/sync", via=None, source=None, - target_path=None, bytes_count=0, status_code=200, - caller_ip="203.0.113.7", user_agent="curl/8", - ) - (log,) = hub.buckets[audit_bucket].values() - record = json.loads(log) - assert record["agent_id"] == "agent-1" - assert ("caller_ip" in record) is not in_org - assert ("user_agent" in record) is not in_org diff --git a/tests/test_channels_api.py b/tests/test_channels_api.py index 397be79fe5a0ba04df1ef90652d6bea2ce494684..919742f6614b41726dd7c3887f28958fca4cea11 100644 --- a/tests/test_channels_api.py +++ b/tests/test_channels_api.py @@ -284,9 +284,7 @@ def test_channel_and_broadcast_mutually_exclusive(env): "/v1/messages", json={"agent_id": "bb", "body": "x", "channel": "evals", "broadcast": True}, ) - assert r.status_code == 400 - assert r.json()["error"]["code"] == "INVALID_REQUEST" - assert "mutually exclusive" in r.json()["error"]["message"] + assert r.status_code == 422 def test_source_channel_frontmatter_rejected(env): diff --git a/tests/test_client_api.py b/tests/test_client_api.py index 5d7f5136c57c1fd73b704a2901cc46f24990103d..3de80a231c6313ec35477b19a4f68415abd5eb38 100644 --- a/tests/test_client_api.py +++ b/tests/test_client_api.py @@ -1,6 +1,6 @@ -"""GET /v1/watch.sh and /v1/share_trace.py — self-distribution of clients/. +"""GET /v1/watch.sh — self-distribution of clients/collab_watch.sh. -The scripts are served from disk rather than baked in, so this also guards the +The script is served from disk rather than baked in, so this also guards the packaging: if a redeploy forgets to ship clients/ next to app/, the route 404s and this test is what catches it. """ @@ -35,13 +35,3 @@ def test_watch_script_is_served_verbatim(env): resp = env.client.get("/v1/watch.sh") assert resp.content == _SCRIPT_PATH.read_bytes() - - -def test_share_trace_client_is_served_verbatim(env): - from app.routes.client import _SHARE_TRACE_PATH - - resp = env.client.get("/v1/share_trace.py") - assert resp.status_code == 200 - assert "text/x-python" in resp.headers["content-type"] - assert resp.content == _SHARE_TRACE_PATH.read_bytes() - compile(resp.text, "share_trace.py", "exec") # agents run it straight away diff --git a/tests/test_collab_watch.py b/tests/test_collab_watch.py index f161de8f43190683569b4da2f22b6732c9d3f78d..e53826e2c1cbee72c948aa9684c5a2f074df6fab 100644 --- a/tests/test_collab_watch.py +++ b/tests/test_collab_watch.py @@ -7,7 +7,7 @@ top-level ``cursor``, and a ``watch`` block whenever ``wait>0`` was requested. Why a stub instead of the FastAPI app (which is how eq2 tested this): the client contract is what is under test here, and half of it only exists in conditions a healthy server will not produce on demand — a 4xx on a typo'd -handle, a 5xx outage, an instantly-empty *degraded* answer, a page whose +handle, ten 5xx in a row, an instantly-empty *degraded* answer, a page whose record content is deliberately shaped like a cursor. The stub makes each of those a one-line setting, keeps the suite honest about what the *client* does, and leaves it runnable while the server side is still being written. @@ -67,8 +67,6 @@ class Stub: omit_cursor: bool = False # pretend the server predates §4.4 matched_override: int | None = None # lie about `matched` grow_polls: int = 0 # land fresh mail during the next N polls - fail_first: int = 0 # answer the next N requests with fail_status - fail_status: int = 503 _n: int = 0 def add(self, body: str = "ping @agent-a", author: str = "agent-b", @@ -143,9 +141,6 @@ class _Handler(BaseHTTPRequestHandler): with stub.lock: stub.requests.append((parsed.path, query)) forced, err = stub.http_status, stub.error_body - if not forced and stub.fail_first > 0: - stub.fail_first -= 1 - forced = stub.fail_status grow = stub.grow_polls > 0 if grow: stub.grow_polls -= 1 @@ -258,9 +253,8 @@ def popen(stub: Stub, state: Path | None, *flags: str, handle: str = "agent-a", def stop(proc: subprocess.Popen, timeout: float = 10) -> tuple[str, str]: """Terminate a watcher and collect its output. The script traps TERM and - exits through its cleanup path at once: curl and sleeps run in the - background under `wait`, which a trapped signal interrupts (only an --exec - handler still delays the trap).""" + exits through its cleanup path, but a shell only runs the trap once the + foreground curl returns — hence the tiny waits everywhere.""" if proc.poll() is None: proc.terminate() try: @@ -635,39 +629,18 @@ def test_3xx_fails_fast_with_a_redirect_hint(stub, tmp_path): assert heartbeat(state)[1] == "http_301" -def test_retry_budget_gives_up_with_exit_4_and_gave_up_heartbeat(stub, tmp_path): - """5xx shares the backoff ladder; once the failures in a row span - COLLAB_WATCH_RETRY_BUDGET_S it exits 4 and leaves status=gave_up behind, - so --status can report it once the process is gone.""" +def test_ten_failures_give_up_with_exit_4_and_gave_up_heartbeat(stub, tmp_path): + """5xx shares the backoff ladder; a streak of 10 exits 4 and leaves + status=gave_up behind, so --status can report it once the process is gone.""" stub.http_status = 503 state = fresh(tmp_path, cursor="") - t0 = time.monotonic() - - result = run(stub, state, timeout=60, COLLAB_WATCH_RETRY_BUDGET_S="1") - elapsed = time.monotonic() - t0 - - assert result.returncode == 4, result.stderr - assert elapsed < 4, f"a 1s budget took {elapsed:.1f}s" - assert "giving up after" in result.stderr - assert heartbeat(state)[1] == "gave_up" - - -@pytest.mark.parametrize("status", [502, 503, 504]) -def test_an_edge_outage_is_outlasted_not_counted(stub, tmp_path, status): - """A Space rebuild answers 502/503/504 from the edge for longer than any - fixed retry count survives; the budget is time, so the watcher keeps going - well past 10 failures and delivers once the Space is back.""" - stub.fail_first = 15 - stub.fail_status = status - state = fresh(tmp_path, cursor="") - filename = stub.add() result = run(stub, state, timeout=60) - assert result.returncode == 0, result.stderr - assert stub.n_requests() == 16 - assert [i["filename"] for i in json.loads(result.stdout)["items"]] == [filename] - + assert result.returncode == 4 + assert stub.n_requests() == 10 + assert "giving up after 10" in result.stderr + assert heartbeat(state)[1] == "gave_up" 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): def test_max_wait_exits_3_on_a_clean_no_mail_timeout(stub, tmp_path): """Exit 3 exists so a bounded wait that found nothing is distinguishable - from a watcher that was killed.""" + from a watcher that was killed. + + The elapsed assertion is the contract, not a tolerance: N is a FLOOR, so + exit 3 must never fire before it. `date +%s` truncates to whole seconds, so + a deadline computed naively from it lands up to a second early — which this + assertion caught intermittently before the script padded for it.""" state = fresh(tmp_path, cursor="") + t0 = time.monotonic() + result = run(stub, state, "--max-wait", "2", timeout=60) + elapsed = time.monotonic() - t0 assert result.returncode == 3 assert result.stdout.strip() == "" + assert elapsed >= 2.0, f"gave up after {elapsed:.2f}s of a 2s floor" + assert elapsed < 12, f"overshot the bound by too much ({elapsed:.2f}s)" assert "clean timeout" in result.stderr assert heartbeat(state)[1] == "no_mail" -def test_max_wait_is_a_ceiling_not_a_floor(stub, tmp_path): - """N is sized to fit a shell tool's timeout, so the run must end within N - even when the per-request wait window (55s here) is longer than N: each - request is clamped to the time left instead of overshooting by a window.""" - state = fresh(tmp_path, cursor="") - t0 = time.monotonic() - - result = run(stub, state, "--max-wait", "3", wait="55", timeout=60) - elapsed = time.monotonic() - t0 - - assert result.returncode == 3, result.stderr - assert elapsed < 3 + 1.5, f"overshot a 3s ceiling ({elapsed:.2f}s)" - assert elapsed >= 1.5, f"gave up far too early ({elapsed:.2f}s)" - assert all(float(q["wait"]) <= 3 for _p, q in stub.requests) - - -def test_max_wait_ceiling_holds_while_the_server_is_down(tmp_path): - """Retry backoff must not carry a bounded run past N either; running out - of time mid-retry is exit 4 (unreachable), never a false "no mail".""" - state = tmp_path / "state" - t0 = time.monotonic() - result = subprocess.run( - ["sh", SCRIPT, "http://127.0.0.1:1", "agent-a", "--max-wait", "3"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=script_env(state, backoff="2"), timeout=60, - ) - elapsed = time.monotonic() - t0 - - assert result.returncode == 4, result.stderr - assert elapsed < 3 + 1.5, f"overshot a 3s ceiling ({elapsed:.2f}s)" - assert "--max-wait 3s is up" in result.stderr - - -def test_after_overrides_the_saved_cursor(stub, tmp_path): - """--after resumes from a cursor the agent got elsewhere (the digest's - watching.last_cursor) — e.g. after its state directory was wiped.""" - state = fresh(tmp_path) # no cursor file: would otherwise baseline - first = stub.add() - second = stub.add() - - result = run(stub, state, "--after", first, "--max-wait", "5") - - assert result.returncode == 0, result.stderr - assert [i["filename"] for i in json.loads(result.stdout)["items"]] == [second] - assert (state / "cursor.updates").read_text().strip() == second - - def test_max_wait_still_delivers_when_mail_arrives(stub, tmp_path): state = fresh(tmp_path, cursor="") filename = stub.add() @@ -941,7 +877,7 @@ def test_second_watcher_exits_5_naming_the_live_pid(stub, tmp_path): try: pid_file = state / "lock" / "pid" assert wait_until(pid_file.exists), "no lock was taken" - assert pid_file.read_text().partition("\n")[0] == str(first.pid) + assert pid_file.read_text().strip() == str(first.pid) second = run(stub, state, timeout=20) assert second.returncode == 5 @@ -953,28 +889,7 @@ def test_second_watcher_exits_5_naming_the_live_pid(stub, tmp_path): "the lock must be released on exit" -def test_term_while_parked_exits_at_once_and_releases_the_lock(stub, tmp_path): - """A TERM must not wait for the parked curl (up to 75s): until the process - exits, its lock reads as live and a replacement watcher exits 5.""" - state = fresh(tmp_path, cursor="") - proc = popen(stub, state, wait="55") - try: - assert wait_until(lambda: stub.n_requests() >= 1), "never parked" - time.sleep(0.3) # let curl settle into the parked request - t0 = time.monotonic() - proc.terminate() - proc.communicate(timeout=10) - elapsed = time.monotonic() - t0 - finally: - if proc.poll() is None: - stop(proc) - assert proc.returncode == 143 - assert elapsed < 2, f"TERM took {elapsed:.1f}s: the trap waited for curl" - assert not (state / "lock").exists(), "the lock must be released" - - def test_stale_lock_is_reclaimed(stub, tmp_path): - """A lock whose PID is gone (kill -9, harness reaping) is not a wall.""" reaped = subprocess.Popen(["true"]) reaped.wait() @@ -990,44 +905,7 @@ def test_stale_lock_is_reclaimed(stub, tmp_path): assert (state / "cursor.updates").read_text().strip() == filename -def test_a_reused_pid_is_a_stale_lock(stub, tmp_path): - """`kill -0` succeeds for ANY process with that pid — a reused one, or one - in another PID namespace sharing the state dir — so lock/pid records the - owner's start time and a live pid started at another time is stale.""" - impostor = subprocess.Popen(["sleep", "30"]) - try: - state = fresh(tmp_path, cursor="") - (state / "lock").mkdir() - (state / "lock" / "pid").write_text(f"{impostor.pid}\nnot-its-start-time\n") - filename = stub.add() - - result = run(stub, state) - - assert result.returncode == 0, result.stderr - assert "stale lock" in result.stderr - assert (state / "cursor.updates").read_text().strip() == filename - finally: - impostor.terminate() - impostor.wait() - - -def test_the_lock_records_its_owners_start_time(stub, tmp_path): - state = fresh(tmp_path, cursor="") - watcher = popen(stub, state) - try: - pid_file = state / "lock" / "pid" - assert wait_until(lambda: pid_file.exists() and pid_file.read_text().count("\n") == 2) - pid, start = pid_file.read_text().splitlines() - assert pid == str(watcher.pid) - assert start.strip(), "no start time recorded (this host has /proc)" - second = run(stub, state, timeout=20) - assert second.returncode == 5, "a matching start time is a live lock" - finally: - stop(watcher) - - def test_the_lock_is_per_handle_and_status_is_stream_aware(stub, tmp_path): - """The lock and heartbeat are per-HANDLE while cursors are per-stream: one watcher per agent is the whole point of the unified `updates` stream. @@ -1085,8 +963,6 @@ def test_status_10_outranks_a_watcher_on_another_stream(stub, tmp_path): fields = _status_line(result) assert fields["STATUS"] == "BEHIND" and fields["UNREAD"] == "1" assert fields["STREAM"] == "updates" - assert "agent-a feed --max-wait 5" in result.stderr - finally: alive.terminate() alive.wait() @@ -1145,7 +1021,7 @@ def test_exit_does_not_remove_a_lock_that_is_no_longer_ours(stub, tmp_path): try: pid_file = state / "lock" / "pid" assert wait_until(lambda: pid_file.exists() - and pid_file.read_text().partition("\n")[0] == str(watcher.pid)), \ + and pid_file.read_text().strip() == str(watcher.pid)), \ "the watcher never took the lock" pid_file.write_text(f"{other.pid}\n") # another watcher now owns it @@ -1235,12 +1111,6 @@ def test_status_10_when_behind_outranks_liveness(stub, tmp_path): assert fields["PID"] == "-" assert fields["LAST"] == "gave_up", "the give-up must survive the process" assert int(fields["HEARTBEAT_AGE"].rstrip("s")) >= 412 - command = f"sh {SCRIPT} {stub.base_url} agent-a --max-wait 5" - assert command in result.stderr, "BEHIND must name the command that reads it" - t0 = time.monotonic() - read = run(stub, state, "--max-wait", "5") - assert read.returncode == 0 and len(json.loads(read.stdout)["items"]) == 3 - assert time.monotonic() - t0 < 3, "pending mail must come back at once" 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): (["http://127.0.0.1:1", "agent-a", "--max-wait"], "--max-wait needs"), (["http://127.0.0.1:1", "agent-a", "--exec"], "--exec needs"), (["http://127.0.0.1:1", "agent-a", "--peek", "--max-wait", "5"], "wait mode only"), - (["http://127.0.0.1:1", "agent-a", "--after", "latest"], "--after must be"), - (["http://127.0.0.1:1", "agent-a", "--status", "--after", - "20260728-120000-000_agent-b.md"], "wait and --exec modes only"), (["http://127.0.0.1:1", "agent-a", "extra", "updates", "x"], "unexpected argument"), (["ftp://nope", "agent-a"], "must start with http"), (["http://127.0.0.1:1", "../etc/passwd"], "characters outside"), @@ -1380,7 +1247,7 @@ def test_help_exits_0_on_stdout(): stderr=subprocess.PIPE, text=True, timeout=20) assert result.returncode == 0 assert "exit codes:" in result.stdout - for flag in ("--max-wait", "--after", "--exec", "--peek", "--status"): + for flag in ("--max-wait", "--exec", "--peek", "--status"): assert flag in result.stdout for warning in ("do NOT wrap", "do NOT detach", "AT-LEAST-ONCE"): assert warning in result.stdout, f"the header must keep documenting: {warning}" diff --git a/tests/test_digest_api.py b/tests/test_digest_api.py index d68a86f18bd8aa69ac774e8359dc4035740a2a4a..aa3dcff8fa117b57d93a8cf3e4770c3b57e464c6 100644 --- a/tests/test_digest_api.py +++ b/tests/test_digest_api.py @@ -1,6 +1,5 @@ import json -from app.frontmatter import serialise from fakes import seed_agent, seed_message, seed_result @@ -72,16 +71,6 @@ def test_discovery_root(env): assert "mentions" in data["conventions"] -def test_discovery_limits_come_from_settings(make_env): - env = make_env(RAW_MESSAGE_PER_HOUR=7, MESSAGE_MAX_BYTES=1234, MENTION_FANOUT_CAP=3) - limits = env.client.get("/v1").json()["limits"] - assert limits["raw_messages_per_hour_per_agent"] == 7 - assert limits["source_max_bytes"] == 1234 - assert limits["mention_fanout_cap"] == 3 - assert limits["raw_body_max_chars"] == 32 * 1024 - assert limits["expand_max_limit"] == env.settings.expand_max_limit - - # ── watch blocks (WATCH_DESIGN.md §4.5) ─────────────────────────────── import threading @@ -147,7 +136,7 @@ def test_digest_updates_is_cursor_aware_via_after(env): cursor = env.client.get("/v1/updates?as=watcher").json()["cursor"] env.client.post("/v1/messages", json={"agent_id": "poster", "body": "two @watcher"}) - assert env.client.get("/v1/digest?as=watcher&after=").json()["updates"]["unread"] == 2 + assert env.client.get("/v1/digest?as=watcher").json()["updates"]["unread"] == 2 caught_up = env.client.get(f"/v1/digest?as=watcher&after={cursor}").json() assert caught_up["updates"]["unread"] == 1 # Fully drained. @@ -155,18 +144,6 @@ def test_digest_updates_is_cursor_aware_via_after(env): assert env.client.get(f"/v1/digest?as=watcher&after={newest}").json()["updates"]["unread"] == 0 -def test_digest_without_after_counts_from_the_server_last_cursor(env): - """An agent that lost its state reads the digest without after=: mail the - watcher already delivered must not read as unread.""" - seed_agent(env.hub, "watcher") - seed_agent(env.hub, "poster") - env.client.post("/v1/messages", json={"agent_id": "poster", "body": "one @watcher"}) - assert env.client.get("/v1/digest?as=watcher").json()["updates"]["unread"] == 1 - assert len(env.client.get("/v1/updates?as=watcher").json()["items"]) == 1 - - assert env.client.get("/v1/digest?as=watcher").json()["updates"]["unread"] == 0 - - def test_digest_updates_is_zero_for_a_quiet_handle(env): seed_agent(env.hub, "watcher") data = env.client.get("/v1/digest?as=watcher").json() @@ -180,80 +157,31 @@ def test_digest_watching_is_null_until_someone_watches(env): assert env.client.get("/v1/digest?as=watcher").json()["watching"] is None -def test_a_digest_read_counts_as_a_poll_but_does_not_answer_itself(env): - """The digest reports presence as it stood BEFORE the read, then stamps it: - the next digest sees the previous one.""" +def test_digest_watching_appears_after_a_wait_poll(env): + """A wait>0 poll stamps the handle's presence; a plain wait=0 poll does + not — polling is not watching.""" seed_agent(env.hub, "watcher") - assert env.client.get("/v1/digest?as=watcher").json()["watching"] is None - block = env.client.get("/v1/digest?as=watcher").json()["watching"] - assert block["mode"] == "poll" and block["stream"] == "digest" - -def test_digest_watching_records_parked_and_plain_reads(env): - """Any /v1/updates read stamps presence — a synchronous `--max-wait` caller - is present between calls — and `mode` says whether it parked.""" - seed_agent(env.hub, "watcher") - - env.client.get("/v1/updates?as=watcher") # wait=0 - block = env.client.get("/v1/digest?as=watcher").json()["watching"] - assert (block["mode"], block["stream"]) == ("poll", "updates") + env.client.get("/v1/updates?as=watcher") # wait=0: not a watch + assert env.client.get("/v1/digest?as=watcher").json()["watching"] is None env.client.get("/v1/updates?as=watcher&wait=0.15") - block = env.client.get("/v1/digest?as=watcher").json()["watching"] - assert (block["mode"], block["stream"]) == ("parked", "updates") - assert block["last_poll_age_s"] >= 0 - - -def test_digest_watching_reports_last_cursor(env): - """The state-loss safety net: the newest after= cursor the handle sent comes - back as last_cursor, and a later read without after= does not erase it.""" - seed_agent(env.hub, "watcher") - cursor = "20260728-120000-000_agent-b.md" - env.client.get(f"/v1/updates?as=watcher&after={cursor}&wait=0.05") - env.client.get("/v1/updates?as=watcher") # no cursor: keeps the last one - - block = env.client.get("/v1/digest?as=watcher").json()["watching"] - assert block["last_cursor"] == cursor - - -def test_digest_watching_records_the_cursor_handed_out(env): - """A watcher that baselined with an empty cursor never SENT one; the cursor - the server handed it is what it must resume from.""" - seed_agent(env.hub, "watcher") - seed_agent(env.hub, "poster") - r = env.client.post("/v1/messages", json={"agent_id": "poster", "body": "ping @watcher"}) - assert r.status_code == 201, r.text - - page = env.client.get("/v1/updates?as=watcher").json() # wait=0, no after= - assert len(page["items"]) == 1 and page["cursor"] - - block = env.client.get("/v1/digest?as=watcher").json()["watching"] - assert block["last_cursor"] == page["cursor"] - - -def test_a_digest_read_does_not_hide_a_parked_watcher(env): - """mode follows the last PARKED poll, not the last read: a digest between - two parks still reports parked, with the parked poll's stream.""" - seed_agent(env.hub, "watcher") - env.client.get("/v1/updates?as=watcher&wait=0.05") - env.client.get("/v1/digest?as=watcher") - - block = env.client.get("/v1/digest?as=watcher").json()["watching"] - assert (block["mode"], block["stream"]) == ("parked", "updates") + data = env.client.get("/v1/digest?as=watcher").json() + assert data["watching"]["mode"] == "updates" + assert data["watching"]["last_poll_age_s"] >= 0 -def test_digest_watching_reports_the_stream(env): +def test_digest_watching_reports_the_mode(env): """inbox / feed / updates are distinguishable, so an organizer can see WHAT an agent is watching, not just that it is alive.""" seed_agent(env.hub, "watcher") env.client.get("/v1/inbox/watcher?wait=0.1") - block = env.client.get("/v1/digest?as=watcher").json()["watching"] - assert (block["mode"], block["stream"]) == ("parked", "inbox") + assert env.client.get("/v1/digest?as=watcher").json()["watching"]["mode"] == "inbox" _create_channel(env, "room") _subscribe(env, "room", "watcher") env.client.get("/v1/channels/feed?as=watcher&wait=0.1") - assert env.client.get("/v1/digest?as=watcher").json()["watching"]["stream"] == "feed" + assert env.client.get("/v1/digest?as=watcher").json()["watching"]["mode"] == "feed" 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): else: time.sleep(0.01) assert seen is not None, "watch presence never became visible" - assert (seen["mode"], seen["stream"]) == ("parked", "updates") + assert seen["mode"] == "updates" finally: t.join(timeout=5) @@ -307,24 +235,3 @@ def test_discovery_documents_watching(env): assert "wait=" in polling and "watch.sh" in polling # The matched-vs-len(items) trap that produced a false "up to date". assert "len(items)" in polling - - -def test_digest_stats_present_with_zeros_before_any_trace(env): - seed_collab(env.hub) - data = env.client.get("/v1/digest").json() - assert data["stats"] == {"total_tokens": 0, "sessions_counted": 0, "agents_reporting": 0} - assert data["you"] is None - - -def test_digest_you_reports_the_callers_traces(env): - seed_collab(env.hub) - env.hub.seed( - "traces/agent-1/s1/manifest.md", - serialise({"promoted_at": "2026-06-05 10:00 UTC", "usage": {"total_tokens": 7}}, ""), - ) - data = env.client.get("/v1/digest?as=agent-1").json() - assert data["you"]["traces"] == {"sessions": 1, "last_shared_at": "2026-06-05 10:00 UTC"} - assert data["stats"] == {"total_tokens": 7, "sessions_counted": 1, "agents_reporting": 1} - assert env.client.get("/v1/digest?as=agent-2").json()["you"] == { - "traces": {"sessions": 0, "last_shared_at": None} - } diff --git a/tests/test_fakes.py b/tests/test_fakes.py deleted file mode 100644 index 984a1af80e62ba51c4543e1d58d614530a77625c..0000000000000000000000000000000000000000 --- a/tests/test_fakes.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Tests for FakeHub's chaos-injection toggles: each one fires once, on the -next matching call, then resets to normal behaviour.""" -import pytest -from huggingface_hub.errors import HfHubHTTPError - -from app.config import Settings -from app.hub import ListingFailed -from fakes import FakeHub, seed_message - - -def make_hub(**overrides) -> FakeHub: - settings = Settings( - HF_TOKEN="test-token", - ORG="test-org", - COLLAB_SLUG="test", - AUDIT_BUCKET="auditor/test-audit", - **overrides, - ) - return FakeHub(settings) - - -def test_fail_next_write_fires_once_then_resets(): - hub = make_hub() - hub.fail_next_write() - with pytest.raises(HfHubHTTPError): - hub.write_text_central("agents/a.md", "hi") - assert "agents/a.md" not in hub._central() - - hub.write_text_central("agents/a.md", "hi") # toggle reset: this succeeds - assert hub._central()["agents/a.md"] == b"hi" - - -def test_fail_next_read_matches_substring_then_resets(): - hub = make_hub() - seed_message(hub, "20260601-100000-000", "agent-1", "hello") - path = "message_board/20260601-100000-000_agent-1.md" - - hub.fail_next_read(path_substring="results/") # doesn't match this path - assert hub.read_central_text(path) # passes through untouched - - hub.fail_next_read(path_substring="message_board/") - with pytest.raises(FileNotFoundError): - hub.read_central_text(path) - - assert hub.read_central_text(path) # toggle reset: this succeeds - - -def test_partial_listing_raises_instead_of_returning_a_prefix_then_resets(): - hub = make_hub() - for i in range(3): - seed_message(hub, f"2026060{i + 1}-100000-000", "agent-1", f"msg {i}") - - hub.partial_listing("message_board", drop=1) - with pytest.raises(ListingFailed): - hub.list_central_dir("message_board") - - assert len(hub.list_central_dir("message_board")) == 3 # toggle reset - - -def test_fail_next_listing_raises_then_resets(): - hub = make_hub() - seed_message(hub, "20260601-100000-000", "agent-1", "hello") - - hub.fail_next_listing("message_board") - with pytest.raises(ListingFailed): - hub.list_central_dir("message_board") - - assert len(hub.list_central_dir("message_board")) == 1 # toggle reset diff --git a/tests/test_health_api.py b/tests/test_health_api.py deleted file mode 100644 index 2251ab990e950f6016ed25f0700bf34b10a6a646..0000000000000000000000000000000000000000 --- a/tests/test_health_api.py +++ /dev/null @@ -1,64 +0,0 @@ -"""/v1/healthz: the warm flag and the read-model gauges.""" -from __future__ import annotations - -import time - -from fastapi.testclient import TestClient - -from app.main import WARM_FOLDERS, app -from fakes import seed_agent, seed_message - - -def test_healthz_is_200_and_cold_until_warm_up_completes(env): - seed_agent(env.hub, "agent-1") - r = env.client.get("/v1/healthz") - assert r.status_code == 200 and r.json()["warm"] is False - env.read_model.warm_up(WARM_FOLDERS) - assert env.client.get("/v1/healthz").json()["warm"] is True - - -def test_warm_up_fills_the_caches(env): - seed_agent(env.hub, "agent-1") - seed_message(env.hub, "20260601-100000-000", "agent-1", "hi") - env.read_model.warm_up(WARM_FOLDERS) - listed, downloaded = env.hub.list_calls, env.hub.download_calls - assert env.client.get("/v1/messages?expand=true").json()["count"] == 1 - assert (env.hub.list_calls, env.hub.download_calls) == (listed, downloaded) - - -def test_warm_up_retries_a_failing_folder_before_reporting_warm(env): - seed_agent(env.hub, "agent-1") - env.hub.fail_next_listing("agents") - env.read_model.warm_up(WARM_FOLDERS, retry_s=0) - assert env.read_model.warm is True - assert env.read_model.registered_agents() == {"agent-1"} - - -def test_lifespan_warms_the_read_model_in_the_background(env): - seed_agent(env.hub, "agent-1") - with TestClient(app) as client: # runs the lifespan - deadline = time.monotonic() + 5 - while not client.get("/v1/healthz").json()["warm"]: - assert time.monotonic() < deadline, "warm-up never completed" - time.sleep(0.01) - - -def test_healthz_read_model_gauges(make_env): - env = make_env(LISTING_TTL_S=0) - seed_message(env.hub, "20260601-100000-000", "agent-1", "hi") - env.client.get("/v1/messages?expand=true") - gauges = env.client.get("/v1/healthz").json()["read_model"] - assert gauges["folders"] >= 1 - assert gauges["content_cache_bytes"] > 0 - assert gauges["listing_errors"] == {} - - # A warm folder whose listing fails is served from cache — and named here. - env.hub.fail_next_listing("message_board") - assert env.client.get("/v1/messages").json()["count"] == 1 - errors = env.client.get("/v1/healthz").json()["read_model"]["listing_errors"] - assert set(errors) == {"message_board"} - assert "simulated" in errors["message_board"]["error"] - assert errors["message_board"]["age_s"] >= 0 - - env.client.get("/v1/messages") # the next listing succeeds and clears it - assert env.client.get("/v1/healthz").json()["read_model"]["listing_errors"] == {} diff --git a/tests/test_hub_client.py b/tests/test_hub_client.py deleted file mode 100644 index 296498d46a448dd5f4ad8c58df36735329eb5fbe..0000000000000000000000000000000000000000 --- a/tests/test_hub_client.py +++ /dev/null @@ -1,79 +0,0 @@ -"""HubClient's read contract against a stubbed huggingface_hub: a listing or -batch download either completes or raises — never a silent subset.""" -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -import httpx -import pytest -from huggingface_hub.errors import HfHubHTTPError - -from app import hub as hub_mod -from app.config import Settings -from app.hub import DownloadFailed, HubClient, ListingFailed - - -def _client() -> HubClient: - return HubClient( - Settings( - HF_TOKEN="test-token", - ORG="test-org", - COLLAB_SLUG="test", - AUDIT_BUCKET="auditor/test-audit", - ) - ) - - -def _http_error() -> HfHubHTTPError: - request = httpx.Request("GET", "https://fake-hub.test/tree") - return HfHubHTTPError("page 3 failed", response=httpx.Response(500, request=request)) - - -def _entry(path: str): - return SimpleNamespace(type="file", path=path, size=1, xet_hash=path) - - -def test_listing_interrupted_mid_pagination_raises(monkeypatch): - def tree(**_kw): - yield _entry("message_board/a.md") - yield _entry("message_board/b.md") - raise _http_error() - - monkeypatch.setattr(hub_mod, "list_bucket_tree", tree) - with pytest.raises(ListingFailed): - _client().list_central_dir("message_board") - # Scratch-bucket callers keep their [] contract, but never get the prefix. - assert _client().list_bucket_dir("test-org/test-agent-1", "message_board") == [] - - -def test_download_many_skips_only_genuinely_missing_files(monkeypatch): - def download(bucket_id, files, raise_on_missing_files, token): - assert raise_on_missing_files is False # missing files are skipped, not raised - for remote, local in files: - if remote != "gone.md": - Path(local).write_bytes(remote.encode()) - - monkeypatch.setattr(hub_mod, "download_bucket_files", download) - assert _client().download_many("b", ["a.md", "gone.md"]) == {"a.md": b"a.md"} - - -def test_download_many_retries_once_then_raises(monkeypatch): - calls = [] - - def flaky(bucket_id, files, raise_on_missing_files, token): - calls.append(1) - if len(calls) == 1: - raise _http_error() - for remote, local in files: - Path(local).write_bytes(b"x") - - monkeypatch.setattr(hub_mod, "download_bucket_files", flaky) - assert _client().download_many("b", ["a.md"]) == {"a.md": b"x"} - - def down(**_kw): - raise _http_error() - - monkeypatch.setattr(hub_mod, "download_bucket_files", down) - with pytest.raises(DownloadFailed): - _client().download_many("b", ["a.md"]) diff --git a/tests/test_hub_errors.py b/tests/test_hub_errors.py deleted file mode 100644 index fc500c17b19ebe8f8ac579c456bf93caff1804ae..0000000000000000000000000000000000000000 --- a/tests/test_hub_errors.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Agent-facing errors at the hub boundary: every failure is the standard -{"error": {code, message, hint?}} body, never a bare 500 or a FastAPI 422.""" -from __future__ import annotations - -from fakes import seed_agent - - -SOURCE = "hf://buckets/test-org/test-agent-1/drafts/note.md" -RESULT = "hf://buckets/test-org/test-agent-1/results/run1.md" - - -def _error(r, status: int, code: str) -> dict: - assert r.status_code == status, r.text - err = r.json()["error"] - assert err["code"] == code and err["message"] - return err - - -def test_missing_message_source_is_404_with_upload_hint(env): - seed_agent(env.hub, "agent-1") - r = env.client.post("/v1/messages", json={"source": SOURCE}) - err = _error(r, 404, "SOURCE_NOT_FOUND") - assert SOURCE in err["message"] - assert "hf buckets cp" in err["hint"] - - -def test_failed_source_read_is_404(env): - seed_agent(env.hub, "agent-1") - env.hub.seed("results/run1.md", "---\nscore: 1\n---\nx", bucket="test-org/test-agent-1") - env.hub.fail_next_read("results/run1.md") - _error(env.client.post("/v1/results", json={"source": RESULT}), 404, "SOURCE_NOT_FOUND") - - -def test_binary_source_is_400(env): - seed_agent(env.hub, "agent-1") - env.hub.buckets["test-org/test-agent-1"] = {"drafts/note.md": b"\xff\xfe\x00binary"} - for route, uri in [("/v1/messages", SOURCE), ("/v1/results", SOURCE)]: - err = _error(env.client.post(route, json={"source": uri}), 400, "INVALID_FRONTMATTER") - assert "UTF-8" in err["message"] - - -def test_oversized_source_is_413_naming_the_limit(make_env): - env = make_env(MESSAGE_MAX_BYTES=100) - seed_agent(env.hub, "agent-1") - env.hub.seed("drafts/note.md", "x" * 101, bucket="test-org/test-agent-1") - err = _error(env.client.post("/v1/messages", json={"source": SOURCE}), 413, "TOO_LARGE") - assert "100" in err["message"] - - -def test_hub_write_failure_is_503_with_retry_after(env): - seed_agent(env.hub, "agent-1") - env.hub.fail_next_write() - r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "hi"}) - err = _error(r, 503, "STORAGE_UNAVAILABLE") - assert "nothing was written" in err["message"] - assert r.headers["retry-after"] == "5" - assert env.client.get("/v1/messages").json()["count"] == 0 - - -def test_missing_required_fields_is_400_listing_each(env): - r = env.client.post("/v1/agents/register", json={"agent_id": "agent-1"}) - err = _error(r, 400, "INVALID_REQUEST") - lines = err["message"].splitlines() - assert "body.model: Field required" in lines - assert "body.harness: Field required" in lines - - -def test_unknown_field_is_400_naming_it(env): - seed_agent(env.hub, "agent-1") - r = env.client.post( - "/v1/messages", json={"agent_id": "agent-1", "body": "hi", "mentions": ["agent-2"]} - ) - err = _error(r, 400, "INVALID_REQUEST") - assert "body.mentions: Extra inputs are not permitted" in err["message"] - - -def test_oversized_raw_body_is_413(env): - seed_agent(env.hub, "agent-1") - r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "x" * 40_000}) - err = _error(r, 413, "TOO_LARGE") - assert "32768" in err["message"] - - -def test_raw_refs_accepts_a_list(env): - seed_agent(env.hub, "agent-1") - seed_agent(env.hub, "agent-2") - refs = ["20260601-100000-000_agent-2.md"] - r = env.client.post( - "/v1/messages", json={"agent_id": "agent-1", "body": "building on it", "refs": refs} - ) - assert r.status_code == 201, r.text - assert r.json()["mentions_delivered"] == ["agent-2"] - fm = env.client.get(f"/v1/messages/{r.json()['filename']}").json()["frontmatter"] - assert fm["refs"] == refs - - -def test_cold_listing_failure_is_503_not_an_empty_folder(env): - env.hub.seed("message_board/20260601-100000-000_agent-1.md", "---\nagent: agent-1\n---\nhi") - env.hub.fail_next_listing("message_board") - r = env.client.get("/v1/messages") - _error(r, 503, "STORAGE_UNAVAILABLE") - assert r.headers["retry-after"] == "5" - assert env.client.get("/v1/messages").json()["count"] == 1 - - -def test_failed_batch_download_is_503_not_404(env): - fn = "20260601-100000-000_agent-1.md" - env.hub.seed(f"message_board/{fn}", "---\nagent: agent-1\n---\nhi") - env.hub.fail_next_read("message_board/") - _error(env.client.get(f"/v1/messages/{fn}"), 503, "STORAGE_UNAVAILABLE") - assert env.client.get(f"/v1/messages/{fn}").status_code == 200 diff --git a/tests/test_me_api.py b/tests/test_me_api.py index f7d7169c10e8d27fd87088ada1d01a8ef85cf2bb..5c676d13880db86da695e2fd17d6fef2908a70d2 100644 --- a/tests/test_me_api.py +++ b/tests/test_me_api.py @@ -2,9 +2,6 @@ hint for whether to show the broadcast toggle. Not the security boundary: POST /v1/messages re-verifies on every broadcast.""" -from app.frontmatter import serialise -from fakes import seed_agent - def _me(env, token="user-token"): return env.client.get("/v1/me", headers={"Authorization": f"Bearer {token}"}) @@ -24,7 +21,6 @@ def test_me_reports_organizer_via_email_lookup(env): "handle": "human-test-user", "is_member": True, "is_organizer": True, - "traces": {"sessions": 0, "last_shared_at": None}, } # the targeted lookup answered; no full-org scan needed assert env.hub.org_member_roles_calls == 0 @@ -58,19 +54,3 @@ def test_me_degrades_to_not_organizer_on_lookup_failure(env): def test_me_handle_is_lowercased(env): env.hub.whoami_user = "Test-User" assert _me(env).json()["handle"] == "human-test-user" - - -def test_me_sums_traces_of_the_callers_agents(env): - seed_agent(env.hub, "mine-1") - seed_agent(env.hub, "mine-2") - seed_agent(env.hub, "theirs", hf_user="someone-else") - for agent, at in [ - ("mine-1", "2026-06-01 10:00 UTC"), - ("mine-2", "2026-06-02 10:00 UTC"), - ("theirs", "2026-06-03 10:00 UTC"), - ]: - env.hub.seed(f"traces/{agent}/s/manifest.md", serialise({"promoted_at": at}, "")) - assert _me(env).json()["traces"] == { - "sessions": 2, - "last_shared_at": "2026-06-02 10:00 UTC", - } diff --git a/tests/test_messages_api.py b/tests/test_messages_api.py index 237f034fc0400467ccb334630d53fb2107928cf9..34277543730da87a352dfaa9b31a392900eebc18 100644 --- a/tests/test_messages_api.py +++ b/tests/test_messages_api.py @@ -364,72 +364,3 @@ def test_raw_variant_needs_no_allowlist(env): json={"agent_id": "agent-1", "body": "hello", "type": "note"}, ) assert r.status_code == 201 - - -# ── retries (DESIGN.md §5 "Message retries") ───────────────────────── - - -def _board_files(env) -> list[str]: - return [p for p in env.hub.buckets[env.settings.central_bucket] if p.startswith("message_board/")] - - -def test_raw_retry_with_idempotency_key_returns_original_with_200(env): - seed_agent(env.hub, "agent-1") - seed_agent(env.hub, "agent-2") - post = {"agent_id": "agent-1", "body": "ping @agent-2", "idempotency_key": "k-1"} - first = env.client.post("/v1/messages", json=post) - assert first.status_code == 201 - # Same key, even with a changed body: the key identifies the post. - again = env.client.post("/v1/messages", json={**post, "body": "ping again @agent-2"}) - assert again.status_code == 200 - assert again.json() == first.json() - assert len(_board_files(env)) == 1 - assert len(env.hub.batch_writes) == 1 # no second post, no second fan-out - # A new key is a new message, even with the same body. - assert env.client.post("/v1/messages", json={**post, "idempotency_key": "k-2"}).status_code == 201 - assert len(_board_files(env)) == 2 - - -def test_idempotency_key_is_scoped_per_agent_and_length_capped(env): - seed_agent(env.hub, "agent-1") - seed_agent(env.hub, "agent-2") - for agent in ("agent-1", "agent-2"): - r = env.client.post("/v1/messages", json={"agent_id": agent, "body": "hi", "idempotency_key": "k"}) - assert r.status_code == 201 - r = env.client.post("/v1/messages", json={"agent_id": "agent-1", "body": "x", "idempotency_key": "k" * 65}) - assert r.json()["error"]["code"] == "TOO_LARGE" - - -def test_bucket_retry_with_idempotency_key_returns_original_with_200(env): - seed_agent(env.hub, "agent-1") - uri = _seed_source(env, "agent-1", "drafts/m.md", "---\ntype: note\n---\nbody") - first = env.client.post("/v1/messages", json={"source": uri, "idempotency_key": "b-1"}) - assert first.status_code == 201 - again = env.client.post("/v1/messages", json={"source": uri, "idempotency_key": "b-1"}) - assert again.status_code == 200 - assert again.json()["filename"] == first.json()["filename"] - assert len(_board_files(env)) == 1 - - -def test_raw_repeat_body_without_key_is_a_duplicate_within_60s(make_env): - from app.dedup import RecentPosts - from app.deps import get_recent_posts - from app.main import app as fastapi_app - - env = make_env() - now = [1000.0] - recent = RecentPosts(100, clock=lambda: now[0]) - fastapi_app.dependency_overrides[get_recent_posts] = lambda: recent - seed_agent(env.hub, "agent-1") - post = {"agent_id": "agent-1", "body": "done with the sweep"} - first = env.client.post("/v1/messages", json=post) - assert first.status_code == 201 - now[0] += 59 - again = env.client.post("/v1/messages", json=post) - assert again.status_code == 200 - assert again.json()["filename"] == first.json()["filename"] - assert len(_board_files(env)) == 1 - # Past the window the same body is a new message. - now[0] += 2 - assert env.client.post("/v1/messages", json=post).status_code == 201 - assert len(_board_files(env)) == 2 diff --git a/tests/test_notify_unit.py b/tests/test_notify_unit.py index 847e8e98ca10a94b6fa360be9013eeae18690fb3..b332ffb5209376ef64a32124b56b66b027b9323e 100644 --- a/tests/test_notify_unit.py +++ b/tests/test_notify_unit.py @@ -93,33 +93,3 @@ def test_wake_from_foreign_thread(): return result assert asyncio.run(scenario()) is True - - -def test_mode_is_parked_within_the_window_then_poll(): - """A plain read after a park keeps reporting parked until the parked stamp - is older than parked_window_s; stream is the parked poll's until then, - the latest read's after.""" - now = [0.0] - n = Notifier( - max_waiters_per_owner=4, - max_waiters_total=256, - wake_spread_s=8.0, - wake_spread_threshold=20, - parked_window_s=110.0, - clock=lambda: now[0], - ) - n.note_poll("a", "updates", parked=True) - now[0] = 50.0 - n.note_poll("a", "digest", parked=False) - assert n.last_poll("a")[1:3] == ("parked", "updates") - now[0] = 111.0 - assert n.last_poll("a")[1:3] == ("poll", "digest") - - -def test_last_cursor_keeps_the_newest_of_sent_and_handed_out(): - n = _notifier() - n.note_poll("a", "updates", parked=False, after="20260101-000000-000_x.md") - n.note_cursor("a", "20260102-000000-000_y.md") - n.note_poll("a", "updates", parked=False, after="20260101-000000-000_x.md") - n.note_cursor("a", None) - assert n.last_poll("a").last_cursor == "20260102-000000-000_y.md" diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index d3fa2be2f446f48c9e1d8d455e8581b92735e7e7..a40f35bb68335587ab0dc8d5fe9d4fc799e659d0 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -1,8 +1,6 @@ """TokenBucket / CompoundLimiter unit tests.""" from __future__ import annotations -from app.config import Settings -from app.deps import raw_message_limiter from app.rate_limit import CompoundLimiter, TokenBucket @@ -43,17 +41,3 @@ def test_compound_keys_are_independent(): assert limiter.try_consume("a")[0] is True assert limiter.try_consume("a")[0] is False assert limiter.try_consume("b")[0] is True - - -def test_raw_message_hourly_refill_is_fractional(): - """30/h refills one token every 2 minutes, not every minute (60/h).""" - settings = Settings( - ORG="o", COLLAB_SLUG="s", AUDIT_BUCKET="a/b", - RAW_MESSAGE_PER_MINUTE=1000, RAW_MESSAGE_PER_HOUR=30, - ) - limiter = raw_message_limiter(settings) - for _ in range(30): - assert limiter.try_consume("k") == (True, 0) - allowed, retry = limiter.try_consume("k") - assert allowed is False - assert 115 <= retry <= 121 diff --git a/tests/test_read_model.py b/tests/test_read_model.py index 5f5baa9cc772ecc156ee40a81bd0ed09c1d19ebc..63ed052a5c0f550dae3325b9fd7a9c85865de389 100644 --- a/tests/test_read_model.py +++ b/tests/test_read_model.py @@ -1,11 +1,7 @@ import json -import threading - -import pytest from app.config import Settings from app.frontmatter import serialise -from app.hub import DownloadFailed, ListingFailed from app.read_model import ReadModel from fakes import FakeHub, seed_message @@ -74,100 +70,13 @@ def test_write_through_is_visible_without_a_new_listing(): assert hub.list_calls == listed # TTL untouched — served from the overlay -def test_failed_listing_keeps_cached_entries(): +def test_transient_empty_listing_keeps_cached_entries(): rm, hub, clock, s = make_rm() seed_message(hub, "20260601-120000-000", "agent-1", "hello") assert len(rm.records("message_board")) == 1 hub.fail_listings = True clock.t += s.listing_ttl_s + 1 - assert len(rm.records("message_board")) == 1 # cached truth, not [] - assert rm._folder("message_board").last_error is not None - hub.fail_listings = False - clock.t += s.listing_ttl_s + 1 - rm.records("message_board") - assert rm._folder("message_board").last_error is None # cleared on success - - -def test_failed_listing_backs_off_one_ttl(): - rm, hub, clock, s = make_rm() - rm.records("message_board") - hub.fail_listings = True - clock.t += s.listing_ttl_s + 1 - rm.records("message_board") - listed = hub.list_calls - rm.records("message_board") - assert hub.list_calls == listed # an outage is not one listing per read - - -def test_failed_listing_on_a_cold_folder_raises(): - rm, hub, _clock, _s = make_rm() - seed_message(hub, "20260601-120000-000", "agent-1", "hello") - hub.fail_next_listing("message_board") - with pytest.raises(ListingFailed): - rm.records("message_board") - assert len(rm.records("message_board")) == 1 # the next read retries - - -def test_empty_listing_is_the_truth_once_failures_are_explicit(): - rm, hub, clock, s = make_rm() - fn = seed_message(hub, "20260601-120000-000", "agent-1", "hello") - assert len(rm.records("message_board")) == 1 - del hub.buckets[s.central_bucket][f"message_board/{fn}"] # admin removal - clock.t += s.listing_ttl_s + 1 - assert rm.records("message_board") == [] - - -def test_partial_listing_is_not_cached_and_a_cursor_does_not_skip(): - """A listing interrupted mid-way must not replace the folder: here it - would have shown m3 without m2, a watcher would advance its after= cursor - to m3, and m2 would be lost to it for good once the full listing - returned.""" - rm, hub, clock, s = make_rm() - m1 = seed_message(hub, "20260601-120000-000", "agent-1", "one") - assert [r.filename for r in rm.records("message_board")] == [m1] - cached = dict(rm._folder("message_board").files) - - seed_message(hub, "20260603-120000-000", "agent-1", "three") - m2 = seed_message(hub, "20260602-120000-000", "agent-1", "two") # listed last - hub.partial_listing("message_board", drop=1) # the page holding m2 fails - clock.t += s.listing_ttl_s + 1 - - def after(cursor): - return [r.filename for r in rm.records("message_board") if r.filename > cursor] - - assert after(m1) == [] # the cached truth, not "m3 but no m2" - assert rm._folder("message_board").files == cached - clock.t += s.listing_ttl_s + 1 - assert after(m1)[0] == m2 # nothing skipped once the listing completes - - -def test_failed_batch_download_raises_rather_than_dropping_records(): - rm, hub, _clock, _s = make_rm() - fn = seed_message(hub, "20260601-120000-000", "agent-1", "hello") - hub.fail_next_read("message_board/") - with pytest.raises(DownloadFailed): - rm.record("message_board", fn) # not None, which would be a false 404 - assert rm.record("message_board", fn).body.strip() == "hello" - - -def test_concurrent_cold_reads_share_one_batch_download(): - """After a restart every watcher reconnects at once; N cold readers of a - folder must cost one batch download, not N.""" - rm, hub, _clock, _s = make_rm() - for i in range(3): - seed_message(hub, f"2026060{i + 1}-120000-000", "agent-1", f"msg {i}") - hub.latency_s = 0.2 - results = [] - threads = [ - threading.Thread(target=lambda: results.append(rm.records("message_board"))) - for _ in range(2) - ] - for t in threads: - t.start() - for t in threads: - t.join() - assert [len(r) for r in results] == [3, 3] - assert hub.list_calls == 1 and hub.download_calls == 1 + assert len(rm.records("message_board")) == 1 # nothing is ever deleted def test_lru_eviction_bounds_memory_but_never_drops_results(): diff --git a/tests/test_results_api.py b/tests/test_results_api.py index 67897a2c6ac3e483559baa61fb5ecfba294ced72..d5d006548b9cd60750cc84ac7a57640f87894427 100644 --- a/tests/test_results_api.py +++ b/tests/test_results_api.py @@ -1,7 +1,5 @@ import json -from app.frontmatter import serialise -from app.naming import stamp_yaml, utc_now from fakes import seed_agent, seed_result @@ -70,61 +68,8 @@ def test_post_result_visible_immediately(env): data = env.client.get("/v1/results?expand=true&limit=1").json() assert data["items"][0]["filename"] == filename assert data["items"][0]["verification"] == "pending" - # promotion never creates the out-of-band verification index - assert "results/verification_status.json" not in env.hub.buckets[env.settings.central_bucket] - - -def test_post_result_leaves_human_verdicts_alone_and_reads_pending(env): - """Promotion never rewrites the index humans / the eval Space edit, so a - verdict landing concurrently can't be lost; the new result reads pending.""" - seed_agent(env.hub, "agent-1") - index_bytes = json.dumps({"20260601-100000-000_agent-1.md": "valid"}).encode() - central = env.hub.buckets[env.settings.central_bucket] - central["results/verification_status.json"] = index_bytes - env.hub.seed( - "results/run1.md", - "---\nscore: 142.7\nmethod: vllm-fp8\nstatus: agent-run\ndescription: fast\n---\nnotes\n", - bucket="test-org/test-agent-1", + # the verification index tracked the promotion + index = json.loads( + env.hub.buckets[env.settings.central_bucket]["results/verification_status.json"] ) - r = env.client.post( - "/v1/results", - json={"source": "hf://buckets/test-org/test-agent-1/results/run1.md"}, - ) - assert r.status_code == 201 - assert central["results/verification_status.json"] == index_bytes - rows = env.client.get("/v1/leaderboard").json()["rows"] - assert [(row["filename"], row["verification"]) for row in rows] == [ - (r.json()["filename"], "pending") - ] - - -# ───────────────────────── share-trace hint ───────────────────────── - -def _post_run(env): - seed_agent(env.hub, "agent-1") - env.hub.seed( - "results/run1.md", - "---\nscore: 1\nmethod: m\nstatus: agent-run\ndescription: x\n---\n", - bucket="test-org/test-agent-1", - ) - r = env.client.post( - "/v1/results", json={"source": "hf://buckets/test-org/test-agent-1/results/run1.md"} - ) - assert r.status_code == 201 - return r.json() - - -def _seed_trace(env, promoted_at, agent="agent-1"): - env.hub.seed(f"traces/{agent}/s1/manifest.md", serialise({"promoted_at": promoted_at}, "")) - - -def test_post_result_hints_share_trace_when_none_shared_recently(env): - _seed_trace(env, "2020-01-01 00:00 UTC") # stale: older than 24 h - _seed_trace(env, stamp_yaml(utc_now()), agent="agent-2") # someone else's - hint = _post_run(env)["hint"] - assert "curl -fsS $API/v1/share_trace.py" in hint and "python3 share_trace.py" in hint - - -def test_post_result_no_hint_after_recent_trace(env): - _seed_trace(env, stamp_yaml(utc_now())) - assert _post_run(env)["hint"] is None + assert index[filename] == "pending" diff --git a/tests/test_share_trace.py b/tests/test_share_trace.py index c2ccd01fb3ee38935ada6d3d565777129e4ae183..f1bfe802d896af0aba2ff59f22b682ced676e29b 100644 --- a/tests/test_share_trace.py +++ b/tests/test_share_trace.py @@ -22,11 +22,7 @@ import share_trace as st # noqa: E402 CWD = "/work/proj" # absolute; detect only uses it to compute slugs / cwd-match -# Env the client reads; cleared so the host environment never leaks into tests. -_MARKERS = ( - "CLAUDE_CODE_SESSION_ID", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", - "CODEX_HOME", "CLAUDE_CONFIG_DIR", "COLLAB_BACKEND", "API", -) +_MARKERS = ("CLAUDE_CODE_SESSION_ID", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED") def _slug(cwd: str) -> str: @@ -81,44 +77,6 @@ def test_codex_marker_never_grabs_a_claude_log(home, monkeypatch): assert path == rollout # never the CC log -def test_config_dir_env_vars_are_honoured(home, monkeypatch, tmp_path): - # CLAUDE_CONFIG_DIR / CODEX_HOME relocate the logs; ~/.claude and ~/.codex - # are only the fallbacks. - assert st._cc_project_dir(CWD) == home / ".claude" / "projects" / _slug(CWD) - assert st._codex_home() == home / ".codex" - monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cc")) - monkeypatch.setenv("CODEX_HOME", str(tmp_path / "cx" / ".codex")) - assert st._cc_project_dir(CWD) == tmp_path / "cc" / "projects" / _slug(CWD) - rollout = _codex(tmp_path / "cx") # writes under /.codex/sessions - assert st._codex_logs() == [rollout] - assert st._infer_harness(rollout) == "codex" - - -def test_claude_config_dir_detects_pinned_session(home, monkeypatch, tmp_path): - monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cc")) - d = tmp_path / "cc" / "projects" / _slug(CWD) - d.mkdir(parents=True) - mine = d / "sid-1.jsonl" - mine.write_text("{}\n") - monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "sid-1") - assert st.detect(CWD, "auto") == ("claude-code", mine, False) - assert st._infer_harness(mine) == "claude-code" - - -def test_codex_home_is_not_a_harness_marker(home, monkeypatch): - # CODEX_HOME is often exported globally (e.g. by agent managers); it only - # relocates the logs and must not decide which harness is running. - monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) - assert st._running_harness() == (None, None) - - -def test_codex_cwd_match_respects_path_boundary(home): - assert _codex(home, cwd="/work/proj") and st._codex_matches_cwd(st._codex_logs()[0], CWD) - assert st._mentions_cwd("cd /work/proj/src && ls", CWD) - assert not st._mentions_cwd("/work/proj2", CWD) - assert not st._mentions_cwd("cd /work/proj-old", CWD) - - def test_ambiguous_without_markers_refuses(home): # No env marker + both harnesses present → refuse rather than misattribute. _cc(home, "cc-sid") @@ -462,91 +420,3 @@ def test_session_id_must_be_a_safe_bucket_component(): assert st._safe_session_id("rollout-2026.06_29") == "rollout-2026.06_29" with pytest.raises(SystemExit, match="safe --session-id"): st._safe_session_id("../another-session") - - -def test_claude_code_usage_is_counted_once_per_message_id(tmp_path): - # Claude Code writes one line per content block; lines of one API response - # repeat message.id and usage. Summing every line over-counted tokens. - usage = {"input_tokens": 10, "output_tokens": 5, - "cache_read_input_tokens": 100, "cache_creation_input_tokens": 1} - - def line(mid, content): - msg = {"model": "m", "content": content, "usage": usage} - if mid: - msg["id"] = mid - return json.dumps({"type": "assistant", "sessionId": "s", "message": msg}) - - tool = {"type": "tool_use", "id": "tu1", "name": "Bash"} - transcript = tmp_path / "s.jsonl" - transcript.write_text("\n".join([ - line("msg_1", [{"type": "thinking"}]), - line("msg_1", [{"type": "text"}]), - line("msg_1", [tool]), - line("msg_1", [tool]), # same tool_use block written twice - line("msg_2", [{"type": "tool_use", "id": "tu2", "name": "Read"}]), - line(None, [{"type": "text"}]), # no id: still counts once - ]) + "\n") - - fields = st.adapter_claude_code(transcript) - assert fields["usage"] == { - "input_tokens": 30, "output_tokens": 15, "cache_read_tokens": 300, - "cache_creation_tokens": 3, "total_tokens": 348, - } - assert fields["extensions"]["api_requests"] == 3 - assert fields["activity"] == {"tool_calls": 2, "tool_calls_by_name": {"Bash": 1, "Read": 1}} - - -def _run(monkeypatch, *argv): - monkeypatch.setattr(sys, "argv", ["share_trace.py", *argv]) - return st.main() - - -def test_unknown_harness_with_transcript_ships_minimal_manifest(home, monkeypatch, tmp_path, capsys): - transcript = tmp_path / "session-1.log" - transcript.write_text("anything\n") - assert _run(monkeypatch, "--harness", "cursor", "--transcript", str(transcript), "--dry-run") == 0 - out = capsys.readouterr().out - assert "harness : cursor" in out - assert "stats will be partial" in out - - -def test_harness_must_be_a_slug(home, monkeypatch): - with pytest.raises(SystemExit): - _run(monkeypatch, "--harness", "Not A Slug", "--dry-run") - with pytest.raises(SystemExit, match="--transcript"): - _run(monkeypatch, "--harness", "cursor", "--dry-run") - - -def test_backend_falls_back_to_api_env(home, monkeypatch, tmp_path): - monkeypatch.delenv("COLLAB_BACKEND", raising=False) - monkeypatch.setenv("API", "https://api.example") - seen = [] - monkeypatch.setattr(st, "_fetch_v1", lambda backend: seen.append(backend)) - transcript = tmp_path / "s.log" - transcript.write_text("x\n") - _run(monkeypatch, "--harness", "cursor", "--transcript", str(transcript), "--dry-run") - assert seen == ["https://api.example"] - - -def test_stats_share_never_needs_confirmation(monkeypatch): - monkeypatch.setattr(sys.stdin, "isatty", lambda: False, raising=False) - kw = dict(log_path=Path("x.jsonl"), uncertain=True, yes=False, raw=False, privacy="balanced") - st._confirm_or_exit(share="stats", **kw) # no exit, no prompt - with pytest.raises(SystemExit, match="--yes"): - st._confirm_or_exit(share="full", **kw) - - -def test_promotion_failure_exits_non_zero(home, monkeypatch, tmp_path, capsys): - transcript = tmp_path / "s.log" - transcript.write_text("x\n") - monkeypatch.setattr(st, "_hf_cp", lambda local, dest: None) - monkeypatch.setattr(st.shutil, "which", lambda _: "/usr/bin/hf") - - def fail(*a, **k): - raise st.urllib.error.URLError("down") - - monkeypatch.setattr(st.urllib.request, "urlopen", fail) - rc = _run(monkeypatch, "--harness", "cursor", "--transcript", str(transcript), - "--agent-id", "a1", "--org", "o", "--slug", "c", "--backend", "https://b.example") - assert rc == 1 - assert "backend promotion failed" in capsys.readouterr().out diff --git a/tests/test_taskforces_api.py b/tests/test_taskforces_api.py new file mode 100644 index 0000000000000000000000000000000000000000..ed711b91edd8f08cdede569b75e82c4b261dc2df --- /dev/null +++ b/tests/test_taskforces_api.py @@ -0,0 +1,344 @@ +"""Taskforces (§18): creation, README updates, contributions, discovery.""" +from __future__ import annotations + +from app.frontmatter import serialise +from fakes import seed_agent + + +AGENT = "agent-1" +OTHER = "agent-2" + + +def _bucket(agent_id: str) -> str: + return f"test-org/test-{agent_id}" + + +def _source(agent_id: str, path: str) -> str: + return f"hf://buckets/{_bucket(agent_id)}/{path}" + + +def create_tf( + env, + name: str = "kernel-research", + agent: str = AGENT, + body: str = "# Kernel Research\n\nMaking attention kernels go fast.", +): + return env.client.post( + "/v1/taskforces", json={"name": name, "agent_id": agent, "body": body} + ) + + +# ───────────────────────── creation ───────────────────────── + + +def test_create_raw_writes_readme(env): + seed_agent(env.hub, AGENT) + r = create_tf(env) + assert r.status_code == 201, r.text + assert r.json() == { + "name": "kernel-research", + "via": "raw", + "path": "taskforces/kernel-research/README.md", + "created": True, + } + + readme = env.hub.read_central_text("taskforces/kernel-research/README.md") + assert "creator: agent-1" in readme + assert "taskforce: kernel-research" in readme + assert "Making attention kernels go fast." in readme + + # No automated announcement (§18.2): the board stays quiet — the creator + # introduces the taskforce themselves. + msgs = env.client.get("/v1/messages").json() + assert msgs["count"] == 0 + + +def test_create_bucket_variant_preserves_client_frontmatter(env): + seed_agent(env.hub, AGENT) + env.hub.seed( + "tf/readme.md", + serialise({"title": "Kernel Research", "creator": "spoofed"}, "All things kernels."), + bucket=_bucket(AGENT), + ) + r = env.client.post( + "/v1/taskforces", + json={"name": "kernel-research", "source": _source(AGENT, "tf/readme.md")}, + ) + assert r.status_code == 201, r.text + assert r.json()["via"] == "bucket" + readme = env.hub.read_central_text("taskforces/kernel-research/README.md") + assert "title: Kernel Research" in readme # client field preserved + assert "creator: agent-1" in readme # server stamp wins over spoof + assert "spoofed" not in readme + + +def test_create_requires_registration(env): + r = create_tf(env) + assert r.status_code == 404 + assert r.json()["error"]["code"] == "NOT_REGISTERED" + + +def test_create_rejects_invalid_name(env): + seed_agent(env.hub, AGENT) + r = create_tf(env, name="-bad-") + assert r.status_code == 400 + assert r.json()["error"]["code"] == "INVALID_PATH" + + +def test_create_conflict_for_non_creator(env): + seed_agent(env.hub, AGENT) + seed_agent(env.hub, OTHER) + assert create_tf(env).status_code == 201 + r = create_tf(env, agent=OTHER, body="mine now") + assert r.status_code == 409 + assert r.json()["error"]["code"] == "TASKFORCE_EXISTS" + + +def test_creator_updates_readme(env): + seed_agent(env.hub, AGENT) + create_tf(env) + first = env.client.get("/v1/taskforces/kernel-research").json() + + r = create_tf(env, body="# Kernel Research\n\nNow with a roadmap.") + assert r.status_code == 200, r.text + assert r.json()["created"] is False + + detail = env.client.get("/v1/taskforces/kernel-research").json() + assert detail["created"] == first["created"] # creation stamp preserved + assert detail["updated"] is not None + assert "roadmap" in detail["readme"]["body"] + + +# ───────────────────────── contributions ───────────────────────── + + +def test_raw_note(env): + seed_agent(env.hub, AGENT) + create_tf(env) + r = env.client.post( + "/v1/taskforces/kernel-research/files", + json={"agent_id": AGENT, "body": "Profiled the baseline: 40% in attention."}, + ) + assert r.status_code == 201, r.text + data = r.json() + assert data["kind"] == "note" + assert data["filename"].endswith("_agent-1.md") + + notes = env.client.get( + "/v1/taskforces/kernel-research/notes?expand=true" + ).json() + assert notes["matched"] == 1 + fm = notes["items"][0]["frontmatter"] + assert fm["taskforce"] == "kernel-research" + assert fm["type"] == "note" + assert fm["via"] == "raw" + + +def test_bucket_note_dedup(env): + seed_agent(env.hub, AGENT) + create_tf(env) + env.hub.seed("notes/profile.md", "kernel profiling notes", bucket=_bucket(AGENT)) + src = _source(AGENT, "notes/profile.md") + first = env.client.post( + "/v1/taskforces/kernel-research/files", json={"source": src} + ) + assert first.status_code == 201, first.text + assert first.json()["via"] == "bucket" + second = env.client.post( + "/v1/taskforces/kernel-research/files", json={"source": src} + ) + assert second.status_code == 409 + assert second.json()["error"]["code"] == "ALREADY_PROMOTED" + + +def test_named_file_roundtrip(env): + seed_agent(env.hub, AGENT) + create_tf(env) + env.hub.seed("out/profile.json", '{"tps": 123}', bucket=_bucket(AGENT)) + r = env.client.post( + "/v1/taskforces/kernel-research/files", + json={ + "source": _source(AGENT, "out/profile.json"), + "dest_path": "profiles/flash_attn_agent-1.json", + }, + ) + assert r.status_code == 201, r.text + assert r.json() == { + "kind": "file", + "filename": "profiles/flash_attn_agent-1.json", + "via": "bucket", + "path": "taskforces/kernel-research/profiles/flash_attn_agent-1.json", + } + + files = env.client.get("/v1/taskforces/kernel-research/files").json() + paths = {f["path"] for f in files["items"]} + assert paths == {"README.md", "profiles/flash_attn_agent-1.json"} + + raw = env.client.get( + "/v1/taskforces/kernel-research/files/profiles/flash_attn_agent-1.json" + ) + assert raw.status_code == 200 + assert raw.content == b'{"tps": 123}' + assert raw.headers["content-type"].startswith("application/json") + + # Binaries/named non-md files never show up as notes. + notes = env.client.get("/v1/taskforces/kernel-research/notes").json() + assert notes["matched"] == 0 + + +def test_named_md_file_appears_in_notes_view(env): + seed_agent(env.hub, AGENT) + create_tf(env) + env.hub.seed( + "out/survey.md", + serialise({"type": "survey"}, "Long-form kernel survey."), + bucket=_bucket(AGENT), + ) + r = env.client.post( + "/v1/taskforces/kernel-research/files", + json={"source": _source(AGENT, "out/survey.md"), "dest_path": "survey_agent-1.md"}, + ) + assert r.status_code == 201, r.text + notes = env.client.get( + "/v1/taskforces/kernel-research/notes?expand=true&q=survey" + ).json() + assert notes["matched"] == 1 + assert notes["items"][0]["filename"] == "survey_agent-1.md" + + +def test_named_file_requires_marker(env): + seed_agent(env.hub, AGENT) + create_tf(env) + env.hub.seed("out/profile.json", "{}", bucket=_bucket(AGENT)) + r = env.client.post( + "/v1/taskforces/kernel-research/files", + json={"source": _source(AGENT, "out/profile.json"), "dest_path": "profile.json"}, + ) + assert r.status_code == 400 + assert "_agent-1" in r.json()["error"]["message"] + + +def test_named_file_readme_leaf_reserved(env): + seed_agent(env.hub, AGENT) + create_tf(env) + env.hub.seed("out/readme.md", "takeover", bucket=_bucket(AGENT)) + r = env.client.post( + "/v1/taskforces/kernel-research/files", + json={ + "source": _source(AGENT, "out/readme.md"), + "dest_path": "docs_agent-1/README.md", + }, + ) + assert r.status_code == 400 + assert "reserved" in r.json()["error"]["message"] + + +def test_contribute_to_unknown_taskforce(env): + seed_agent(env.hub, AGENT) + r = env.client.post( + "/v1/taskforces/nope/files", json={"agent_id": AGENT, "body": "hello?"} + ) + assert r.status_code == 404 + assert r.json()["error"]["code"] == "TASKFORCE_NOT_FOUND" + + +def test_contribution_requires_registration(env): + seed_agent(env.hub, AGENT) + create_tf(env) + r = env.client.post( + "/v1/taskforces/kernel-research/files", + json={"agent_id": "ghost", "body": "drive-by"}, + ) + assert r.status_code == 404 + assert r.json()["error"]["code"] == "NOT_REGISTERED" + + +# ───────────────────────── discovery ───────────────────────── + + +def test_list_taskforces(env): + seed_agent(env.hub, AGENT) + seed_agent(env.hub, OTHER) + create_tf(env, name="kernel-research") + create_tf(env, name="eval-tooling", agent=OTHER, body="# Evals\n\nBetter eval harnesses.") + env.client.post( + "/v1/taskforces/kernel-research/files", + json={"agent_id": OTHER, "body": "joining in"}, + ) + + listing = env.client.get("/v1/taskforces").json() + assert listing["count"] == 2 + assert listing["matched"] == 2 + by_name = {t["name"]: t for t in listing["items"]} + # kernel-research has the newest activity (a note), so it sorts first. + assert listing["items"][0]["name"] == "kernel-research" + kr = by_name["kernel-research"] + assert kr["creator"] == AGENT + assert kr["contributors"] == [AGENT, OTHER] + assert kr["note_count"] == 1 + assert kr["last_activity"] is not None + assert "kernels" in kr["readme_excerpt"] + et = by_name["eval-tooling"] + assert et["contributors"] == [OTHER] + assert et["last_activity"] is None + + filtered = env.client.get("/v1/taskforces?q=eval harnesses").json() + assert filtered["matched"] == 1 + assert filtered["items"][0]["name"] == "eval-tooling" + + +def test_detail(env): + seed_agent(env.hub, AGENT) + seed_agent(env.hub, OTHER) + create_tf(env) + env.client.post( + "/v1/taskforces/kernel-research/files", + json={"agent_id": OTHER, "body": "fused kernel draft attached"}, + ) + + detail = env.client.get("/v1/taskforces/kernel-research").json() + assert detail["name"] == "kernel-research" + assert detail["creator"] == AGENT + assert detail["contributors"] == [AGENT, OTHER] + assert detail["note_count"] == 1 + assert detail["file_count"] == 2 # README + note + assert detail["readme"]["body"].startswith("# Kernel Research") + assert detail["recent_notes"][0]["body"] == "fused kernel draft attached" + + +def test_detail_unknown_taskforce(env): + r = env.client.get("/v1/taskforces/nope") + assert r.status_code == 404 + assert r.json()["error"]["code"] == "TASKFORCE_NOT_FOUND" + + +def test_notes_grammar_filters(env): + seed_agent(env.hub, AGENT) + seed_agent(env.hub, OTHER) + create_tf(env) + for agent, body in ((AGENT, "first note"), (OTHER, "second note")): + env.client.post( + "/v1/taskforces/kernel-research/files", + json={"agent_id": agent, "body": body}, + ) + notes = env.client.get( + f"/v1/taskforces/kernel-research/notes?agent={OTHER}&expand=true" + ).json() + assert notes["matched"] == 1 + assert notes["items"][0]["body"] == "second note" + + +def test_raw_file_get_missing(env): + seed_agent(env.hub, AGENT) + create_tf(env) + r = env.client.get("/v1/taskforces/kernel-research/files/nope.txt") + assert r.status_code == 404 + assert r.json()["error"]["code"] == "NOT_FOUND" + + +def test_digest_includes_taskforces(env): + seed_agent(env.hub, AGENT) + create_tf(env) + digest = env.client.get("/v1/digest").json() + assert digest["taskforces"]["count"] == 1 + assert digest["taskforces"]["newest"] == ["kernel-research"] diff --git a/tests/test_updates_api.py b/tests/test_updates_api.py index aa0578a71b549f8dba6d86c224b2724e2d5d6fc7..a3d53f4b047d5b5fb72b5ce13f759efdc782ae09 100644 --- a/tests/test_updates_api.py +++ b/tests/test_updates_api.py @@ -411,9 +411,9 @@ def test_watching_is_empty_before_anyone_parks(env): assert data["watching"] == {} assert data["max_wait_s"] == env.settings.longpoll_max_wait_s - # Generous (240s by default): a synchronous `--max-wait 100` poller calls - # once per work step, not once per wait window. - assert data["fresh_s"] == env.settings.watch_fresh_s == 240.0 + # 2× the ceiling: a watcher re-arms at most one wait window after the last + # one ended, so this is the youngest age that can still be stale. + assert data["fresh_s"] == 2 * env.settings.longpoll_max_wait_s # The waiter counters ride along (§10.4), same shape as /v1/healthz. assert data["longpoll"] == env.client.get("/v1/healthz").json()["longpoll"] @@ -432,7 +432,7 @@ def test_watching_reports_a_parked_handle_with_its_mode(env): live = env.client.get("/v1/watching").json() assert set(live["watching"]) == {"watcher"} entry = live["watching"]["watcher"] - assert (entry["mode"], entry["stream"]) == ("parked", "updates") + assert entry["mode"] == "updates" assert 0 <= entry["last_poll_age_s"] <= 5 assert live["longpoll"]["waiters"] == 1 @@ -445,12 +445,11 @@ def test_watching_reports_a_parked_handle_with_its_mode(env): assert after["longpoll"]["waiters"] == 0 -def test_watching_covers_every_handle_and_only_watch_reads(env): +def test_watching_covers_every_handle_and_only_wait_pollers(env): """One call, every watcher — the whole reason this exists next to the - digest's per-handle block. A plain inbox read is not the watch stream, so it - must not earn a presence entry; a plain /v1/updates read is (that is the - synchronous `watch.sh --max-wait` recipe) and does.""" - for handle in ("alpha", "beta", "plain", "sync"): + digest's per-handle block. A plain (wait=0) poll is not watching, so it must + not earn a presence entry that would read as "reachable in seconds".""" + for handle in ("alpha", "beta", "plain"): seed_agent(env.hub, handle) stores: dict = {} @@ -460,23 +459,21 @@ def test_watching_covers_every_handle_and_only_watch_reads(env): for h in ("alpha", "beta"): assert _wait_until(lambda h=h: len(_subs_for(env.notifier, f"inbox:{h}")) >= 1) env.client.get("/v1/inbox/plain") # no wait= : a plain read, not a watch - env.client.get("/v1/updates?as=sync") # wait=0 on the watch stream: present data = env.client.get("/v1/watching").json() - assert set(data["watching"]) == {"alpha", "beta", "sync"} - assert data["watching"]["sync"]["mode"] == "poll" + assert set(data["watching"]) == {"alpha", "beta"} broadcast(env, "all hands — releases both parks") # wake_all, so no sleeping for t in threads: t.join(timeout=5) -def test_watching_records_the_inbox_and_feed_streams_too(env): - """`stream` names the stream, so an operator can tell a unified watcher from +def test_watching_records_the_inbox_and_feed_modes_too(env): + """`mode` names the stream, so an operator can tell a unified watcher from an agent still polling the inbox endpoint directly.""" seed_agent(env.hub, "watcher") env.client.get("/v1/inbox/watcher?wait=0.05") - assert env.client.get("/v1/watching").json()["watching"]["watcher"]["stream"] == "inbox" + assert env.client.get("/v1/watching").json()["watching"]["watcher"]["mode"] == "inbox" env.client.get("/v1/channels/feed?as=watcher&wait=0.05") - assert env.client.get("/v1/watching").json()["watching"]["watcher"]["stream"] == "feed" + assert env.client.get("/v1/watching").json()["watching"]["watcher"]["mode"] == "feed" diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 456f69ad641e7e85fe4bfedb17789a004236129f..a3357d703a9d4cc84e6cf2bacf4f2f50713db4c9 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -302,7 +302,7 @@ def test_non_sota_post_does_not_launch(venv): venv.hub.seed("results/verification_status.json", json.dumps({champ: "valid"})) filename = post_result(venv, score=200.0) assert venv.runner.launches == [] - assert filename not in central_index(venv) # absent = pending + assert central_index(venv)[filename] == "pending" def test_invalid_verdict_announced_with_failing_check(venv): @@ -321,7 +321,7 @@ def test_unresolvable_submission_nudges_and_stays_pending(venv): seed_agent(venv.hub, "agent-1") filename = post_result(venv, score=200.0, artifacts="nowhere/") assert venv.runner.launches == [] - assert filename not in central_index(venv) # absent = pending + assert central_index(venv)[filename] == "pending" msgs = venv.client.get("/v1/messages?expand=true&limit=1").json()["items"] assert msgs and "couldn't" in msgs[0]["body"] and "runnable submission" in msgs[0]["body"] @@ -332,7 +332,7 @@ def test_failed_job_leaves_pending_no_announcement(venv): venv.runner.terminal = ("error", "ERROR", "boom") filename = post_result(venv, score=200.0) assert len(venv.runner.launches) == 1 - assert filename not in central_index(venv) # absent = pending + assert central_index(venv)[filename] == "pending" assert venv.client.get("/v1/messages").json()["items"] == [] # single-flight marker released → a later event may retry assert filename not in venv.verifier._in_flight