osolmaz commited on
Commit
a2fecc2
·
verified ·
1 Parent(s): 8391f6f

perf(consumer): materialize observation boundaries

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +248 -44
  2. docs/2026-07-06-implementation-plan.md +292 -0
  3. docs/2026-07-27-unit-consumer-api.md +104 -0
  4. docs/2026-07-28-durable-enrichment-implementation-plan.md +263 -0
  5. docs/2026-07-28-labels-and-free-labels-implementation-plan.md +177 -0
  6. docs/2026-07-29-parallel-enrichment-plan.md +193 -0
  7. docs/2026-08-04-durable-enrichment-index-plan.md +219 -0
  8. docs/2026-08-06-scrape-job-receipts-plan.md +99 -0
  9. docs/2026-08-12-bucket-object-log-plan.md +361 -0
  10. docs/2026-08-16-resumable-registry-review-plan.md +96 -0
  11. docs/2026-08-19-small-worker-checkpoints-plan.md +1122 -0
  12. docs/2026-09-07-consumer-http-handoff.md +252 -0
  13. docs/2026-09-07-incremental-consumer-and-engagement-history-plan.md +558 -0
  14. docs/2026-09-08-consumer-deployment.md +144 -0
  15. extension/.coveragerc +5 -0
  16. extension/.github/workflows/release.yml +58 -0
  17. extension/.github/workflows/test.yml +35 -0
  18. extension/.gitignore +18 -0
  19. extension/LICENSE +21 -0
  20. extension/README.md +404 -0
  21. extension/VENDORED.md +81 -0
  22. extension/background.js +1111 -0
  23. extension/cutover.html +12 -0
  24. extension/cutover.js +10 -0
  25. extension/debug.css +232 -0
  26. extension/debug.html +76 -0
  27. extension/debug.js +168 -0
  28. extension/icons/icon128.png +0 -0
  29. extension/icons/icon16.png +0 -0
  30. extension/icons/icon48.png +0 -0
  31. extension/lib/dedup.js +60 -0
  32. extension/lib/graphql-capture.js +214 -0
  33. extension/lib/observations.js +59 -0
  34. extension/lib/pool-sync.js +286 -0
  35. extension/lib/scrape-bridge.js +235 -0
  36. extension/lib/scrape-receipts.js +537 -0
  37. extension/lib/tweet-parser.js +525 -0
  38. extension/manifest.json +48 -0
  39. extension/native-host/com.xtap.daemon.plist +32 -0
  40. extension/native-host/com.xtap.daemon.service +15 -0
  41. extension/native-host/com.xtap.host.json +7 -0
  42. extension/native-host/install.ps1 +139 -0
  43. extension/native-host/install.sh +280 -0
  44. extension/native-host/xtap_core.py +836 -0
  45. extension/native-host/xtap_daemon.bat +1 -0
  46. extension/native-host/xtap_daemon.py +432 -0
  47. extension/native-host/xtap_host.bat +1 -0
  48. extension/native-host/xtap_host.py +110 -0
  49. extension/options.html +45 -0
  50. extension/options.js +33 -0
README.md CHANGED
@@ -1,46 +1,250 @@
1
- ---
2
- title: xtap-pool
3
- emoji: 🐦
4
- colorFrom: gray
5
- colorTo: blue
6
- sdk: docker
7
- app_port: 7860
8
- hf_oauth: true
9
- hf_oauth_expiration_minutes: 43200
10
- pinned: false
11
- ---
12
-
13
  # xtap-pool
14
 
15
- Private tweet pool for a group of friends running the
16
- [xtap-pool extension](https://github.com/osolmaz/xtap-pool).
17
-
18
- - `POST /api/ingest` — extension submissions (Bearer pool token)
19
- - `GET /connect` — sign in with Hugging Face to connect the extension
20
- - `GET /api/units` — revision-consistent enriched units for scoped service accounts
21
- - `/` — tweet explorer and pool administration
22
- - `GET /healthz` and `GET /readyz` — machine-readable runtime health
23
-
24
- Required Space secrets: `HF_TOKEN` (fine-grained, read/write access to the raw
25
- and index Buckets only), `POOL_SIGNING_SECRET`, and `SESSION_SECRET`.
26
- Production classification does not run in the Space, and `ENRICH_ENABLED` must
27
- remain `false` there.
28
-
29
- Required Space variables: `RAW_BUCKET`, `INDEX_BUCKET`, `ALLOWED_USERS`
30
- (initial comma-separated HF usernames), `POOL_ADMINS` (bootstrap admins),
31
- `SPACE_HOST` (auto-injected by HF), and the bounded enrichment configuration
32
- reconciled by setup. Doctor creates a separate suspended Hugging Face Job with
33
- encrypted Bucket-writer and inference secrets. The Job refuses missing pricing,
34
- cost ceilings, or a source revision that differs from the deployed Space image.
35
-
36
- After setup, admins manage individual members and one allowed member
37
- organization in the Space Admin tab. Durable membership is stored in the raw
38
- Bucket at `config/pool.json`; the Space variables are bootstrap and recovery
39
- inputs. The `member_orgs` config key is an array, but only one organization
40
- grant can be active.
41
-
42
- Admins issue read-only machine credentials from the Admin tab. Only credential
43
- hashes are stored in `config/service-accounts.json`; raw credentials are shown
44
- once. `units:read` grants `GET /api/units`, while `taxonomy:read` grants the
45
- label, free-label, and graph read endpoints. These credentials cannot ingest or
46
- administer the pool.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # xtap-pool
2
 
3
+ Pool [xTap](https://github.com/mkubicek/xTap) captures with a group of friends.
4
+
5
+ xtap-pool has these parts:
6
+
7
+ - **`extension/`** — a vendored xTap Chrome extension that keeps local captures
8
+ and syncs them to a shared Hugging Face Space.
9
+ - **`space/`** — a Hugging Face Docker Space that authenticates contributors,
10
+ validates and deduplicates submissions, and commits them to an immutable raw
11
+ Bucket log before it returns success.
12
+ - **`explorer/`** — a React web interface for browsing, filtering, searching,
13
+ and administering the pool.
14
+ - **Consumer API** — revision-consistent enriched conversation-author units for
15
+ downstream applications.
16
+
17
+ The private raw Bucket is the system of record. A separate private Bucket holds
18
+ replaceable, checksum-verified SQLite index generations.
19
+
20
+ See [Move pool storage to an immutable Bucket log](docs/2026-08-12-bucket-object-log-plan.md)
21
+ for the storage contract and cutover procedure.
22
+ [Incremental consumer reads and engagement history](docs/2026-09-07-incremental-consumer-and-engagement-history-plan.md)
23
+ describes cursor-based changes and bounded observation history for downstream applications.
24
+ The other implementation plans under [`docs/`](docs/) record earlier design work.
25
+
26
+ ## Set up a pool
27
+
28
+ You need Node.js 22 or newer, npm, the Hugging Face CLI, and an active
29
+ `hf auth login` with write access to the target namespace.
30
+
31
+ ```sh
32
+ npm ci
33
+ npm run setup
34
+ ```
35
+
36
+ Setup creates or verifies the private raw and index Buckets and the public
37
+ Docker Space. It configures the Space, publishes the first SQLite index, and
38
+ verifies a storage token with read/write access to exactly both Buckets.
39
+
40
+ New production pools write their initial configuration through the Bucket log.
41
+ The migration commands below are only for the one-time dataset cutover.
42
+
43
+ ## Diagnose and repair
44
+
45
+ Inspect an existing pool without changing secrets or restarting it:
46
+
47
+ ```sh
48
+ npm run doctor -- osolmaz/xtap-pool
49
+ npm run --silent doctor -- osolmaz/xtap-pool --json
50
+ ```
51
+
52
+ Run bounded repair when deployment configuration or the scheduled enrichment
53
+ Job is missing or invalid:
54
+
55
+ ```sh
56
+ npm run doctor -- osolmaz/xtap-pool --fix
57
+ ```
58
+
59
+ The repair flow prompts privately for replacement credentials when required.
60
+ The storage token must cover the raw and index Buckets. The inference token must
61
+ be a separate token with the `Make calls to Inference Providers` permission.
62
+ The flow does not copy credentials from another store, print them, or save them
63
+ locally.
64
+
65
+ Run the two-Job recovery canary while the schedule remains suspended. The
66
+ default 32-call configuration has a combined hard ceiling above $5, so record
67
+ the operator's explicit cumulative approval in the command:
68
+
69
+ ```sh
70
+ npm run doctor -- osolmaz/xtap-pool --fix --canary \
71
+ --approved-cost-ceiling-usd=25
72
+ ```
73
+
74
+ Enable the recurring schedule only after the canary passes and the operator
75
+ confirms the displayed recurring cost:
76
+
77
+ ```sh
78
+ npm run doctor -- osolmaz/xtap-pool --fix --canary \
79
+ --approved-cost-ceiling-usd=25 --enable-schedule
80
+ ```
81
+
82
+ The approved ceiling must cover the two-Job canary. It does not change either
83
+ Job's configured run limit.
84
+
85
+ ## Update an existing pool
86
+
87
+ ```sh
88
+ npm run update
89
+ npm run update -- osolmaz/xtap-pool
90
+ # Only after the pinned import and index verification:
91
+ npm run update -- osolmaz/xtap-pool \
92
+ --verified-bucket-cutover=/secure/path/import-report.json
93
+ ```
94
+
95
+ Update reads the current `RAW_BUCKET` and `INDEX_BUCKET` variables, keeps all
96
+ secrets, uploads the current Space code, and reconciles missing variables. It
97
+ removes the retired `DATASET_REPO` variable. It does not create or rotate
98
+ signing or session secrets.
99
+
100
+ A reviewed deployment can continue an unfinished enrichment plan from an older
101
+ worker revision only through one exact immutable revision handoff. The updater
102
+ must first prove that the canonical schedule is suspended, no enrichment Job is
103
+ active, and the active plan and checkpoint fully verify. It records the
104
+ secret-free handoff in `.xtap-deployment.json` in the Space commit. It does not
105
+ change any plan, checkpoint, result, claim, receipt, pointer, index, database,
106
+ or manifest in either Bucket.
107
+
108
+ The worker normally requires the plan worker revision to equal the deployed
109
+ source revision. A mismatch is valid only when the deployment manifest pins the
110
+ exact active generation, activation, run, plan, contract, source snapshot,
111
+ predecessor and target worker revisions, checkpoint pointer, sequence, object,
112
+ digest, and size. The worker verifies the complete handoff and restores the
113
+ pinned checkpoint before it creates a provider or any write-capable runtime
114
+ object. Missing, stale, conflicting, malformed, or forked identities fail
115
+ closed. There is no revision list, wildcard, fallback, or environment override.
116
+
117
+ Keep the schedule suspended and do not run a paid Job while preparing,
118
+ reviewing, deploying, or restore-testing this handoff. After deployment, verify
119
+ the exact Space repository and runtime revisions, manifest, health, one
120
+ suspended non-concurrent schedule, zero active Jobs, exact restore, zero orphan
121
+ segments, zero provider calls, and unchanged pointers and object listings. A
122
+ later paid retry needs its own cost and non-overlap gate. See
123
+ [Add small worker checkpoints](docs/2026-08-19-small-worker-checkpoints-plan.md#reviewed-worker-revision-handoff)
124
+ for the complete contract and repair plan.
125
+
126
+ The lower-level deployment script is also available. Supplying
127
+ `XTAP_STORAGE_TOKEN` authorizes the script to bootstrap the index and install
128
+ that value as the Space `HF_TOKEN` secret. An existing pool also requires the
129
+ verified report from the pinned final dataset revision. Run it only after the
130
+ coordinated cutover has paused legacy writers and removed `DATASET_REPO`. It
131
+ checks that the dataset head still equals the imported revision before it can
132
+ bootstrap the index. Use `ALLOW_EMPTY_POOL=1` only for a confirmed new pool.
133
+
134
+ ```sh
135
+ scripts/deploy-space.sh <namespace> # creates resources, then stops for credentials
136
+ PINNED_IMPORT_REPORT=/secure/path/import-report.json \
137
+ XTAP_STORAGE_TOKEN=... scripts/deploy-space.sh <namespace>
138
+ ```
139
+
140
+ ## Import the retired dataset
141
+
142
+ These commands read one explicit 40-character dataset commit only. They are
143
+ migration tools and are not part of the runtime storage path.
144
+
145
+ ```sh
146
+ npm run storage:import -- \
147
+ --dataset osolmaz/xtap-pool-data \
148
+ --revision <40-character-sha> \
149
+ --raw-bucket osolmaz/xtap-pool-data \
150
+ --report /secure/path/import-report.json \
151
+ --work-dir /secure/path/work
152
+
153
+ npm run storage:verify -- \
154
+ --dataset osolmaz/xtap-pool-data \
155
+ --revision <same-sha> \
156
+ --raw-bucket osolmaz/xtap-pool-data \
157
+ --report /secure/path/verify-report.json \
158
+ --work-dir /secure/path/work
159
+ ```
160
+
161
+ The importer validates every approved record, writes deterministic immutable
162
+ segments, creates an exact snapshot, and compares source and target counts and
163
+ digests. Reports contain hashes and counts, not post text. Existing report paths
164
+ are never overwritten.
165
+
166
+ Build a fresh SQLite projection from only the two Buckets:
167
+
168
+ ```sh
169
+ RAW_BUCKET=osolmaz/xtap-pool-data \
170
+ INDEX_BUCKET=osolmaz/xtap-pool-bucket \
171
+ HF_TOKEN=... \
172
+ npm run index:bootstrap
173
+ ```
174
+
175
+ ## Pool administration
176
+
177
+ Admins manage members and one allowed Hugging Face organization from the Space
178
+ **Admin** tab. Membership is stored in the raw Bucket at `config/pool.json`.
179
+ The Space bootstrap variables remain recovery inputs.
180
+
181
+ Admins can also issue read-only service credentials. The raw credential is
182
+ shown once; only its hash is stored in `config/service-accounts.json`.
183
+
184
+ ## Scheduled enrichment
185
+
186
+ Production enrichment runs in one non-concurrent scheduled Hugging Face Job.
187
+ The private raw Bucket is the system of record. The SQLite index is a
188
+ replaceable public read model.
189
+
190
+ A logical run freezes one source snapshot, queue order, registry order, and
191
+ enrichment contract. Each physical Job restores the small active plan and
192
+ compact checkpoint, then processes only missing ordinals. It does not download
193
+ the public SQLite database during normal queue or registry work. The completed
194
+ logical run restores the frozen base once, applies its verified result batches,
195
+ and publishes a new checksum-verified index.
196
+
197
+ After publication activates a verified successor, the physical Job stops.
198
+ The next scheduled Job restores that successor and processes only its unresolved
199
+ queue or registry work. This keeps one physical Job tied to one logical run and
200
+ prevents a completed run from starting work that cannot finish before the
201
+ platform timeout.
202
+
203
+ Run enrichment manually only for local development:
204
+
205
+ ```sh
206
+ npm run enrich --workspace space
207
+ ```
208
+
209
+ The scheduled production command is `space/dist/src/enrich-job-main.js`. The
210
+ schedule must remain non-concurrent and use only `HF_TOKEN` and
211
+ `INFERENCE_TOKEN`. Setup doctor owns schedule creation, replacement, canaries,
212
+ and activation. Keep a replacement schedule suspended until bounded restore,
213
+ interruption recovery, final publication, successor activation, and the
214
+ required two-Job recovery canary have passed.
215
+
216
+ The checked-in defaults give each Job a 40-minute worker budget, a 45-minute
217
+ platform timeout, and a $10 inference limit. Each scheduled Job runs one logical
218
+ plan and leaves its verified successor for the next Job. The operator-approved
219
+ cumulative ceiling must cover both canary Jobs before either starts. The web
220
+ Space keeps enrichment disabled, and GitHub Actions remains CI-only.
221
+
222
+ Use an explicit rebuild only as a recovery operation:
223
+
224
+ ```sh
225
+ npm run index:bootstrap
226
+ ```
227
+
228
+ See [Add small worker checkpoints](docs/2026-08-19-small-worker-checkpoints-plan.md)
229
+ for the logical-run contract, production transition gates, and failure rules.
230
+
231
+ ## Join a pool
232
+
233
+ 1. Load `extension/` unpacked from `chrome://extensions` in Developer mode.
234
+ 2. Select the extension icon, select **Connect**, and sign in with Hugging Face.
235
+ 3. Browse X. Captures sync to the pool automatically.
236
+
237
+ Local JSONL saving stays available when the optional daemon/native host under
238
+ `extension/native-host/` is installed. Image downloads remain disabled in the
239
+ pool deployment.
240
+
241
+ ## Development
242
+
243
+ ```sh
244
+ npm ci
245
+ npm run check
246
+ ```
247
+
248
+ ## License
249
+
250
+ [MIT](LICENSE)
docs/2026-07-06-implementation-plan.md ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "xtap-pool — Implementation Plan"
3
+ author: "Bob <dutifulbob@gmail.com>"
4
+ date: "2026-07-06"
5
+ ---
6
+
7
+ # xtap-pool — Implementation Plan
8
+
9
+ ## Problem
10
+
11
+ A group of friends each run [xTap](https://github.com/mkubicek/xTap) (a Chrome
12
+ MV3 extension that passively captures X/Twitter tweets from GraphQL responses
13
+ into daily JSONL files). We want to pool everyone's captures in one private,
14
+ centralized place where:
15
+
16
+ - uploading happens through each person's own Hugging Face account,
17
+ - every tweet is attributed to whoever captured it,
18
+ - the pool is browsable and filterable (by contributor, author, date, text),
19
+ - nobody can damage anyone else's data (no shared write access, no force
20
+ pushes),
21
+ - and nobody runs or maintains a server.
22
+
23
+ ## Architecture
24
+
25
+ Three components in this monorepo, one durable store on the HF Hub:
26
+
27
+ ```
28
+ ┌─────────────────────┐
29
+ │ extension/ │ vendored xTap fork ("xtap-pool")
30
+ │ captures tweets │ keeps local JSONL saving (optional daemon),
31
+ │ on x.com │ adds a sync queue → POST /api/ingest
32
+ └─────────┬───────────┘
33
+ │ HTTPS, Bearer pool token (per user, signed)
34
+ ▼
35
+ ┌─────────────────────┐ ┌──────────────────────────────┐
36
+ │ space/ │ │ HF dataset repo (private) │
37
+ │ private HF Docker │─────▶│ data/<user>/YYYY/MM/ │
38
+ │ Space (Hono, TS) │commit│ tweets-YYYY-MM-DD.jsonl │
39
+ │ the ONLY writer │ │ system of record, git-backed │
40
+ └─────────┬───────────┘ └──────────────────────────────┘
41
+ │ serves
42
+ ▼
43
+ ┌─────────────────────┐
44
+ │ explorer/ │ Vite + React + shadcn/ui
45
+ │ browse & filter UI │ tweet cards styled after solmaz.io
46
+ └─────────────────────┘
47
+ ```
48
+
49
+ Design invariants:
50
+
51
+ 1. **The dataset repo is the system of record.** The Space is stateless glue:
52
+ everything it holds locally (SQLite index) is a cache rebuilt from the
53
+ dataset snapshot on boot. The Space can be deleted and recreated without
54
+ data loss.
55
+ 2. **The Space is the only writer.** Friends never get write access to the
56
+ dataset repo; a fine-grained HF token scoped to the one dataset repo lives
57
+ as a Space secret. Force pushes by contributors are structurally
58
+ impossible.
59
+ 3. **Attribution is enforced server-side.** The contributor identity comes
60
+ from the verified HF OAuth login / signed pool token, never from
61
+ client-supplied fields.
62
+ 4. **Local capture behavior is unchanged.** The vendored extension saves
63
+ tweets exactly like upstream xTap when the local daemon/native host is
64
+ installed. Pool sync is additive. Friends who don't want local copies can
65
+ skip the daemon entirely and run pool-only.
66
+
67
+ ### Why no Gradio
68
+
69
+ The explorer is a real TypeScript/shadcn UI, and the ingest API is a plain
70
+ HTTP endpoint. Wrapping either in Gradio would add a Python layer whose only
71
+ job is to host things we build elsewhere. A **Docker Space** running a single
72
+ Node process (Hono) serves the API and the built explorer with less moving
73
+ machinery, one language across the repo, and full control over auth/session
74
+ behavior. HF OAuth (`hf_oauth: true`) works identically for Docker Spaces.
75
+
76
+ ## Data model
77
+
78
+ ### Dataset repo layout
79
+
80
+ ```
81
+ data/<hf-username>/YYYY/MM/tweets-YYYY-MM-DD.jsonl
82
+ ```
83
+
84
+ - One JSONL line per captured tweet, in the **exact xTap output format**
85
+ (see upstream README "Output Format"), plus two stamped fields:
86
+ - `contributed_by`: HF username (verified server-side)
87
+ - `pooled_at`: ISO timestamp of ingestion
88
+ - Daily file selected by tweet `captured_at` (UTC), matching xtap-sync
89
+ conventions.
90
+ - Per-user dedup at ingest: a `(contributed_by, id)` pair is stored at most
91
+ once; re-submissions with newer `captured_at` update the row (fresher
92
+ metrics) — implemented as append + latest-wins on read, with periodic
93
+ compaction as a later optimization (not in MVP).
94
+ - Cross-user dedup happens at query time (same tweet captured by several
95
+ friends collapses into one card with contributor chips).
96
+
97
+ Attribution therefore exists at three levels: file path, `contributed_by`
98
+ field, and Space commit messages (`pool: <user> +N tweets (YYYY-MM-DD)`).
99
+
100
+ ### Shared schema
101
+
102
+ `shared/` package: TypeScript types + [zod] validation for the xTap tweet
103
+ object (superset-tolerant: unknown fields pass through untouched so upstream
104
+ xTap format evolution doesn't break ingestion; only structurally required
105
+ fields are enforced: `id`, `url`, `author.username`, `captured_at`, `text`).
106
+ Used by the extension (pre-send sanity check), Space (ingest validation), and
107
+ explorer (rendering types).
108
+
109
+ ## Auth & UX — the click budget
110
+
111
+ Target friend onboarding: **install extension, one OAuth authorize, done.**
112
+
113
+ 1. Friend loads the extension (unpacked from a GitHub release zip).
114
+ 2. Extension popup shows **"Connect to pool"** → opens the Space's
115
+ `/connect` page.
116
+ 3. The Space app itself is public (a private Space would gate friends who
117
+ are not repo collaborators before the app even runs) → the app shows
118
+ **"Sign in with Hugging Face"** (one click if already logged into HF).
119
+ Everything beyond the sign-in page is enforced in-app by the allowlist;
120
+ the dataset repo stays private.
121
+ 4. `/connect` verifies the HF identity against the pool membership config
122
+ (individual users plus one allowed HF organization), mints a **pool token**,
123
+ and renders it in the page DOM.
124
+ 5. A content script (matching the Space origin only) picks the token up
125
+ automatically and stores it in `chrome.storage.local`. Popup flips to
126
+ "Connected as @user". **No copy-paste, no manual HF token creation.**
127
+
128
+ Pool token design: stateless signed token (HMAC-SHA256 with a Space secret;
129
+ payload = username + expiry + an optional OAuth-proven organization ID). The
130
+ Space verifies signatures against current pool membership. Explicit user tokens
131
+ last ~180 days; organization-derived tokens are shorter-lived so org removals
132
+ take effect without keeping HF access tokens. Revocation = remove the user/org
133
+ grant or rotate the signing secret (re-connect is one click). A "paste token
134
+ manually" field in extension options is the fallback for browsers where the
135
+ content-script handoff fails.
136
+
137
+ Only one organization grant is active. Multiple organization grants are
138
+ deprecated because Hugging Face OAuth `orgIds` behaves like a required-org check
139
+ rather than an any-of-orgs check. The `member_orgs` config field stays as an
140
+ array for compatibility, but admin updates replace the active organization.
141
+
142
+ Explorer access: same HF login; session cookie (signed, httpOnly) issued after
143
+ OAuth. Friends need **zero** repo permissions — the app-level pool membership
144
+ config is the entire access system. Optionally friends can also be given read
145
+ access on the dataset repo for `load_dataset`/DuckDB power use; not required for
146
+ any flow.
147
+
148
+ ## Components
149
+
150
+ ### `extension/` — vendored xTap fork
151
+
152
+ - Vendor `mkubicek/xTap` at a pinned commit; record SHA + local modifications
153
+ in `extension/VENDORED.md`. Keep upstream MIT `LICENSE` in place. Diet:
154
+ keep everything (incl. native host + daemon for local saving); rename
155
+ extension to "xtap-pool", new icons deferred.
156
+ - Keep upstream code style (vanilla JS, MV3, no bundler) to minimize the
157
+ vendor diff and keep future re-syncs cheap.
158
+ - New module `lib/pool-sync.js` wired into `background.js` after the
159
+ existing parse/dedup step:
160
+ - append captured tweets to a persistent **sync queue**
161
+ (`chrome.storage.local`, `unlimitedStorage` permission);
162
+ - flush in batches (≤500 tweets or 20s debounce) via
163
+ `fetch(POOL_URL + '/api/ingest', {Authorization: Bearer <pool token>})`;
164
+ - at-least-once delivery: batch removed from queue only on 2xx; server
165
+ dedup makes retries safe; exponential backoff on failure; queue survives
166
+ service-worker restarts and offline periods.
167
+ - Popup additions: connect button, sync status (queued / synced counts,
168
+ last error), pause pool sync independently of capture.
169
+ - Options: Space URL (default baked in), manual token fallback.
170
+ - Manifest additions: `unlimitedStorage`; host permission + content script
171
+ for the Space origin (token handoff).
172
+
173
+ ### `space/` — HF Docker Space (TypeScript, Hono, Node 22)
174
+
175
+ Endpoints:
176
+
177
+ | Route | Auth | Purpose |
178
+ | --------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
179
+ | `GET /` + static | session | serves built explorer |
180
+ | `GET /oauth/login` → `/oauth/callback` | — | HF OIDC code flow, sets session cookie, enforces pool membership |
181
+ | `GET /connect` | session | mints + renders pool token for extension pickup |
182
+ | `POST /api/ingest` | pool token | validate (zod), stamp, dedup, persist |
183
+ | `GET /api/tweets` | session | filters: `contributors`, `author`, `q` (FTS), `since`/`until`, `has_media`, `is_article`, `dedup=true`, cursor pagination |
184
+ | `GET /api/units` | session or `units:read` service account | complete enriched conversation-author units with revision-bound cursor pagination |
185
+ | `GET /api/labels`, `/api/free-labels`, `/api/graph` | session or `taxonomy:read` service account | revision-consistent enrichment vocabulary and graph reads |
186
+ | `/api/admin/service-accounts/*` | admin session | issue, rotate, and revoke hashed, scoped machine credentials |
187
+ | `GET /api/contributors` | session | per-user counts, last sync |
188
+ | `GET /healthz` | — | liveness |
189
+
190
+ Storage engine inside the Space: **SQLite** (`better-sqlite3`) on ephemeral
191
+ disk + FTS5 for text search. Boot: download dataset snapshot (`@huggingface/hub`)
192
+ → rebuild index (idempotent). Ingest: insert new rows → append to the day's
193
+ JSONL → **commit to the dataset repo in the same request** (no local-only
194
+ buffering; ephemeral disk must never hold unpersisted data). Commits use
195
+ `@huggingface/hub` `commit()` with retry; concurrent ingests serialized
196
+ through a single writer queue.
197
+
198
+ Config via Space secrets/variables: `HF_TOKEN` (fine-grained, read/write on
199
+ the one dataset repo), `POOL_SIGNING_SECRET`, `SESSION_SECRET`,
200
+ `ALLOWED_USERS` (initial comma-separated HF usernames), `POOL_ADMINS`
201
+ (bootstrap admins), `DATASET_REPO`. After bootstrap, durable pool membership
202
+ lives in `config/pool.json` in the private dataset repo and is managed from the
203
+ Space Admin tab.
204
+
205
+ README metadata: `sdk: docker`, `hf_oauth: true`, scopes `openid profile`.
206
+
207
+ ### `explorer/` — Vite + React + TS + Tailwind + shadcn/ui
208
+
209
+ Views:
210
+
211
+ - **Feed** — infinite scroll over `/api/tweets`; left filter rail:
212
+ contributor checkboxes, author filter, date range, media/article toggles,
213
+ search box; dedup toggle (default on) collapsing cross-user duplicates
214
+ into one card with contributor chips.
215
+ - **Tweet detail** — full card incl. quoted tweet, article rendering, link
216
+ out to x.com.
217
+ - **Stats** — per-contributor counts, capture timeline (simple bars).
218
+
219
+ Tweet rendering ported from the solmaz.io blog's X display (author's own
220
+ prior art, visually faithful to X):
221
+ `~/repos/solmazio/_sass/minima/_x_cards.scss`, `_x_shell.scss`,
222
+ `_x_responsive.scss`, and `_includes/tweet.html` / `x_quoted_tweet.html` /
223
+ `x_tweet_media.html` — avatar/content grid, identity row, media grid,
224
+ quoted-tweet nesting, metrics row; translated to Tailwind + CSS variables,
225
+ light/dark via `prefers-color-scheme` + toggle.
226
+
227
+ ### Quality gates — Slophammer
228
+
229
+ - Root `slophammer.yml` with `typescript` targets (`shared`, `space`,
230
+ `explorer`) per the slophammer TS template: strict `tsconfig`, ESLint,
231
+ Vitest with coverage thresholds, duplication (dry) budget.
232
+ - The vendored `extension/` is excluded from slophammer structural rules
233
+ (upstream style preserved) but its parser/sync tests run in CI.
234
+ - CI (GitHub Actions): typecheck, lint, tests + coverage for all workspaces,
235
+ explorer + space build, pinned `slophammer` GitHub Action.
236
+ - Conventional Commits throughout.
237
+
238
+ ## Delivery phases
239
+
240
+ Each phase lands as commits on this PR's branch; the PR stays green.
241
+
242
+ 1. **Scaffolding** — pnpm workspaces (`shared`, `space`, `explorer`),
243
+ slophammer config + CI, strict TS/ESLint/Vitest baselines.
244
+ 2. **Shared schema** — tweet types + zod validators + fixtures drawn from
245
+ real xTap output data (sanitized); unit tests.
246
+ 3. **Space backend** — OAuth + sessions + allowlist, pool tokens, ingest
247
+ pipeline (validate → stamp → dedup → JSONL append → hub commit), SQLite
248
+ index + query API, boot-time rebuild. Hub layer mocked in tests; real
249
+ round-trip in phase 6.
250
+ 4. **Explorer** — components, feed/filters/detail/stats, API client; built
251
+ output served by the Space.
252
+ 5. **Extension vendoring** — vendor xTap at pinned SHA, rename, `pool-sync`
253
+ queue + flush + backoff, connect flow (content-script token handoff),
254
+ popup/options additions; Vitest/node tests for queue and flush logic.
255
+ 6. **Deploy + E2E** — create private dataset repo + private Docker Space
256
+ under the `dutifuldev` HF org (exists already; friends never need org
257
+ membership — the allowlist governs access), set secrets, import existing
258
+ xTap output under `data/osolmaz/`, live round-trip:
259
+ connect → ingest fixture batch → verify dataset commit → explore in UI.
260
+
261
+ ## Testing
262
+
263
+ - Unit: schema validation, token sign/verify, dedup, JSONL day-bucketing,
264
+ queue flush/backoff, ingest handler (hub mocked), query filters (SQLite
265
+ in-memory), React component tests for tweet card edge cases (RT, quote,
266
+ article, media grid).
267
+ - Integration (local): full Space against a temp dir + mocked hub remote;
268
+ curl ingest with a real signed token; explorer `vite build` served by the
269
+ Space; manual browser pass.
270
+ - Live (after deploy): real OAuth + ingest + commit verified on the Hub;
271
+ documented in the PR. Chrome extension manual test on x.com (capture →
272
+ queue → sync → visible in explorer).
273
+
274
+ **Known non-automatable pieces:** the HF OAuth browser dance and the Chrome
275
+ extension's on-x.com capture path need manual verification; everything below
276
+ them is covered by tests.
277
+
278
+ ## Risks / notes
279
+
280
+ - **Space sleep**: free CPU Spaces sleep when idle; first hit wakes in
281
+ ~30–60 s. Extension backoff absorbs this transparently (retry until 2xx).
282
+ Upgrade to always-on hardware later if it annoys anyone.
283
+ - **Commit contention**: single-writer queue in the Space serializes hub
284
+ commits; friend-scale volume is far below HF rate limits.
285
+ - **Upstream xTap drift**: vendored at a SHA with `VENDORED.md`; re-sync is
286
+ a deliberate manual step. Long-term option: upstream the sync feature into
287
+ xTap and shrink this vendor copy.
288
+ - **Token leak blast radius**: a pool token only allows _adding_ tweets as
289
+ that user; dataset write token never leaves the Space; both rotate via
290
+ Space secrets.
291
+ - **Ephemeral disk**: no unpersisted state by construction (commit per
292
+ ingest request).
docs/2026-07-27-unit-consumer-api.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "Unit consumer API"
3
+ author: "Onur Solmaz <2453968+osolmaz@users.noreply.github.com>"
4
+ date: "2026-07-27"
5
+ ---
6
+
7
+ # Unit consumer API
8
+
9
+ The machine contract below is superseded by the [incremental HTTP contract](2026-09-07-consumer-http-handoff.md). `/api/units` now requires explicit pinned bootstrap; it no longer returns the old full-read envelope. Explorer whole-unit browsing uses the separate session-only `/api/explorer/units` route.
10
+
11
+ The [incremental consumer and engagement-history plan](2026-09-07-incremental-consumer-and-engagement-history-plan.md) records the planned replacement for the normal full-read and boot-revision pagination procedures below. It includes repeated-observation delivery, historical counts, removals, and stable source cursors. These changes are not implemented yet; this document continues to describe the existing consumer path.
12
+
13
+ xtap-pool is the semantic source of truth for downstream applications. Consumers read enriched conversation-author units instead of scanning the private dataset or rebuilding labels, threads, free-label vocabulary, and graph edges.
14
+
15
+ This document describes the target consumer contract in [Labels and Free Labels Implementation Plan](2026-07-28-labels-and-free-labels-implementation-plan.md) and [Durable Enrichment Implementation Plan](2026-07-28-durable-enrichment-implementation-plan.md). Production keeps serving the previous contract until the replacement reader and worker are complete and the migration recovery canary passes.
16
+
17
+ ## Service accounts
18
+
19
+ Admins issue service accounts from the explorer's **Admin** tab. A service account has a stable name and explicit read scopes. One or more rotatable keys authenticate it:
20
+
21
+ - `units:read` authorizes `GET /api/units`.
22
+ - `taxonomy:read` authorizes `GET /api/labels` and `GET /api/free-labels/:name`. It also authorizes `GET /api/graph`.
23
+
24
+ The raw credential is shown once. xtap-pool stores only its SHA-256 hash in `config/service-accounts.json` in the private dataset. Keys use 256 bits of random secret material, expire after 365 days, can overlap during rotation, and can be revoked individually. Revoking an account removes every active key. Service credentials never authorize ingest, enrichment runs, membership changes, or administration.
25
+
26
+ Store the raw credential only in the consumer's secret store and send it as:
27
+
28
+ ```http
29
+ Authorization: Bearer xtap_sa_...
30
+ ```
31
+
32
+ Creating or copying the credential into another secret store is an explicit operator action. Do not reuse `HF_TOKEN`, `INFERENCE_TOKEN`, or a contributor pool token.
33
+
34
+ ## Enriched units
35
+
36
+ ```http
37
+ GET /api/units?author_ids=123,456&labels=ai,local-models&label_mode=any&publication=public-original&limit=200
38
+ ```
39
+
40
+ The endpoint accepts contributor and author filters. It also supports text and date filters, media and article filters, and both label kinds. It also accepts `unlabeled` and `limit`. `author_ids` is a comma-separated exact allowlist of immutable X user IDs. It does not fuzzy-match handles. `label_mode` is `any` or `all`; consumers should set it explicitly.
41
+
42
+ `publication=public-original` excludes an entire unit if any member post is subscriber-only and excludes units containing only retweets. Use this filter for public projections. Apply the same parameter to label and graph reads so label metadata and edge weights come from exactly the publishable unit set.
43
+
44
+ Each item is one complete conversation-author unit:
45
+
46
+ ```json
47
+ {
48
+ "revision": "boot-epoch:revision",
49
+ "units": [
50
+ {
51
+ "id": "conversation-id:author",
52
+ "posts": [],
53
+ "contributors": ["osolmaz"],
54
+ "preset_labels": [
55
+ {
56
+ "name": "ai",
57
+ "evidence": [{ "tweet_id": "123", "quote": "frontier AI" }]
58
+ }
59
+ ],
60
+ "free_labels": [
61
+ {
62
+ "name": "open-weight-model",
63
+ "evidence": [{ "tweet_id": "123", "quote": "open-weight frontier model" }]
64
+ }
65
+ ]
66
+ }
67
+ ],
68
+ "next_cursor": "opaque"
69
+ }
70
+ ```
71
+
72
+ Posts are ordered oldest first. Units are ordered by their latest post, newest first. Only units enriched under the active contract are returned. Consumer responses include approved free labels only; candidate and rejected labels remain private to administration reads.
73
+
74
+ ## Consistent pagination
75
+
76
+ The first page returns a result `revision`. Every cursor embeds that revision. A write that changes served tweets, label assignments, free-label registry state, or graph data advances it. Continuing an older cursor returns HTTP `409`:
77
+
78
+ ```json
79
+ {
80
+ "error": "the unit result changed during pagination; restart from the first page",
81
+ "current_revision": "..."
82
+ }
83
+ ```
84
+
85
+ Discard the partial result and restart from page one. Invalid cursors return `400`.
86
+
87
+ The label, free-label detail, and graph responses also return `revision`, `contract_hash`, and numeric `free_label_registry_revision`. When supplied, they echo the shared `cutoff`. Pass the unit revision as their `revision` query parameter. Free-label and graph reads accept the same `author_ids=<csv>`, `labels=<csv>`, `label_mode=any|all`, and `publication=public-original` selection. They return only approved free-label data from matching publishable units. A revision mismatch returns `409`, preventing one publication from mixing source states or counting authors outside the selected boundary.
88
+
89
+ `GET /api/enrichment/status` returns the same revision and contract metadata, the normalized exact `author_ids` selection, queue totals, and the latest `complete_through` cutoff. Consumers must use that exact cutoff and revision for every subsequent unit or taxonomy read. They do not need to wait for queue totals to reach zero. The totals and cutoff age show how far the projection has caught up.
90
+
91
+ ## Static publication
92
+
93
+ A static downstream application should:
94
+
95
+ 1. Require `GET /readyz` to be ready.
96
+ 2. Load and validate the consumer's exact author-ID allowlist.
97
+ 3. Read enrichment status and choose its returned revision and `complete_through` cutoff even when backlog counts are nonzero.
98
+ 4. Download every unit page at that cutoff with the exact `author_ids` selection into a temporary local result.
99
+ 5. Fetch approved free-label and graph data at the same revision and cutoff with the same author IDs.
100
+ 6. Validate the complete response and reject any unit outside the allowlist.
101
+ 7. Upload an immutable snapshot.
102
+ 8. Replace a small current-manifest object only after the snapshot is uploaded and verified.
103
+
104
+ On any error, leave the previous manifest unchanged. A later successful run advances the cutoff as scheduled enrichment catches up. Never expose the service credential to browser code and do not silently fall back to raw dataset scanning.
docs/2026-07-28-durable-enrichment-implementation-plan.md ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "Durable Enrichment Implementation Plan"
3
+ author: "Onur Solmaz <2453968+osolmaz@users.noreply.github.com>"
4
+ date: "2026-07-28"
5
+ ---
6
+
7
+ # Durable Enrichment Implementation Plan
8
+
9
+ ## Status
10
+
11
+ Implemented. Production schedule activation remains gated on deployment, purpose-scoped Job credentials, the live two-Job recovery canary, and explicit approval for recurring paid work. The existing capture and membership contracts stay in place, along with service accounts and consumer authorization.
12
+
13
+ ## Problem
14
+
15
+ The dataset contains durable tweets and enrichment results, but the work queue lives only in the Space's SQLite cache. A unit is currently considered complete when a result covers its member tweet IDs under the active taxonomy. The result does not bind the text, links, prompt, output schema, or processor version that produced it.
16
+
17
+ A failed unit stops after three attempts and stays failed until the Space restarts or its membership changes. The scheduler chooses the oldest queue update, so a large historical backlog can keep recent captures out of downstream feeds. Deployments also interrupt the worker because it runs inside the web process.
18
+
19
+ The system needs one stronger guarantee: every current unit is either backed by a matching durable result or appears as pending, retrying, or blocked work. No unit may disappear from the backlog.
20
+
21
+ ## Boundaries
22
+
23
+ xtap-pool remains responsible for conversation-author units, preset labels, free labels, the approved free-label graph, privacy filtering, and revision-consistent consumer reads. The classifier produces only preset labels and free labels, as specified in [Labels and Free Labels Implementation Plan](labels-and-free-labels-implementation-plan.md).
24
+
25
+ This work does not:
26
+
27
+ - hardcode Local Frontier accounts or any other consumer selection;
28
+ - move model-to-canonical-Hugging-Face association into xtap-pool;
29
+ - change capture attribution, contributor authorization, or dataset ownership;
30
+ - expose private posts or credentials;
31
+ - make SQLite a durable source of truth.
32
+
33
+ ## Completion contract
34
+
35
+ A boolean `processed` field is not sufficient because unit contents and processor behavior can change.
36
+
37
+ Every unit gets two deterministic digests:
38
+
39
+ - `input_hash` covers the ordered semantic input: unit ID, member tweet IDs, text, expanded URLs, reply and quote references, privacy flags, and other fields used by the prompt. Capture metrics that do not affect enrichment stay outside the hash.
40
+ - `contract_hash` covers the taxonomy, prompt template, output schema, normalization rules, configured model, and processor implementation version.
41
+
42
+ An enrichment result is current only when:
43
+
44
+ 1. its `unit_id` matches the unit;
45
+ 2. its `tweet_ids` cover the current membership;
46
+ 3. its `input_hash` matches the current semantic input;
47
+ 4. its `contract_hash` matches the active processor contract;
48
+ 5. its taxonomy version matches the active taxonomy.
49
+
50
+ Any mismatch creates pending work automatically. Consumer endpoints continue returning only current completed units.
51
+
52
+ ## Durable records
53
+
54
+ ### Enrichment results
55
+
56
+ Append-only enrichment rows gain `input_hash` and `contract_hash`. They store exactly two classifier outputs. Both may be empty, and every assignment carries evidence from a member tweet:
57
+
58
+ ```json
59
+ {
60
+ "unit_id": "conversation-id:author",
61
+ "tweet_ids": ["123"],
62
+ "input_hash": "<sha256>",
63
+ "contract_hash": "<sha256>",
64
+ "preset_labels": [
65
+ {
66
+ "name": "ai",
67
+ "evidence": [{ "tweet_id": "123", "quote": "frontier AI" }]
68
+ }
69
+ ],
70
+ "free_labels": [
71
+ {
72
+ "name": "open-weight-model",
73
+ "evidence": [{ "tweet_id": "123", "quote": "open-weight frontier model" }]
74
+ }
75
+ ],
76
+ "model": "provider/model",
77
+ "taxonomy_version": 2,
78
+ "enriched_at": "2026-07-27T00:00:00.000Z"
79
+ }
80
+ ```
81
+
82
+ The worker accepts a preset label only when its name is configured and its evidence is valid. A free label must have a normalized name, valid evidence, and a registry state that permits assignment. New free labels enter the registry as private candidates. Approved labels may appear in consumer reads, while rejected labels are discarded and fed back to the prompt as negative examples.
83
+
84
+ The dataset commit must succeed before SQLite marks the unit complete. Replaying the dataset reconstructs the same completed set after a crash or replacement deployment.
85
+
86
+ ### Free-label registry events
87
+
88
+ The dataset also stores append-only discovery and lifecycle events for free labels. Each decision records its rule, supporting counts, representative quotes, timestamp, and registry revision. SQLite rebuilds all registry states from these events and current enrichment rows.
89
+
90
+ Registry state is operational metadata rather than another classifier output. The model still returns only preset labels and free labels. Candidate and rejected free labels cannot contribute to consumer units, counts, filters, or graph data.
91
+
92
+ ### Attempt events
93
+
94
+ Batch commits also append compact attempt events containing the unit ID, both hashes, attempt number, outcome, timestamp, retry time, and a bounded error classification. These events preserve retry and blocked state across restarts without storing credentials or provider response bodies.
95
+
96
+ The SQLite queue remains a projection. It holds the current status, attempts, next retry time, lease owner and expiry, first queued time, latest unit activity time, and bounded last error. It is rebuilt from tweets and durable enrichment records on boot.
97
+
98
+ ## Worker command
99
+
100
+ Enrichment moves behind a standalone bounded worker command. The API Space may invoke that command during development, but production scheduling must not depend on the web server's lifetime.
101
+
102
+ One run follows this order:
103
+
104
+ 1. Rebuild or refresh the desired work set.
105
+ 2. Recover expired leases.
106
+ 3. Claim a bounded batch with an expiring lease.
107
+ 4. Build prompts from the claimed input hashes.
108
+ 5. Call the configured inference provider with a request timeout.
109
+ 6. Validate and normalize the complete response.
110
+ 7. Commit results and attempt events to the dataset.
111
+ 8. Verify the commit, apply it to SQLite, and release the leases.
112
+ 9. Stop at the configured unit, token, time, error-rate, or cost ceiling.
113
+
114
+ The first production version uses one worker. More workers require a durable shared lease implementation and a separate cost review.
115
+
116
+ ## Hugging Face deployment and repair
117
+
118
+ Production execution stays on Hugging Face. The Docker Space serves capture, administration, and read APIs. A non-concurrent scheduled Hugging Face Job runs the standalone worker on `cpu-basic` with an explicit timeout. The private dataset remains the durable source of truth. GitHub Actions is limited to CI and cannot run, schedule, or hold credentials for production enrichment.
119
+
120
+ The setup doctor and repair command owns the scheduled Job as part of deployment reconciliation. It must:
121
+
122
+ 1. inspect active and scheduled Jobs before changing state;
123
+ 2. validate the exact Space revision, dataset repository, worker command, model, taxonomy, pricing, ceilings, timeout, and schedule;
124
+ 3. accept dataset-writer and inference tokens only through hidden interactive input or explicitly named environment inputs;
125
+ 4. verify dataset read/write access and inference-provider permission before creating a Job;
126
+ 5. create or replace the schedule with concurrency disabled and the schedule suspended;
127
+ 6. attach the two purpose-scoped values as encrypted Hugging Face Job secrets without printing or persisting them locally;
128
+ 7. trigger the two-Job bounded recovery canary through that suspended schedule;
129
+ 8. verify durable outputs, strict `JOB_ID`-bound receipts, source revision, contract continuity, cost accounting, continuation in a second physical Job, and absence of overlap; and
130
+ 9. resume the schedule only after every canary check succeeds and recurring paid work is explicitly approved.
131
+
132
+ Space secrets are scoped to the Space and their values are write-only. Repair cannot recover them by name, silently copy them into a Job, or mint access tokens. When Job secrets are missing or need rotation, the operator supplies the original values or purpose-scoped replacements. If required input is unavailable, capability validation fails, a transient Hub error prevents a trustworthy read, required approval is absent, or an active worker cannot be safely stopped at a durable boundary, repair leaves scheduling suspended and reports the blocker.
133
+
134
+ Hugging Face Job configuration is immutable for a physical run. Repair suspends the existing schedule and verifies that no matching Job is active before replacing a mismatched schedule. It does not mutate unrelated Jobs. Manual canaries and automatic runs use the same schedule identity and non-concurrency policy.
135
+
136
+ `doctor --fix` creates or repairs the suspended schedule. `doctor --fix --canary` runs two physical Jobs without resuming cron. `doctor --fix --canary --enable-schedule` presents the schedule and per-run ceiling, then requires explicit approval before recurring execution. The default ceiling is $2 of inference and 400,000 total tokens per physical Job. Including worst-case `cpu-basic` time, the two-Job canary has a cumulative hard ceiling below $4.02.
137
+
138
+ ## Scheduling and retries
139
+
140
+ Each claim reserves equal bounded capacity for recent and old work:
141
+
142
+ - newest pending units by latest unit activity;
143
+ - oldest pending units by first queued time.
144
+
145
+ Unused capacity from either side can be filled by the other. This keeps recent feeds moving while guaranteeing that continuous ingest cannot starve historical work.
146
+
147
+ Transient failures such as timeouts, rate limits, and provider 5xx responses use exponential backoff with jitter. Invalid model output gets a small bounded retry budget. A unit becomes `blocked` after the configured retry policy is exhausted. Blocked units remain visible, are not counted as complete, and receive infrequent bounded retries. A changed input or processor contract resets their retry state.
148
+
149
+ There is no silent terminal drop. The operational guarantee is that every unit is either current and complete or visible with a reason that prevents completion.
150
+
151
+ ## Freshness and status API
152
+
153
+ Add a revision-consistent endpoint authorized by `taxonomy:read`:
154
+
155
+ ```http
156
+ GET /api/enrichment/status?author_ids=123,456&publication=public-original
157
+ ```
158
+
159
+ The response reports:
160
+
161
+ - total, pending, running, retrying, blocked, and completed units;
162
+ - oldest pending and newest completed activity times;
163
+ - a `complete_through` timestamp for which every selected unit at or before that time has a current result;
164
+ - the current result revision and processor contract hash.
165
+
166
+ `author_ids` uses the same exact immutable-ID semantics as unit and taxonomy reads. The endpoint cannot filter by enrichment labels because pending units do not have current labels yet.
167
+
168
+ Unit, label, free-label detail, and graph reads gain a shared activity cutoff so a consumer can request one complete window ending at `complete_through`. The cutoff participates in the revision and must be applied identically to every response. Consumers may publish that completed window while queue totals remain nonzero. Backlog size and cutoff age describe staleness; they do not invalidate the window.
169
+
170
+ ## Existing-result migration
171
+
172
+ The migration is a hard replacement of the current completion rule.
173
+
174
+ An existing result can be adopted without another inference call only when all of the following are provable:
175
+
176
+ - its tweet IDs exactly cover current membership;
177
+ - no semantic capture is newer than the result;
178
+ - its taxonomy and model match the contract being adopted;
179
+ - it contains only preset and free-label assignments;
180
+ - every assignment carries evidence that passes the current schema and normalization rules.
181
+
182
+ All earlier model-generated free labels and concepts are discarded from the active projection. They are never adopted, copied into the new assignment arrays, promoted, or used to seed candidate records. Legacy rows remain only as append-only history and cannot contribute evidence, counts, filters, or graph edges. Curated preset taxonomy entries remain because their source is `config/labels.json`. Every source unit must receive a new evidence-bearing result under the active contract.
183
+
184
+ The migration appends a current row with both hashes. Every other result is queued for processing. After migration, runtime reads do not accept rows without the new hashes.
185
+
186
+ Before reprocessing any unverifiable rows, record their count, measured throughput, projected inference cost, the configured full-run ceiling, and the outputs that will be reused. Once the implementation checks and recovery canary pass, the worker continues through all queued units instead of stopping at a pilot-only spending boundary. Consumers can roll out against the current completed cutoff while this background convergence continues.
187
+
188
+ ## Observability and controls
189
+
190
+ Run receipts record successful units, retries, blocked units, provider calls, tokens, elapsed time, and cost when the provider reports it. They also record emitted assignments, labels discarded for missing evidence or rejected names, new candidates, and registry decisions. The Admin UI shows queue counts, cutoff age, the active contract hash, recent error classes, whether a worker is active, and the candidate, approved, and rejected free-label counts. Queue counts and cutoff age are operational signals rather than publication gates.
191
+
192
+ The worker stops automatically when:
193
+
194
+ - the configured cost or runtime ceiling is reached;
195
+ - the batch error rate crosses its limit;
196
+ - invalid or ungrounded label output crosses its limit;
197
+ - the dataset commit or result verification fails;
198
+ - the model, method, credential state, contract, or observed cost differs from the recorded full-backlog run.
199
+
200
+ A deterministic shared defect pauses further claims until the worker code and affected outputs are checked.
201
+
202
+ ## Tests
203
+
204
+ ### State and hashing
205
+
206
+ - Stable semantic input produces a stable `input_hash`.
207
+ - Text, links, privacy flags, or membership changes invalidate completion.
208
+ - Metrics-only changes do not trigger semantic reprocessing.
209
+ - Taxonomy, prompt, schema, registry revision, model, or processor changes alter `contract_hash`.
210
+ - Rows with missing or mismatched hashes are never served as current.
211
+ - Empty preset and free-label arrays are valid completed output.
212
+ - Preset and free-label evidence must name a member tweet and quote its text.
213
+ - `deixis`, unsupported `manufacturing`, and `quality-philosophy` regression fixtures cannot reach public reads.
214
+
215
+ ### Queue and recovery
216
+
217
+ - New and stale units enter the queue exactly once.
218
+ - A successful dataset commit happens before `done` is visible.
219
+ - A crash before commit retries the unit.
220
+ - A crash after commit but before SQLite apply converges on replay without another model call.
221
+ - Expired leases return to pending work.
222
+ - Retry timing survives restart.
223
+ - Blocked work stays visible and resets only under the stated rules.
224
+ - Newest and oldest work both progress under continuous ingest.
225
+ - Doctor reports missing, suspended, active, and mismatched Hugging Face Job schedules without exposing secrets.
226
+ - Repair creates replacement schedules suspended and enables them only after a successful bounded canary.
227
+ - Repair refuses missing credentials, invalid capabilities, transient Hub failures, overlapping workers, and changed paid-run assumptions.
228
+
229
+ ### API and consumer consistency
230
+
231
+ - Status counts match the selected immutable author IDs.
232
+ - Mixed-author and missing-author-ID units fail the selection.
233
+ - `complete_through` never passes pending or blocked selected work.
234
+ - Unit, label, free-label detail, and graph reads apply the same cutoff and revision.
235
+ - A consumer can publish the completed cutoff while selected pending, retrying, or blocked work remains after it.
236
+ - Candidate and rejected free labels cannot affect consumer units, counts, filters, nodes, or edges.
237
+ - A stale cutoff or revision returns `409` instead of mixed data.
238
+
239
+ ### Live canary
240
+
241
+ The production canary processes representative old, recent, changed, failing, and multilingual units. It must persist at least two batches, survive an interruption, resume without duplicates, and prove checksums and receipts. When it passes, the same worker resumes and processes the remaining backlog under the recorded full-run ceiling. Consumer publication may start from the current completed cutoff during that work.
242
+
243
+ ## Delivery order
244
+
245
+ 1. Add hash and state-machine tests around the current store.
246
+ 2. Replace the enrichment schema with evidence-bearing preset and free-label assignments, including valid empty output.
247
+ 3. Add attempt and free-label registry event schemas plus replay support.
248
+ 4. Replace completion checks with the two-hash contract.
249
+ 5. Add leases, retry timing, fair claims, and the bounded worker command.
250
+ 6. Restrict consumer reads and graph materialization to approved free labels.
251
+ 7. Add the status endpoint, shared cutoff, registry metrics, and Admin UI reporting.
252
+ 8. Add doctor and repair reconciliation for a non-concurrent suspended Hugging Face scheduled Job; remove production scheduling and credentials from GitHub Actions.
253
+ 9. Run the no-inference migration analysis, record the reuse and cost report, and configure the full-run ceiling.
254
+ 10. Run the bounded recovery canary through the suspended schedule, including the known loose-label regression fixtures.
255
+ 11. Deploy the new reader and worker together, remove the old queue and generated-concept paths, and resume the schedule only after the canary passes.
256
+ 12. Publish and verify a consumer snapshot at the current completed cutoff without waiting for the backlog to drain.
257
+ 13. Monitor queue convergence, cutoff advancement, registry replay, consumer revisions, dataset receipts, cost, Job receipts, and restart recovery in production.
258
+
259
+ ## Completion criteria
260
+
261
+ The work is complete when every captured unit is either backed by a verified current result or remains visible as pending, retrying, or blocked work, and consumers can publish an internally complete revision and cutoff. No earlier model-generated label or concept assignment may enter the replacement registry or consumer projection. Candidate and rejected labels cannot enter public reads. Registry replay must be deterministic, and restarts or deployments cannot lose work. Recent and historical queues must both advance. The full repository checks and live recovery canary must pass.
262
+
263
+ If a durable lease, resumable output, defensible per-run ceiling, or consistent consumer cutoff cannot be established, the implementation is not complete. Report that blocker instead of claiming readiness. Once those safeguards pass, scheduled Jobs continue the historical backlog after rollout.
docs/2026-07-28-labels-and-free-labels-implementation-plan.md ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "Labels and Free Labels Implementation Plan"
3
+ author: "Onur Solmaz <2453968+osolmaz@users.noreply.github.com>"
4
+ date: "2026-07-28"
5
+ ---
6
+
7
+ # Labels and Free Labels Implementation Plan
8
+
9
+ ## Status
10
+
11
+ Planned. This replaces the current three-output enrichment contract. The classifier will produce only preset labels and free labels. The durable worker, retries, completion hashes, and freshness API are specified in [Durable Enrichment Implementation Plan](durable-enrichment-implementation-plan.md).
12
+
13
+ ## Purpose
14
+
15
+ xtap-pool classifies each conversation-author unit with two kinds of labels:
16
+
17
+ - Preset labels come from the configured taxonomy.
18
+ - Free labels describe specific subjects that the preset taxonomy does not cover.
19
+
20
+ Both may be empty. Every assignment carries evidence from the source unit. The model does not generate concepts, entities, candidates, or any other classification output.
21
+
22
+ The system keeps discovering free labels without publishing one-off mistakes as navigation categories. New free labels accumulate evidence in a private registry before they can appear in public filters or the graph.
23
+
24
+ ## Classification contract
25
+
26
+ The model returns exactly two arrays per unit:
27
+
28
+ ```json
29
+ {
30
+ "units": {
31
+ "conversation-id:author": {
32
+ "preset_labels": [
33
+ {
34
+ "name": "ai",
35
+ "evidence": [{ "tweet_id": "123", "quote": "frontier AI" }]
36
+ }
37
+ ],
38
+ "free_labels": [
39
+ {
40
+ "name": "open-weight-model",
41
+ "evidence": [{ "tweet_id": "123", "quote": "open-weight frontier model" }]
42
+ }
43
+ ]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ A label assignment contains a normalized name and one or more evidence records. Each evidence record identifies a member tweet and quotes text from it. Evidence is metadata on a label assignment; it is not another kind of label.
50
+
51
+ The response rules are:
52
+
53
+ - `preset_labels` may contain only exact names from `config/labels.json`.
54
+ - `free_labels` contains at most five lowercase-dash names.
55
+ - Either array may be empty. Thin replies, emoji-only posts, and units without a substantive subject should normally return no free labels.
56
+ - Every quote must be a verbatim substring of the named tweet. Code checks membership and the quote before accepting the assignment.
57
+ - A free label must name a specific subject supported by its evidence. Grammatical features, discourse forms, pronoun categories, generic nouns, and unsupported abstractions are invalid.
58
+ - Free labels that match a rejected registry entry are discarded.
59
+ - One invalid assignment does not invalidate valid assignments in the same unit. The worker records every discarded assignment and its reason.
60
+
61
+ The prompt, output schema, normalization rules, rejection registry, preset taxonomy, and configured model participate in `contract_hash`.
62
+
63
+ ## Free-label lifecycle
64
+
65
+ Preset labels are approved by configuration. Free labels use three internal lifecycle states:
66
+
67
+ - `candidate` means the label has valid unit evidence but has not earned public use.
68
+ - `approved` means the label may appear in consumer responses and their filters, counts, or graph.
69
+ - `rejected` means the label cannot be assigned or rediscovered under the current registry revision.
70
+
71
+ Registry state stays outside the classifier response. The model still returns only preset labels and free labels.
72
+
73
+ A new valid free label enters the registry as `candidate`. Candidate assignments remain durable and visible to administrators, but consumer APIs omit them. The registry stores append-only discovery and lifecycle events. SQLite projects the current state and evidence counts from enrichment rows and registry events.
74
+
75
+ Rejected labels that overlap the current batch are included in the prompt as negative examples. This prevents mistakes from feeding themselves back into later calls. Normalization also rejects known generic abstractions and unsupported suffix patterns. A rejected label can return only through an explicit registry event under a new registry revision.
76
+
77
+ ## Promotion and rejection
78
+
79
+ Promotion requires independent evidence from more than one model response.
80
+
81
+ A free label backed by a verified exact Hugging Face model or dataset repository may be approved from one unit. Other surface-grounded names must appear in at least five units from three authors on two days. Labels whose names are not directly present in their evidence require at least fifteen units from eight authors on two days and a constrained review call over the stored quotes.
82
+
83
+ The review call decides only whether the supplied candidate is a specific, useful AI or local-model subject. It cannot rename the label or introduce another label. Invalid output leaves the candidate unchanged.
84
+
85
+ A candidate is rejected when it matches a deterministic rejection rule, fails constrained review, or remains below the evidence threshold for 30 days. Every decision records the rule and counts plus representative quotes. It also records the timestamp and contract revision. Thresholds are configuration covered by tests and included in the registry revision.
86
+
87
+ Approved recurring free labels are reported as possible additions to the preset taxonomy. Changing the preset taxonomy remains a deliberate configuration change because it redefines broad relevance and triggers a new `contract_hash`.
88
+
89
+ ## Derived graph
90
+
91
+ The old model-generated `concepts` output and global concept vocabulary are removed. Graph nodes come from approved free labels, and graph edges count their co-occurrence in current units. Candidate and rejected labels never contribute public nodes, counts, edges, filters, or inline links.
92
+
93
+ The API exposes approved free-label vocabulary and graph data through revision-consistent reads. Display names and aliases, when needed for presentation, belong to registry metadata and never create another classifier output.
94
+
95
+ ## Durable data
96
+
97
+ An enrichment result stores the two evidence-bearing assignment arrays:
98
+
99
+ ```json
100
+ {
101
+ "unit_id": "conversation-id:author",
102
+ "tweet_ids": ["123"],
103
+ "input_hash": "<sha256>",
104
+ "contract_hash": "<sha256>",
105
+ "preset_labels": [
106
+ {
107
+ "name": "ai",
108
+ "evidence": [{ "tweet_id": "123", "quote": "frontier AI" }]
109
+ }
110
+ ],
111
+ "free_labels": [
112
+ {
113
+ "name": "open-weight-model",
114
+ "evidence": [{ "tweet_id": "123", "quote": "open-weight frontier model" }]
115
+ }
116
+ ],
117
+ "model": "provider/model",
118
+ "taxonomy_version": 2,
119
+ "enriched_at": "2026-07-27T00:00:00.000Z"
120
+ }
121
+ ```
122
+
123
+ The dataset remains the system of record. SQLite is rebuilt from tweets, enrichment rows, attempt events, and free-label registry events. A result is durable before the queue marks its unit complete.
124
+
125
+ ## API and explorer
126
+
127
+ `GET /api/units` returns evidence-bearing preset and approved free-label assignments. Label filters use assignment names. Candidate free labels are available only through authenticated administration endpoints.
128
+
129
+ `GET /api/labels` returns preset-label counts, approved free-label counts, queue depth, and enrichment coverage. `GET /api/free-labels/:name` returns one approved free label, its unit count, and related approved labels. `GET /api/graph` returns approved free-label nodes and co-occurrence edges.
130
+
131
+ The explorer shows preset labels and approved free labels on units. Its graph and detail pages use the approved free-label projection. The Admin UI shows candidate evidence, rejected labels, lifecycle events, and promotion progress.
132
+
133
+ ## Observability
134
+
135
+ Each worker receipt records:
136
+
137
+ - emitted preset and free-label assignments;
138
+ - assignments discarded for missing evidence, invalid names, or rejection state;
139
+ - candidate labels first observed;
140
+ - candidates approved or rejected;
141
+ - provider calls, token use, elapsed time, and cost.
142
+
143
+ The worker stops when invalid or ungrounded output crosses its configured rate ceiling. Admin status reports the registry state counts, top candidates with representative quotes, recent decisions, and candidate creation per thousand units.
144
+
145
+ ## Migration
146
+
147
+ This is a hard replacement of the current enrichment contract.
148
+
149
+ All earlier model-generated free labels and concepts are discarded during migration. None are grandfathered, promoted, copied into the new assignment arrays, or used to seed candidate records. Old enrichment rows remain in the append-only dataset as historical records, but the replacement reader ignores their label output. The new registry starts without approved or candidate entries inherited from model output. Curated preset labels remain because their source is `config/labels.json`.
150
+
151
+ Known bad names such as `deixis`, `quality-philosophy`, and unsupported `manufacturing` assignments become rejection rules and regression fixtures. This prevents rediscovery without treating the old assignments as evidence.
152
+
153
+ The changed schema and prompt plus the normalization rules and registry revision produce a new `contract_hash`. The durable worker reprocesses every source unit under the measured full-run ceiling. Labels enter the new registry only from evidence validated under this contract. Public consumers may publish the completed prefix under the new contract while the worker continues through later units. Each replacement snapshot uses one revision and cutoff.
154
+
155
+ ## Tests
156
+
157
+ The contract tests cover empty arrays, exact preset-name validation, free-label limits, tweet membership, verbatim evidence, duplicate normalization, and rejected names. Regression fixtures require an emoji-only reply to produce no free labels, a DGX enclosure post to avoid unsupported `manufacturing`, and the word `frontier` to avoid `quality-philosophy`.
158
+
159
+ Lifecycle tests cover deterministic replay, each legal state transition, threshold boundaries, the verified Hub fast path, constrained-review abstention, stale-candidate rejection, and reopening under a changed registry revision. Migration tests prove that old free labels and concepts do not seed registry entries or assignments. Public API tests prove that legacy, candidate, and rejected labels cannot affect units, counts, filters, graph nodes, or edges.
160
+
161
+ Recovery tests prove that evidence-bearing rows and registry events survive interruption, replay to the same state, and become visible only after their dataset commits succeed.
162
+
163
+ ## Delivery order
164
+
165
+ 1. Replace the model response and enrichment-row schemas with the two evidence-bearing label arrays.
166
+ 2. Add validation, empty-output fixtures, and the known-bad regression cases.
167
+ 3. Add the append-only free-label registry and deterministic replay.
168
+ 4. Restrict consumer reads and graph materialization to approved free labels.
169
+ 5. Add promotion, rejection, negative-prompt retrieval, and Admin reporting.
170
+ 6. Remove model-generated concepts and their vocabulary path.
171
+ 7. Integrate the new contract with durable hashes and queue recovery, including receipts and freshness reads.
172
+ 8. Run a no-inference migration report, then the bounded pause-resume canary.
173
+ 9. Reprocess the backlog under the recorded cost ceiling, publish the current completed cutoff, and let scheduled runs advance it as more work finishes.
174
+
175
+ ## Completion criteria
176
+
177
+ The work is complete when every current unit is either backed by a durable evidence-bearing result or remains visible in the durable queue, and consumers can read a complete prefix at one revision and cutoff. No legacy model-generated label or concept assignment may contribute to the new registry, consumer reads, counts, filters, or graph. The model must accept empty arrays, and candidate or rejected free labels cannot enter public reads. Registry replay must be deterministic. The old generated-concept path must be absent. All local and production checks, including recovery, must pass.
docs/2026-07-29-parallel-enrichment-plan.md ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Parallel enrichment plan
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-07-29
5
+ ---
6
+
7
+ # Parallel enrichment plan
8
+
9
+ ## Status
10
+
11
+ Implemented. The worker supports up to 32 provider calls in one physical Job, and production has completed 32-way runs with durable receipts. This update makes 32 the persistent setup default. The Hugging Face schedule remains physically non-concurrent so one coordinator owns queue selection, cost limits, registry order, Bucket commits, and receipts.
12
+
13
+ The normal six-hour schedule uses a $10 run limit. That limit covers the $8 reservation bound for one full 32-call wave. A backlog repair may temporarily use a $100 run limit and a 5.5-hour worker limit under an explicit cumulative budget. The normal limits return after the backlog clears, while concurrency stays at 32.
14
+
15
+ ## Current performance
16
+
17
+ The worker groups six conversation-author units into each call to `zai-org/GLM-5.2:fireworks-ai`. Forty-five production receipts with concurrency 32 completed 111,079 units in 52.42 worker-hours for $179.171605. The aggregate rate was 2,119 durable units per hour at $1.61 per thousand units.
18
+
19
+ The August 15 backlog contains 48,495 pending units. The aggregate history projects about 23 worker-hours and $78 of inference. The observed rate varies widely with provider errors and input mix, so the operating range is 15 to 40 worker-hours with a $100 expected repair ceiling. Each useful result and attempt event is durable before a worker exits.
20
+
21
+ ## Decision
22
+
23
+ The implementation will support a configurable maximum from 1 through 32 concurrent inference calls. Production will target 32 immediately, as approved, unless provider errors require a reduction.
24
+
25
+ Each request keeps the existing six-unit prompt contract. Sixteen requests place at most 96 units in inference at once. Thirty-two requests place at most 192 units in inference at once.
26
+
27
+ The primary metric is durable completed units per wall-clock hour. Moving from sequential execution must produce at least twice the baseline goodput to justify the additional coordinator logic. Cost per durable completed unit may rise by no more than 5 percent. Provider failures, invalid output, evidence rejection, and dataset commit failures remain veto gates.
28
+
29
+ Under ideal provider behavior, 16 concurrent calls would process the current queue in under two hours. A practical estimate is one to three hours after Hub commit delays and provider backoff. This estimate is provisional until the production-parity canary measures it.
30
+
31
+ ## Process boundary
32
+
33
+ Parallel Hugging Face Jobs are outside this design. Every Job currently rebuilds a private SQLite projection and obtains leases from that local database. Separate Jobs cannot see one another's leases. They can select the same units, pay for duplicate calls, and race while replacing shared JSONL shards.
34
+
35
+ One physical Job will retain the following responsibilities:
36
+
37
+ - rebuild the dataset snapshot and claim one fair bounded queue.
38
+ - admit inference calls under every shared run ceiling.
39
+ - serialize durable writes and registry revisions.
40
+ - release unprocessed leases before exit.
41
+ - publish one strict receipt tied to its Hugging Face `JOB_ID`.
42
+
43
+ The scheduled Job keeps `concurrency: false`, `attempts: 1`, and its revision-pinned image. GitHub Actions remains CI-only.
44
+
45
+ ## Request coordinator
46
+
47
+ `runEnrichTick` will replace the sequential call loop with a coordinator that has separate inference and commit lanes.
48
+
49
+ The inference lane may have several HTTP requests in flight. Each dispatch gets a monotonically increasing sequence number and an immutable reservation containing:
50
+
51
+ - the exact unit IDs and input hashes.
52
+ - the serialized prompt digest and prompt byte count.
53
+ - the completion-token limit.
54
+ - the maximum charge permitted for the call.
55
+ - the dispatch time and current processor contract hash.
56
+
57
+ The coordinator records the reservation before sending the request. Missing usage, missing pricing, a timeout after dispatch, or an orphaned request is charged conservatively against the reservation. There are no hidden HTTP retries. A later unit retry is another durable attempt and needs a new reservation.
58
+
59
+ The commit lane receives completed calls through a bounded queue. It commits them in dispatch order so free-label registry revisions and attempt events remain stable. One call can finish while an earlier call is still running, but its result cannot update SQLite or release its reservation until every earlier sequence has reached a durable terminal event.
60
+
61
+ Dataset writes remain serialized through `DatasetMirror.commitBatch`. The dataset commit must succeed before the local queue marks any unit complete. A failed commit writes the existing durable failure form and leaves the affected units eligible for replay under the current policy.
62
+
63
+ ## Admission control
64
+
65
+ A concurrency maximum caps active calls. The worker repeatedly claims one bounded wave and starts it immediately, subject to the shared cost and runtime ceilings. It does not stop after an arbitrary unit count or aggregate token count.
66
+
67
+ Provider pressure reduces later waves quickly:
68
+
69
+ - a rate limit, timeout, or provider 5xx response halves the active limit, rounded down to at least one.
70
+ - a reduced limit remains in force for the rest of that Job.
71
+ - reaching the run error ceiling closes admissions immediately.
72
+ - admitted calls finish and produce durable success or failure events before the worker exits.
73
+
74
+ A full 32-call wave can reserve at most $8 under the current $0.25 per-call bound. Provider errors reduce the next wave before further requests are admitted.
75
+
76
+ The coordinator checks capacity before every dispatch. It includes all active reservations when evaluating the run ceilings. With the current `$0.25` per-call bound, 16 active calls reserve $4 and 32 active calls reserve $8. The worker cannot admit a call that would exceed the remaining cost capacity.
77
+
78
+ Elapsed time stops new admissions. It does not abandon in-flight calls or skip their durable accounting. A platform timeout remains an emergency limit and must leave enough time for the request timeout, ordered commit queue, receipt write, and lease release.
79
+
80
+ ## Configuration
81
+
82
+ Add `ENRICH_MAX_CONCURRENT_CALLS` as a strict positive integer in the following locations:
83
+
84
+ - `space/src/config.ts` for worker configuration.
85
+ - `space/src/enrich-command.ts` when constructing worker dependencies.
86
+ - `setup/src/enrichment-job.ts` for defaults, validation, desired schedule state, and exact schedule matching.
87
+ - deployment and doctor output so the effective value is visible before activation.
88
+
89
+ The setup schedule template defaults to `32`. Setup stores this value as a Space variable, and doctor includes it in the exact schedule contract. Deployments and schedule repair therefore keep 32 unless an operator deliberately chooses a lower safe value. The generic worker parser keeps its fail-safe default of 1 when no deployment setting exists.
90
+
91
+ The normal run limit is $10 because a full wave reserves at most $8. Backlog repair uses temporary $100 and 5.5-hour limits. The replacement schedule is always created suspended, checked with the bounded recovery canary, and enabled only after the canary passes. Canary commands whose hard ceiling is $5 or more must include `--approved-cost-ceiling-usd` with the operator's approved cumulative limit.
92
+
93
+ Values above 32 fail configuration validation. A 32-call run also requires at least $8 of available reservation capacity under the current per-call bound. The configured run ceiling must cover in-flight reservations plus final commit and receipt work.
94
+
95
+ Extend the strict enrichment receipt in `shared/src/enrichment.ts` with:
96
+
97
+ ```json
98
+ {
99
+ "configured_concurrency": 16,
100
+ "peak_concurrency": 16,
101
+ "provider_backoffs": 2,
102
+ "reservation_peak_usd": 4,
103
+ "commit_queue_peak": 11
104
+ }
105
+ ```
106
+
107
+ These fields are required for the new processor revision. Earlier receipts remain historical and do not satisfy current worker-heartbeat checks after the contract change.
108
+
109
+ ## Correctness rules
110
+
111
+ Parallel execution must preserve every current contract:
112
+
113
+ 1. The classifier still returns exactly `preset_labels` and `free_labels`.
114
+ 2. Every accepted assignment still cites an exact retained member-tweet quote.
115
+ 3. The semantic input hash and reservation bind the prompt content. They also bind its candidates and serialized bytes.
116
+ 4. Dataset rows, attempt events, registry events, and receipts become visible only after verified Hub commits.
117
+ 5. Queue status and leases remain a rebuildable projection of durable records.
118
+ 6. Every registry state stays outside model output, including candidate and approved or rejected states.
119
+ 7. Every current run ceiling includes all in-flight work.
120
+ 8. A changed contract invalidates stale projections and requeues affected units.
121
+ 9. Unprocessed claims are released at every normal or exceptional exit.
122
+ 10. Replay produces the same completed set without duplicate current rows or missing attempts.
123
+
124
+ A deterministic defect in prompt construction, parsing, evidence validation, registry sequencing, or dataset commits closes admissions for the whole Job. Provider-specific transient errors use the bounded reduction and retry policy described above.
125
+
126
+ ## Tests
127
+
128
+ Unit tests will use deferred fake inference promises so completion order can differ from dispatch order. They must prove that durable order, registry revisions, and receipt totals remain deterministic.
129
+
130
+ Coverage must include:
131
+
132
+ - concurrency values from 1 through 32 plus invalid zero and over-cap values.
133
+ - peak in-flight requests never exceeding the configured or dynamically reduced limit.
134
+ - exact prompt and cost reservations before dispatch.
135
+ - conservative charging when usage or pricing is absent.
136
+ - token and cost ceilings counting every active reservation.
137
+ - out-of-order model responses committed in dispatch order.
138
+ - one slow request applying bounded backpressure to the commit queue.
139
+ - timeout, 429, provider 5xx, malformed output, and missing-unit responses.
140
+ - no further admission after the error, elapsed-time, cost, token, or discard ceiling.
141
+ - all admitted calls producing durable terminal events before exit.
142
+ - commit failure leaving SQLite incomplete and replay recovering correctly.
143
+ - registry events keeping unique consecutive revisions.
144
+ - receipt replay rejecting malformed or wrong-contract concurrency fields.
145
+ - schedule repair treating a concurrency-setting mismatch as a replacement.
146
+ - production schedules remaining non-concurrent with zero platform retries.
147
+
148
+ The repository quality gate remains `npm run check`. Coverage, mutation testing, setup tests, Space tests, and the two-Job recovery canary must pass for the new processor revision.
149
+
150
+ ## Throughput canary
151
+
152
+ The canary uses the production model with six units per call. It keeps the current taxonomy and prompt contract while sampling representative old and recent units. It records every request because aggregate Job duration alone is insufficient.
153
+
154
+ Run a bounded production-parity validation at concurrency 32. The worker records peak concurrency, reservations, ordered commits, and provider backoffs in its receipt. Any timeout, rate limit, or provider 5xx halves subsequent waves.
155
+
156
+ The autonomous canary keeps its cumulative hard ceiling below $5. It publishes durable results and attempt events after each completed call, then proves replay from the Hub snapshot. The production run does not start from an unverified local checkpoint.
157
+
158
+ Continuing at 32 requires all of the following evidence:
159
+
160
+ - at least 2 times the baseline durable units per hour.
161
+ - cost per durable completed unit within 5 percent of baseline.
162
+ - no duplicate current rows, registry revision gaps, missing attempt events, or receipt disagreement.
163
+ - provider error rate below the configured ceiling and no meaningful increase over baseline.
164
+ - successful crash recovery with in-flight calls and an occupied commit queue.
165
+ - exact read-back of every cache object and each shard, receipt or registry object used by the verifier.
166
+
167
+ If the bounded run has provider failures or violates a ceiling, the replacement schedule is reduced to the highest stable setting before another paid run.
168
+
169
+ ## Deployment
170
+
171
+ Implementation and deployment follow this order:
172
+
173
+ 1. Add coordinator tests and the new strict receipt fields.
174
+ 2. Implement bounded inference admission and ordered commits with the persistent default set to 32.
175
+ 3. Run the full repository checks and review the change against `main`.
176
+ 4. Deploy the exact Space revision without changing the active schedule.
177
+ 5. Replace the schedule in suspended state at concurrency 32 and run the bounded recovery canary.
178
+ 6. Inspect every measured cost and error. Check request latency, commit latency, replay behavior and current API projections.
179
+ 7. Trigger the approved production run and verify its receipt before enabling cron.
180
+ 8. Resume the single exact schedule and continue publishing completed cutoffs while backfill drains.
181
+
182
+ Rollback creates a suspended replacement schedule at the highest measured stable concurrency under the same source revision, or deploys the previous reviewed revision. It never overlaps physical Jobs, deletes durable enrichment records, or rewrites historical receipts.
183
+
184
+ ## External limits
185
+
186
+ Hugging Face does not publish one fixed concurrency limit for routed Fireworks requests. Fireworks documents a 600 requests-per-minute serverless account rate together with adaptive token limits and overload responses. At the measured 19 to 42 second latency, 16 active calls imply roughly 23 to 51 requests per minute. That leaves substantial request-rate headroom, while token limits and model capacity still require measurement.
187
+
188
+ References:
189
+
190
+ - [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers/en/index)
191
+ - [Hugging Face Fireworks provider](https://huggingface.co/docs/inference-providers/en/providers/fireworks-ai)
192
+ - [Fireworks serverless rate limits](https://docs.fireworks.ai/serverless/rate-limits)
193
+ - [Fireworks inference error codes](https://docs.fireworks.ai/guides/inference-error-codes)
docs/2026-08-04-durable-enrichment-index-plan.md ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ date: 2026-08-04
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ title: Add a durable enrichment index
5
+ tags: [enrichment, sqlite, hugging-face, operations]
6
+ ---
7
+
8
+ # Add a durable enrichment index
9
+
10
+ ## Goal
11
+
12
+ Every fresh xtap-pool process currently reconstructs SQLite from the complete private dataset. One production Job scanned about 90,000 tweet rows, 74,000 enrichment rows, and 134,000 attempt events and spent about three hours before useful work began. A later rebuild was much faster, so the exact delay varies, but the amount of replay work grows with the full history.
13
+
14
+ Replace full replay with a checksum-verified SQLite index in the private Hugging Face Bucket `osolmaz/xtap-pool-bucket`. The private dataset `osolmaz/xtap-pool-data` remains authoritative. The index is a disposable projection that the Space and enrichment Job can restore and advance from strict JSONL append suffixes.
15
+
16
+ ## Requirements
17
+
18
+ - Use the existing purpose-scoped `HF_TOKEN`, which has read and write access to exactly `osolmaz/xtap-pool-data` and `osolmaz/xtap-pool-bucket`.
19
+ - Keep one non-concurrent enrichment Job and the current paid inference, reservation, retry, quality, and runtime limits.
20
+ - Bind every index generation to one exact dataset Git revision and enrichment contract hash.
21
+ - Upload an immutable database before replacing the current manifest.
22
+ - Verify the database checksum, SQLite integrity, provenance, and physical counts before use and before publication.
23
+ - Apply only new JSONL files or strict byte-for-byte append suffixes during normal startup.
24
+ - Reject deleted files, changed prefixes, unknown paths, malformed manifests, and incompatible database schemas.
25
+ - Preserve durable batch commits and receipts when a Job exits before publishing a new index. The next process must recover them from the dataset tail.
26
+ - Keep the Space available as a public service and keep the dataset and Bucket private.
27
+ - Replace full replay in production. Do not keep an automatic legacy fallback after the initial index has been seeded.
28
+
29
+ ## Scope
30
+
31
+ This work covers:
32
+
33
+ - the Bucket manifest and immutable SQLite files;
34
+ - source-file inventory and exact dataset revision tracking;
35
+ - index restore, incremental replay, validation, publication, and retention;
36
+ - Space startup and enrichment Job startup;
37
+ - setup configuration, token validation, schedule hashing, doctor checks, and deployment staging;
38
+ - bootstrap and repair commands for an explicit full rebuild;
39
+ - local, CI, and live recovery verification.
40
+
41
+ This work does not change:
42
+
43
+ - tweet, unit, enrichment, attempt, registry, or receipt contracts;
44
+ - classifier prompts or labels;
45
+ - inference providers, pricing, or concurrency;
46
+ - the dataset's role as the system of record;
47
+ - Our Models storage or publication.
48
+
49
+ ## Storage model
50
+
51
+ ### Current manifest
52
+
53
+ The mutable dataset file `index/current.json` points to one immutable SQLite file in the private Bucket:
54
+
55
+ ```json
56
+ {
57
+ "schema_version": 1,
58
+ "dataset": {
59
+ "repo": "osolmaz/xtap-pool-data",
60
+ "revision": "0123456789abcdef"
61
+ },
62
+ "projection": {
63
+ "contract_hash": "<sha256>"
64
+ },
65
+ "database": {
66
+ "key": "index/databases/<sha256>.sqlite",
67
+ "sha256": "<sha256>",
68
+ "predecessors": ["index/databases/<previous-sha256>.sqlite"]
69
+ },
70
+ "counts": {
71
+ "tweets": 84571,
72
+ "units": 73374,
73
+ "enrichments": 74325,
74
+ "attempt_events": 134568,
75
+ "registry_events": 1
76
+ }
77
+ }
78
+ ```
79
+
80
+ The manifest is strict and versioned. Publishing it uses a dataset commit whose `parentCommit` is the exact revision recorded in the database. Hugging Face rejects the commit if ingest or another publisher advances the dataset first, so two publishers cannot replace the pointer concurrently. Keys outside `index/` are outside this feature's control.
81
+
82
+ ### SQLite metadata
83
+
84
+ The index keeps the existing tweet and enrichment tables and adds two small metadata tables:
85
+
86
+ ```sql
87
+ CREATE TABLE index_metadata (
88
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
89
+ schema_version INTEGER NOT NULL,
90
+ dataset_repo TEXT NOT NULL,
91
+ dataset_revision TEXT NOT NULL,
92
+ contract_hash TEXT NOT NULL
93
+ );
94
+
95
+ CREATE TABLE source_files (
96
+ path TEXT PRIMARY KEY,
97
+ kind TEXT NOT NULL,
98
+ oid TEXT NOT NULL,
99
+ byte_length INTEGER NOT NULL,
100
+ content_sha256 TEXT NOT NULL,
101
+ row_count INTEGER NOT NULL
102
+ );
103
+ ```
104
+
105
+ `source_files` covers authoritative JSONL inputs only. The content hash describes exactly `byte_length` bytes. When a file grows, the updater hashes the same prefix in the new object and parses only the remaining complete lines. A shorter object, a prefix mismatch, or a deleted path requires an explicit rebuild.
106
+
107
+ Schemator reviewed the draft model and converged after two iterations. The accepted simplifications remove timestamps and duplicated taxonomy metadata from the manifest, use `dataset` instead of `source`, and use `enrichments` for the physical result count.
108
+
109
+ ## Runtime flow
110
+
111
+ ### Restore and advance
112
+
113
+ 1. Read and strictly parse `index/current.json` from one exact dataset revision.
114
+ 2. Download the referenced immutable SQLite object to a temporary path.
115
+ 3. Verify its SHA-256 digest, `PRAGMA quick_check`, metadata, contract hash, and physical counts.
116
+ 4. Pin the current dataset Git revision.
117
+ 5. List authoritative JSONL files at that revision with their Git object IDs and sizes.
118
+ 6. Skip unchanged files, parse complete new files, and parse only verified append suffixes.
119
+ 7. Commit all database and source inventory changes in transactions.
120
+ 8. Re-run integrity, metadata, and count checks before serving or claiming work.
121
+
122
+ The Space opens a writable local copy because successful ingest requests still update the local projection after the dataset commit. The published Bucket object remains immutable. The Job uses the same restore path before it claims enrichment work.
123
+
124
+ ### Publish
125
+
126
+ After the worker writes its final durable receipt, it pins a dataset revision that includes every worker output, advances the local index through that revision, and then:
127
+
128
+ 1. writes exact metadata and counts;
129
+ 2. checkpoints SQLite and creates a compact standalone copy;
130
+ 3. hashes and validates that copy;
131
+ 4. uploads it under `index/databases/<sha256>.sqlite` in the Bucket;
132
+ 5. downloads and validates the uploaded object;
133
+ 6. commits `index/current.json` to the dataset with the indexed dataset revision as its required parent commit;
134
+ 7. reads the manifest back from the returned commit and verifies the active generation; and
135
+ 8. removes old unreferenced Bucket generations while retaining the current generation and three recent predecessors.
136
+
137
+ A crash before the manifest commit leaves the previous generation active. The next Job loads it and replays the durable dataset tail, including partial work committed by the crashed Job. A concurrent dataset write makes the parent-commit check fail and leaves the uploaded database unreferenced; the next successful publication removes that orphan during retention cleanup.
138
+
139
+ ### Bootstrap and repair
140
+
141
+ A separate command performs the intentional full replay and publishes the first generation. Production startup requires a valid manifest after bootstrap. Missing or invalid index state fails closed with a direct repair command; it does not silently spend hours rebuilding history.
142
+
143
+ The first deployment order is:
144
+
145
+ 1. build and validate an index from the current dataset without changing production;
146
+ 2. upload and read back the first generation;
147
+ 3. deploy the Space reader;
148
+ 4. create the exact replacement schedule suspended;
149
+ 5. run the required two-Job recovery canary;
150
+ 6. compare the second Job's restored counts and tail size with the first;
151
+ 7. activate scheduling only after all checks pass.
152
+
153
+ ## Setup and credentials
154
+
155
+ Add `INDEX_BUCKET=osolmaz/xtap-pool-bucket` to Space and Job configuration. Include it in schedule identity and doctor checks.
156
+
157
+ The token validator must accept one fine-grained token scoped to exactly the dataset and index Bucket, with read and write permissions on both and no unrelated global or repository permissions. It must perform direct authenticated reads against both resources without printing the token. The setup prompts continue to install this value as `HF_TOKEN` in the Space and scheduled Job.
158
+
159
+ The Bucket client uses the official `@huggingface/hub` release already pinned by the project. No external service, source build, third-party runtime, or credential copy is required.
160
+
161
+ ## Validation
162
+
163
+ ### Unit and integration tests
164
+
165
+ - Strict manifest parsing rejects unknown fields, bad hashes, wrong repositories, and unsafe object keys.
166
+ - Database validation rejects checksum, integrity, provenance, contract, and count mismatches.
167
+ - A new source file applies once.
168
+ - A strict append applies only its suffix.
169
+ - Reapplying the same dataset revision changes nothing.
170
+ - Prefix edits, truncations, deletions, malformed final lines, and unknown source kinds fail closed.
171
+ - Tweet, enrichment, attempt, registry, and receipt suffixes reproduce full replay results.
172
+ - A crash before database upload, before manifest replacement, and after manifest replacement recovers safely.
173
+ - The Space and Job restore the same generation and counts.
174
+ - Token checks require both exact scopes.
175
+ - Schedule hashes change with `INDEX_BUCKET`.
176
+ - Retention cannot delete the active database or any retained predecessor.
177
+
178
+ ### Local checks
179
+
180
+ Run:
181
+
182
+ ```bash
183
+ npx -y @simpledoc/simpledoc check
184
+ npm run check
185
+ ```
186
+
187
+ Also run a local full-replay-versus-incremental comparison over representative tweet and enrichment fixtures. Compare every relevant SQLite table, queue total, contract hash, and physical count.
188
+
189
+ ### Live checks
190
+
191
+ - Publish a bootstrap generation and download it with the purpose-scoped token.
192
+ - Verify anonymous Bucket access remains `401`.
193
+ - Confirm the Space reaches ready state from the index.
194
+ - Run two non-overlapping Jobs from the suspended exact schedule.
195
+ - Verify both receipts, source revisions, index manifests, database hashes, integrity results, counts, tail rows, and costs.
196
+ - Confirm the second Job restores the first generation rather than replaying all history.
197
+ - Resume cron and verify the next scheduled Job uses the same source revision and index contract.
198
+
199
+ ## Implementation measurements
200
+
201
+ A local read-only production-data check pinned dataset revision `a4bbebdba5cde0c508bd4365480d32b488974da6`. The clean replay read 91,384 tweet rows, 74,913 enrichment rows, 135,744 attempt events, and 13,808 registry events in 120.351 seconds. Loading the resulting index and checking for an unchanged dataset revision took 1.417 seconds, a reduction of 118.934 seconds, or 98.8%.
202
+
203
+ The durable and clean projections matched for tweets, unit membership, enrichment results, label assignments, label evidence, the free-label registry, recent errors, and the registry revision. Queue rows also matched except for `updated_at`, which records the local rebuild time and is not part of completion, retry, or consumer semantics.
204
+
205
+ ## Acceptance criteria
206
+
207
+ The feature is complete when:
208
+
209
+ - normal Space and Job startup does not replay unchanged historical JSONL rows;
210
+ - the dataset manifest references a checksum-verified Bucket database covering one exact dataset revision;
211
+ - incremental state matches a clean full replay in tests and the live canary;
212
+ - a stale or corrupt index cannot become active;
213
+ - a crashed Job's durable results are recovered from the dataset tail;
214
+ - production has one exact non-concurrent active schedule;
215
+ - the Bucket and dataset remain private;
216
+ - CI is green and Pi Reviewer reports no P0 or P1 findings; and
217
+ - the pull request is merged with a final validation report.
218
+
219
+ If exact source provenance, strict append detection, deterministic replay, or safe manifest replacement cannot be established, leave production on the suspended schedule and report the blocker.
docs/2026-08-06-scrape-job-receipts-plan.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ date: 2026-08-06
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ title: Add durable receipts for browser scrape jobs
5
+ tags: [extension, scraping, receipts, chrome]
6
+ ---
7
+
8
+ # Durable scrape-job receipts
9
+
10
+ This plan adds a browser-local record of which posts xTap observed during an Infinite Feed Scroller job. The result should let one command open an X list, capture posts through xTap, stop when coverage is complete, recover after an extension restart, and explain why the run stopped.
11
+
12
+ xTap remains passive outside an explicitly opened scrape run. Infinite Feed Scroller remains responsible for movement and retry handling. It also owns job state. Tweet contents continue to live in xTap's local output and the pool dataset.
13
+
14
+ ## Requirements
15
+
16
+ - Support steady catch-up runs and explicit historical date ranges.
17
+ - Use xTap observations as the source of truth for coverage.
18
+ - Preserve enough browser-local state to replay observations after an MV3 worker or page restart.
19
+ - Record the list, post ID, post time, first-seen time, and capture sequence without copying tweet contents into the scroller.
20
+ - Leave a durable run report with start and finish times, counts, final confidence, and stop reason.
21
+ - Stop with a clear blocked or failed state when xTap is unavailable. Scrape jobs must not silently use DOM timestamps as replacement evidence.
22
+ - Keep normal recording, playback, loop, and live-generator behavior independent of xTap.
23
+
24
+ ## Boundaries
25
+
26
+ This change does not add a scheduler or system service. It does not change pool credentials, send new requests to X, or copy credentials between extensions. It does not move tweet contents into a second store.
27
+
28
+ The first steady run for a list has no earlier list coverage. That run uses xTap post timestamps to cover the current UTC day. Later steady runs stop at posts that xTap observed for the same list before the run began.
29
+
30
+ ## Ownership
31
+
32
+ xTap owns capture receipts and the external protocol. Infinite Feed Scroller owns scrape jobs and reports. The launcher starts a dedicated Chrome profile, loads both extensions, opens the xTap run, and then opens the list.
33
+
34
+ The scroller has a stable unpacked-extension ID. xTap accepts the external port only from that ID. The xTap extension keeps its existing ID and storage, so its pool connection is not migrated.
35
+
36
+ ## xTap data
37
+
38
+ xTap stores the following records in IndexedDB:
39
+
40
+ - A receipt for each unique post with its post time, first-seen time, source endpoint, and global capture sequence.
41
+ - A list receipt for each `(listId, postId)` pair. This prevents a post seen elsewhere on X from counting as prior coverage of the target list.
42
+ - A run record with its list, baseline capture sequence, known-list count at start, last observation cursor, state, and timestamps.
43
+ - Ordered run observations. Each observation records the post ID, post time, observation time, capture sequence, and whether that list-post pair was known before the run.
44
+
45
+ Run observations contain no post text, author data, or pool token.
46
+
47
+ ## External protocol
48
+
49
+ The protocol uses a named `chrome.runtime.connect()` port. Every message carries protocol version `1` and a run ID.
50
+
51
+ The scroller opens a run before opening the X tab. xTap returns the durable run record and replays observations after the requested cursor. The list tab then reconnects with its last applied cursor and receives new observation batches. Duplicate batches are safe because observation cursors are monotonic.
52
+
53
+ The scroller closes the xTap run with a terminal state. The state records successful completion, a failure, or a user abort. xTap retains the run and its observations for later inspection.
54
+
55
+ ## Completion rules
56
+
57
+ A backfill job keeps the last 50 unique list observations. It gains confidence only while new observations arrive and the whole window is older than the requested start day. Three clean updates complete the job.
58
+
59
+ A steady job uses the same window. After a list has earlier coverage, a clean window contains only list-post pairs known before the run. The first steady run for a list instead uses the current UTC day as its bootstrap range. Hard limits still stop a job after two hours or 3,600 update cycles.
60
+
61
+ The exact window and streak remain visible in the job trace so later tuning can use observed runs.
62
+
63
+ ## Recovery
64
+
65
+ The job page opens the xTap run before list navigation, which prevents the first timeline response from racing the receipt setup. A reconnect asks xTap to replay observations after the last applied cursor. The estimator can also rebuild from cursor zero after a full page restart.
66
+
67
+ This one-run rule was replaced by the coordinated [concurrent scrape jobs plan](https://github.com/osolmaz/infinite-feed-scroller/blob/main/docs/2026-08-09-concurrent-scrape-jobs-plan.md). xTap now permits two tab-bound runs. Reopening the same run is idempotent, and a third run receives a capacity error.
68
+
69
+ ## Acceptance criteria
70
+
71
+ - xTap records list observations before applying its normal capture deduplication.
72
+ - A post seen on another timeline does not count as prior coverage of the target list.
73
+ - Reopening a run returns the same baseline and ordered observations.
74
+ - A sender other than the stable scroller extension ID cannot open the protocol.
75
+ - The scroller opens the xTap run before creating the list tab.
76
+ - Steady jobs stop on established list coverage and bootstrap from xTap timestamps when the list has no coverage.
77
+ - Backfill jobs stop only after passing the requested range.
78
+ - A missing bridge blocks the job and stops movement.
79
+ - Completion and abort append one durable run report.
80
+ - Both repositories pass their local quality gates and browser-independent protocol tests.
81
+
82
+ ## Verification
83
+
84
+ In `xtap-pool`:
85
+
86
+ ```sh
87
+ npm run test:extension
88
+ npm run check
89
+ ```
90
+
91
+ In `infinite-feed-scroller`:
92
+
93
+ ```sh
94
+ npm run check
95
+ npm run mutate
96
+ npm run slophammer
97
+ ```
98
+
99
+ A manual browser pass still needs a logged-in X profile. Run one bootstrap steady job, a second steady job against the same list, and a historical backfill. The second steady job should stop when its fresh window reaches posts recorded before that run.
docs/2026-08-12-bucket-object-log-plan.md ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Move pool storage to an immutable Bucket log
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-08-12
5
+ ---
6
+
7
+ # Move pool storage to an immutable Bucket log
8
+
9
+ The pool dataset uses about 311.7 GB while its current files use about 528.8 MB. Each ingest rewrites one or more growing JSONL files, and the dataset keeps the old revisions. This change must preserve every pooled post and durable enrichment record while replacing that storage path in one coordinated deployment.
10
+
11
+ The private Hugging Face Bucket `osolmaz/xtap-pool-data` will become the only raw system of record. The application will not keep dataset readers, dataset writers, dual writes, fallback reads, compatibility routes, or a legacy deployment period.
12
+
13
+ ## Scope
14
+
15
+ This work will:
16
+
17
+ - store raw pool transactions as immutable compressed Bucket objects;
18
+ - store immutable source snapshots so any SQLite generation names its exact inputs;
19
+ - keep replaceable SQLite generations and their active manifest in the separate private index Bucket;
20
+ - import one explicitly pinned final dataset revision;
21
+ - prove exact preservation before production starts on the new storage path;
22
+ - update the Space, enrichment Job, setup, doctor, repair, deployment, and operator commands;
23
+ - remove `DATASET_REPO` and all dataset-backed runtime code; and
24
+ - leave the old dataset unchanged as a read-only cold backup.
25
+
26
+ The first deployment keeps all owned wire and storage formats at version 1. This repository has no public release, so the coordinated replacement changes version 1 in place.
27
+
28
+ ## Non-goals
29
+
30
+ - Do not store or download images.
31
+ - Do not keep a queryable dataset projection.
32
+ - Do not use Git history as runtime storage.
33
+ - Do not add a service or another scheduler.
34
+ - Do not delete, rewrite, squash, or reclaim the old dataset in this task.
35
+ - Do not change pool identity, X capture, enrichment models, taxonomy meaning, or the Infinite Feed Scroller schedule.
36
+
37
+ ## Required data
38
+
39
+ The import must preserve every valid record from these paths at the pinned revision:
40
+
41
+ | Source | Durable value |
42
+ | ------------------------------------------------------- | ---------------------------------- |
43
+ | `data/<contributor>/YYYY/MM/tweets-YYYY-MM-DD.jsonl` | Pooled tweets |
44
+ | `enrichment/YYYY/MM/enrichment-YYYY-MM-DD.jsonl` | Enrichment rows |
45
+ | `enrichment/attempts/YYYY/MM/attempts-YYYY-MM-DD.jsonl` | Attempt events |
46
+ | `enrichment/registry/YYYY/MM/registry-YYYY-MM-DD.jsonl` | Free-label registry events |
47
+ | `enrichment/receipts/YYYY-MM-DD.jsonl` | Worker receipts |
48
+ | `config/pool.json` | Pool membership and administrators |
49
+ | `config/service-accounts.json` | Service accounts |
50
+ | `config/labels.json` | Taxonomy configuration |
51
+ | `enrichment/vocabulary.json` | Durable vocabulary configuration |
52
+
53
+ `index/current.json` is not imported. It belongs to the superseded index contract and is replaced by a new index manifest in the index Bucket.
54
+
55
+ ## Storage layout
56
+
57
+ The raw Bucket contains only immutable version 1 objects:
58
+
59
+ ```text
60
+ v1/segments/<category>/YYYY/MM/DD/<time-ms>-<uuid>-<sha256>.json.gz
61
+ v1/snapshots/<sha256>.json
62
+ ```
63
+
64
+ `category` is `tweet`, `enrichment`, `attempt`, `registry`, `receipt`, `config`, or `mixed`. It helps bounded configuration reads and operator inspection. It does not control validation; each operation path does.
65
+
66
+ The index Bucket contains replaceable projections:
67
+
68
+ ```text
69
+ index/databases/<sha256>.sqlite
70
+ index/current.json
71
+ ```
72
+
73
+ The raw and index Buckets have different deletion rules. Runtime code never deletes a raw segment or raw snapshot. Index publication may retain the active SQLite generation and three predecessors and delete older index generations.
74
+
75
+ ## Segment contract
76
+
77
+ One segment records one successful application transaction:
78
+
79
+ ```json
80
+ {
81
+ "schema_version": 1,
82
+ "transaction_id": "dfbc9fea-f42c-42d8-ae2d-44f909c417bf",
83
+ "created_at": "2026-08-12T12:34:56.789Z",
84
+ "operations": [
85
+ {
86
+ "path": "data/osolmaz/2026/08/tweets-2026-08-12.jsonl",
87
+ "mode": "append",
88
+ "lines": ["{\"id\":\"...\"}"]
89
+ }
90
+ ]
91
+ }
92
+ ```
93
+
94
+ An append operation contains one or more complete non-empty JSON lines for one approved record path. A write operation contains the complete UTF-8 value for one approved configuration path:
95
+
96
+ ```json
97
+ {
98
+ "path": "config/pool.json",
99
+ "mode": "write",
100
+ "content": "{\n \"version\": 1\n}\n"
101
+ }
102
+ ```
103
+
104
+ The segment object is canonical JSON with no insignificant whitespace and keys emitted in schema order. The SHA-256 in the key covers these uncompressed bytes. Gzip uses deterministic headers so a retry produces the same compressed bytes. The timestamp and UUID identify the transaction and give configuration writes a total order. A process must generate a timestamp greater than its prior transaction timestamp when two writes occur in the same millisecond or the clock moves backward.
105
+
106
+ The writer validates every path and record before upload. Unknown fields, unknown paths, malformed JSON, invalid records, duplicate operation paths, empty append sets, unsupported versions, and invalid timestamps fail closed.
107
+
108
+ The writer uploads to a new key, downloads it, decompresses it, verifies its uncompressed checksum and complete bytes, parses and validates it again, and only then returns success. A retry may reuse an existing key only when the stored object decodes to the exact same bytes. A conflicting object fails closed.
109
+
110
+ Content-addressed keys make concurrent raw writes independent. The chance of a different valid payload using the same SHA-256 key is below the operational risk threshold. No mutable raw head or compare-and-swap credential is required.
111
+
112
+ ## Snapshot contract
113
+
114
+ A raw snapshot names the exact immutable objects used to build an index:
115
+
116
+ ```json
117
+ {
118
+ "schema_version": 1,
119
+ "bucket": "osolmaz/xtap-pool-data",
120
+ "files": [
121
+ {
122
+ "key": "v1/segments/tweet/2026/08/12/...json.gz",
123
+ "oid": "immutable-hub-object-id",
124
+ "size": 1234,
125
+ "content_sha256": "sha256-from-segment-key"
126
+ }
127
+ ]
128
+ }
129
+ ```
130
+
131
+ Files are sorted by key. The snapshot revision is the SHA-256 of the canonical snapshot bytes, and its key is `v1/snapshots/<revision>.json`. Snapshot creation lists raw segments, validates every key and immutable object ID, writes the content-addressed snapshot, reads it back, and verifies it.
132
+
133
+ A snapshot can omit a transaction that finishes after the listing passes its key. This is safe because the snapshot explicitly lists its complete input set and the next advance includes the new segment. No transaction can appear partly: Bucket uploads publish complete objects, and replay verifies each complete object before changing SQLite.
134
+
135
+ A reader never reconstructs an old snapshot from the current Bucket listing. It reads the exact immutable snapshot object named by the index manifest.
136
+
137
+ ## Configuration ordering
138
+
139
+ Pool membership, service accounts, taxonomy, and vocabulary use write operations. The current value is the last valid write ordered by:
140
+
141
+ 1. `created_at`;
142
+ 2. `transaction_id`; and
143
+ 3. segment key as a final deterministic tie-breaker.
144
+
145
+ Pool and service-account mutations remain serialized by the Space. The enrichment Job does not write these values. Taxonomy and vocabulary changes remain operator actions. A fresh restore derives configuration only from the raw Bucket.
146
+
147
+ ## Runtime storage boundary
148
+
149
+ A `BucketLog` replaces `DatasetMirror`.
150
+
151
+ It must provide:
152
+
153
+ - validated and verified transaction upload;
154
+ - stable snapshot creation and loading;
155
+ - exact segment download and replay;
156
+ - current configuration reads;
157
+ - local bounded caching that is never authoritative; and
158
+ - the latest valid worker receipt observed during replay.
159
+
160
+ Ingest validates, stamps, and deduplicates a batch against SQLite. It writes one verified raw segment before updating SQLite or returning success. An upload or verification failure returns a storage error and leaves SQLite unchanged.
161
+
162
+ The enrichment worker writes rows, attempt events, registry events, and receipts as immutable segments before applying them locally. Its existing single-Job schedule remains non-concurrent. The Space and Job may write different segments concurrently.
163
+
164
+ ## SQLite projection
165
+
166
+ The SQLite database is a disposable projection. Its metadata records:
167
+
168
+ ```text
169
+ schema_version = 1
170
+ raw_bucket
171
+ raw_snapshot_revision
172
+ contract_hash
173
+ ```
174
+
175
+ The source tables record each segment key, immutable Hub object ID, compressed byte length, uncompressed SHA-256, and row counts by record kind. A source object mutation, deletion from an exact snapshot, checksum mismatch, repeated key with changed identity, or replay count mismatch fails closed.
176
+
177
+ Index bootstrap builds a fresh database from one new raw snapshot. Index advance creates a new snapshot, compares exact segment keys with the local source table, rejects mutation or deletion, validates all new segments before starting a SQLite transaction, and applies all new segments atomically.
178
+
179
+ Index publication:
180
+
181
+ 1. advances to one exact raw snapshot;
182
+ 2. makes and validates a SQLite backup;
183
+ 3. uploads the content-addressed SQLite generation;
184
+ 4. downloads it and verifies its checksum, integrity, metadata, and counts;
185
+ 5. writes `index/current.json` in the index Bucket;
186
+ 6. reads the manifest back and verifies the complete value; and
187
+ 7. prunes only old index generations.
188
+
189
+ The active manifest binds one exact raw snapshot and one exact SQLite generation:
190
+
191
+ ```json
192
+ {
193
+ "schema_version": 1,
194
+ "source": {
195
+ "bucket": "osolmaz/xtap-pool-data",
196
+ "revision": "<snapshot-sha256>"
197
+ },
198
+ "projection": {
199
+ "contract_hash": "<sha256>"
200
+ },
201
+ "database": {
202
+ "key": "index/databases/<sha256>.sqlite",
203
+ "sha256": "<sha256>",
204
+ "predecessors": []
205
+ },
206
+ "counts": {
207
+ "tweets": 0,
208
+ "units": 0,
209
+ "enrichments": 0,
210
+ "attempt_events": 0,
211
+ "registry_events": 0,
212
+ "receipts": 0
213
+ }
214
+ }
215
+ ```
216
+
217
+ The manifest keeps physical counts because they detect the wrong or incomplete database before serving. The index Bucket has one authorized publisher at a time: the suspended setup/bootstrap command or the non-concurrent enrichment Job. The Space advances its local database after ingest but does not publish the shared generation.
218
+
219
+ If a manifest write fails after the SQLite upload, the generation is unreferenced and harmless. The next publication may reuse it. If manifest read-back differs, publication fails and does not prune anything.
220
+
221
+ ## Import contract
222
+
223
+ The importer requires:
224
+
225
+ - an explicit dataset repository;
226
+ - an explicit 40-character dataset revision;
227
+ - the raw Bucket name;
228
+ - an output report path; and
229
+ - a local work directory with enough space for bounded files and verification data.
230
+
231
+ It refuses `main`, a branch name, or an unpinned source. Import is resumable and idempotent. Each source file maps to one deterministic transaction ID and one content-addressed segment. Re-running the same pinned import verifies and reuses matching objects.
232
+
233
+ The importer lists all approved source and configuration paths at the pinned revision. It downloads each file, validates UTF-8 and every record, and records:
234
+
235
+ - source path and immutable object ID;
236
+ - byte length and SHA-256;
237
+ - valid non-empty line count;
238
+ - record kind;
239
+ - for tweets, the sorted `(id, contributed_by)` identity digest and per-contributor/per-capture-day counts; and
240
+ - for configuration, the exact content SHA-256.
241
+
242
+ The importer uploads and verifies each target segment. It then creates a target snapshot and replays only that snapshot into a fresh SQLite database.
243
+
244
+ ## No-loss reconciliation
245
+
246
+ Cutover is blocked unless all checks pass:
247
+
248
+ - every approved source path is represented once;
249
+ - every source non-empty line is represented once with the exact UTF-8 line bytes;
250
+ - source and target raw line counts match by kind and logical path;
251
+ - source and target sorted line digests match by kind and logical path;
252
+ - the complete sorted multiset of `(tweet id, contributor, exact line hash)` matches;
253
+ - unique `(tweet id, contributor)` sets match;
254
+ - per-contributor and per-capture-day tweet counts match;
255
+ - enrichment unit IDs and exact row hashes match;
256
+ - attempt event exact row hashes match;
257
+ - registry `(name, revision)` values and exact row hashes match;
258
+ - receipt exact row hashes match;
259
+ - pool, service-account, taxonomy, and vocabulary bytes match exactly;
260
+ - fresh Bucket-only replay produces the expected SQLite unique tweet and enrichment counts;
261
+ - SQLite `PRAGMA integrity_check` returns `ok`; and
262
+ - rebuilding a second time from the same snapshot produces the same logical table digests.
263
+
264
+ The report contains repository and Bucket names, source and snapshot revisions, object counts, row counts, aggregate hashes, and pass/fail results. It does not contain tweet text, configuration contents, access tokens, or service-account secrets.
265
+
266
+ ## Fault tests
267
+
268
+ Tests must prove that reconciliation stops for:
269
+
270
+ - one deleted source line;
271
+ - one duplicated source line;
272
+ - one changed source line;
273
+ - one truncated compressed object;
274
+ - one object under the wrong key;
275
+ - one key with the wrong checksum;
276
+ - one source object with a changed immutable ID;
277
+ - one missing configuration file;
278
+ - one changed configuration byte;
279
+ - one unsupported or malformed record;
280
+ - one source object deleted after index state records it;
281
+ - one failed upload;
282
+ - one failed read-back; and
283
+ - one manifest that names the wrong snapshot or SQLite generation.
284
+
285
+ Tests must also prove retry idempotence, deterministic replay under shuffled listing order, safe concurrent segment creation, stable snapshot revisions, and a changed revision for any changed file set.
286
+
287
+ ## Setup and credentials
288
+
289
+ Setup creates or verifies two private Buckets and one public Space:
290
+
291
+ - raw: `<namespace>/xtap-pool-data`;
292
+ - index: `<namespace>/xtap-pool-bucket`; and
293
+ - Space: `<namespace>/xtap-pool`.
294
+
295
+ `RAW_BUCKET` replaces `DATASET_REPO` everywhere. `INDEX_BUCKET` remains. The storage token validator requires read and write access to exactly both Buckets and rejects dataset-only scope. Setup, doctor, repair, and enrichment Job reconciliation use these names.
296
+
297
+ The raw Bucket already exists for this deployment, and the existing xTap storage token has already been granted access in its current secret source. The implementation may use that token in place. It must not print it or copy it into a new store. If the Space and Job already hold that same token, they keep the existing secrets. If deployment needs a credential copied from `offline/secrets` into a new destination, work stops for explicit source-and-destination approval.
298
+
299
+ ## Deployment sequence
300
+
301
+ 1. Finish implementation, focused tests, full `npm run check`, SimpleDoc, Pi Reviewer, PR review, CI, merge, and deployment staging.
302
+ 2. Verify the new raw Bucket is private and empty or contains only objects from the same pinned import contract.
303
+ 3. Suspend the enrichment schedule and verify that no enrichment Job is running.
304
+ 4. Stop or pause the Space at a safe boundary. Extension durable staging queues must retain unsent captures while the ingest endpoint is unavailable.
305
+ 5. Resolve and record the final dataset commit SHA after the Space is stopped.
306
+ 6. Run the importer and no-loss reconciliation against that exact revision.
307
+ 7. Create and verify the final raw snapshot.
308
+ 8. Bootstrap the SQLite index from only that snapshot and publish its verified generation and index manifest.
309
+ 9. Configure `RAW_BUCKET` and retain `INDEX_BUCKET`; remove `DATASET_REPO` from Space and Job variables.
310
+ 10. Deploy the merged code. No production process may read the old dataset.
311
+ 11. Wait for the Space to report `RUNNING`, the intended source revision, valid Bucket credentials, the intended raw snapshot, and the expected counts.
312
+ 12. Let one already-staged real extension submission reach ingest. Verify its transaction segment, response, SQLite row, explorer/API visibility, pool sync receipt, and local xTap JSONL durability.
313
+ 13. Restart the Space once. Verify a fresh process restores the published index, advances from the raw Bucket, and serves that real post.
314
+ 14. Run the existing bounded enrichment canary. Verify raw enrichment segments, a new exact snapshot, a verified index generation, receipt durability, and matching source revision.
315
+ 15. Resume the enrichment schedule only after the canary passes.
316
+ 16. Verify pool sync and passive xTap capture without image downloads.
317
+ 17. Resume the historical Infinite Feed Scroller backfill from its deepest verified safe day with a one-day overlap and continue its separate recovery-capable monitoring.
318
+
319
+ ## Failure behavior
320
+
321
+ Before the final dataset revision is pinned, a failure leaves production on the old code and no cutover has started.
322
+
323
+ After the Space stops, any import, reconciliation, index, configuration, or deployment failure keeps ingestion offline. Operators fix the new Bucket path and retry from the same pinned source. They do not restart the legacy dataset runtime. Client staging queues retain captures during this bounded outage.
324
+
325
+ After the new Space starts, a failed real ingest, restart proof, or enrichment canary suspends enrichment and stops the Space if continued writes could make diagnosis unsafe. Recovery stays on the Bucket contract. The old dataset is input to the pinned recovery importer only and never becomes a production fallback.
326
+
327
+ A raw object conflict, checksum failure, missing pinned object, reconciliation mismatch, unknown record, invalid credential scope, or inconsistent index manifest is a material blocker. No cleanup follows it.
328
+
329
+ ## Verification commands
330
+
331
+ The implementation must add documented commands for these operations:
332
+
333
+ ```sh
334
+ npm run storage:import -- --dataset <repo> --revision <sha> --raw-bucket <bucket> --report <path>
335
+ npm run storage:verify -- --dataset <repo> --revision <sha> --raw-bucket <bucket> --report <path>
336
+ RAW_BUCKET=<bucket> INDEX_BUCKET=<bucket> npm run index:bootstrap
337
+ npm run doctor -- <space>
338
+ ```
339
+
340
+ Before merge:
341
+
342
+ ```sh
343
+ npm run check
344
+ npx -y @simpledoc/simpledoc check
345
+ pi-reviewer --base main
346
+ ```
347
+
348
+ Production evidence must include the pinned source SHA, raw snapshot revision, imported source and target object counts, all reconciliation counts and digests, SQLite manifest counts, deployed Git SHA, Space runtime SHA, real ingest segment key and receipt identifiers without post text, restart proof, and enrichment canary receipt.
349
+
350
+ ## Acceptance criteria
351
+
352
+ - The plan, schema, implementation, operator commands, and deployment behavior agree.
353
+ - The runtime contains no dataset source, dataset fallback, dataset variable, dataset write, dual read, or dual write.
354
+ - Exact pinned-source reconciliation proves that no valid post or durable record was lost.
355
+ - A fresh process builds and serves the same logical state using only the raw and index Buckets.
356
+ - A real extension submission is durable before success and remains visible after restart.
357
+ - The enrichment canary writes raw Bucket evidence and publishes a verified matching index.
358
+ - Production remains on Hugging Face and uses one raw Bucket plus one index Bucket.
359
+ - Images remain disabled.
360
+ - The old dataset remains unchanged and unavailable to production runtime code.
361
+ - Bob's historical backfill resumes only after storage cutover verification and preserves its existing receipts and local JSONL.
docs/2026-08-16-resumable-registry-review-plan.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Make registry review resumable
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-08-16
5
+ ---
6
+
7
+ # Make registry review resumable
8
+
9
+ > **Historical plan:** [Add small worker checkpoints](2026-08-19-small-worker-checkpoints-plan.md)
10
+ > replaces this receipt-cursor continuation path in production. Existing receipt
11
+ > cursor fields remain valid historical evidence. New Jobs use a frozen logical
12
+ > run, registry ordinals, immutable output claims, and compact checkpoints. Use
13
+ > the newer plan for production recovery, spending limits, canaries, and schedule
14
+ > activation.
15
+
16
+ The enrichment worker must save useful work before a Hugging Face Job reaches its timeout. The current worker saves post enrichment as it goes, then reviews free-label candidates in one long pass. It keeps that pass in memory until the full scan ends. A timeout can therefore discard hours of registry review.
17
+
18
+ This plan makes registry review resume from a durable cursor and makes the worker's elapsed-time limit include index restoration. It also removes the full mixed-segment scan from normal index restoration so the steady 45-minute Job can start useful work quickly.
19
+
20
+ A production recovery later exposed a second timeout gap. The router deadline ended when response headers arrived, before the worker read the response body. Three requests therefore stayed dispatched for more than 62 minutes despite a 90-second timeout. The worker must keep one deadline active through the full response body read and release reservations after a timeout.
21
+
22
+ ## Requirements
23
+
24
+ - Keep the current raw Bucket, index Bucket, model, provider, taxonomy, hardware class, and concurrency.
25
+ - Preserve existing receipts and immutable segments.
26
+ - Save registry decisions before a bounded stop.
27
+ - Write a durable cursor in each receipt when a registry scan is incomplete.
28
+ - Resume an incomplete scan only when no new enrichment work changed its inputs.
29
+ - Start the worker elapsed-time budget before index restoration.
30
+ - Restore the latest receipt without downloading every mixed segment.
31
+ - Keep old receipts valid.
32
+ - Keep one physical enrichment Job at a time.
33
+ - Apply one router deadline to connection setup, response headers, the response body, and parsing.
34
+ - Bound router response bodies before parsing them.
35
+ - Record a timed-out body read as a normal timeout failure and clear its cost reservation.
36
+
37
+ ## Scope
38
+
39
+ The change covers receipt metadata, receipt replay, registry settlement, worker timing, and regression tests. It also covers deployment, catch-up recovery, two steady canaries, and steady schedule activation.
40
+
41
+ ## Non-goals
42
+
43
+ - No model, prompt, taxonomy, provider, storage, concurrency, or hardware change.
44
+ - No new remote store.
45
+ - No public API contract change beyond optional receipt fields used for operations.
46
+ - No concurrent registry writes or unordered decisions. Pure Hub verification may run in bounded parallel, but registry decisions and commits must remain in candidate-name order.
47
+
48
+ ## Design
49
+
50
+ 1. Add optional registry-scan progress to the receipt schema. The progress records the last fully reviewed candidate, the number scanned, the candidate count, and whether the scan completed.
51
+ 2. Persist and apply collected registry decisions before any bounded stop. A continuation resumes after the last durable candidate only when the current run processed no new enrichment units.
52
+ 3. Restore the latest valid receipt from receipt-category segments, then inspect only mixed segments that are not older than the selected receipt segment. This preserves newer historical mixed receipts without downloading thousands of older mixed segments.
53
+ 4. Run pure Hub verification in bounded parallel. Settle decisions and write registry events in deterministic candidate-name order.
54
+ 5. Start the elapsed-time clock before index restoration. Pass only the remaining budget to enrichment processing. This preserves the configured gap between the worker limit and the platform timeout for receipt and index publication.
55
+ 6. Keep old receipt rows readable by making the new progress field optional.
56
+ 7. Run each router exchange inside one reusable deadline helper. The helper owns the abort timer until the callback finishes, so callers cannot return a live response body after the deadline ends.
57
+ 8. Read success and error response bodies through a byte-limited reader while the deadline remains active. Map a deadline to the existing timeout error class and reject an oversized or malformed response without keeping the worker blocked.
58
+
59
+ ## Acceptance criteria
60
+
61
+ - A registry scan that reaches its elapsed ceiling writes a valid receipt with an incomplete cursor.
62
+ - The next no-unit run resumes after that cursor and does not repeat prior external checks.
63
+ - Decisions found before a ceiling are durable and replay correctly.
64
+ - A run that processes new units starts a fresh registry scan.
65
+ - A complete registry scan marks the receipt complete.
66
+ - Index restoration reads the latest receipt without scanning mixed segments older than the selected receipt segment.
67
+ - Restore time reduces the remaining worker processing budget.
68
+ - Existing receipt fixtures and historical rows remain valid.
69
+ - Repository checks, targeted tests, Pi Reviewer, and CI pass.
70
+ - Catch-up completes within the approved $500 total budget.
71
+ - Two steady canaries prove bounded continuation before schedule activation.
72
+ - A router response that sends headers and then stalls its body fails within the configured deadline.
73
+ - A stalled error body follows the same deadline.
74
+ - An oversized router body fails before unbounded buffering.
75
+ - A timed-out body read writes a durable timeout outcome, clears the cost reservation, and lets the worker finish its receipt.
76
+
77
+ ## Verification
78
+
79
+ Run:
80
+
81
+ ```bash
82
+ npm run check
83
+ npm test --workspace @xtap-pool/shared
84
+ npm test --workspace @xtap-pool/space -- enrich-worker enrich-job durable-index bucket-log
85
+ npx -y @simpledoc/simpledoc check
86
+ pi-reviewer --base main
87
+ ```
88
+
89
+ After merge and deployment:
90
+
91
+ 1. Verify the deployed source revision.
92
+ 2. Run one suspended catch-up continuation with only the approved Job secrets.
93
+ 3. Validate the receipt hash, cursor, settled cost, zero reservations, registry events, and index pointer.
94
+ 4. Replace the catch-up schedule with the exact suspended steady profile.
95
+ 5. Run two non-overlapping steady canaries and validate continuation.
96
+ 6. Activate the steady schedule and verify production health, queue state, schedule count, and cleanup.
docs/2026-08-19-small-worker-checkpoints-plan.md ADDED
@@ -0,0 +1,1122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Add small worker checkpoints
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-08-19
5
+ updated: 2026-09-10
6
+ tags: [enrichment, sqlite, checkpoints, hugging-face, resume]
7
+ ---
8
+
9
+ # Add small worker checkpoints
10
+
11
+ ## Purpose
12
+
13
+ A replacement enrichment Job must resume useful work without first restoring and
14
+ later republishing the complete public SQLite index.
15
+ Physical Jobs should be disposable. The logical run, fixed work plan, verified
16
+ results, queue state, and registry cursor must survive them.
17
+
18
+ The current durable index removed full raw-history replay, as described in
19
+ [Add a durable enrichment index](2026-08-04-durable-enrichment-index-plan.md).
20
+ The index is now about 1.3 GB, so making every physical Job restore it has become
21
+ the next bottleneck. The existing
22
+ [resumable registry plan](2026-08-16-resumable-registry-review-plan.md) saves a
23
+ cursor, but new enrichment work can still reset that scan.
24
+
25
+ This plan keeps the raw Bucket as the system of record and the public index as a
26
+ replaceable read model. It adds a separate compact work plan and worker
27
+ checkpoint. The shared checkpoint mechanics come from
28
+ [HF Job Control](https://github.com/osolmaz/hf-job-control/blob/feat/durable-job-resume/docs/2026-08-19-durable-job-resume-plan.md).
29
+
30
+ ## Outcome
31
+
32
+ A normal continuation will download one small work plan and checkpoint, then
33
+ process only missing units or registry candidates. It will not open the public
34
+ index. The large index will be restored and updated once, during final
35
+ publication for the logical run.
36
+
37
+ When a physical Job completes that logical run, it activates the verified
38
+ successor and exits. The next scheduled Job restores that successor and
39
+ processes only its unresolved work. One physical Job therefore runs one logical
40
+ plan and does not start work that can exceed the platform deadline.
41
+
42
+ ## Requirements
43
+
44
+ The implementation must:
45
+
46
+ - Freeze one source snapshot, enrichment contract, queue order, and registry
47
+ candidate order for each logical run.
48
+ - Keep one logical run ID across physical Jobs.
49
+ - Keep each physical Job under a separate attempt ID.
50
+ - Store completion with compact bitmaps instead of full completed queue rows.
51
+ - Preserve retry and blocked details only for unresolved work.
52
+ - Save a registry cursor that queue work cannot reset.
53
+ - Persist one ordered registry event for every scanned candidate, including candidates that stay unchanged, so the cursor can always advance across exact names and ordinals.
54
+ - Validate every orphan row and attempt against the frozen unit input hash, taxonomy version, and enrichment contract before recovery advances progress.
55
+ - Publish and verify immutable result batches before checkpoint progress moves.
56
+ - Treat a checkpoint failure after a durable success as an orphan-output recovery case, not as a failed provider result.
57
+ - Use immutable sequence claims as the authoritative checkpoint history.
58
+ - Use a contiguous immutable activation-claim chain as the authoritative active-run history.
59
+ - Treat the mutable active-run pointer as a startup shortcut only.
60
+ - Resolve the active run from that claim chain so the recurring schedule never pins one completed run forever.
61
+ - Prepare the next compact work database from the already open final publication database, advance that local copy to the latest raw snapshot, carry the completed registry baseline forward while retaining candidates first observed after the frozen plan time, and activate the successor only after its plan and bootstrap checkpoint verify.
62
+ - Stop the physical Job after it activates a verified successor, even when that successor has unresolved queue or registry work.
63
+ - Give each physical Job one command start time, one logical run and one $10 inference ceiling.
64
+ - Let the next non-concurrent scheduled Job restore the successor from its verified checkpoint.
65
+ - Stop at a durable boundary without polling or spinning when the logical run completes or when the 40-minute worker or $10 inference ceiling is reached.
66
+ - Repeat no more than one in-flight concurrency batch after interruption.
67
+ - Keep progress monotonic across attempts in one logical run.
68
+ - Build and fully verify the public SQLite index only after work completes.
69
+ - Reuse an already uploaded final database after an interrupted publication.
70
+ - Use the existing raw and index Buckets.
71
+ - Preserve the current model and provider. Preserve taxonomy, privacy, receipt
72
+ semantics, and public index behavior.
73
+ - Keep one physical enrichment Job at a time.
74
+
75
+ ## Boundaries
76
+
77
+ This work does not change enrichment prompts, model selection, provider,
78
+ taxonomy meaning, queue fairness, public routes, or capture storage. It does not
79
+ create another Bucket, Dataset, service, scheduler, or credential. It also does
80
+ not change the `cpu-upgrade` hardware, six-hour cadence, secret names or
81
+ destinations, quality limits, $0.25 per-call limit, $10 physical-attempt
82
+ inference ceiling, 40-minute worker ceiling, or 45-minute platform timeout.
83
+
84
+ The current immutable recovery Jobs and their saved data remain untouched. New
85
+ code is implemented and tested on a feature branch. Production deployment waits
86
+ until recovery finishes and its final index validates.
87
+
88
+ The deployment is a hard replacement. After it is active, normal worker code
89
+ will not retain the old full-index startup path. Existing immutable databases,
90
+ segments, and receipts remain available for audit and operator-directed
91
+ rollback.
92
+
93
+ ## Reviewed worker revision handoff
94
+
95
+ An unfinished frozen plan can outlive the worker revision that created it. A
96
+ new worker revision must not edit that plan, ignore its worker identity, or use
97
+ a list of accepted predecessor revisions. It can continue the same logical run
98
+ only through one exact handoff in the immutable Space deployment commit.
99
+
100
+ The handoff is part of `.xtap-deployment.json`. The file remains strict and
101
+ secret-free. It retains `source_revision` and gains one required nullable field:
102
+
103
+ ```ts
104
+ export type DeploymentManifest = {
105
+ source_revision: string;
106
+ enrichment_revision_handoff: null | {
107
+ active_generation: number;
108
+ activation_sha256: string;
109
+ run_id: string;
110
+ plan_sha256: string;
111
+ plan_worker_revision: string;
112
+ target_worker_revision: string;
113
+ contract_sha256: string;
114
+ source_snapshot_revision: string;
115
+ checkpoint_pointer_sha256: string;
116
+ checkpoint_sequence: number;
117
+ checkpoint_key: string;
118
+ checkpoint_sha256: string;
119
+ checkpoint_bytes: number;
120
+ };
121
+ };
122
+ ```
123
+
124
+ A null handoff means the active plan already names the deployed worker revision.
125
+ A non-null handoff permits only the exact target revision in the same manifest.
126
+ It binds that target to one active generation, activation chain, run, immutable
127
+ plan, contract, source snapshot, and checkpoint boundary. It contains no time,
128
+ secret, mutable URL, revision list, wildcard, fallback, environment override,
129
+ or predecessor alias. The existing deployment manifest contract changes in
130
+ place before the first public release. There is no second manifest version and
131
+ no old-manifest reader.
132
+
133
+ Canonical JSON makes the handoff and complete manifest deterministic. The
134
+ schema rejects unknown fields, malformed hashes and IDs, self-handoffs,
135
+ target/source disagreement, missing checkpoint identity, and extra predecessor
136
+ revisions. The immutable Space Git commit is the handoff record. The repair does
137
+ not add a handoff object, service, store, or coordination path to either Bucket.
138
+
139
+ ### Read-only preparation
140
+
141
+ The checked-in updater prepares the handoff before it stages the Space upload.
142
+ It must:
143
+
144
+ 1. Require a clean local Git revision and use it as the requested target.
145
+ 2. Read the current Space variables only to identify the existing index Bucket.
146
+ 3. Require one canonical schedule, require it to be suspended and
147
+ non-concurrent, and require no active enrichment Job.
148
+ 4. Resolve and hash the contiguous active-run activation chain.
149
+ 5. Parse and hash the exact immutable plan and verify its run, source, contract,
150
+ work order, and predecessor worker revision.
151
+ 6. Read the current checkpoint pointer and retain its exact bytes and digest.
152
+ Restore the newest checkpoint from the complete immutable claim chain.
153
+ 7. Require the pointer to name one checkpoint in that verified chain. A pointer
154
+ may lag the chain head after an interrupted write. Verify the checkpoint
155
+ sequence, key, bytes, digest, plan identity, output frontiers, queue bitmap,
156
+ registry cursor, claims, and absence of orphan output.
157
+ 8. Return deterministic handoff bytes without creating a provider, updating
158
+ progress, or exposing a write method.
159
+ 9. Re-read the activation, checkpoint pointer, schedule, and Job list
160
+ immediately before staging and again before upload. Stop on any drift.
161
+ 10. Embed the handoff in `.xtap-deployment.json`, upload the Space commit, and
162
+ make no Bucket write.
163
+
164
+ If the active plan already names the target revision, the updater records null.
165
+ If any identity is missing, stale, forked, corrupt, conflicting, or different,
166
+ the updater stops before upload. A failed preflight must not change the Space,
167
+ schedule, Jobs, Buckets, secrets, progress, or durable run state.
168
+
169
+ ### Worker preflight
170
+
171
+ Direct equality between `plan.contract.worker_revision` and
172
+ `XTAP_SOURCE_REVISION` remains the normal path. When they differ, the worker
173
+ must read the deployment manifest from its image and require one exact handoff.
174
+ It verifies:
175
+
176
+ - The manifest source and handoff target equal `XTAP_SOURCE_REVISION`.
177
+ - The current active generation and activation digest equal the handoff.
178
+ - The run ID, plan digest, plan worker revision, contract digest, and source
179
+ snapshot equal the unchanged plan.
180
+ - On the first start, the current checkpoint pointer either equals the pinned
181
+ pointer or names an older checkpoint in the same complete verified chain. The
182
+ worker restores the chain head and then repairs a lagging pointer before it
183
+ processes more work.
184
+ - On a later attempt for the same plan, the current checkpoint is accepted only
185
+ when its sequence is higher and the complete verified claim chain descends
186
+ from the pinned handoff checkpoint without a gap, fork, or conflict.
187
+ - The complete checkpoint bundle, immutable sequence claims, result claims,
188
+ bitmap, registry cursor, and output frontiers restore without a gap, fork,
189
+ conflict, duplicate ordinal, or orphan output.
190
+
191
+ The worker performs this verification before it creates progress, an inference
192
+ provider, a raw writer, or any other write-capable runtime object. A pointer
193
+ that moves behind the handoff, changes plan identity, or does not descend from
194
+ the pinned checkpoint stops the worker. Every missing, malformed, mismatched,
195
+ or conflicting identity also stops before a provider call or remote write.
196
+
197
+ After verification, the worker continues the same run, checkpoint chain, result
198
+ claims, bitmap, queue ordinals, and registry ordinals. It processes only missing
199
+ work. Checkpoint sequences stay monotonic. Existing immutable objects do not
200
+ change. A successor plan records the deployed worker revision and enters the
201
+ normal direct-revision path. Successor activation stays fenced. One physical
202
+ attempt keeps one command start time and one inference budget across
203
+ successors. The existing elapsed and cost ceilings, blocked-only stop,
204
+ zero-work stop, and interruption recovery do not change.
205
+
206
+ Restore-only mode uses the same manifest, handoff, activation, plan, pointer,
207
+ and checkpoint verification. It receives no `INFERENCE_TOKEN`, creates no
208
+ provider, writes nothing, and makes zero provider calls.
209
+
210
+ ### Bounded cutover repair
211
+
212
+ The current repair starts from active run
213
+ `xtap-1321e3a40c38f32c0c210d8859622379`. Its frozen plan records worker revision
214
+ `aa7ccee986b7c96b494cc16e17a483740f3afb3d`. The healthy Space repository and
215
+ runtime revision is `1ac51e39c73e871cfd5335cab8e5e3bbcc58fa76`, with deployment
216
+ source `c6d07950b31eda8e0ecf74022fa72733a3bc87f8`. Canonical schedule
217
+ `6a92f66145686a1580c1543c` is suspended and non-concurrent, and no enrichment
218
+ Job is active. The first exact deployment probe stopped at the worker identity
219
+ gate before execution. The preserved boundary has active generation 29,
220
+ checkpoint sequence 807, checkpoint SHA-256
221
+ `5ca69ab4853b0e0e462d49a6f69954867f4343fa8e71655a6715d4859425171f`,
222
+ and progress sequence 834. The failed probe made zero provider calls, spent
223
+ $0 on the provider, used $0.000125 of CPU, produced no receipt, and left zero
224
+ active reservation exposure.
225
+
226
+ Implement the repair in this order:
227
+
228
+ 1. Create a clean repair branch from the accepted `main` revision. Recheck the
229
+ exact Space revision and health, suspended schedule, empty active Job list,
230
+ active generation, plan digest, and checkpoint sequence, key, digest, and
231
+ size. Stop on drift.
232
+ 2. Update the strict deployment schema and canonical serializer in
233
+ `shared/src/deployment.ts`. Add schema round-trip and rejection tests.
234
+ 3. Add a focused read-only handoff preparation and verification module under
235
+ `space/src/`. Export only protocol helpers that the setup package needs. Do
236
+ not expose write methods or provider construction.
237
+ 4. Extend `setup/src/stage.ts` and the existing updater boundary to prepare,
238
+ revalidate, and embed the claim before upload. Keep upload as its only remote
239
+ mutation. Add setup process tests for ordering, drift, no upload after a
240
+ failed preflight, no Bucket writes, and no secrets in staged bytes.
241
+ 5. Refactor `space/src/enrich-planned-command.ts` so deployment, activation,
242
+ plan, handoff, pointer, and full checkpoint verification finishes before
243
+ progress, provider, raw writer, or another write-capable client exists.
244
+ 6. Add the production regression with the predecessor revision, active
245
+ generation 29, and checkpoint sequence 807 identity shape. It must fail
246
+ without the claim, accept only the exact claim, call no provider before all
247
+ identities verify, preserve claimed work, avoid duplicate queue and registry
248
+ ordinals, and resume safely after interruption.
249
+ 7. Extend the drain, runtime, and restore tests to prove exact result replay,
250
+ monotonic checkpoints, unchanged immutable objects, bounded repeated
251
+ in-flight work, successor fencing, cumulative elapsed and cost accounting,
252
+ blocked-only termination, zero-work termination, and a second clean restore.
253
+ 8. Run `npm run check`, `npm run mutate`, `npm run build`,
254
+ `npx slophammer-ts check .`, and
255
+ `npx -y @simpledoc/simpledoc check`. Review the complete diff. Run
256
+ pi-reviewer against `main` until no P0 or P1 finding remains, and address
257
+ proportionate P2 findings. Push one narrow branch, require green CI, and
258
+ rebase-merge the reviewed pull request.
259
+ 9. Deploy the exact merged revision only through
260
+ `npm run update -- osolmaz/xtap-pool`. Wait for the exact Space repository
261
+ and runtime revisions, manifest read-back, and healthy application. Replace
262
+ the canonical schedule only if its source differs. Keep exactly one schedule
263
+ suspended and non-concurrent, and require zero active Jobs.
264
+ 10. Run restore-only verification against the preserved production run with no
265
+ inference token. Require exact replay, zero provider calls, zero orphan
266
+ segments, and unchanged public pointer and complete run-object listing.
267
+ Do not trigger a paid Job during this repair.
268
+
269
+ ### Regression matrix
270
+
271
+ Tests must cover:
272
+
273
+ - Deterministic manifest and handoff bytes, strict field validation, and unknown
274
+ field rejection.
275
+ - The exact predecessor-plan/current-worker mismatch before provider creation.
276
+ - A valid sequence-807 handoff, safe recovery from a pointer that lags a unique
277
+ verified chain, and failures for a missing claim, wrong target, wrong plan,
278
+ wrong contract, wrong source snapshot, wrong activation digest, a pointer
279
+ outside the chain, wrong checkpoint sequence, key, size, bytes, or digest,
280
+ malformed claim, activation fork, or active writer.
281
+ - Read-only preparation with zero object-store writes, zero progress updates,
282
+ and zero provider construction.
283
+ - Complete checkpoint and result-claim replay with no gaps, hash differences,
284
+ duplicate ordinals, or orphan output.
285
+ - No duplicate queue or registry work across the handoff and an interrupted
286
+ first post-handoff checkpoint.
287
+ - Successor fencing, one physical-attempt start time, cumulative provider cost,
288
+ active-reservation admission, cost-overrun rejection, elapsed ceiling,
289
+ blocked-only stop, and finite zero-work stop.
290
+ - A clean second restore with zero provider calls and unchanged pointer and
291
+ object listing.
292
+ - Post-merge Space source and runtime identity, manifest, health, suspended
293
+ schedule, zero active Jobs, and production restore-only verification.
294
+
295
+ ### Risks and stop rules
296
+
297
+ A stale handoff could bind an unrelated checkpoint. The updater prevents this
298
+ by requiring writer exclusion, hashing the exact pointer bytes, and re-reading
299
+ the activation, pointer, schedule, and Job list before staging and upload. The
300
+ worker requires that exact pointer on the first start. On later attempts, it
301
+ accepts only a higher checkpoint in the same fully verified claim chain whose
302
+ immutable ancestry includes the handoff checkpoint.
303
+
304
+ Moving revision verification later could permit an early provider call or
305
+ write. The worker must instead move the complete identity and checkpoint
306
+ verification earlier than every progress, provider, writer, and write-capable
307
+ client construction.
308
+
309
+ A handoff could become a compatibility path. The schema therefore permits one
310
+ exact target for one exact active plan and checkpoint. It has no list, fallback,
311
+ wildcard, environment override, predecessor alias, old-manifest path, or second
312
+ format version.
313
+
314
+ A bad Space commit cannot be edited in place. The updater prepares deterministic
315
+ bytes locally, verifies all identities, rechecks writer exclusion, and requires
316
+ exact deployment read-back. Any correction is another reviewed deployment, not
317
+ a history rewrite.
318
+
319
+ Existing results could repeat after the handoff. The worker must continue the
320
+ same restored bitmap, result claims, checkpoint chain, queue ordinals, and
321
+ registry ordinals. Tests cover interruption at the first post-handoff
322
+ checkpoint and reject duplicate work.
323
+
324
+ A full restore can make startup slower. Use the existing bounded restore and
325
+ prefetch interfaces and require a cold restore below five minutes. Keep provider
326
+ and writer initialization deferred until restore finishes.
327
+
328
+ Credentials must not enter the handoff or logs. The schema accepts only IDs,
329
+ hashes, revisions, keys, and counts. Approved credentials remain in the
330
+ short-lived updater process and the existing approved Space and schedule secret
331
+ destinations.
332
+
333
+ Stop the repair if a paid Job becomes active, an immutable production identity
334
+ drifts, history would need to change, the identity gate would need to weaken, a
335
+ protected provider or runtime contract would need to change, review or CI leaves
336
+ a material defect, deployment provenance is not exact, or restore-only
337
+ verification writes or calls a provider. If the same worker-plan mismatch
338
+ repeats after the exact repaired deployment, preserve the evidence and stop.
339
+
340
+ This repair does not run a production probe, recovery canary, recurring Job, or
341
+ OurModels publication. It does not change an existing plan, checkpoint, output,
342
+ claim, receipt, activation, pointer, index, database, manifest, or other
343
+ historical object. It does not change OurModels, publication locks, the pinned
344
+ HF Job Control dependency, Hugging Face or Fireworks behavior, runtime images,
345
+ provider, model, hardware, cadence, concurrency, timeout, elapsed or cost
346
+ limits, taxonomy, quality rules, secret names, or credential destinations. It
347
+ does not add a migration reader, dual path, compatibility alias or allowlist,
348
+ new dependency, service, repository, store, Endpoint, or extra schedule.
349
+
350
+ ## Resume model
351
+
352
+ Each completed batch is saved once. A small claimed checkpoint records the
353
+ finished ordinals and unresolved work. `current.json` points to the latest known
354
+ claim so startup is fast, but the immutable claims remain the source of truth.
355
+
356
+ A normal continuation downloads the frozen plan and small checkpoint. It then
357
+ replays only the raw output segments claimed by that checkpoint into the compact
358
+ worker database so queue results, registry statuses, and registry revisions are
359
+ exact before new work starts. It does not restore the public database. The Job
360
+ builds that database once after queue and registry work is complete.
361
+
362
+ The physical-attempt coordinator records the command start time once. It adds
363
+ settled inference cost and active reservations from each logical run before it
364
+ admits work in a successor. A logical-run receipt remains tied to its immutable
365
+ plan, but moving to a successor does not create a new physical-attempt budget.
366
+ After publication activates a successor, the coordinator verifies that plan and
367
+ its bootstrap checkpoint, checks whether queue or registry work remains, and
368
+ continues it only when both physical-attempt ceilings still admit work. A
369
+ zero-work successor ends the physical Job cleanly.
370
+
371
+ ## Logical run plan
372
+
373
+ Create one canonical run plan:
374
+
375
+ ```ts
376
+ export type ObjectReference = {
377
+ key: string;
378
+ sha256: string;
379
+ bytes: number;
380
+ };
381
+
382
+ export type EnrichmentRunPlan = {
383
+ schema_version: 1;
384
+ run_id: string;
385
+ created_at: string;
386
+ source: {
387
+ bucket: string;
388
+ snapshot_revision: string;
389
+ ordered_segments: ObjectReference;
390
+ };
391
+ contract: {
392
+ worker_revision: string;
393
+ contract_sha256: string;
394
+ taxonomy_version: number;
395
+ model: string;
396
+ provider: string;
397
+ };
398
+ base_index: {
399
+ key: string;
400
+ sha256: string;
401
+ bytes: number;
402
+ source_revision: string;
403
+ source_segment_count: number;
404
+ receipt_count: number;
405
+ registry_revision: number;
406
+ };
407
+ work: ObjectReference & {
408
+ queue_total: number;
409
+ queue_baseline_done: number;
410
+ registry_total: number;
411
+ registry_baseline_scanned: number;
412
+ };
413
+ };
414
+ ```
415
+
416
+ Canonical JSON bytes without `run_id` determine the plan SHA-256. The run ID is
417
+ `xtap-` plus the first 32 hexadecimal characters of that hash. Stored plan bytes
418
+ include the derived run ID and remain stable across attempts.
419
+
420
+ New raw segments and candidates wait for the next logical run. They cannot
421
+ change the total or reset progress in an active run.
422
+
423
+ ## Immutable work plan
424
+
425
+ Store the fixed work order in `work-plan.sqlite`:
426
+
427
+ ```sql
428
+ CREATE TABLE worker_queue_plan (
429
+ ordinal INTEGER PRIMARY KEY,
430
+ unit_id TEXT NOT NULL UNIQUE,
431
+ input_hash TEXT NOT NULL,
432
+ taxonomy_version INTEGER NOT NULL,
433
+ initial_status TEXT NOT NULL,
434
+ attempts INTEGER NOT NULL,
435
+ next_retry_at TEXT
436
+ );
437
+
438
+ CREATE TABLE worker_registry_plan (
439
+ ordinal INTEGER PRIMARY KEY,
440
+ name TEXT NOT NULL UNIQUE,
441
+ evidence_hash TEXT NOT NULL
442
+ );
443
+
444
+ CREATE TABLE plan_metadata (
445
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
446
+ run_id TEXT NOT NULL,
447
+ plan_sha256 TEXT NOT NULL,
448
+ queue_total INTEGER NOT NULL,
449
+ registry_total INTEGER NOT NULL
450
+ );
451
+ ```
452
+
453
+ Rows use deterministic ordinal order. The database retains only tweets needed
454
+ by unresolved queue work and evidence needed by remaining registry candidates.
455
+ Historical completed rows are represented by the baseline count and bitmap, so
456
+ the worker database stays small. The plan is immutable and downloaded once per
457
+ physical attempt.
458
+
459
+ ## Compact worker state
460
+
461
+ The checkpoint payload contains compact application state:
462
+
463
+ ```ts
464
+ export type OutputFrontier = {
465
+ sequence: number;
466
+ chain_sha256: string | null;
467
+ };
468
+
469
+ export type EnrichmentCheckpointState = {
470
+ schema_version: 1;
471
+ run_id: string;
472
+ plan_sha256: string;
473
+ sequence: number;
474
+ previous_checkpoint_sha256: string | null;
475
+ queue: {
476
+ completed_bitmap: Uint8Array;
477
+ done: number;
478
+ retrying: RetryRecord[];
479
+ blocked: BlockedRecord[];
480
+ };
481
+ registry: {
482
+ next_ordinal: number;
483
+ approved: number;
484
+ rejected: number;
485
+ };
486
+ outputs: {
487
+ enrichment: OutputFrontier;
488
+ attempt: OutputFrontier;
489
+ registry: OutputFrontier;
490
+ receipt: OutputFrontier;
491
+ };
492
+ publication: {
493
+ state: "pending" | "building" | "uploaded" | "verified" | "published";
494
+ database_key: string | null;
495
+ database_sha256: string | null;
496
+ database_bytes: number | null;
497
+ manifest: DurableIndexManifest | null;
498
+ };
499
+ };
500
+ ```
501
+
502
+ A 251,000-item completion bitmap is about 31 KB. Completed queue rows do not
503
+ remain in mutable SQLite state. Retry and blocked arrays contain only unresolved
504
+ records. Output chain frontiers replace an ever-growing list of result objects.
505
+
506
+ The adapter serializes this state into the content-addressed HF Job Control
507
+ checkpoint bundle. Before restore, it validates bitmap length, counts, ordinals,
508
+ hashes, and output frontiers. Restore finds the latest committed state from
509
+ immutable sequence claims. `current.json` only avoids listing those claims on a
510
+ normal startup.
511
+
512
+ ## Deterministic batches
513
+
514
+ Each batch has this identity:
515
+
516
+ ```text
517
+ <run_id>:<phase>:<batch_sequence>
518
+ ```
519
+
520
+ The batch intent names its exact ordinals. The result object names the intent,
521
+ contract, predecessor result hash, unit outcomes, and exact immutable segment
522
+ reference.
523
+
524
+ For each batch, the worker:
525
+
526
+ 1. Reads missing ordinals from the frozen plan.
527
+ 2. Saves the batch identity and exact unit list locally.
528
+ 3. Runs at most the configured concurrency.
529
+ 4. Writes one immutable attempt segment, including concurrent dispatch
530
+ reservations before provider calls.
531
+ 5. Uploads the segment and downloads it for SHA-256 verification.
532
+ 6. Updates the local bitmap and retry details, then updates counters and the output chain.
533
+ 7. Commits and verifies a small checkpoint bundle.
534
+ 8. Publishes and verifies its immutable sequence claim.
535
+ 9. Updates and reads back the pointer hint.
536
+ 10. Publishes progress from that checkpoint sequence.
537
+
538
+ A process that stops before the raw result upload may repeat that batch. On
539
+ startup, recovery compares verified raw segments after the frozen source
540
+ snapshot with deterministic result manifests. If a raw segment exists without a
541
+ claimed result, recovery creates the same result manifest, applies it, and
542
+ commits a checkpoint before any provider call.
543
+
544
+ ## Queue semantics
545
+
546
+ The queue total comes from the frozen plan. A unit is done only when its result
547
+ segment is durable and the verified checkpoint marks its ordinal complete.
548
+
549
+ Retries retain attempts, error class, next retry time, and input identity.
550
+ Blocked records retain the deterministic reason and evidence needed by the
551
+ existing completion contract. Queue completion requires no pending or retrying
552
+ records and only explicitly validated blocked records. Completion of one frozen
553
+ queue is not a reason to end the physical Job: after verified publication and
554
+ successor activation, the same Job continues the successor when it contains
555
+ unresolved work and the shared physical-attempt budget remains.
556
+
557
+ Leases remain physical-attempt state. They are not restored. The deterministic
558
+ batch identity and result reconciliation prevent a cleared lease from losing a
559
+ completed result.
560
+
561
+ ## Registry semantics
562
+
563
+ Registry candidates have a fixed order in `worker_registry_plan`. The checkpoint
564
+ stores `next_ordinal` and decision counters. Every decision is uploaded and
565
+ verified before the cursor advances.
566
+
567
+ Enrichment work cannot reset the cursor. New candidates wait for the next
568
+ logical run. A completed scan means `next_ordinal === registry_total` and the
569
+ registry output chain agrees with all decisions in that prefix.
570
+
571
+ ## Final public index
572
+
573
+ Normal worker attempts do not call `DurableIndex.restore()`.
574
+
575
+ After queue and registry completion, the publication attempt:
576
+
577
+ 1. Commits publication state `building`.
578
+ 2. Downloads the exact base public database named by the frozen plan.
579
+ 3. Verifies byte count, SHA-256, metadata, and `PRAGMA quick_check`.
580
+ 4. Applies only the verified raw output segments named by this run's result
581
+ manifests.
582
+ 5. Verifies queue totals, registry revision, source frontier, and physical row
583
+ counts.
584
+ 6. Runs `PRAGMA quick_check` and uploads the database under
585
+ `index/databases/<sha256>.sqlite`.
586
+ 7. Commits state `uploaded` with the immutable key, digest, byte count, and
587
+ complete manifest.
588
+ 8. Downloads it and verifies the bytes and digest. It then verifies metadata and
589
+ counts, checks integrity, and commits state `verified`.
590
+ 9. Confirms that `index/current.json` still has the frozen predecessor bytes.
591
+ 10. Replaces the public pointer, reads it back, and commits state `published`.
592
+ 11. Publishes the final receipt from the claimed `published` checkpoint.
593
+
594
+ Each state change has its own checkpoint claim. A replacement reuses an uploaded
595
+ or verified database and continues at the next step. If the public pointer was
596
+ written before the `published` checkpoint, recovery recognizes the same
597
+ manifest and commits that state without another pointer write.
598
+
599
+ ## Storage layout
600
+
601
+ Use the existing index Bucket:
602
+
603
+ ```text
604
+ operations/enrichment/runs/<run_id>/plan.json
605
+ operations/enrichment/runs/<run_id>/work-plan.sqlite
606
+ operations/enrichment/runs/<run_id>/checkpoints/sha256-<hash>/checkpoint.hfjob
607
+ operations/enrichment/runs/<run_id>/checkpoints/claims/sequence-<n>/<attempt_id>.json
608
+ operations/enrichment/runs/<run_id>/batches/<phase>/<sequence>/result.json
609
+ operations/enrichment/runs/<run_id>/current.json
610
+ index/databases/<sha256>.sqlite
611
+ index/current.json
612
+ ```
613
+
614
+ All paths except the two current pointers are immutable. The run pointer is a
615
+ startup hint. `index/current.json` keeps its existing public read contract and
616
+ changes only after a verified immutable publication claim.
617
+
618
+ ## Bootstrap preparation and commit
619
+
620
+ Bootstrap has two phases. Preparation is read-only. Commit owns every remote
621
+ write.
622
+
623
+ This boundary fixes a defect found during the production transition. The first
624
+ bootstrap resolved taxonomy before it loaded the validated raw snapshot. An
625
+ empty `BucketLog` therefore treated all 19,140 historical segment files as new,
626
+ downloaded them serially, retained them in memory, and reported no exact
627
+ progress. The repair must load known snapshot identities before taxonomy
628
+ resolution.
629
+
630
+ ### Bucket capabilities
631
+
632
+ Reader interfaces expose only listing, download, and text reads. Writer
633
+ interfaces add upload, deletion, checkpoint publication, activation, and
634
+ pointer replacement. Preparation accepts readers only. The read-only shadow
635
+ binary must not import a provider, checkpoint writer, activation writer, upload
636
+ method, delete method, or schedule method.
637
+
638
+ A validated snapshot can seed `BucketLog` only after all of these checks pass:
639
+
640
+ - The snapshot bytes hash to the source revision in `index/current.json`.
641
+ - The snapshot names the expected raw Bucket.
642
+ - Every segment key, size, content hash, and listed object identity is valid.
643
+ - No key or identity is duplicated.
644
+
645
+ Seeding records known immutable identities. It does not claim that the current
646
+ process downloaded those segment bodies. Head discovery still lists all keys
647
+ so it can detect deletion, size drift, or identity drift. It downloads and
648
+ fully verifies bodies only for new or conflicting tail objects.
649
+
650
+ Tail body verification can use a small fixed worker pool. Verification results
651
+ must be retained by immutable key and applied in sorted key order, so request
652
+ completion order cannot change the projection. The first missing, deleted,
653
+ changed, duplicate, or invalid object fails preparation.
654
+
655
+ ### Read-only preparation
656
+
657
+ Preparation does this work in order:
658
+
659
+ 1. Read and parse `index/current.json`. Record its exact bytes and SHA-256.
660
+ 2. Require the expected raw Bucket, database key and SHA-256, source snapshot
661
+ revision, projection contract hash, predecessor identities, and counts.
662
+ 3. Use the manifest contract hash only to restore the already validated
663
+ database and raw snapshot. Verify the 1,328,619,520-byte database, its
664
+ SHA-256, provenance, schema, and `PRAGMA quick_check` result.
665
+ 4. Seed `BucketLog` with the validated snapshot before it reads taxonomy.
666
+ 5. Resolve taxonomy from the seeded log. Derive the taxonomy and model contract
667
+ and require exact equality with the manifest contract before tail work.
668
+ 6. Discover the current raw head against the known snapshot. Check every known
669
+ listing identity and download only the tail.
670
+ 7. Verify tail bodies with bounded concurrency and apply them in deterministic
671
+ order. Durable registry revision 31,201 must appear exactly once after base
672
+ revision 31,200.
673
+ 8. Advance a disposable local database. Verify source continuity, receipt
674
+ continuity, queue state, registry state, leases, retry timestamps, and all
675
+ physical row counts.
676
+ 9. Export queue IDs and input hashes in deterministic order. Build the completion
677
+ bitmap and preserve unresolved retry or blocked details.
678
+ 10. Build the registry candidate plan with the production discovery algorithm.
679
+ Verify the saved cursor by scanned count, last name, and receipt time.
680
+ Exclude old candidates at or before that name, but retain candidates first
681
+ observed after the receipt even when their names sort before the cursor.
682
+ 11. Build and verify the compact local work database. Run its SQLite integrity
683
+ and provenance checks.
684
+ 12. Return a content-addressed `BootstrapCandidate`. Do not write a remote
685
+ object.
686
+
687
+ The candidate records:
688
+
689
+ - The source pointer bytes and digest.
690
+ - The base database key, size, digest, and source revision.
691
+ - The validated raw snapshot and discovered head revisions.
692
+ - The ordered tail digest and counts.
693
+ - The taxonomy, model, provider, worker revision, and contract digest.
694
+ - Queue and registry totals, baselines, ordinals, cursor, and unresolved state.
695
+ - The compact local database path, size, and digest.
696
+ - Exact progress totals and elapsed time for each preparation stage.
697
+
698
+ Observational timestamps do not contribute to semantic identity. Repeated
699
+ preparation from the same inputs must produce the same candidate identities and
700
+ local artifact digests.
701
+
702
+ ### Exclusive commit
703
+
704
+ `commitBootstrapCandidate` is the only bootstrap operation that can write the
705
+ plan, work database, initial checkpoint, activation claim, or active-run hint.
706
+ It must revalidate the candidate digest, source head, public pointer bytes, and
707
+ exclusive-writer admission immediately before its first write. It uses the
708
+ existing `bootstrapEnrichmentRun` and `activateEnrichmentRun` formats. The repair
709
+ must not change compact-run keys, checkpoint formats, claims, or public pointer
710
+ formats.
711
+
712
+ The production bootstrap command composes preparation and commit. The shadow
713
+ command calls preparation only. A candidate prepared while another writer is
714
+ active cannot commit until a later exclusive-writer check passes.
715
+
716
+ ### Progress
717
+
718
+ Preparation reports these stable stages:
719
+
720
+ - Manifest and pointer validation.
721
+ - Database download and byte verification.
722
+ - Snapshot load and known-identity seeding.
723
+ - Head listing and known-identity checks.
724
+ - Tail body verification.
725
+ - Deterministic tail application.
726
+ - Queue and registry projection.
727
+ - Compact database validation.
728
+ - Candidate validation.
729
+
730
+ Each stage reports exact completed and total items or bytes and elapsed
731
+ milliseconds. These events are local observations. They do not add a remote
732
+ progress API or a new durable format.
733
+
734
+ ### Read-only shadow validation
735
+
736
+ While the original production importer is active, a shadow run must remain
737
+ structurally read-only. For this transition, session 216 and PID 3252076 identify
738
+ the original importer and only possible writer:
739
+
740
+ 1. Create a clean repair worktree from merged revision
741
+ `c0a1a95ebf6a4209f9f4d9505e9b6748af08216b`. Leave the dirty documentation
742
+ tree and exact deployment worktree unchanged.
743
+ 2. Record the original process state and I/O, active xTap Jobs, Space revision,
744
+ enrichment state, suspended schedule, public pointer bytes, and complete
745
+ `operations/enrichment/runs` object listing.
746
+ 3. Run all deterministic tests, repository gates, mutation tests, and review in
747
+ the repair worktree. Fix all P0 and P1 findings before a live shadow run.
748
+ 4. Start the shadow in a fresh local directory with only the approved storage
749
+ credential mapped to `HF_TOKEN`. Do not load `INFERENCE_TOKEN`.
750
+ 5. Use conservative tail concurrency. Stop only the shadow if it harms original
751
+ process I/O or host health.
752
+ 6. Monitor exact shadow progress and the original process independently. Do not
753
+ signal, cancel, restart, or replace the original process while it progresses.
754
+ 7. If the original writes during shadow preparation, treat that as observed
755
+ source drift. The shadow remains read-only and must revalidate its candidate
756
+ before later use.
757
+ 8. Verify the candidate schema and digest, local artifact sizes and hashes,
758
+ SQLite integrity and provenance, source and ordinal continuity, queue and
759
+ registry parity, and exact single application of revision 31,201.
760
+ 9. Prove provider calls are zero.
761
+ 10. Re-read the public pointer, active-run object listing, Jobs, Space, and
762
+ schedule after the shadow exits. Require no shadow-caused change.
763
+
764
+ The shadow writes only local candidate and evidence files. It cannot write a
765
+ plan, checkpoint, activation claim, publication, pointer, Space setting, or
766
+ schedule. It cannot launch a Hugging Face Job.
767
+
768
+ Do not commit, push, open, merge, or deploy the repair during shadow validation.
769
+ If the original importer completes validly, compare its immutable result with
770
+ the shadow candidate and prefer the accepted production result. If the original
771
+ fails without writes, the production transition can consider the reviewed
772
+ repair only after it proves that no writer is active and repeats all admission
773
+ checks.
774
+
775
+ ## Files
776
+
777
+ Add:
778
+
779
+ ```text
780
+ space/src/enrich-run-plan.ts
781
+ space/src/enrich-checkpoint.ts
782
+ space/src/enrich-state.ts
783
+ space/src/enrich-batch.ts
784
+ space/src/enrich-planned-command.ts
785
+ space/src/bootstrap-enrichment-run.ts
786
+ space/src/prepare-enrichment-bootstrap.ts
787
+ space/src/verify-enrichment-bootstrap-main.ts
788
+ space/src/bootstrap-enrichment-main.ts
789
+ ```
790
+
791
+ Change:
792
+
793
+ ```text
794
+ space/src/enrich-command.ts
795
+ space/src/enrich-worker.ts
796
+ space/src/enrich-store.ts
797
+ space/src/durable-index.ts
798
+ space/src/job-progress.ts
799
+ space/src/bucket-log.ts
800
+ space/package.json
801
+ package.json
802
+ ```
803
+
804
+ `durable-index.ts` remains the public-index import and publication boundary.
805
+ Normal queue and registry work uses the work plan and checkpoint plus immutable
806
+ raw output segments. The new preparation module is the shared read-only import
807
+ path. The production bootstrap adds commit capability at its composition root.
808
+ The shadow composition root has read capability only.
809
+
810
+ ## Forced-stop tests
811
+
812
+ Stop the worker at each boundary:
813
+
814
+ - Before result upload.
815
+ - After result upload and before verification.
816
+ - After result verification and before checkpoint upload.
817
+ - After checkpoint upload and before claim publication.
818
+ - After claim publication and before pointer-hint publication.
819
+ - After pointer-hint publication and before progress publication.
820
+ - During public database build.
821
+ - During public database upload.
822
+ - After the `uploaded` checkpoint and before verification.
823
+ - After the `verified` checkpoint and before `index/current.json` replacement.
824
+ - After pointer replacement and before the `published` checkpoint.
825
+ - After the `published` checkpoint and before the final receipt.
826
+
827
+ Each replacement must restore verified state, avoid repeating durable batches,
828
+ and produce the same final output as an uninterrupted reference.
829
+
830
+ Corruption tests cover wrong run and plan identities, wrong hashes or sizes,
831
+ missing predecessors, multiple roots, different claims for one sequence,
832
+ malformed bitmaps, inconsistent counters, changed source snapshots, missing
833
+ result segments, broken output chains, invalid SQLite files, pointers outside
834
+ the verified chain, and conflicting public publication claims. A separate crash
835
+ test leaves the pointer one checkpoint behind and proves that the next worker
836
+ repairs it without repeating completed work.
837
+
838
+ ## Verification
839
+
840
+ Run:
841
+
842
+ ```bash
843
+ npm run check
844
+ npm run coverage
845
+ npm run mutate
846
+ npx slophammer-ts check .
847
+ npm run doctor
848
+ npm run storage:verify
849
+ npx -y @simpledoc/simpledoc check
850
+ ```
851
+
852
+ Add tests for plan determinism, production import, checkpoint round trips,
853
+ missing-only queue work, registry cursor continuity, deterministic batch replay,
854
+ publication recovery, and resumed-versus-uninterrupted equivalence.
855
+
856
+ The single-run Job change also needs direct tests for:
857
+
858
+ - Running only one logical plan when its verified successor has work.
859
+ - Passing the physical Job's full time and cost limits to that plan.
860
+ - Propagating interruption without starting another logical run.
861
+ - Restoring the successor in the next scheduled Job without duplicate results,
862
+ checkpoints, receipts, schedules or physical Jobs.
863
+
864
+ The bootstrap repair also needs tests for:
865
+
866
+ - Validated snapshot seeding, known-body skipping, and rejection of duplicate,
867
+ missing, deleted, changed, malformed, or wrong-Bucket identities.
868
+ - The tail verification concurrency ceiling, deterministic application order,
869
+ fail-fast behavior, and exact progress totals.
870
+ - Manifest-first database and snapshot restore followed by independent taxonomy
871
+ and model contract verification.
872
+ - Queue and registry parity, source continuity, and exactly one application of
873
+ registry revision 31,201.
874
+ - Read-only capability enforcement, zero provider construction, and zero remote
875
+ writes from the shadow binary.
876
+ - `BootstrapCandidate` schema, canonical bytes, digest, local artifact hashes,
877
+ and repeated-run determinism.
878
+ - Prepared-state equivalence between seeded-tail preparation and full replay.
879
+ - Live before-and-after evidence for pointer bytes, active-run objects, Jobs,
880
+ Space state, original process state, progress counts, and provider calls.
881
+
882
+ ## Production baseline
883
+
884
+ The final legacy Job completed on 2026-08-20. Use this generation as the only
885
+ bootstrap input:
886
+
887
+ - Public pointer SHA-256:
888
+ `5a259cc95a3437d1c983d7d123efe476cf6bf3d48e14f97fec31bb4067834740`.
889
+ - Database SHA-256:
890
+ `9e9940b063f615dae42ed86ba08ec6aab27873b61b8da8b7ad42ee8fff413c12`.
891
+ - The database is 1,328,619,520 bytes.
892
+ - The queue has 252,042 completed records out of 252,504. It has 462 pending
893
+ records, no leases, and no retry timestamps.
894
+ - The registry scan has checked 15,840 of 25,675 candidates.
895
+ - The database contains registry revision 31,200.
896
+ - The later durable registry revision is 31,201. The importer must apply it
897
+ exactly once after the base revision.
898
+ - Settled restoration spend is $102.3746080.
899
+ - Conservative exposure before the new canaries is $602.3746080.
900
+ - The approved cumulative restoration ceiling is $2,000.
901
+ - The merged worker revision is
902
+ `c0a1a95ebf6a4209f9f4d9505e9b6748af08216b`.
903
+
904
+ The final receipt, pointer, full database hash, `PRAGMA quick_check`, source
905
+ identity, queue state, registry revision, and raw segment hashes were verified.
906
+ The legacy schedule is suspended and no enrichment Job is active.
907
+
908
+ ## Production transition
909
+
910
+ This is a hard replacement. Do not start another legacy full-index worker.
911
+ Keep all schedules suspended until the final activation step.
912
+
913
+ 1. Re-read the final receipt, pointer, database, raw segment frontier, Job list,
914
+ and schedule. Stop if any identity differs from the production baseline.
915
+ 2. Create or verify a clean deployment worktree at the exact merged worker
916
+ revision. Keep documentation updates outside that worktree. Do not deploy
917
+ uncommitted files or a later revision, and do not merge or change the pinned
918
+ HF Job Control dependency during this transition.
919
+ 3. Before each paid action, add settled spend, prior unreceipted attempt caps,
920
+ active reservations, and the next worst-case action. Stop before an action
921
+ that can take conservative exposure above $2,000.
922
+ 4. Load only the approved storage and inference credentials in a short-lived,
923
+ non-traced process. The Space, canary Jobs, and scheduled Job may contain
924
+ only `HF_TOKEN` and `INFERENCE_TOKEN` where each interface requires them.
925
+ 5. During the bounded bootstrap repair, leave the original importer running as
926
+ the only possible writer. Run the repaired shadow with `HF_TOKEN` only. It
927
+ prepares and verifies a local candidate but cannot commit or activate it.
928
+ If the original importer completes validly, use its immutable result and
929
+ compare it with the candidate. If it fails without writes, use the repaired
930
+ commit path only after all tests and review pass and a new admission check
931
+ proves no writer is active.
932
+ 6. Run `npm run enrich:bootstrap-run` from the accepted exact worktree only when
933
+ exclusive-writer admission passes. The importer must restore the exact base,
934
+ advance over later valid raw segments including registry revision 31,201,
935
+ write the frozen plan, compact work database, bootstrap checkpoint,
936
+ activation claim, and active-run hint, then read them back. A repaired
937
+ bootstrap must prepare the candidate first and revalidate the pointer,
938
+ source head, candidate digest, and writer exclusion before commit.
939
+ 7. Treat `operations/enrichment/runs` as the isolated future-runtime namespace.
940
+ The suspended legacy schedule and old deployed worker do not read it.
941
+ Capture `index/current.json` before and after import and require byte equality.
942
+ 8. Validate the imported plan SHA, run ID, source snapshot, worker revision,
943
+ contract hash, base reference, queue and registry order, cursor, ordinal
944
+ coverage, checkpoint sequence, activation chain, claims, output frontiers,
945
+ object hashes, and unresolved rows. Revision 31,201 must be neither missing
946
+ nor applied twice.
947
+ 9. Restore the active plan and checkpoint twice into clean directories, with
948
+ the second run using no local cache. Both restores must return the same state
949
+ in less than five minutes, make zero provider calls, and leave all production
950
+ pointers unchanged.
951
+ 10. Deploy the merged revision from the exact deployment worktree with:
952
+
953
+ ```sh
954
+ npm run update -- osolmaz/xtap-pool
955
+ ```
956
+
957
+ Wait for the exact repository and runtime revision to become `RUNNING` and
958
+ `READY`. Require HTTP 200 from `/readyz`, ready storage, and enrichment
959
+ disabled before schedule repair.
960
+
961
+ 11. Inspect the schedule contract without mutation:
962
+
963
+ ```sh
964
+ npm run --silent doctor -- osolmaz/xtap-pool --json
965
+ ```
966
+
967
+ Then use `npm run doctor -- osolmaz/xtap-pool --fix` to replace the legacy
968
+ schedule. Read back exactly one suspended, non-concurrent `cpu-upgrade`
969
+ schedule at `17 */6 * * *`. It must run
970
+ `space/dist/src/enrich-job-main.js`, use the merged deployment contract,
971
+ have a 2,700-second platform timeout and 2,400,000-millisecond worker budget,
972
+ keep the checked-in $10 run ceiling, and use only `HF_TOKEN` and
973
+ `INFERENCE_TOKEN`.
974
+
975
+ 12. Recheck cost admission and trigger one physical Job from the suspended
976
+ schedule. Require restore under five minutes and wait for a verified output
977
+ claim and checkpoint boundary before any interruption.
978
+ 13. Save the Job inspection, logs, active run, plan, checkpoint hashes, output
979
+ frontier, provider receipt frontier, and cost evidence. Cancel only that
980
+ physical Job. Do not create a receipt for work that did not publish one.
981
+ 14. After the canceled Job is terminal and no xTap Job is active, trigger the
982
+ same suspended schedule once. The replacement must use the same run ID and
983
+ plan SHA, restore the prior checkpoint, reconcile orphan outputs, process
984
+ only missing ordinals, and keep progress monotonic. At most the documented
985
+ in-flight concurrency batch may repeat.
986
+ 15. Continue the frozen run one physical Job at a time. After each attempt,
987
+ validate the terminal state, exact receipt, referenced outputs, checkpoint
988
+ chain, claims, cost, and no-overlap state before another trigger. A valid
989
+ deterministic blocked record is data, not a shared failure.
990
+ 16. After queue and registry completion, validate resumable publication. The
991
+ database key must equal its SHA-256, `PRAGMA quick_check` must return `ok`,
992
+ metadata and counts must match the frozen plan and completed checkpoint,
993
+ and every manifest and receipt reference must exist. Move
994
+ `index/current.json` only after full read-back.
995
+ 17. Resolve every activation claim and require one contiguous chain. The
996
+ completed plan must create exactly one verified successor, with no competing
997
+ plan or work object. The active hint must match the last valid claim.
998
+ 18. Run the two-Job steady canary while the schedule is suspended. Use the hard
999
+ ceiling reported by setup doctor and pass the approved ceiling explicitly:
1000
+
1001
+ ```sh
1002
+ npm run doctor -- osolmaz/xtap-pool --fix --canary \
1003
+ --approved-cost-ceiling-usd=<approved-canary-ceiling>
1004
+ ```
1005
+
1006
+ Validate both receipts and every referenced output. Both Jobs must use the
1007
+ successor, restore bounded state, and remain non-overlapping.
1008
+
1009
+ 19. Activate only after every earlier gate passes:
1010
+
1011
+ ```sh
1012
+ npm run doctor -- osolmaz/xtap-pool --fix --canary \
1013
+ --approved-cost-ceiling-usd=<approved-canary-ceiling> --enable-schedule
1014
+ ```
1015
+
1016
+ Read back exactly one active canonical schedule. Remove the legacy schedule
1017
+ and source revision. Leave no physical xTap Job active at handoff.
1018
+
1019
+ ## Backlog-drain rollout
1020
+
1021
+ The same-attempt successor change uses the existing production contract. Do not
1022
+ deploy it while physical Job `6a92b7ae45686a1580c14a67` or another matching
1023
+ Job is active. First wait for the Job to become terminal, then validate its
1024
+ receipt, result batches, checkpoint, public index, manifest, and active
1025
+ successor. Do not discard or repeat its durable work.
1026
+
1027
+ Use this order:
1028
+
1029
+ 1. Run the complete repository checks and recovery tests on the final branch.
1030
+ 2. Review and merge one narrow pull request.
1031
+ 3. Wait for the xTap Space to report `RUNNING` at the exact merged revision.
1032
+ 4. Use setup doctor to reconcile one exact suspended schedule. Run the required
1033
+ sequential canary before activation. Keep the schedule non-concurrent at
1034
+ `17 */6 * * *` on `cpu-upgrade` with the existing secrets and limits.
1035
+ 5. Trigger or resume only one physical Job at a time. After each terminal
1036
+ attempt, validate its cumulative physical-attempt cost, receipts, checkpoint
1037
+ chain, result batches, index read-back, manifest, active successor, and zero
1038
+ overlap before another attempt.
1039
+ 6. Continue until one captured enrichment status reports `pending=0`,
1040
+ `running=0`, `retrying=0`, and `blocked=0`. Record that source revision and
1041
+ cutoff with the verified active successor and public index.
1042
+ 7. Use the existing OurModels publication tooling only after that empty xTap
1043
+ revision verifies. Reconcile its exact producer, generate and verify one
1044
+ fresh publication from the captured revision, and leave one active
1045
+ non-concurrent six-hour OurModels schedule.
1046
+
1047
+ This rollout does not add a cross-service trigger or a new runtime contract.
1048
+ The recurring xTap Job drains successors within its existing physical-attempt
1049
+ bounds. The recurring OurModels Job keeps its own frozen-plan and cost
1050
+ contracts.
1051
+
1052
+ ## Failure handling
1053
+
1054
+ Fail closed before new provider calls or pointer writes when an identity, hash,
1055
+ ordinal, predecessor, cursor, source revision, or count differs.
1056
+
1057
+ If import writes only part of the new immutable graph, keep the public pointer
1058
+ and schedules unchanged. Re-run only after every existing immutable object
1059
+ matches the intended bytes.
1060
+
1061
+ If a read-only shadow detects pointer, source, or object drift, stop only the
1062
+ shadow and preserve its candidate and evidence. It must not signal the original
1063
+ importer or commit remote state. Revalidate the candidate only after the source
1064
+ and writer state are stable.
1065
+
1066
+ If shadow CPU, memory, disk, or network use harms the original importer or host,
1067
+ stop only the shadow. Keep its bounded concurrency low and preserve the original
1068
+ as the only writer.
1069
+
1070
+ If deployment fails, keep the schedule suspended. Repair only the bounded defect
1071
+ inside this repository, run the full quality gates and review, and repeat the
1072
+ failed gate. Do not substitute an upstream dependency or unreviewed runtime.
1073
+
1074
+ If a Job stops outside a verified boundary, preserve its inspection and logs,
1075
+ then restore from the last valid checkpoint and reconcile orphan outputs. Do not
1076
+ fabricate a receipt or mark unverified work complete.
1077
+
1078
+ If repeated work exceeds one in-flight concurrency batch, stop the run and
1079
+ investigate the claim frontier before another paid attempt.
1080
+
1081
+ If the next action can take conservative exposure above $2,000, do not launch
1082
+ it. Preserve the current state and report the exact cost evidence.
1083
+
1084
+ ## Acceptance criteria
1085
+
1086
+ The work is complete when:
1087
+
1088
+ - Read-only preparation seeds the validated snapshot before taxonomy lookup,
1089
+ downloads no known historical body, verifies only the tail, and reports exact
1090
+ progress.
1091
+ - Shadow validation makes zero provider calls and leaves the public pointer,
1092
+ active-run objects, Jobs, Space, and schedule unchanged.
1093
+ - The exact legacy generation and revision 31,201 are imported without changing
1094
+ the public pointer or any raw object.
1095
+ - A normal continuation does not download the public SQLite database.
1096
+ - Two cold checkpoint restores take less than five minutes and make zero
1097
+ provider calls.
1098
+ - Checkpoint restore takes less than five minutes at current production size.
1099
+ - Queue and registry totals remain fixed for one logical run.
1100
+ - Completed queue work and registry decisions are not repeated.
1101
+ - At most one in-flight concurrency batch repeats after interruption.
1102
+ - Progress stays monotonic across physical attempts.
1103
+ - Registry work never resets because queue work or new source data appeared.
1104
+ - A completed run activates exactly one verified successor. The same physical
1105
+ Job uses it while unresolved work and the original time and cost budgets
1106
+ remain; otherwise the next scheduled Job resumes it without schedule
1107
+ replacement.
1108
+ - One physical Job uses one command start time and one cumulative $10 inference
1109
+ ceiling across every successor it processes.
1110
+ - A zero-work successor stops the physical Job without polling, spinning, or
1111
+ creating duplicate work.
1112
+ - Interrupted final publication resumes from its last claimed state and reuses
1113
+ uploaded or verified immutable artifacts.
1114
+ - Resumed and uninterrupted runs produce the same logical database and manifest.
1115
+ - Invalid, duplicate, overlapping, or conflicting ordinal state fails before
1116
+ new provider calls or pointer writes.
1117
+ - Two steady canary receipts and every referenced output validate.
1118
+ - Settled spend and conservative exposure stay at or below $2,000.
1119
+ - The Space is healthy on the merged runtime with enrichment disabled.
1120
+ - Production uses one active non-concurrent schedule at `17 */6 * * *`, with
1121
+ only `HF_TOKEN` and `INFERENCE_TOKEN`, and no legacy runtime path.
1122
+ - No physical xTap Job is active at final handoff.
docs/2026-09-07-consumer-http-handoff.md ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Incremental consumer HTTP contract
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-09-07
5
+ updated: 2026-09-10
6
+ tags: [consumer-api, implementation]
7
+ ---
8
+
9
+ # Incremental consumer HTTP contract
10
+
11
+ This is the HTTP handoff for the [incremental consumer plan](2026-09-07-incremental-consumer-and-engagement-history-plan.md). The source schemas are `space/src/consumer-http-contract.ts`, `consumer-page.ts`, `consumer-observations.ts`, `consumer-privacy.ts`, and `consumer-query.ts`. Runtime and route code are `consumer-runtime.ts` and `consumer-routes.ts`. This replaces the version-1 machine contract in place. Deployment and the Our Models cutover remain operator gates.
12
+
13
+ ## Requests and grants
14
+
15
+ | Operation | Request | Service-account scopes |
16
+ | ------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------- |
17
+ | Start bootstrap | `/api/units?author_ids=123,456&labels=ai&label_mode=any&publication=public-original` | `units:read`, `taxonomy:read` |
18
+ | Continue bootstrap | `/api/units?cursor=<cursor>` | Same |
19
+ | Start or continue changes | `/api/changes?after=<cursor>` | `units:read`, `taxonomy:read`, `observations:read` |
20
+ | Start history | `/api/observations?at=<fully-consumed-cursor>&post_ids=123,456&since=<UTC>&until=<UTC>` | `units:read`, `observations:read` |
21
+ | Reconcile privacy | `/api/reconcile?cursor=<recovery-cursor>` | `units:read` |
22
+ | Continue history | `/api/observations?cursor=<next_cursor>` | `units:read`, `observations:read` |
23
+
24
+ Every request checks current authorization. An authorized member session also permits these reads. Existing credentials gain no scopes. An operator must issue an explicit grant for `observations:read`. Default grants stay unchanged. Explorer whole-unit browsing uses `/api/explorer/units` and requires a member session; it is not a machine recovery route. The explorer's separate `/api/tweets` route is unchanged.
25
+
26
+ The selection is fixed: sorted, unique exact `author_ids`, sorted unique `labels` (default empty), `label_mode` (`any` by default, or `all`), optional `free_label`, and required `publication=public-original`. Repeated or unsupported query options fail. Continuations may repeat matching selection fields; conflicting fields fail. Names are case-sensitive. History continuation binds source, IDs, range, and selection; optional repeated fields must agree. History IDs are distinct and sorted. Times must be UTC with `Z`; they normalize to millisecond precision.
27
+
28
+ ## Change envelope
29
+
30
+ All bootstrap and change responses have exactly these fields:
31
+
32
+ ```json
33
+ {
34
+ "schema_version": 1,
35
+ "source": "<full raw snapshot SHA-256>",
36
+ "complete_through": null,
37
+ "observations_through": null,
38
+ "history_since": "<inclusive UTC>",
39
+ "history_until": "<exclusive UTC>",
40
+ "cursor": "<signed next cursor>",
41
+ "has_more": true,
42
+ "changes": []
43
+ }
44
+ ```
45
+
46
+ The two coverage timestamps are null or UTC. Observation freshness does not promise complete observation coverage. The source identifies the exact raw object set, including late arrivals.
47
+
48
+ Changes are the existing `metadata`, `unit_upsert`, `unit_remove`, and `observation` variants in `consumer-page.ts`. Bootstrap starts with a metadata-only page, even for an empty selection. Continue that page to receive upserts or the empty final page. The signed `metadata_sent` field prevents repeats at `limit=1`. Changes emit metadata only when the frozen taxonomy or approved registry state differs. Registry counters and coverage movement alone do not cause metadata changes.
49
+
50
+ Each `unit_upsert` has exactly `type`, `content_hash`, `post_content_hashes`, and `unit`:
51
+
52
+ ```json
53
+ {
54
+ "type": "unit_upsert",
55
+ "content_hash": "<canonical unit SHA-256>",
56
+ "post_content_hashes": [
57
+ "<canonical post SHA-256 for unit.posts[0]>",
58
+ "<canonical post SHA-256 for unit.posts[1]>"
59
+ ],
60
+ "unit": {
61
+ "id": "...",
62
+ "posts": ["<post body 0>", "<post body 1>"],
63
+ "contributors": ["..."],
64
+ "preset_labels": [],
65
+ "free_labels": []
66
+ }
67
+ }
68
+ ```
69
+
70
+ The example abbreviates post bodies. `post_content_hashes` is a required array of 64-character lowercase hex SHA-256 strings, with exactly one entry per `unit.posts` entry, in the same order. Entry `i` equals the shared source `contentHash(unit.posts[i])` and the `content_hash` from `normalizeObservation` for that exact body. It is present even when the post has no observations in the advertised hot window. The API validates each hash against the returned body; the historical reader also checks the reconstructed body against its stored source hash. No client normalization or observation lookup is required.
71
+
72
+ Pair each body with its hash before indexing or sorting. Same-ID contributor copies do not collapse into a hash map: if multiple copies are returned, each has its own aligned entry. The current unit reader selects one body per post ID with the existing deterministic observed-time, privacy, content-hash, and contributor order. The upsert hash identifies that selected body, not an arbitrary other contributor's copy or all past versions. Observations can identify other retained versions; compare their hash with the saved body's supplied hash. Sample counters, capture/receipt times, attribution, transport metadata, and follower count do not change the post content hash. Content edits do. The new array is outside the post bodies and does not change either canonical hashing rule. It is included in complete-item and final response byte limits.
73
+
74
+ Empty pages can have `has_more=true`. Continue until false. The final cursor is an idle cursor for the fully consumed target. Commit each page and cursor together. Do not poll changes or start history with an unfinished bootstrap cursor. Bootstrap does not deliver observations; use the final cursor for initial bounded history. Activation and fresh change phases can deliver the same logical observation. Deduplicate by observation `id`.
75
+
76
+ ## History envelope
77
+
78
+ History has `schema_version: 1`, `source`, `history_since`, `history_until`, `observations`, `coverage`, `has_more`, and nullable `next_cursor`. Its bounds are the requested inclusive/exclusive range, checked against the source's advertised range. Each coverage item has `post_id`, `state` (`available`, `no_history`, or `unavailable`), `count`, `earliest`, and `latest`. Unavailable counts and times are null. Coverage describes the complete requested range, not only the current page. Pages sort by `(post_id, observed_at, id)`.
79
+
80
+ A final history page has `has_more=false` and `next_cursor=null`. A history cursor cannot become a global or idle cursor. Keep the original fully consumed source cursor separately.
81
+
82
+ ## Limits and recovery
83
+
84
+ Default page limit is 200; maximum is 500. History permits at most 100 IDs and 30 days. The target response size is 2 MiB; the hard serialized limit is 8 MiB. A complete oversized item fails without an acknowledgment cursor. Page sequences last at most 48 hours; sources last at most 30 days from context creation. The earlier deadline applies when a history or change sequence uses an older source. Context cleanup waits 32 days. Raw snapshot bases and raw segments are retained; old full databases are not needed.
85
+
86
+ Each read has a 60-second wall deadline, including source/context I/O and mutex wait. A request that reaches an exact full-selection coverage calculation gets a five-minute absolute deadline measured from the original request start. No other stage extends its deadline. Clients that can request a fresh source context must allow at least 315 seconds for a response. SQLite runs in at most two persistent read-only child processes. Deadline cancellation kills a running child, including native SQLite. Requests do not copy or rebuild the database. The database must contain every exact pinned source descriptor and the current verified source boundary.
87
+
88
+ Errors use `{"error":{"code":"...","message":"...","recovery":{}}}`. The recovery field is present only when needed. No error includes an acknowledgment cursor.
89
+
90
+ | HTTP status | Meaning and action |
91
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
92
+ | 400 | Invalid, conflicting, unsupported, or wrong-kind query/cursor. Correct the request. |
93
+ | 401 | Missing, invalid, expired, or revoked authentication. |
94
+ | 403 | Valid service credential lacks an explicit required scope. |
95
+ | 409 | `privacy_changed`: apply the removal procedure below. `contract_changed`: use an explicit bootstrap for the new contract. |
96
+ | 410 | Cursor/context expired or history outside its retained range. Use explicit bootstrap or operator history recovery. |
97
+ | 413 | A complete source item or final serialized response exceeds its bound. Do not advance. |
98
+ | 429 | Consumer capacity is full. Retry the same cursor after five seconds. |
99
+ | 502 | Source/context read or verification failed. Retry the same cursor; persistent failures need operator repair. |
100
+ | 503 | Source projection unavailable, database replaced, worker failed, or deadline exceeded. Retry the same cursor. |
101
+
102
+ ## Coverage maintenance
103
+
104
+ Idle changes polls still pin a fresh immutable context under the existing mutex. The source, current metadata, context lease, and history window are refreshed. Every page still checks authorization, current privacy, database identity, and exact pinned source containment. HTTP response shapes are unchanged.
105
+
106
+ `consumer-coverage-update.ts` selects one of three internal coverage paths:
107
+
108
+ - **Reuse:** the base has the same selection, contract, projection, taxonomy version, and approval membership for its selected free label. Exact source-set differences and the existing `source_segments` counts and source-effect indexes show no relevant post or new result effects. Identical sources, bookkeeping writes, ignored/replayed attempts, duplicate results, and unrelated author units retain both clocks without reading post bodies. Attempt replay uses the existing queue rules: completed work stays done, and retry/blocked states remain incomplete. There is no second queue projection.
109
+ - **Metrics:** source-index proofs compare affected posts' latest contributor-copy hash sets, the selected content hash, and the start of the content run at the base with current `unit_members` facts. A same-time hash conflict uses the existing privacy tie-break for that affected post. Once the entire delta passes, observation coverage reads only affected posts and their units' privacy/eligibility facts, in batches of at most 100 post IDs. It retains the prior maximum and includes any later permitted observation. Delayed samples cannot move the maximum backwards. No unrelated cohort body is read.
110
+ - **Semantic recalculation:** new selected results, changed content or membership, a corrected content-run clock, a changed taxonomy version, or a selected free-label approval change require a current selected-coverage calculation. This can read the selected cohort. It computes the new maximum directly, so withdrawal of the previous maximum can lower it or return null. Private, unselected, pending, and currently retweeted post samples do not advance observation coverage. This is a coverage calculation, not a fallback content download.
111
+
112
+ Taxonomy descriptions and unrelated approval changes can leave coverage unchanged while still emitting the required metadata change. Registry revision numbers alone do not determine either decision. The page privacy and fresh-observation queries use fixed join order from their bounded source candidates; SQLite cannot start by evaluating privacy on the whole content table.
113
+
114
+ There is no new persistence, table, index, projection identity, or classifier change. `eligibleUnits` in `enrich-store.ts` gains only an optional internal unit-ID filter for the affected-post query. Keep that filter and the source-driven join order when combining later parent changes. The running source-index preparation does not need a restart or rebuild.
115
+
116
+ The regression fixture measures the real forked worker. Test-only wrappers count evaluated JSON body predicates and returned post-body fields, including reads outside the coverage operation. The bootstrap is a positive control for the counter. With 250 posts, twenty identical-source polls and twenty harmless-revision comparisons read **zero post bodies**; all 40 coverage evaluations use the reuse path. This includes a fresh worker process and context-store restart, blocked/dispatched attempt replay, and an unpublished registry candidate. With 260 posts, ten batches of ten metric observations update ten posts: only those ten post IDs are read; the 250 unrelated post IDs are not read. The clocks are checked against an independent full-coverage calculation. Additional tests cover delayed samples, corrected clocks, mixed edits and counters, pending-to-eligible changes, private/retweet maximum withdrawal, contributor winner changes, and actual metadata changes.
117
+
118
+ These are small-fixture read counts, not a completed production-size performance check. They measure logical body access, not SQLite cache-page I/O. The full-source local canary below adds real reader timings. Cold restore and remote transport still need separate measurements.
119
+
120
+ Local commands for this branch are `npm run build --workspace shared`, `npm run build --workspace space`, `npx --no-install vitest run space/tests/consumer-coverage-update.test.ts`, `UV_OFFLINE=1 npm run check`, and `npm_config_offline=true npx --no-install --package=@simpledoc/simpledoc simpledoc check`. The merged build hooks also build space before tests and coverage. The counts-only ingest acknowledgment, extension version 0.26.0, and bootstrap implementation are unchanged by the initial-coverage fix.
121
+
122
+ ## Full-source initial coverage fix
123
+
124
+ The September 7 local canary used the immutable prepared source `36f27e68bf5ba7cfa53a6fedd7ba6fa66906113d058a048dd1384e7aa37034aa`: 38,219 segments, 689,421 logical observations, and a 4,811,792,384-byte SQLite database. The current Our Models publication account file selects 123 exact author IDs. Their source history contains 114,603 distinct unit candidates before current eligibility and label filtering. The source database was opened read-only. No index rebuild, schema migration, source copy, or publication was required.
125
+
126
+ The original first request reached the 30-second deadline in `coverage` (30.012 seconds in the parent log). `EXPLAIN QUERY PLAN` showed observation membership driven by `idx_observation_source_segment (segment_key=? AND observation_id=?)`. This probes the large segment selection for each observation. Content completeness also took two bounded queries of 6.05 and 4.43 seconds in the diagnostic process; the following observation query had not finished when that process reached its 35-second diagnostic limit.
127
+
128
+ The fix keeps every eligibility, privacy, and exact source predicate:
129
+
130
+ - `consumer-coverage.ts` and `historical-unit-reader.ts` use the existing observation-ID index for source membership. The membership set is still the exact pinned source. The fixed observation-coverage query took 10.688, 4.673, and 5.496 seconds in three local reads and returned the same maximum each time.
131
+ - `source-effect-store.ts` uses the existing unit-ID index for bootstrap order. SQLite can stop at the next page boundary instead of sorting all remaining author matches. A measured 33-ID candidate query fell from 1.747 seconds to 0.011–0.016 seconds across three repetitions, with identical rows.
132
+ - Historical observation reconstruction for the same measured slice fell from 0.413 seconds to 0.023 seconds across three repetitions, with identical rows.
133
+ - `recorded-result.ts` batches exact-input result lookups for the requested unit slice. It builds source membership once, probes by result hash, and keeps deterministic result order, quote validation, and the 50-candidate limit for each unit. The single-unit caller uses the same implementation. No classifier input, model, prompt, source effect, or projection identity changes.
134
+ - `consumer-observations.ts` uses observation-ID lookups for history rows and history coverage. The same 100-post source query spent 5.338 and 5.344 seconds in these two statements before the fix. The final history canary returned 133 observations in three pages, each taking 0.610–0.760 seconds, with identical coverage across pages and a null final history cursor.
135
+ - An exact no-op returns an empty page with an idle cursor. It skips candidate SQL when there are no source additions or changed approvals, after validating source containment. The earlier implementation returned an unnecessary empty observation continuation. This was detected after the complete bootstrap, and the saved global cursor was reused for the fixed probes.
136
+ - Bootstrap uses the historical reader's existing 200-unit request bound. Content deltas retain their 32-candidate bound. The 2,000-post reconstruction bound, complete-item byte checks, response limits, and 30-second request deadline remain in force. A measured page processed 200 candidates and returned 59 eligible units in 0.761 seconds after these changes.
137
+
138
+ These query timings are diagnostics on one host, not cold-storage or cloud measurements. The meaningful acceptance test is a complete, correct local source read within the unchanged per-request deadline, followed by warm no-op and restart probes. The full sequence uses local immutable context files and the local pinned snapshot in place of Bucket metadata transport. It does not measure remote metadata latency, source download, browser capture, metric-delta publication, or website freshness. SQL plan tests and the existing body-read tests check the access paths separately.
139
+
140
+ The private harness is `/tmp/xtap-consumer-initial-coverage/consumer-read-canary.mjs`; its imports point to this worktree. The parent's harness is unchanged. Reproduce with `timeout -k 3s 5450s node /tmp/xtap-consumer-initial-coverage/consumer-read-canary.mjs <prepared-bootstrap.sqlite> <private-output-directory>`. It checkpoints every page locally, limits the complete run to 5,000 pages and 90 minutes, replays page 10 after a worker/context-store restart, polls the idle cursor twenty times, and performs another restart probe. These harness limits do not change the production deadline. The compiled source was built with `npm run build --workspace shared` and `npm run build --workspace space`.
141
+
142
+ The successful report is `/tmp/xtap-consumer-initial-coverage/run-3/report.json`; bounded history is in `run-3/history-report.json`. The bootstrap log is `canary-3.log`, and the resumed no-op/restart log is `canary-final.log` under the same private root. The bootstrap query implementation stayed fixed during those 575 pages. The later no-op transition and history lookup fixes were tested by resuming that completed checkpoint; no accepted bootstrap page was discarded or read again as a new bootstrap.
143
+
144
+ | Full-source local measurement | Result |
145
+ | ---------------------------------------------- | ---------------------------------------: |
146
+ | Bootstrap pages | 575 |
147
+ | Unit upserts | 34,743 |
148
+ | Distinct post IDs | 68,291 |
149
+ | Serialized bootstrap bytes | 90,116,606 |
150
+ | Largest serialized page | 326,282 bytes |
151
+ | Sum of bootstrap request times | 523.959 seconds |
152
+ | Bootstrap request median / p95 / maximum | 0.916 / 1.092 / 13.755 seconds |
153
+ | First coverage worker / complete first request | 13.203 / 13.755 seconds |
154
+ | Identical page-10 replay after restart | 0.788 seconds |
155
+ | Each no-op response body | 598 bytes; zero changes |
156
+ | Twenty no-op polls: minimum / p95 / maximum | 1.463 / 1.609 / 1.682 seconds |
157
+ | Final restart poll | 1.682 seconds; zero changes |
158
+ | Bounded history | 100 posts; 133 observations; three pages |
159
+ | History total invocation time | 2.137 seconds |
160
+ | Resumed no-op/restart invocation time | 33.301 seconds |
161
+
162
+ **523.959 seconds is the sum of all 575 saved bootstrap page timings. The resumed report's 33.301 seconds covers only its no-op/restart invocation.** Neither value includes all diagnostic work or time between invocations. The source file was preserved: device, inode, byte size, and modification time match the recorded pre-canary values. Context transport was local immutable files and a local pinned snapshot, with zero network reads/writes and no credentials. The canary's authorization callback is local; deployed grants remain an operator gate.
163
+
164
+ The host exposed 20 ARM64 CPUs and 130,596,577,280 bytes of RAM; the CPU model was unavailable. The database page cache was warm from the read-only diagnostics. Some early bootstrap pages overlapped local checks. The no-op timing run followed those checks. The resumed main process reached 564,800 KiB peak RSS; this is neither bootstrap peak memory nor combined worker memory. These are one-host measurements, with repeated query/no-op probes rather than a statistical confidence interval. All measured requests stayed below the unchanged 30-second deadline, and the uninstrumented warm no-op p95 met the two-second local target.
165
+
166
+ A separate real-database worker probe counted 8,011 body evaluations across 319 post IDs as a positive control. Twenty no-op polls and one restart poll then produced **zero post-body evaluations** across 42 worker operations; all 21 coverage operations reused their base clocks. The private result is `/tmp/xtap-consumer-initial-coverage/body-read-report.json`. Its instrumented timings are not used for the latency target. It measures logical JSON-body evaluation and returned body fields, not physical SQLite cache-page I/O.
167
+
168
+ The bounded history harness is `/tmp/xtap-consumer-initial-coverage/consumer-history-canary.mjs`, run with the same database and completed checkpoint directory under a 335-second outer diagnostic bound. Each HTTP request still has the production 30-second deadline. It verifies all 133 distinct observations against the returned coverage, uses history continuations for all three pages, and rejects a history cursor that becomes global/idle. The body probe uses `consumer-noop-probe.mjs` with a separate copy of local context metadata and the completed source cursor; it does not copy the database or restart bootstrap.
169
+
170
+ The report records base commit `b229b70` plus hashes of the final changed source files. The canonical JSON hash of that source-file map is `d21b9a78cadb5039fe11b7f5be6c8ab268c5943c47a13db36aa307cc5f7ef273`. The publication-account file hash is `850bb1009aaa5f4deb40055cc062161111d6cec6498b2ea132ef085f320d3949`. Source coverage remained `complete_through=2026-09-06T22:21:24.712Z` and `observations_through=2026-09-06T22:56:43.226Z`.
171
+
172
+ ### Final-code repeat and candidate bound
173
+
174
+ A fresh run at committed `532668f` repeated all 575 bootstrap pages without changing source or code during the run. It returned the same 34,743 units, 68,291 distinct posts, and 90,116,606 response bytes. Bootstrap request time totaled 614.507 seconds; the longest request took 17.268 seconds. Page 10 replayed identically after worker restart. Twenty no-op requests and another restart also passed. The full invocation took 666.467 seconds, including local metadata and checkpoint work. The report is `/tmp/xtap-consumer-initial-coverage/run-final-532668f/report.json`.
175
+
176
+ This repeat measured no-op p95 at 2.630 seconds and restart at 2.640 seconds, compared with 1.609 seconds in the earlier warm run. Other local implementation work was active. The two runs do not establish consistent compliance with the two-second local target, and neither measures cloud transport or website freshness. All requests remained within the unchanged 30-second deadline. Do not report only the faster run.
177
+
178
+ Follow-up review identified that the batched SQL limited results after its window function, which could rank an entire unit's history. The corrected query limits each indexed unit lookup to 51 candidates before ranking. A regression uses 500 additional valid results and counts actual result-body evaluations entering the window: only 51 may be evaluated. Existing tests still reject a unit when all first 50 candidates fail validation. A valid early result remains accepted even when older results exist, as in the previous single-unit implementation; the bound limits validation work, not the total retained history. The fresh full-run timing above predates this final candidate-bound correction. A separate read-only parity probe compared both queries for the 100 real units with the most retained results. All 100 returned rows matched exactly; the largest retained history in this source was 10. The report is `/tmp/xtap-consumer-initial-coverage/result-bound-report.json`. The 500-result regression covers the longer-history case that this real source does not contain.
179
+
180
+ ## Privacy reconciliation
181
+
182
+ A withdrawal after an earlier accepted page stops that sequence with HTTP 409:
183
+
184
+ ```json
185
+ {
186
+ "error": {
187
+ "code": "privacy_changed",
188
+ "message": "Apply the affected removals before resuming this page.",
189
+ "recovery": {
190
+ "action": "reconcile",
191
+ "path": "/api/reconcile",
192
+ "cursor": "<signed removal cursor; no acknowledgment>"
193
+ }
194
+ }
195
+ }
196
+ ```
197
+
198
+ Call `/api/reconcile?cursor=<recovery.cursor>&limit=200`. Every successful page has exactly:
199
+
200
+ ```json
201
+ {
202
+ "schema_version": 1,
203
+ "source": "<full raw snapshot SHA-256 for this removal range>",
204
+ "removals": [
205
+ { "type": "post_remove", "post_id": "123", "reason": "not_available" },
206
+ { "type": "unit_remove", "unit_id": "123:author", "post_id": "123", "reason": "not_available" }
207
+ ],
208
+ "has_more": true,
209
+ "next_cursor": "<signed next removal cursor>",
210
+ "resume_cursor": null,
211
+ "resume_path": null
212
+ }
213
+ ```
214
+
215
+ The final page has `has_more=false`, `next_cursor=null`, a signed `resume_cursor`, and `resume_path` equal to `/api/units`, `/api/changes`, or `/api/observations`. Use `cursor=<resume_cursor>` for units/history, or `after=<resume_cursor>` for changes. The resume cursor retains the original source comparison, selection, history bounds, and page position. It also records the applied privacy checkpoint. It does not restart bootstrap. A history resume cursor remains a history cursor.
216
+
217
+ Apply these rules in the Our Models client:
218
+
219
+ 1. Keep the unfinished content/history cursor and unrelated accepted and published content. Stop that sequence and follow the removal cursor. Do not treat the 409 cursor or the response `source` as applied progress.
220
+ 2. For `post_remove`, remove that post's saved body, observations, and dependent published stories. Use the maintained post-to-unit index to remove accepted units that contain the post. For `unit_remove`, remove the named saved unit **only if it still contains `post_id`**. That condition protects a later version which reuses an old unit ID without the withdrawn post. Remove orphaned bodies and observations when their last permitted unit reference is removed. Keep public content which still has a separate unaffected unit reference.
221
+ 3. Save each complete removal page and its next cursor in one transaction. Publish affected removals through the existing publication writer before marking that privacy checkpoint published. A failed transaction retries the same cursor. Both removal variants are idempotent. Never clear the entire selection.
222
+ 4. Follow `next_cursor` while `has_more=true`, including empty pages. Only after the final page's removals are applied may the client save and use `resume_cursor`. Do not feed a pending removal cursor to a content/history route or use it as a history `at` cursor.
223
+ 5. Keep the last completed `resume_cursor` as the selection's privacy receipt, in addition to the normal global source cursor. When starting a separate changes poll, send `/api/changes?after=<fully-consumed-global-cursor>&reconciled=<privacy-receipt>`. A separate initial history read can also send `reconciled=<privacy-receipt>`. This transfers only the applied privacy checkpoint; it never turns a history cursor into a global cursor. The receipt must be signed, completed, unexpired, and for the same selection. `reconciled` is rejected on continuations. A resumed sequence already carries its checkpoint.
224
+ 6. Continue the original sequence, then poll changes normally. The engine re-emits affected restored units even when their content hash equals the old public hash. It also recovers their permitted retained observations. The next fully consumed source which contains the privacy checkpoint clears that pending repair state. Once all active streams carry that checkpoint or a later source, the client can stop sending the separate receipt.
225
+
226
+ Removal records sort by `(post_id, unit_id)`, with the direct post removal first. They come from restricted source events after the last applied privacy checkpoint (or the pinned target), and exact historical post/unit dependencies in the original base and target. A restriction event remains in its fixed range even if the post becomes public again during recovery. Historical dependencies are qualified by `post_id`; they are not unconditional deletion of every past unit ID. The query returns IDs only and does not reconstruct historical bodies. Repeated restriction events can produce idempotent removals in later ranges.
227
+
228
+ If new source writes arrive during removal paging, the server finishes the fixed range and returns a next cursor for another pinned range. New withdrawals which sort before a previous page position are included in that new range. No cursor acknowledges a range before all its removal records have been returned. Replay and restart preserve the range and position. New withdrawals after the final recovery page cause another 409 on resume. Current privacy checks continue to suppress all forbidden old-source bodies and observations.
229
+
230
+ The normal page limits and wall deadline apply. Signed cursors are limited to 8 KiB, including 100 long history IDs and the removal position. Recovery keeps the original 48-hour page lease and uses immutable metadata in the existing context store. Expiry is explicit; it does not silently clear content or start a full read.
231
+
232
+ ## Exact production-selection follow-up
233
+
234
+ The earlier complete read used only `ai`. A later run used the actual Our Models selection: 123 authors, `ai,local-models`, `label_mode=any`, and `publication=public-original`. At frozen source `b5ab0341f57f931ea975ff72c1c2e25b5f0e135caa833619889cc10ae3862981`, its first coverage request hit the unchanged 30-second deadline.
235
+
236
+ The query joined each conversation member to each matching label before checking post bodies. Thus a conversation with both labels repeated the same author and publication checks. `eligibleUnits` now materializes distinct candidate IDs before those checks. The explicit ID bound remains inside the candidate query; cutoff, author, publication, and all-label requirements remain enforced. No schema, cursor, source contract, or deadline changed.
237
+
238
+ The regression uses a 20-post conversation with both production labels. It verifies identical rows and body-check counts for one label, either label, both required labels, and no labels. A missing required label still excludes the conversation. The existing 12 coverage tests also pass, including metric-only, no-op, privacy, and restart behavior. `npm run check` passed after the fix.
239
+
240
+ On the same local frozen database, the fixed initial coverage took 14.597 seconds. The earlier unbounded diagnostic took 4.459 and 4.330 seconds for completion queries and 24.792 seconds for observation coverage. These are single local measurements, not cloud latency estimates. The decision boundary remains the existing 30-second request limit, with unchanged row/privacy checks. The complete corrected production-selection canary passed: 576 pages, 42,633 conversations, 84,570 distinct posts, and 111,763,946 response bytes. Request time totalled 702.085 seconds; median was 1.114 seconds, p95 was 1.736 seconds, and the maximum was 15.496 seconds. Twenty unchanged polls and a reader restart emitted zero changes; unchanged p95 was 2.598 seconds. Selected content was complete through `2026-09-07T11:02:05.771Z`. This is one local frozen-source run, not cloud or live-website performance. No source write, paid call, or Job was required.
241
+
242
+ Review at `ac8f29102706926fc302fba1cca14aae2771f000` found no issues. [CI run 34148170156](https://github.com/osolmaz/xtap-pool/actions/runs/34148170156) passed. The follow-up is [pull request 48](https://github.com/osolmaz/xtap-pool/pull/48).
243
+
244
+ ## Remaining live gates
245
+
246
+ Local checks passed: `npm run check` ran 761 Vitest tests in 78 files, 189 extension tests, and 180 native extension tests. This includes 12 coverage-maintenance tests, 13 paged privacy recovery tests, and 14 post-hash contract tests. Coverage was 88.04% lines/statements, 85.75% branches, and 92.08% functions. The duplicate-code check reported zero candidates. The parent must implement and test the Our Models transaction, removal, history, and cursor rules against these schemas. An operator must verify purpose-scoped grants, deployed projection readiness, raw-base retention, current source containment after restore, production unit sizes and deadlines, and the approved history window. The coordinated deployment, live restart/privacy canary, cold/cloud and real metric-delta measurements, and website publication proof remain pending. No production completion is claimed.
247
+
248
+ ## Parent integration
249
+
250
+ This branch changes no classifier model, prompt, or semantic contract. It does not change `index-bootstrap.ts`, `index-command.ts`, command entry points, or the setup wizard. In `durable-index.ts`, it adds only the optional fetch adapter to `createDurableIndexBucketClient`; in `bucket-log.ts`, it adds that adapter to the existing raw Bucket clients. Keep those options and their read/write/list propagation when merging the parent's resumable CPU bootstrap and tail verification work. Preserve the parent's `compareSegmentKeys` export. No HTTP path publishes `index/current.json`. The bounded engine in `space/src/consumer-changes.ts` now accepts an optional applied privacy context to repair same-hash restoration. Preserve that argument, the cursor privacy fields in `consumer-cursor.ts`, and worker source-containment checks when combining later core changes. Keep the per-post hash field in `consumer-page.ts` and the exact source-hash check in `historical-unit-reader.ts` when combining later core changes. No source-effect table or classifier contract changed.
251
+
252
+ The requested documentation command is `npx --no-install @simpledoc/simpledoc check`. In this local npm installation, that form returned exit 127 (`@simpledoc/simpledoc: not found`). The same scoped checker, version 0.1.6, passed with `npx --no-install --package=@simpledoc/simpledoc simpledoc check`. No untracked generated `index.html` was present. The unscoped npm package is unrelated.
docs/2026-09-07-incremental-consumer-and-engagement-history-plan.md ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Incremental consumer reads and engagement history plan
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-09-07
5
+ tags: [consumer-api, observations, engagement, incremental, extension, storage]
6
+ ---
7
+
8
+ # Incremental consumer reads and engagement history plan
9
+
10
+ Our Models needs two source capabilities from xTap: efficient access to changed content, and timestamped engagement observations for story ranking. Implement them together in xtap-pool before resuming the dependent story work.
11
+
12
+ The earlier Our Models work stopped repeated AI processing and made checks cheap when xTap's revision stayed unchanged. It did not eliminate the complete source read after a revision change. The reported failure case was a 56-minute run over roughly 42,550 source entries that found no new, changed, or deleted entries and needed no AI calls. Another reported changed-data run took 86 minutes. These are incident inputs, not measurements repeated for this document. Verify the entry-versus-individual-post counts during the baseline measurement.
13
+
14
+ The required result is specific: a harmless source revision change must not cause a complete post download, and a new likes/views observation must not rerun semantic processing. Saving future counts in Our Models alone would leave both historical access and the content-change problem unresolved.
15
+
16
+ Status: **implementation in progress** on `feat/incremental-consumers`. The bounded raw-history audit, repeat delivery, indexed source effects, historical point reads, content clocks, bootstrap readiness, signed cursors, and bounded HTTP change/history reads pass local tests. The HTTP contract also includes source-authored post hashes and paged privacy removals that preserve unrelated consumer data. Production bootstrap cutover, Our Models integration, and live verification are not complete. No replacement extension, Space, Job, schedule, credential, resource, or spending limit has been deployed by this implementation. A read-only check on September 7 confirmed that the canonical six-hour enrichment and five-minute publication schedules remain active.
17
+
18
+ ## Ownership and related work
19
+
20
+ This is the canonical cross-repository explanation of the xTap prerequisite. Keep source collection, provenance, observation history, and incremental source reads here. Our Models owns model eligibility, story grouping and writing, attention calculations, ranking, and its website publication.
21
+
22
+ Related documents:
23
+
24
+ - [Unit consumer API](2026-07-27-unit-consumer-api.md): the current full-read contract. Its normal full-download procedure will be replaced when the new reader and consumers pass the checks below.
25
+ - [Immutable Bucket log](2026-08-12-bucket-object-log-plan.md): the authoritative storage and replay boundary. This plan extends that design; it does not restore Dataset/Git runtime storage.
26
+ - [Small worker checkpoints](2026-08-19-small-worker-checkpoints-plan.md): logical runs, durable results, revision handoff, and scheduled Job recovery remain applicable.
27
+ - [Browser scrape-job receipts](2026-08-06-scrape-job-receipts-plan.md): browser-local coverage receipts are distinct from engagement observations.
28
+ - [Our Models story plan](https://huggingface.co/spaces/osolmaz/ourmodels/blob/main/docs/2026-09-06-daily-feed-and-organizations-plan.md): the consumer's model-only story requirements. This is an operational source link; the public website is [ourmodels.cc](https://ourmodels.cc).
29
+
30
+ Do not copy this plan into Our Models. Link to it from the consumer implementation record. Keep the separate model-profile work independent.
31
+
32
+ ## Current implementation evidence
33
+
34
+ The following facts were checked in the repository at `3945d92`. They describe the checked-in implementation, not proof of the installed browser version or the amount of historical production data.
35
+
36
+ | Area | Current behavior | Consequence |
37
+ | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
38
+ | `shared/src/tweet.ts` | Validates basic tweet fields with a loose schema. Extra fields, including supplied metrics, survive validation. | Historical counter values can exist in stored records, but the schema does not establish complete or well-typed counter coverage. |
39
+ | `space/src/ingest.ts` | Appends accepted records to the verified raw Bucket log before updating the local index or returning success. | Accepted repeat observations are durable. |
40
+ | `space/src/store.ts` | Accepts only a newer `captured_at` for each post/contributor. Keeps only the latest record within a submitted batch and in its current SQLite row. | Older arrivals and intermediate same-batch observations can be discarded before reaching the raw log. |
41
+ | `space/src/bucket-log.ts` | Writes complete accepted records into immutable, verified segments. | The raw log is the existing history source. No additional raw archive is needed. |
42
+ | `extension/background.js`, `extension/lib/dedup.js` | Uses persisted seen post IDs to skip ordinary repeats before `poolEnqueue`. Article and image-backfill exceptions are not a general engagement-history path. | Repeated browsing does not prove repeated metrics reached the pool. |
43
+ | `extension/lib/pool-sync.js` | Uses a persistent, at-least-once queue. Its current overflow behavior drops the oldest entries. | Increasing observation volume requires explicit queue bounds, recovery, and loss reporting. |
44
+ | `extension/lib/scrape-receipts.js` | Records list/run observations before ordinary post deduplication. The records contain post IDs, post times, observation times, and run positions, but no engagement counters. | A scroller coverage receipt cannot reconstruct past likes or views. |
45
+ | `shared/src/hash.ts` | Excludes counters and capture timestamps from semantic input hashes. | Preserve this separation. A repeat observation must not reclassify unchanged text. |
46
+ | `space/src/unit-store.ts` | Uses a process boot UUID plus SQL revision triggers. Many table writes invalidate cursors, including tweet and queue writes. | Revision changes are not a list of actual content changes. A restart can invalidate a consumer read without changing its content. |
47
+ | `space/src/unit-store.ts` | The cutoff filters the latest stored capture for a unit. It is not a historical version lookup. | A later count observation can affect cutoff membership unless content and observation times are separated. |
48
+ | `space/src/durable-index.ts`, `space/src/server.ts` | Restore a verified index, discover raw segments, and apply a verified tail. The Space advances locally after ingest and checks external results every 5–60 seconds. | Reuse this bounded source-advance path to build change indexes. |
49
+ | Existing index publication | The non-concurrent Job, or an authorized maintenance command, owns the shared `index/current.json` publication. The Space does not publish that shared generation. | Do not introduce a second mutable publication authority for this feature. |
50
+
51
+ The statement “xTap already saves history” therefore needs a qualification: its raw storage preserves accepted repeated records, but the current extension and ingest deduplication can prevent those records from arriving or being saved. Production history coverage remains unmeasured.
52
+
53
+ ## Requirements
54
+
55
+ - Preserve useful observations of existing posts, including unchanged counter values at genuinely different observation times.
56
+ - Keep exact content changes, observation changes, and internal bookkeeping changes separate.
57
+ - Let consumers read only changes since a durable cursor, without scanning all post bodies to discover them.
58
+ - Include removals, unit membership changes, label changes, registry visibility changes, and public-source eligibility changes. A new-observation endpoint alone does not satisfy this requirement.
59
+ - Give a consumer one consistent source boundary for content, labels, related metadata, and observations. Identify observation freshness separately from content completeness.
60
+ - Preserve the current author-ID allowlist and `public-original` restrictions. No subscriber-only content, retweet-only units, contributor credentials, or private administration data may enter a public consumer feed.
61
+ - Make paging and replay safe across concurrent source writes, retries, Space restarts, and consumer crashes.
62
+ - Reuse the private raw Bucket, private index Bucket, existing Space, existing extension storage, and existing Job/checkpoint machinery.
63
+ - Keep observations useful to ordinary authorized consumers. Do not require a Story schema, an Our Models dependency, or an agent-specific progress protocol in xTap.
64
+ - Validate both source efficiency and the final website update time. Fewer AI calls alone do not complete the work.
65
+
66
+ ## Scope and non-goals
67
+
68
+ The implementation includes a production-history audit, the repeated-observation path, typed normalized history, indexed content differences, stable snapshot/cursor reads, bounded recovery, setup checks, and the coordinated Our Models reader change.
69
+
70
+ It does not add a new Bucket, Dataset, database service, scheduler, queue service, vector database, or independent raw store. It does not change the enrichment model, taxonomy meaning, hardware, schedule, source account permissions, or spending limits as an incidental fix.
71
+
72
+ There is no automatic creation of new X requests or a guarantee that a six-hour list visit observes every post again. A changed browsing/revisit policy is a separate, explicitly reviewed step after coverage is measured. Do not bypass platform access restrictions.
73
+
74
+ The capture implementation must follow `AGENTS.md`: passive browser-level observation from extension-owned contexts. The existing main-world fetch/XHR interception must be replaced rather than extended or copied. First inventory any existing compliant capture adapter. If a replacement needs additional browser permissions or a debugger attachment, report those requirements and obtain approval before enabling it. Do not use page-owned API wrappers as a temporary fallback.
75
+
76
+ ## History and capture audit
77
+
78
+ Perform this first, before promising growth measurements or freezing the history schema.
79
+
80
+ 1. Identify the actual installed extension build and the capture path used by the scheduled browser visits. Compare it with the checked-in deduplication and pool enqueue paths. Check whether another approved exporter sends repeats.
81
+ 2. Pin a verified raw Bucket snapshot and record its identity. Inspect a bounded sample across the last seven days and more than one completed visit. Start with at most 50 segments or 64 MiB compressed input, whichever is reached first. Report the actual files, bytes, and time. Expand only through an explicit bounded audit decision.
82
+ 3. Count physical records, distinct post IDs, distinct normalized observation identities, posts with two observations, and posts with three observations. Report counter availability separately for likes, replies, reposts, and views.
83
+ 4. Compare observation times with browser run receipts. A run receipt proves that a post was encountered, not that its metrics were retained or sent.
84
+ 5. Inspect time gaps, exact duplicates, same-time conflicting counts, counter decreases, invalid timestamps, and values that were missing, rounded, or represented as strings.
85
+ 6. Run a small browser canary using the approved capture path: observe a selected public post, observe it again, and confirm distinct observation times survive browser restart, queue delivery, raw persistence, and index replay. Use recorded fixtures for most tests; do not generate unnecessary X traffic.
86
+ 7. Record whether the first implementation can recover useful history, has only sparse history, or must begin measuring growth from deployment onward.
87
+
88
+ Store raw audit inputs only in approved private working storage. Track the bounded audit procedure, aggregate counts, snapshot/checksum references, and conclusions in this repository. Do not commit captured text, account-specific browsing history, credentials, or a raw export.
89
+
90
+ No repeat records means unknown historical growth. Do not fabricate an earlier count of zero. Do not infer metrics from scroller receipts, current totals, or a post's publication date.
91
+
92
+ ### September 7 audit and first implementation slice
93
+
94
+ The audit used existing local Hugging Face authentication in place. It read only the canonical private raw and index Buckets. The source snapshot was `36f27e68bf5ba7cfa53a6fedd7ba6fa66906113d058a048dd1384e7aa37034aa`; the active manifest named database checksum `2083eaf860cc69d038371eb2e3adf83366411baf21fbcb83e0062d44d3f623e2` and projection contract `2a2814ad4162457c0b8fe6de9cc07e0813f9fffadd3bde21ca46c731cc58de69`.
95
+
96
+ The selection took seven evenly spaced tweet segments per available UTC day from September 1 through September 6. The pinned snapshot had no September 7 tweet segment. Each selected object passed compressed-size and uncompressed-checksum verification. This was a bounded sample, not a complete history audit.
97
+
98
+ | Measure | Observed value |
99
+ | ---------------------------------------------------- | -------------------------: |
100
+ | Selected segments | 42 |
101
+ | Compressed bytes | 263,173 |
102
+ | Physical records and distinct raw observation tuples | 1,196 |
103
+ | Distinct post IDs | 1,136 |
104
+ | Posts with two distinct observation times | 60 |
105
+ | Posts with three observation times | 0 |
106
+ | Repeat pairs from the same contributor | 60 |
107
+ | Repeat pairs 1–18 hours apart | 18 |
108
+ | Minimum / median / maximum interval, hours | 5.8992 / 25.6423 / 25.6427 |
109
+ | Download and first-pass audit time, seconds | 33.682 |
110
+
111
+ All 60 pairs had changed view counts. Likes changed in 40 pairs, replies in 29, and reposts in 16. Two pairs had decreasing reply counts. Among the 18 pairs 1–18 hours apart, nonzero, nondecreasing endpoints were available for likes in 16 pairs, replies in 10, reposts in 8, and views in all 18. This sample supports some historical growth measurements, but does not establish acceleration coverage.
112
+
113
+ All 1,196 records had numeric values for the four counters. That does not prove that each counter was observed: the old parser replaced absent likes, replies, and reposts with zero, and partially parsed view strings. The sample contains 289 zero likes, 574 zero replies, and 918 zero reposts without a format marker. Their original meaning cannot be recovered from the saved parser output. Historical normalized zero values therefore remain null. Positive historical values retain their raw source reference; the original GraphQL counter was not independently verified.
114
+
115
+ New parser output marks the metrics object with `format: "exact-v1"`, admits only exact nonnegative safe integers or exact decimal strings, and preserves actual zero separately from null. This marker is excluded from content identity with the other metric fields. It does not change historical raw records.
116
+
117
+ The checked-in extension already uses passive Chrome Debugger Network capture in `lib/graphql-capture.js`. It does not need a new capture permission or a replacement main-world script for this task. A read-only process check found no local Chrome or Chromium browser, so the installed browser build and live browser canary remain unverified.
118
+
119
+ The first source slice adds normalized observations, immutable content versions, and physical provenance keyed by segment, operation, logical path, and line position. Server admission keeps delayed and same-batch observations. Exact logical retries remain idempotent. Browser delivery occurs before unique-post export deduplication, and queue admission is saved with sampling state. A full queue leaves staged work pending; it does not discard the oldest observation. Staged responses now survive browser restart and retain their original observation time.
120
+
121
+ A complete ingest acknowledgment accounts for every submitted observation as accepted, duplicate, or explicitly rejected. The browser saves explicit rejections in a local archive capped at 500 records; it continues valid work without silently dropping rejected data. Queue removal and rejection records are saved together. Archive overflow blocks removal and leaves the pending batch intact.
122
+
123
+ Local validation of this slice passed `npm run check`: formatting, lint, TypeScript, 597 Vitest tests, 188 extension tests, 180 native Python tests, coverage, and the duplicate-code check. Raw replay tests compare the same physical source references against immediate ingest, including operation and line positions. These are local results, not evidence of deployment or end-to-end publication speed.
124
+
125
+ The next committed slices add historical content/result dependencies and point reads for old and target source sets. Metric-only updates and exact result retries produce no content candidates. Membership changes, label approvals, delayed source keys, and content conflicts remain candidates for exact comparison. Historical reads reconstruct only the requested entries in a bounded temporary database, not a complete old database.
126
+
127
+ Content coverage now uses the start of the current content version, not the latest metric observation. Tests cover all six delivery orders of a three-observation edit/revert sequence, late discovery of an intervening edit, UTC normalization, and same-time private changes. Current and historical result reads use the same deterministic tie rule.
128
+
129
+ A separate consumer projection hash binds the derived index rules without changing the LLM contract. The saved consumer boundary contains the actual approved registry state and commits with the source inventory. Every new segment checks its observation, membership, and result-reference counts. Unsupported historical result rows retain a physical reference but cannot supply current labels. An old index with empty new tables remains unavailable to consumer reads and cannot publish a new index until explicit bootstrap. Output-segment application now stores the verified source snapshot before committing its rows and boundary; a storage failure does not acknowledge partially applied work.
130
+
131
+ These slices passed `npm run check`: 635 Vitest tests, 189 extension tests, 180 native Python tests, formatting, lint, TypeScript, coverage, and the duplicate-code check. The overall line coverage was 86.11%. These are local checks, not live throughput measurements.
132
+
133
+ The combined HTTP and bootstrap implementation subsequently passed `npm run check`: 744 Vitest tests in 77 files, 189 extension tests, and 180 native Python tests. Line coverage was 87.92%; the duplicate-code check found zero candidates. `npx --no-install @simpledoc/simpledoc check` and `git diff --check` passed. The exact consumer contract and required transaction rules are in the [HTTP handoff](2026-09-07-consumer-http-handoff.md). These checks include bounded native-query cancellation, pinned paging, targeted privacy recovery, and post-body/hash agreement. They are not deployment or live performance evidence.
134
+
135
+ Pi Reviewer found one P1 in the first pass: the extension required an `ok` flag that the Space's successful ingest response does not return. The extension now validates the existing `added`, `duplicates`, and `rejected` fields, and its normal queue test uses that exact response shape. The second review found no issues. CI then exposed a separate clean-build error: forked consumer processes were not compiled before tests. Root and Space test/coverage commands now build them first. The combined checks and all 518 Space tests passed after that correction. The third review found no issues, and CI passed on `d573fd4`. The configured mutation targets also passed at 82.51%; these targets do not establish mutation coverage for the new consumer modules. Extension version 0.26.0 identifies the new repeat-observation behavior; it has not been installed in the live browser.
136
+
137
+ A subsequent code audit found an acceptance gap: every idle change poll still requested a coverage calculation, and `selectedObservationThrough` read historical body JSON across the selected cohort. An empty HTTP response therefore did not prove zero unrelated body reads. The next correction must reuse proven unchanged coverage and calculate metric coverage from affected posts while preserving exact source, selection, privacy, and completion rules. This work is in progress; do not treat the passing review and CI as proof that the performance acceptance matrix is complete.
138
+
139
+ The real local source preparation saved 10,368 of 38,219 segments before a network read failed. A bounded restart read four additional segments, not the completed prefix, and produced a valid paused checkpoint at 10,372 segments. Its database held 400,822 logical observations and occupied 3,222,917,120 bytes. Preparation resumed from that point. This is recovery evidence, not a completed bootstrap or a live deployment. Computing the classifier contract with the deployed GLM-5.2 model and built-in taxonomy gives the same `2a2814ad4162457c0b8fe6de9cc07e0813f9fffadd3bde21ca46c731cc58de69` hash as the incumbent index.
140
+
141
+ The semantic schema checks are incomplete. Schemator's real Codex strategy timed out first at 120 seconds and again at 600 seconds per field. The longer attempts returned three of 39 SQL-field reviews and seven of ten observation-field reviews. Aggregation correctly reported incomplete coverage; no proposal was applied and no converged schema approval is claimed. In particular, the extracted `unknown` counter type is an extraction limitation: the production validators already require nullable, nonnegative safe integers. HTTP and Our Models schemas remain outside that partial result.
142
+
143
+ These changes alone do not remove the Our Models full read. Explicit production bootstrap and incremental website publication remain required before this task is complete.
144
+
145
+ ## Repeated observations
146
+
147
+ ### Separate delivery from post-ID deduplication
148
+
149
+ Keep the post-ID deduplication needed by local unique-post exports independent from pool observation delivery. A known post must still reach the observation path when it is observed again. Count unique posts and observations separately in local status.
150
+
151
+ Create a stable observation identity before durable enqueue. Persist the observation and delivery state together before marking it handled. A worker restart or retry must resend the same observation identity and timestamp, not invent a fresh observation at recovery time. Staged browser responses must retain their original observation time as well; reparsing a staged response must not stamp it with the recovery time.
152
+
153
+ Use the existing pool outbox where it can provide these guarantees. If its current storage cannot update queue and dedup state safely together, use a transaction in the extension's existing IndexedDB storage. Do not maintain two competing outboxes. Treat a full queue as an explicit degraded/blocked state; do not silently discard acknowledged work or persist a seen marker for an observation that was never saved.
154
+
155
+ For burst control, start with at most one ordinary metric sample per post per five-minute sampling interval. Record its actual observation time, never the interval boundary. Six-hour visits must not be suppressed by lifetime post-ID deduplication. Content edits and visibility changes must be delivered immediately and must not wait for this metric sampling interval. Test and report any intentional sampling loss; do not describe sampled history as every browser response.
156
+
157
+ A distinct later sample with unchanged counters is useful: it supports a measured zero increase. Within an interval, exact delivery replays are duplicates. Do not replace several independently queued observations with only the latest record before persistence.
158
+
159
+ ### Server acceptance
160
+
161
+ Replace the current “latest timestamp per post/contributor” ingest admission rule for observations with observation-identity idempotence. Keep a separate latest-post projection for browsing.
162
+
163
+ - Accept a valid delayed observation even if a newer one is already stored. It must not overwrite newer content or newer latest counts.
164
+ - Preserve multiple valid observations for one post in the same batch.
165
+ - Treat an exact retry as one logical observation. A successful retry returns the original result without writing duplicate logical work.
166
+ - Preserve conflicting observations rather than silently selecting the larger count. Identical IDs with different bytes are an integrity error.
167
+ - Normalize valid timestamps to UTC before comparison. Bound future clock skew and report invalid or unsupported timestamps. Receipt time and observation time remain distinct.
168
+ - Keep contributor attribution server-controlled. Client observation IDs do not authorize another contributor's identity.
169
+ - Persist accepted raw records before success. A projection failure after raw persistence remains recoverable by replay and does not require recapture.
170
+
171
+ Historical raw objects remain immutable. The new reader normalizes old accepted records during the explicit backfill; it does not rewrite them to look like newly generated observations.
172
+
173
+ ## Observation data
174
+
175
+ Use a small normalized observation record. The following is a proposed consumer example, not a currently available endpoint response:
176
+
177
+ ```json
178
+ {
179
+ "id": "obs-example-001",
180
+ "post_id": "1234567890",
181
+ "observed_at": "2026-09-07T06:00:00.000Z",
182
+ "received_at": "2026-09-07T06:00:12.000Z",
183
+ "content_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
184
+ "metrics": {
185
+ "likes": 400,
186
+ "replies": 18,
187
+ "reposts": 25,
188
+ "views": null
189
+ },
190
+ "source_ref": "opaque-source-record-reference"
191
+ }
192
+ ```
193
+
194
+ The example IDs are illustrative. Freeze the actual identity encoding and canonicalization vectors in shared schemas and tests during implementation.
195
+
196
+ | Field | Type | Meaning and validation |
197
+ | -------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
198
+ | `id` | string | Stable logical observation identity. Deduplicate delivery by this value, independently of post identity. |
199
+ | `post_id` | string | Immutable X post ID. Retweet wrappers must not add another copy of the original post's counters. |
200
+ | `observed_at` | UTC timestamp | When the source actually observed these counts. Preserve the original time through retries. |
201
+ | `received_at` | UTC timestamp | First durable pool receipt time for this logical observation. Useful for delay measurements, never a substitute for observation time. |
202
+ | `content_hash` | SHA-256 string | Reference to the observed content version. A later content edit must not attach an old observation to an invented text version. |
203
+ | `metrics` | object | Required named counters, each an exact nonnegative safe integer or null. Unknown is null. |
204
+ | `source_ref` | opaque string | Resolves internally to immutable segment identity, operation, and record position. It does not expose a Bucket URL or contributor identity to the public website. |
205
+
206
+ Use `reposts` in the new consumer contract and normalize the source's existing `retweets` field at the source-reader boundary. Do not maintain two synonymous consumer fields. Preserve supplied bookmarks, quotes, and other raw fields in the original record; they do not need to become ranking inputs in this task.
207
+
208
+ Do not turn an abbreviated display such as `1.2K` into an exact count of 1,200. For the initial exact-count contract, report that metric as null and retain the original value and normalization outcome in private provenance. Invalid or negative historical fields are also unknown with a diagnostic, not silently clamped to zero.
209
+
210
+ Define logical identity from stable normalized source identity, observation time, content identity, and counters. Preserve enough authenticated source context internally to distinguish independent observations and diagnose conflicts. Exclude receive/retry time from logical identity. Keep exact raw references for duplicate deliveries; the consumer must not count those deliveries separately. Historical normalization must produce the same IDs across shuffled replay, index rebuild, and process restart.
211
+
212
+ ## Indexed storage and change detection
213
+
214
+ ### Canonical storage
215
+
216
+ Inventory and reuse the deployed `RAW_BUCKET` and `INDEX_BUCKET`; the existing design uses `osolmaz/xtap-pool-data` for raw segments and `osolmaz/xtap-pool-bucket` for replaceable indexes. Verify actual deployment variables during implementation. An access failure does not authorize replacement resources or credential copying.
217
+
218
+ Retain the raw Bucket as the only complete source history. Extend the existing SQLite projection with normalized observations, distinct content versions, and reverse indexes for affected posts and units. Keep immutable source references and replay checkpoints in the existing storage scheme.
219
+
220
+ Do not add a separately mutable consumer head or an independent feed publisher. The Space can derive and serve bounded changes from its verified local source advance, as it already does for current reads. The existing authorized index publisher remains the only writer of the shared index manifest.
221
+
222
+ ### Stable boundaries
223
+
224
+ Use a content-addressed raw snapshot plus the semantic contract and normalized selection as the durable consumer boundary. The current boot UUID and SQL revision counter are unsuitable for this purpose.
225
+
226
+ A consumer cursor must describe the exact raw object set already consumed, not a timestamp or a lexicographic segment-key high-water mark. Raw writers can run concurrently, and a newly discovered object can have an earlier timestamp. Compare verified snapshot membership to find new segments. Store the exact snapshot manifest before issuing a durable cursor that refers to it; an unpersisted local hash is insufficient.
227
+
228
+ This reuses the existing immutable snapshot mechanism. Snapshot manifests are metadata, not duplicate post exports. Normal advance reads and applies only new source segments. Query handlers must not rescan historical segment bodies.
229
+
230
+ The audited snapshot has 38,219 segment descriptors and takes 15,752,981 bytes as canonical JSON. A metadata-size diagnostic used that snapshot with synthetic tails of 1, 16, 128, and 1,024 files. A retained base reference plus those exact additions took 502, 6,682, 52,837, and 422,019 bytes, respectively. At 1,024 additions, it saves 15,330,962 bytes per boundary, or 97.32%, before amortizing a new full base. This exceeds the 90% minimum worthwhile reduction chosen for this extra metadata logic. These are deterministic size calculations, not historical source deltas, network throughput, USD cost estimates, or launch approval.
231
+
232
+ The bounded page engine now compares exact old and target content, emits removals, and returns retained observations when content first becomes eligible. It filters by historical account membership before reading content and checks current privacy on every upsert and history read. Raw retries do not duplicate logical observations. Recorded classifier results can be reused after an exact content revert; an invalid later quote does not erase a valid prior result. Local validation passed after the resumable bootstrap changes: `npm run check` with 673 Vitest tests, 189 extension tests, 180 native extension tests, 87.60% line coverage, and `npx --no-install @simpledoc/simpledoc check`. HTTP authorization, worker deadlines, and live recovery remain separate unfinished gates.
233
+
234
+ The implementation therefore stores an immutable full base through the existing raw snapshot store and places its hash plus explicit additions in the read context. It rolls the base after 1,024 additions or a 1 MiB descriptor. Reconstruction verifies the checksum of the complete source set. It rejects removed, mutated, or duplicated files and includes late keys; it does not use a key watermark. At most two loaded bases are cached in memory. Read contexts belong in the existing index Bucket and do not replace its shared manifest. HTTP deadlines, retained-context cleanup, and real publication timing still need their rollout checks.
235
+
236
+ Separate the time of a substantive content version from the time of its latest metric observation. A count-only revisit must neither remove an already completed unit from cutoff selection nor advance content completeness by itself. Status and content reads must use the same completed-content boundary. An edit that invalidates the current enrichment must produce the documented pending/removal state until a matching result is ready; stale enrichment must not be attached to the edited text. Include this distinction in status, replay, and cutoff regression tests.
237
+
238
+ ### Derived indexes
239
+
240
+ Settle exact SQL names in the shared schema review, but implement these distinct responsibilities:
241
+
242
+ | Derived data | Required key/index | Purpose |
243
+ | ----------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
244
+ | Normalized observations | Unique observation ID; `(post_id, observed_at, id)`; source segment reference | Bounded post history and idempotent replay. |
245
+ | Content versions | `(post_id, content_hash)` with source references and visibility metadata | Separate text/context edits from metrics; evaluate a pinned source version. |
246
+ | Source effects | `(segment_key, affected_entity_kind, affected_id)` | Find changed candidates from a raw snapshot difference without scanning all posts. |
247
+ | Unit dependencies | Post-to-unit and prior-to-current membership references | Handle added replies, splits, merges, author corrections, and removed membership. |
248
+ | Label dependencies | Unit-to-label and label-to-unit references, including registry decisions | Find the units whose consumer-visible labels or selection changed. |
249
+ | Pinned read metadata | Snapshot identity, contract identity, selection, bounded page position | Reproduce a multi-page read without relying on process memory. |
250
+
251
+ Build source-effect entries in the same SQLite transaction that applies a verified raw segment and advances the indexed source inventory. A crash must not mark a segment applied without its observations and change candidates. A schema/bootstrap change must not discard these indexes merely because the latest-post table can still be restored.
252
+
253
+ For each candidate unit, compare the exact consumer-visible content at the old and target boundaries. Emit an upsert only if its content or permitted relationships changed. Exclude counts, observation time, pooled time, queue bookkeeping, and attempt receipts from that content identity. Include text, expanded links, media metadata used by consumers, authorship, membership, visibility, and published labels. Keep the narrower existing enrichment input hash where its semantics remain unchanged.
254
+
255
+ An account rename can require display metadata to change without requiring an LLM call. A registry approval/rejection can change label selection without changing tweet text. Distinguish these dependencies explicitly rather than using one hash to invalidate every processing stage.
256
+
257
+ An internal attempt, receipt, or queue write can advance the raw snapshot while producing zero consumer changes. It should advance the consumer cursor through a small empty result. A taxonomy/policy change that truly affects all selected units is broad work; report it explicitly and require the bounded re-evaluation or reset procedure. Do not disguise it as an ordinary small update.
258
+
259
+ ### Historical reads and bounded page construction
260
+
261
+ The selected approach is indexed differences between pinned source snapshots, using stored content/observation versions and source effects. Do not create a globally increasing consumer sequence independently in each process. Such sequences can disagree after a Job restore or a Space restart.
262
+
263
+ Before finalizing this implementation, prove that the old and target selected views can be read for affected IDs without reconstructing a full old database. Test delayed segments, unit regrouping, and registry changes. If the existing SQLite model cannot provide this, extend its version/dependency indexes before adding API handlers.
264
+
265
+ A page must remain fixed while new source data arrives. Read the target version through indexed immutable versions, or freeze the necessary bounded response batch in checksum-addressed derived storage in the existing index Bucket. Use one mechanism after the prototype; do not ship parallel fallback readers. Any derived page cache is temporary, authorization-checked, and reconstructible. It must not become a second permanent source archive or a mutable publication authority.
266
+
267
+ This proof is a design gate. “Fetch changed IDs and then read each ID's latest row” is insufficient: a concurrent edit could mix versions, and a removal may no longer have a current row.
268
+
269
+ ## Consumer API
270
+
271
+ Change the owned version-1 contract in place and coordinate its consumers. Do not add a parallel `v2`, boot-epoch fallback, or automatic full-scan compatibility path. Keep current query routes that still serve independent browsing or administration uses; do not retain them as a hidden fallback for the incremental publisher.
272
+
273
+ ### Bootstrap
274
+
275
+ A new consumer explicitly requests one pinned snapshot for its normalized selection. Continue using `/api/units` for this explicit bootstrap, with the new durable snapshot/cursor semantics. Use exact author IDs, label mode, and `publication=public-original` throughout.
276
+
277
+ The bootstrap returns the matching complete content state, approved label/graph state or exact references to them, a declared observation-history range, and the resume cursor at the same boundary. Bootstrap pages must survive a Space restart and later source writes. The consumer does not advance its durable cursor until the snapshot is fully validated and saved.
278
+
279
+ A pre-existing verified Our Models publication may be adopted as the bootstrap baseline only if its source snapshot, contract, selection, content digest, and cursor boundary are proven to match. Otherwise perform one explicit bootstrap. “It looks similar” and matching row counts are insufficient.
280
+
281
+ ### Changes
282
+
283
+ Proposed route:
284
+
285
+ ```http
286
+ GET /api/changes?after=<opaque-cursor>&limit=200
287
+ Authorization: Bearer <configured-service-credential>
288
+ ```
289
+
290
+ The cursor binds the normalized author/label/publication selection from bootstrap. Reject conflicting query filters. Do not expose raw segment URLs or ask the consumer to list the Bucket.
291
+
292
+ Proposed response envelope:
293
+
294
+ ```json
295
+ {
296
+ "schema_version": 1,
297
+ "source": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
298
+ "complete_through": "2026-09-07T01:22:00.000Z",
299
+ "observations_through": "2026-09-07T06:00:00.000Z",
300
+ "cursor": "opaque-next-position",
301
+ "has_more": false,
302
+ "changes": []
303
+ }
304
+ ```
305
+
306
+ `source` names the pinned raw snapshot. `complete_through` describes completed content/enrichment coverage for the selected accounts. `observations_through` is the latest included observation time, or null if none is available; it is not a promise of complete observation coverage up to that time.
307
+
308
+ The returned `cursor` is the position to save only after applying that page. While `has_more` is true, it remains inside the same fixed old-to-target snapshot comparison. After the final page it represents the fully consumed target boundary. New writes wait for the next comparison. A response with zero changes can still advance the source boundary.
309
+
310
+ Provide typed changes for:
311
+
312
+ - **Unit upsert:** complete selected content and published associations/labels for a new or changed unit, with a stable content hash. Separate metrics from this content identity.
313
+ - **Unit removal:** the previously visible unit ID and an appropriate public removal reason, without the withdrawn text. Include old IDs after regrouping, author/filter changes, subscriber-only transitions, or label eligibility changes.
314
+ - **Observation:** a normalized observation for a selected, permitted post. It must not imply that content changed.
315
+ - **Shared metadata change:** approved taxonomy/registry/graph changes, or exact bounded references to matching state at this source boundary. They must not require a full post read to update metadata.
316
+
317
+ Emit observations for currently completed, publishable content without waiting for another LLM pass on unchanged text. An observation associated with content that is not yet eligible must not leak through the public-original selection. When that content first becomes eligible, provide its permitted retained observations even if they were received before the consumer's previous cursor. Test this explicitly.
318
+
319
+ Compute removals against both old and target membership. Filtering only the newest rows misses posts that left the selection. An absent post in a browsing session is not evidence of deletion; use an explicit source removal, an observed content/visibility change, or a documented membership/eligibility change.
320
+
321
+ ### Post history
322
+
323
+ Proposed route:
324
+
325
+ ```http
326
+ GET /api/observations?post_ids=1234567890,2345678901&since=2026-09-01T00:00:00Z&until=2026-09-08T00:00:00Z&at=<source-cursor>&limit=200
327
+ Authorization: Bearer <configured-service-credential>
328
+ ```
329
+
330
+ Use inclusive `since` and exclusive `until` on actual observation time. Require a bounded post-ID set and time range. Return stable pages ordered by `(post_id, observed_at, id)` at the pinned source boundary. Include observation counts/coverage and the earliest available history bound. Missing history must be explicit.
331
+
332
+ This endpoint supports the initial bounded history recovery, audits, and later story activation. Normal operation uses `/api/changes`; it must not re-read the complete history of every story on every poll.
333
+
334
+ ### Bounds, validation, and errors
335
+
336
+ Initial limits to verify against real unit sizes and throughput:
337
+
338
+ - 200 changes or observations per page by default; 500 maximum.
339
+ - 100 post IDs and a 30-day range per history request.
340
+ - 2 MiB target response size and an 8 MiB hard page limit.
341
+ - 30 seconds maximum server-side work per request; client deadlines must leave time to receive the response.
342
+ - A 48-hour lease for an unfinished pinned page sequence; 30 days of supported incremental cursor recovery and hot observation history initially.
343
+
344
+ These are proposed operational limits, not measured capacity or spending approval. Test unusually large threads and articles. Never silently truncate a complete unit or skip it and acknowledge its cursor. If a valid unit exceeds the hard limit, return a specific oversized-source error. If the audit finds such units in the required source set, bounded unit-member paging is an implementation prerequisite rather than a production surprise.
345
+
346
+ Return structured errors:
347
+
348
+ | Status | Meaning | Consumer action |
349
+ | ------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
350
+ | `400` | Malformed cursor, dates, IDs, limits, or conflicting selection | Correct the request. Do not retry unchanged. |
351
+ | `401` / `403` | Missing, expired, revoked, or insufficient credentials | Stop; preserve the last good publication. |
352
+ | `409` | Contract/selection mismatch or a privacy change that invalidates an active page | Reconcile the explicit reason; do not silently restart a complete download. |
353
+ | `410` | Cursor or pinned page lease is outside retained support | Use explicit bounded recovery or an approved bootstrap. |
354
+ | `413` | One valid content item exceeds the supported complete-item bound | Stop that content advance with an actionable error; do not fabricate a partial item. |
355
+ | `429` | Capacity limit | Retry the same cursor with bounded backoff and `Retry-After`. |
356
+ | `502` / `503` | Temporary source/index unavailability | Retry the same page with a finite elapsed-time and attempt limit. |
357
+
358
+ Cursor decoding validates schema, size, source references, selection, contract, and position. A cursor is not an authorization token. Never trust a client-supplied Bucket path or fetch an arbitrary URL from it. Ordinary source writes and restarts do not invalidate a valid cursor.
359
+
360
+ ### Authorization and privacy
361
+
362
+ Reuse service accounts and configured credentials in place. Define an explicit observation-history read permission alongside the current unit/taxonomy permissions. Confirm whether the existing Our Models account has the required grants; a broader grant or credential copy requires the named operator action before rollout.
363
+
364
+ Apply current authorization on every page, including cached pages. Historical selection never grants access to subscriber-only, revoked, or otherwise withdrawn source bodies. If a privacy withdrawal invalidates an unfinished page, stop that page with a specific privacy-change response and provide a safe removal/reconciliation path. A consumer must not need to fetch the forbidden body to process its removal.
365
+
366
+ Do not expose contributor IDs, raw private history, API credentials, or private Bucket URLs in Our Models. Its browser reads only the published public result.
367
+
368
+ ## Growth measurements and rechecks
369
+
370
+ xTap supplies source observations and coverage facts. Our Models calculates attention using ordinary code, not an LLM.
371
+
372
+ For a valid pair, retain both counts and both timestamps. Calculate the absolute difference and divide by actual elapsed hours. A change from 100 to 400 likes over six hours is an increase of 300, averaging 50 per hour. It does not identify when those likes arrived within the interval.
373
+
374
+ Use at least three suitable observations for acceleration. Compare adjacent elapsed-time-adjusted rates, retaining both interval lengths. Do not mix a twelve-hour gap with a six-hour interval without accounting for the difference.
375
+
376
+ Missing counters, a first observation, identical timestamps, excessive gaps, stale endpoints, and conflicting observations produce an unavailable signal for that metric. Counter decreases remain recorded as corrections/anomalies; do not convert them into positive growth or silently clamp them to zero. Missing intervals must not prevent other valid intervals from being inspected.
377
+
378
+ Keep likes, replies, reposts, and views separate. Views are not unique people or endorsements. Compare similar post ages; use author baselines only when enough matched observations exist. Keep raw counts, intervals, coverage, and sample sizes alongside normalized scores. Cap individual-post and author contributions in Our Models, and treat copied/reposted sources as shared evidence rather than independent confirmation.
379
+
380
+ Start with existing visits. If coverage is inadequate and a revisit policy is later approved, evaluate:
381
+
382
+ - Broad coverage of new relevant posts during their first 48 hours, at the existing six-hour cadence.
383
+ - Continued visits for posts in active discussions for a few more days.
384
+ - Older posts only when new evidence or an explicit request justifies them.
385
+
386
+ Do not select only already popular posts. Do not couple xTap to Our Models Story IDs; any requested post set must remain a normal, bounded source operation with its own authorization. Changes to Infinite Feed Scroller belong in that repository and need their own implementation/merge authority. This plan does not silently expand that work.
387
+
388
+ ## Our Models integration
389
+
390
+ Implement this consumer phase only after xTap's source contract and recovery tests pass.
391
+
392
+ 1. Replace revision-triggered full reads with explicit bootstrap plus persisted change cursors. Reuse the existing frozen source work and publication coordination machinery.
393
+ 2. Apply each page's content, removals, observations, and metadata in a local transaction with its resume cursor. A failed transaction leaves the earlier cursor intact.
394
+ 3. Save the applied source state and cursor through the existing durable checkpoint mechanism. Distinguish the cursor already applied to a staged result from the cursor actually published to the website.
395
+ 4. Reuse cached results by their real dependencies. Text/model-relevant evidence changes can require association, claims, grouping, or writing. Display-only changes need only display updates. Metrics-only changes require **zero** association, claim, grouping, writing, or checking calls.
396
+ 5. Map changed observations to existing supporting post IDs, then recompute only affected stories' attention. New popularity cannot bypass model eligibility, change impact into a factual claim, duplicate a story, or rewrite its original date.
397
+ 6. Remove or recheck stories when their supporting source or eligibility changes. Keep the last verified story only when its evidence still remains valid.
398
+ 7. Retry a failed fetch or publication from the durable page/batch boundary. Never fall back to a complete read on ordinary `409`, `502`, or `503` responses.
399
+ 8. Keep exactly one publication writer. Publish a coherent verified result, then advance the live manifest and its published cursor together.
400
+
401
+ Do not stop at an incremental API. The consumer must also stop decoding the complete result cache and rebuilding/verifying/uploading the full JSON and SQLite artifacts for every count update. Measure those stages separately. Reuse the existing accepted source state and update affected data with indexed writes; make the small public story/feed result independently publishable under the same authoritative manifest if measurements show the large commentary artifact still blocks freshness. This is a consumer publication change, not a new xTap store or competing writer. Select the exact artifact layout in the Our Models implementation plan and verify atomic consistency before shipping.
402
+
403
+ Preserve one-time full build/bootstrap and explicit repair paths. Remove normal-path full scans after the coordinated replacement; do not retain them as indefinite compatibility code.
404
+
405
+ ## Retention and recovery
406
+
407
+ Keep full source history in the immutable raw Bucket. Do not delete or rewrite raw segments for this task.
408
+
409
+ Start with a rolling 30-day hot observation window and sufficient version/source-effect metadata to serve every supported cursor. Retain the predecessor observation needed at the beginning of a comparison window. The first backfill should cover the requested recent story window, with a 30-day maximum per bounded backfill run. Record exact dates and coverage before launch.
410
+
411
+ An older history request can use an explicit resumable rebuild into the existing index storage. Bound source objects, bytes, elapsed time, and output size; save durable partial outputs and verified progress after each chunk. A request handler must never perform this historical rebuild implicitly.
412
+
413
+ Current index retention keeps an active generation and three predecessors. That count alone cannot guarantee a 48-hour page lease or 30-day cursor support. Retention must preserve all versions/derived references required by the declared API window, or the API must advertise a smaller verified window. Do not promise a cursor lifetime that pruning can silently break.
414
+
415
+ Cursors must survive ordinary index replacement and Space restart within that window. A full index rebuild from the same raw snapshot must reproduce observation IDs, content identities, source effects, and the same logical query results. Missing/corrupt raw objects or a mismatched contract fail closed with explicit repair evidence. A reset is an operator-visible exceptional path, not a hidden hourly bootstrap.
416
+
417
+ Before any historical build or remote canary, measure a bounded local/CPU sample, estimate the complete runtime, disk use, low/high USD cost, and worst-case retry exposure. Reuse available canonical artifacts. Verify an applicable authorization and ceiling for changed work; the unchanged enrichment contract does not automatically authorize a different backfill Job, model, hardware, or schedule. Test a real interruption and resume before substantial work.
418
+
419
+ ### Local bootstrap recovery
420
+
421
+ `index:bootstrap` now separates preparation from publication. Preparation freezes an exact source snapshot beside the working SQLite database and commits bounded, chronologically ordered chunks. A restart checks that the database is an exact prefix of that target and reads only the remaining segment bodies. An existing database is never silently replaced. An incomplete-bootstrap marker travels inside SQLite and blocks serving or publication, including after the database is copied without its companion snapshot file. Publication also rejects a candidate that omits a previously published source segment. The default chunk is 128 segments; `INDEX_BOOTSTRAP_MAX_SEGMENTS` bounds a diagnostic run. SIGINT and SIGTERM stop after a saved chunk. These are local SQLite checkpoints, not remote Job checkpoints.
422
+
423
+ `INDEX_BOOTSTRAP_MODE=publish` is a separate operator action. It requires complete preparation and, when a live index exists, its exact `INDEX_BOOTSTRAP_EXPECTED_DATABASE` value. It applies the remaining source tail, verifies the full database, uploads and verifies it, and then replaces the pointer. Other publishers must be stopped for this cutover. Preparation does not grant publication approval or change a schedule. Setup uses the same two stages and retains partial work after failure.
424
+
425
+ Full SQLite integrity checks now remain at restore, explicit verification, and publication. Normal tail transactions still verify raw identities and segment projection counts, but do not rescan every SQLite page, including after a harmless source check.
426
+
427
+ A real local process-interruption canary used the audited 42 tweet segments. The process was killed with SIGKILL after 14 committed segments. Restart read only the remaining 28 segments (197,323 compressed bytes), preserved all 1,196 logical observations, and matched a fresh replay's observation, content, and physical-source digests. The fresh replay read 263,173 compressed bytes and produced a 7,405,568-byte database. It took 0.595 seconds on this 20-core ARM64 host with about 128 MiB peak process RSS; restart took 0.462 seconds for its remaining work. No remote state or live pointer changed, and no model or paid Job ran. This small tweet-only fixture proves local recovery, not production-sized performance, classifier-result coverage, or remote Job recovery. The full source includes mixed/result/registry records and 38,219 network objects, so these times must not be extrapolated as a production completion estimate.
428
+
429
+ A read-only source pilot then prepared the first 128 real segments of the audited full snapshot. These contain 104,831,277 compressed bytes, 293,280 tweet records and logical observations, 196,276 raw enrichment rows, 372,876 attempt events, 22,122 registry events, and 76 receipts. These are whole-source counts, not the Our Models cohort or proof of its eligibility results. Two bounded invocations took 12.64 and 288.73 seconds. The saved database is 2,500,280,320 bytes; peak RSS in the larger invocation was 1,356,296 KiB. There were no model calls, paid Jobs, remote writes, or live pointer changes.
430
+
431
+ The full pinned-source preparation completed on September 7. It contains all 38,219 segments, 689,421 logical observations, 326,102 raw enrichment rows, 636,129 attempt events, 48,081 registry events, and 195 receipts. The verified SQLite candidate is 4,811,792,384 bytes. These are whole-source counts, not the Our Models selection. The candidate remains local at `space/.data/consumer-bootstrap-2026-09-07/index/bootstrap.sqlite`; no production pointer changed.
432
+
433
+ A network read failed after 10,368 saved segments. A four-segment recovery probe then completed, and the final continuation read only the remaining 27,847 segments (92,462,211 compressed bytes). That continuation, including final verification, took 6,213.060 seconds and reached 573,592 KiB peak process RSS. This is not the total time across the initial pilot, failed attempt, probe and continuation. Preparation used 128-segment checkpoints and eight concurrent reads on the same 20-core ARM64 host. Actual new HF Job and model-call cost is $0.00 because neither ran; ordinary host power and existing storage charges are excluded. No runtime or service was installed.
434
+
435
+ Full-source reader tests exposed and corrected query-order and result-history bounds that smaller fixtures did not reveal. Both code changes are merged in [#46](https://github.com/osolmaz/xtap-pool/pull/46) and [#47](https://github.com/osolmaz/xtap-pool/pull/47), with passing review and CI. The [HTTP handoff](2026-09-07-consumer-http-handoff.md#full-source-initial-coverage-fix) records the complete local read, restart, no-op, history, body-read and final query-parity evidence, including the slower repeated timings. These tests do not establish cloud latency or website freshness. Deployment still requires source-tail catch-up, current predecessor checks, observation grants and the coordinated Our Models cutover. The canonical enrichment and publication schedules remained active and non-concurrent at 15:07 UTC.
436
+
437
+ ## Implementation sequence
438
+
439
+ ### Source audit and fixed tests
440
+
441
+ - Complete the production-history/capture audit and the count-semantics baseline.
442
+ - Freeze representative private fixtures and their public aggregate report.
443
+ - Include exact repeats, unchanged counts, delayed observations, same-time conflicts, content edits, reply additions, restricted posts, label changes, and a source revision containing only receipts.
444
+ - Fix acceptance limits before comparing implementations. Use practical significance and raw counts when measured differences determine architecture, scaling, or spending.
445
+
446
+ ### Observation delivery and storage
447
+
448
+ - Establish the compliant passive capture path before changing extension delivery.
449
+ - Separate unique-post export dedup from sampled pool observations.
450
+ - Add durable identity/outbox behavior and replace latest-only ingest admission.
451
+ - Add normalized observation/content-version schemas and deterministic historical normalization.
452
+ - Preserve semantic hashes and prove metric-only input causes zero xTap enrichment calls.
453
+ - Add extension and server failure tests; record vendored changes in `extension/VENDORED.md` and follow the repository's vendor-maintenance process.
454
+
455
+ ### Source effects and consistent reads
456
+
457
+ - Add source-effect and dependency indexes to verified tail application.
458
+ - Prove before/after membership for affected IDs without a full old-view rebuild.
459
+ - Implement stable raw-snapshot cursors and one pinned pagination mechanism.
460
+ - Handle unit removals/regrouping, metadata changes, completed-content gates, and observations that become visible with newly eligible content.
461
+ - Update index checks, count/digest verification, bootstrap, retention, restore, setup doctor, and worker restore compatibility together.
462
+
463
+ ### API and bounded history recovery
464
+
465
+ - Implement shared validators first, domain operations in `space/src/`, and thin routes in `space/src/app.ts`.
466
+ - Add explicit bootstrap, changes, and history access with authorization and byte/time limits.
467
+ - Backfill the approved recent history in bounded chunks; save receipts and run interruption/resume tests.
468
+ - Complete local performance and crash tests before deploying the reader.
469
+
470
+ ### Consumer replacement and production proof
471
+
472
+ - Prepare the Our Models reader and cursor checkpoints against fixed source fixtures.
473
+ - Finish the remaining incremental publication work, including metric-only output publication.
474
+ - Stage both repositories without exposing incompatible live readers. Use the exact predecessor and verified source boundary for transition.
475
+ - During required maintenance, prevent overlap between the canonical worker and maintenance/index publication. Restore the validated canonical schedule immediately afterward.
476
+ - Run the approved bounded production canary, including one source update and one zero-change poll after a restart.
477
+ - Resume story generation and landing-page implementation only after these prerequisites pass.
478
+
479
+ Do not merge or deploy a companion repository merely because this plan depends on it. Obtain the applicable authorization for each repository and changed operational contract.
480
+
481
+ ## Acceptance matrix
482
+
483
+ | Case | Required result |
484
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
485
+ | Harmless raw revision change | Empty bounded change response; zero historical post-body reads/downloads and zero LLM calls. Cursor can advance. |
486
+ | Ten posts receive new counts | Exactly their new permitted observations, subject to documented sampling; no complete unit/post download and no semantic calls in either application. |
487
+ | Unchanged counts at a later visit | A distinct timestamped observation and a valid zero increase when both endpoints are usable. |
488
+ | Content edit plus count change | One content-version update plus the observation; recompute only affected semantic dependencies. |
489
+ | New reply in an old conversation | Relevant unit membership update and current reply timestamp preserved; no unrelated units reread. |
490
+ | Unit split or merge | Remove replaced IDs and upsert replacement membership coherently. No duplicate or stranded consumer entries. |
491
+ | A label loses approval | Affected labels/selections change, including removals where needed. No broad post scan merely to discover affected IDs. |
492
+ | Newly completed content | Publish eligible content and its permitted earlier observations even when the observation arrival predates the old cursor. |
493
+ | Restricted or withdrawn source | No forbidden body or counters leak through historical/cached pages; process a safe removal. |
494
+ | New unrelated source write during pagination | Remaining pages stay at the pinned target; the next cycle receives the later write. |
495
+ | Restart between pages | The same cursor resumes the same logical read within its advertised lifetime. |
496
+ | Late raw segment or delayed observation | Arrives in a later snapshot difference and is delivered once; time-based ordering does not lose it. |
497
+ | Duplicate delivery or worker replay | Same observation IDs and same logical result; no duplicate growth contribution. |
498
+ | Failure after raw upload, before SQLite apply | Verified tail replay restores observations and source effects together. |
499
+ | Consumer crash after page receipt | Page replay is idempotent; its cursor was not acknowledged ahead of durable state. |
500
+ | Consumer upload succeeds, pointer update fails | Old website remains valid; retry reuses the prepared output and source cursor. |
501
+ | Expired cursor | Explicit reset/recovery response. No silent complete scan. |
502
+ | Counter missing/decreasing/conflicting | Raw evidence retained; affected growth unavailable or marked anomalous, never invented or clamped. |
503
+ | Six-hour versus twelve-hour interval | Rates use actual elapsed time. No unsupported minute-level or daily-total claim. |
504
+ | Rebuild and shuffled replay | Matching logical digests, observation IDs, selection results, and source boundaries. |
505
+ | Queue pressure or interrupted browser | Durable queued work survives; any sampling/backpressure is explicit and countable. |
506
+
507
+ ## Performance and freshness proof
508
+
509
+ Use a fixed production-sized fixture plus small controlled deltas. Record CPU/hardware, source size, individual-post and unit counts, observation count, cache state, and software revisions. Separate cold bootstrap/restore from warm steady-state reads. Do not include startup in one candidate and omit it from another.
510
+
511
+ Proposed acceptance targets on the current supported CPU configuration:
512
+
513
+ - Twenty warm no-change polls: no post-body reads, no inference, and p95 response time at most two seconds.
514
+ - Twenty warm ten-observation updates: bounded observation payloads, zero content upserts, zero inference, and p95 source response time at most five seconds.
515
+ - Small content updates: work and transferred bytes scale with changed units and required context, not the full 42,550-entry fixture.
516
+ - Existing completed content's new metrics: visible through xTap within one successful ingest/index advance or external refresh, without waiting for the six-hour enrichment Job.
517
+ - Our Models: an already eligible observation/content change reaches the public feed within ten minutes of source availability under the tested workload. This is an end-to-end target, not a result established by faster source reads alone.
518
+
519
+ If a target is missed, report the stage and measured distribution. Do not rename a partial success as completion. Separate browser observation time, pool receipt, source availability, content complete-through, consumer apply time, and live publication time. The website's Today label cannot conceal missing content or old coverage.
520
+
521
+ Track actual changed IDs, normalized observation count, source segment bodies read, source bytes transferred, SQL rows examined/query plans, output bytes, provider calls, build/verify/upload durations, and applied/published cursor identities. These are ordinary operational facts. No monitoring-specific API or separate telemetry service is required.
522
+
523
+ Report absolute savings and uncertainty. A speed result alone cannot justify new infrastructure or paid scaling. Prefer the simpler implementation when measurements do not establish a worthwhile improvement.
524
+
525
+ ## Verification commands and production checks
526
+
527
+ Repository checks for the implementation:
528
+
529
+ ```sh
530
+ npm run test:extension
531
+ npm run test:extension:native
532
+ npm run check
533
+ npm run build
534
+ npm run mutate
535
+ npx -y @simpledoc/simpledoc check
536
+ ```
537
+
538
+ `npm run check` includes format, lint, type checks, tests, extension tests, coverage, and duplicate-code checks. Add named regression tests for each acceptance case; do not replace failures with broad exclusions. Keep changes to installed capture components separate from fixture-only tests.
539
+
540
+ The documentation task runs `npm run check`, SimpleDoc, and `git diff --check`; implementation, mutation, live API, and production canary results must be recorded when they are actually run.
541
+
542
+ The live report must identify the tested deployed builds, source snapshot, declared selection, observation coverage, canonical index generation, and exact consumer publication. It must include an interrupted/resumed source read, an interrupted/resumed consumer update, a repeated no-op, and a metric-only update with zero measured semantic calls. Validate the actual website response after publication, not only a successful Job exit.
543
+
544
+ Never print credentials in the command line, logs, examples, or report. Use the installed CLI's configured authentication in place. New permissions, secret copies, runtime changes, and paid work require their own applicable authorization.
545
+
546
+ ## Remaining decisions and completion
547
+
548
+ Settle these decisions with evidence during implementation:
549
+
550
+ - Installed capture path, available historical counter coverage, and whether passive capture needs additional approved browser permissions.
551
+ - Exact normalized identity vectors and raw-field precision handling.
552
+ - Indexed historical-version reads versus one bounded frozen-page mechanism; prove correctness before choosing the simpler viable implementation.
553
+ - Measured response, retention, backfill, and outbox limits, including maximum real unit size.
554
+ - Existing service-account grants and the required observation-history permission.
555
+ - The Our Models artifact layout that permits a small metric/story update without a complete commentary rebuild.
556
+ - Approved history window, remote canary cost ceiling, and transition timing with current Jobs.
557
+
558
+ The task is complete only when real repeat observations are preserved, historical coverage is honestly reported, content and metric changes can be consumed without normal full reads, recovery passes, and the website's measured update path meets the agreed freshness target. Story generation can then use this source boundary without rebuilding the missing foundation inside Our Models.
docs/2026-09-08-consumer-deployment.md ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Consumer deployment and coverage deadline
3
+ author: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
4
+ date: 2026-09-08
5
+ tags: [consumer, deployment, verification]
6
+ ---
7
+
8
+ # Consumer deployment and coverage deadline
9
+
10
+ The reviewed consumer source at `a56d5b0` was deployed to the existing Space as `ce0aad213f8bcd7fe48a4a9302046e147044adec` on September 8. Index `8cb6974753cd765c288e6fa2c551e3b9db028c346f8045c21d7038557fa5129f` was uploaded, downloaded, checksum-checked, and verified before replacing the pointer. Its 4,891,852,800-byte database preserves the prior index references and all raw records. The source revision is `5dc867c4665bf2360a95a24bca320dadba03fae68b11a528e878a29a152898b1`.
11
+
12
+ The existing Our Models account now has `observations:read` in addition to its previous scopes. Keys and credential stores are unchanged. Space-side enrichment remains disabled. The canonical enrichment schedule was restored and Job `6a9fd0bd259f8e97255ee9e2` was triggered under its unchanged $10 inference and 2,700-second limits.
13
+
14
+ ## Live checks
15
+
16
+ Readiness passed after the index and metadata restored. A bounded single-author selection returned 135 posts in three pages. Its history request returned 71 observations for 17 posts with repeated observations; eight of the 25 requested posts had no history in that window. A subsequent unchanged-source request returned zero changes and 598 bytes. The history request took 1.559 seconds and the unchanged request took 3.656 seconds. This is observed source history, not proof that the browser has loaded extension 0.26.0.
17
+
18
+ The first full-selection Our Models recovery Job, `6a9fcfede686246ca69aa5bf`, then failed after three `503 deadline_exceeded` responses. No full-selection context was published. The failure occurred before source-page completion or model calls. Our Models scheduling remains suspended for repair; the website keeps its verified published database. The independent xTap enrichment Job is not affected by the consumer request deadline.
19
+
20
+ ## Completion boundary query
21
+
22
+ The completion query repeated the same selected queue twice: once to find the earliest unfinished work, then again to find completed work before it. The replacement materializes that selection once in a single SQL statement. It retains the strict time boundary and handles every unfinished state, empty selections, and all-completed selections. It does not increase HTTP timeouts, change the classifier contract, or require another index migration.
23
+
24
+ On the same verified local database, the old completion calculation took 7.322 seconds and the replacement took 3.669 seconds, 3.653 seconds less. The returned boundary was identical: September 7 at 22:52:28.518 UTC. Observation coverage remained identical and took 3.264 and 3.212 seconds respectively. These are single local measurements, not statistical or cloud performance claims. The release gate remains successful full-selection requests and real worker recovery on the deployed CPU hardware, with the existing 30-second request deadline unchanged.
25
+
26
+ Local tests assert one prepared selection statement and check pending, running, retrying, and blocked work, including completed work at the same time as the unfinished boundary. Full validation and live recovery results must be recorded before claiming the consumer is ready for full catch-up.
27
+
28
+ ## Follow-up query and recovery checks
29
+
30
+ The first completion optimization was not sufficient on the live CPU. A full-selection metadata page took 22.861 seconds, and a later attempt exceeded the unchanged 30-second deadline. The follow-up materializes distinct unit IDs before correlated eligibility checks, then reads each selected post's newest permitted observation through the existing post/time index. It still searches older observations when newer samples are outside the pinned source. No database migration or timeout change is required.
31
+
32
+ On the same local database, completion took 1.766 seconds and observation coverage took 2.684 seconds. Both clocks matched the previous implementation exactly. These remain local single-run measurements; deployed first-page and content-page checks are required. Tests cover a pinned source with nine later observations, restricted post IDs, an empty selection, and indexed source membership checks.
33
+
34
+ Activation history reads now use the existing bounded batch helper with concurrency eight. Every immutable plan and activation is still validated, and predecessor checks remain in generation order. Tests require every plan read, a maximum of eight simultaneous reads, unchanged read-only behavior, and rejection of a missing historical plan.
35
+
36
+ The first enrichment Job failed because the initial deployment did not include the required revision handoff. Canonical stage preparation corrected that deployment error. The subsequent read-only Job `6a9fdb2b8e5f7b7fd14cb6d7` found that the active bootstrap plan still referenced the index from before the consumer deployment. It stopped before inference or output writes. Recovery must preserve the prior plan and checkpoint, verify whether any outputs exist, and prepare a successor from the verified current index. Both schedules remain suspended only for this active recovery.
37
+
38
+ ## Classifier work copy size
39
+
40
+ Preparing a successor exposed another migration defect before activation: the compact classifier database still contained all consumer observation and change-history tables. Its 2,953,281,536-byte file exceeded Node's 2 GiB `readFile` limit. No successor was activated and no inference call was made.
41
+
42
+ The worker compaction now excludes those eight consumer-only tables from its disposable copy. Publication still restores the verified full base index and applies worker outputs there; the public history and raw source are not removed. A regression test requires the public source file to remain byte-identical and preserves queue and registry work.
43
+
44
+ On the verified prepared source, the work file falls to 60,850,176 bytes. The source database SHA-256 remains `5cf69cd60e424d83da3ac2e4b6d4161ca154a1873b392abdd56d361e15859eb8`; all 308,130 queue positions, 306,936 completed positions, and 29,629 registry positions remain represented. Full local checks pass with 771 tests, 88.12% configured coverage, and zero Slophammer DRY candidates. The corrected candidate still requires activation and cloud recovery verification.
45
+
46
+ ## Prepare source metadata before consumer requests
47
+
48
+ The corrected worker copy was activated as generation 87, run `xtap-b0e01d0c7828c30def3948bcb4251e68`. Its read-only cloud restore passed. The following production retry failed with `fetch failed` after 243 seconds, before any provider calls or output changes. Its checkpoint remains at sequence one. The same run can resume; its plan and prior source files remain intact.
49
+
50
+ Full-selection HTTP reads still exceeded the 30-second deadline after the SQL changes. A read-only cloud check on cpu-basic measured coverage at 8.220 seconds and privacy at 0.287 seconds against the published index. A separate local check of immutable source preparation uploaded and verified 16,260,675 bytes in 17.187 seconds. These are different executions, not an end-to-end latency measurement. They identify full snapshot preparation as additional work in the first HTTP request.
51
+
52
+ Source bases are now prepared during verified index startup and the existing background refresh, before storage readiness is restored. Consumer requests only describe an already prepared base and its bounded additions. A missing or oversized base returns `503 source_not_ready` without uploading a snapshot. The existing background refresh prepares the next base; it does not require another API, store, schema version, or longer HTTP deadline. Ingest requests do not perform this preparation.
53
+
54
+ Regression tests require no snapshot writes from cold, bounded, or oversized descriptions; explicit preparation and rollover; restart recovery; and unchanged immutable-file and checksum validation. Deployment must still prove full-selection metadata and content pages within the existing deadline before Our Models recovery Jobs resume.
55
+
56
+ ## Verified source-read deployment
57
+
58
+ Source `35f8318026a3ad56868fd0fc57a42142a50bef3b` from PR 52 is deployed as Space revision `1b4196c0439563b00647610af118bb4d5a245513`. Local checks passed with 771 tests, 88.14% configured coverage, and zero DRY candidates. Pi Reviewer reported no findings and CI passed.
59
+
60
+ The canonical revision handoff preserved active generation 87 and its sequence-one checkpoint. Read-only cloud Job `6a9ffc91b012ba1d5b8f2bc6` verified the handoff in 157.010 seconds. Restore Job `6a9ffdf48e5f7b7fd14cbca7` completed in 149.624 seconds with zero provider calls, zero orphan segments, and unchanged run objects and public index pointer.
61
+
62
+ At 12:28 UTC, three full-selection metadata requests returned HTTP 200 in 13.899, 20.001, and 13.689 seconds. Their first content pages returned 112 changes each in 4.051, 5.215, and 5.049 seconds. All six requests stayed below the existing 30-second deadline. These tests used source `4b090d0a65daa4806ba97a5af15e97a00c716f8d5471503cd01602b6e7596711`; they prove bounded initial reads, not full backlog publication.
63
+
64
+ Canonical schedule `6a9ffde3b012ba1d5b8f2bea` is active on the unchanged six-hour schedule and $10 inference/2,700-second bounds. Catch-up Job `6a9ffef6b012ba1d5b8f2c08` started at 12:26:40 UTC. Our Models cloud recovery Job `6a9fff9eb012ba1d5b8f2c1b` then completed in 82.331 seconds. A separate-directory restore recovered its two committed source pages, 112 pending source records, and all 1,117,337 cache entries without a provider call. The second physical recovery Job, `6aa000b9b012ba1d5b8f2c3e`, failed after two `503` responses and a `429 consumer_busy`; the first Job's saved pages remain intact.
65
+
66
+ The live Space logs explain this failure: new mixed enrichment segments started full `configuration-refresh` scans at 12:34 UTC. Those scans held the same lock used by consumer reads. They continued while xTap made valid progress: its checkpoint reached sequence 41 and queue completion rose from 306,936 to 307,164. The reader must inspect only new segments for actual configuration writes, rather than treating every mixed segment as a configuration change, before retrying the Our Models recovery check.
67
+
68
+ ## Limit configuration refresh to real writes
69
+
70
+ The configuration-change check now loads only newly verified config or mixed segments and examines their operations. Append-only enrichment, attempt, and registry batches do not start a full configuration refresh. Real configuration writes still take the existing validated reload path. Missing or corrupt new segments fail the check instead of being treated as unchanged.
71
+
72
+ At 12:46 UTC, the raw log had 138 new mixed segments since 12:30. The first three and last three were checksum-verified and inspected; all six contained append operations only. This matches the failure in the live refresh logs. Regression tests cover append-only mixed batches, genuine dedicated and mixed configuration writes, late-arriving files, unchanged old files, irrelevant tweet files, and failed segment verification.
73
+
74
+ ## Final recovery and resumed schedules
75
+
76
+ The configuration-tail fix passed 776 tests, 88.15% configured coverage, zero DRY candidates, review with no findings, and CI. Source `cfd46b156aeec2c30225e63f9873c295fb247731` is deployed as Space revision `2b422fa8b081389ab28f930053b3257e8564eee2`.
77
+
78
+ The earlier catch-up completed all 1,194 pending entries in 202 provider calls, at a receipt cost of $2.0000476. All 308,130 queue positions and 29,629 registry positions were complete. Public index `8ec8e961756db809db65897a1129a7af00e7442ba626c8c699a47b25837ddc6e` was published, and checkpoint sequence 237 retained the outputs. The physical Job exceeded its configured 2,700-second timeout and was canceled. Hugging Face omitted its terminal timestamp; the observed cancellation bounds CPU cost conservatively at $0.025. This is not an invoice. The unused inference reservation was released.
79
+
80
+ Canonical cloud handoff Job `6aa00b2c32d5d0c22c5ad683` passed. Its result was reused after checking the current activation, immutable plan, and exact checkpoint pointer hashes; replaying all checkpoint history again locally was unnecessary. The new image's read-only restore Job `6aa00e0b32d5d0c22c5ad6c9` recovered sequence 237, all completed work, and 232 claimed segments with zero orphan segments and zero provider calls.
81
+
82
+ Three full-selection metadata requests then passed in 17.192, 17.950, and 14.115 seconds. Their content pages took 10.278, 4.472, and 11.999 seconds. All stayed below 30 seconds. The verified source was complete through September 8 at 10:14:55 UTC. This source boundary is distinct from the live Our Models publication boundary.
83
+
84
+ Our Models recovery Job `6aa00f6632d5d0c22c5ad716` completed in 78.315 seconds. A separate-directory restore proved progression from source sequence 2 to 4 and from 112 to 302 source records, preserving the first page and all 1,117,337 cache entries with zero provider calls. The failed intermediate attempt remains recorded.
85
+
86
+ Both schedules are active again. xTap schedule `6aa00dfa900620b5c77e22ed` started continuation Job `6aa00f2432d5d0c22c5ad70d`; Our Models schedule `6a9fcc6ae686246ca69aa578` started catch-up Job `6aa01065900620b5c77e2385`. The latter retains the approved $180 cumulative inference limit under the $200 task approval. Counted settled cost before these active attempts is $3.7983566, including provider receipts and conservative CPU estimates. Their active reservations are not money spent. Our Models backlog completion has not yet been established.
87
+
88
+ The first continuation attempt, `6aa00f2432d5d0c22c5ad70d`, ended with `fatal: terminated` after 547.208 seconds. The active generation and checkpoint 237 were unchanged, with all work still complete and no successor activated. No AI work was repeated. Its $0.005 conservative CPU estimate was settled and its unused inference reservation released. The same validated schedule started retry `6aa0124d900620b5c77e23da`. Settled counted cost is now $3.8033566, excluding the active attempts. Our Models catch-up remains active and its observed source requests return HTTP 200 on their first attempt.
89
+
90
+ ## Saved continuation and browser delivery
91
+
92
+ Job `6aa0124d900620b5c77e23da` reached its physical timeout at September 8 14:38:53 UTC. It had processed 84 additional entries through 14 provider calls for $0.1255376. Its conservative CPU estimate is $0.025 for 2,984.928 seconds. The unused inference and CPU reservations were released. Counted settled cost is $3.9538942, excluding the active Our Models receipt.
93
+
94
+ The Job advanced to generation 89 and saved the initial checkpoint for `xtap-27c86045f33978c91c9279a0c193b425`. Outer and inner checkpoint hashes verified. All 308,214 queue entries were already complete; 11 registry entries and publication remained. The new run had no output segments or initial progress record. Worker code must publish that record before provider calls, so the preceding Job's final receipt accounts for its inference work. Resume Job `6aa02f9932d5d0c22c5adc0a` uses the existing schedule and checkpoint, with a $10 inference maximum and $0.03 CPU reservation. Those limits are not money spent.
95
+
96
+ Chrome's registered service worker now reports xTap 0.26.0. After an unsuccessful logged-out test, the operator restored the login and a normal scraper retry recorded 242 observations across 237 posts, including 138 observations of previously known posts. This proves browser collection, not yet delivery of those particular observations through the history API to Our Models.
97
+
98
+ ## Changed-source request deadline
99
+
100
+ A read-only production request from the Our Models saved idle cursor failed after the client deadline. A second diagnostic request, limited to one change, returned `503 deadline_exceeded` after 31.323 seconds. The Space health check remained successful. The initial full download had completed; this failure concerns the subsequent change-read path.
101
+
102
+ Source pinning computes coverage before page construction. When taxonomy and approvals were unchanged, the first request also read changed bodies under that same 30-second deadline. The fix returns a privacy-checked boundary-only page after pinning a changed source, using the existing `metadata_sent` cursor marker. Unchanged metadata is not resent. The next request reads bodies against the saved source. An unchanged source still completes in one empty response. No response schema, deadline, compatibility reader, or new state store is added.
103
+
104
+ Coverage candidate selection now collects distinct changed post IDs before checking selected unit membership. On the existing local September 8 database, a 1,000-segment window returned the same first 100 post IDs in all three comparisons. Baseline times were 2,778, 2,788, and 2,778 ms; candidate times were 980, 981, and 981 ms. The absolute reduction was 1,798–1,807 ms. These are local ARM64 query measurements, not cloud endpoint latency or proof of production recovery. Live change reads must still complete within the existing deadline after deployment.
105
+
106
+ Regression tests cover boundary-only responses, restart and fixed-source behavior, unchanged-source completion, privacy changes between pages, and deduplicated candidate selection through the existing indexes.
107
+
108
+ ## September 9 verification
109
+
110
+ At 02:17 UTC, the Space health check passed with storage ready. A change request from the actual Our Models saved cursor still returned `503 deadline_exceeded` after 31.568 seconds, before returning any page. PR #54 is therefore insufficient to establish bounded live change reads. Do not treat its deployment or recovery checks as end-to-end success. Further diagnosis must measure source preparation and coverage separately on the current source before another repair is accepted.
111
+
112
+ Both earlier catch-up Jobs completed: xTap `6aa03fdb900620b5c77e2ee1` at September 8 17:46 UTC, and Our Models `6aa01065900620b5c77e2385` at 18:38 UTC. Our Models continued automatically under the same logical run. Checkpoint 8949, written September 9 at 02:16 UTC, reports 4,582 calls and $19.5950914 in cumulative inference cost, with one $0.051519 call reservation. Story output remains unset. Physical Job completion did not mean backlog publication.
113
+
114
+ The live Our Models digest still reports artifact `4e0c744c880378c27391eb2d2bda65c32b9338e7e1a6454beb3439dc5930db9f`, generated September 8 at 06:46 UTC and complete through September 7 at 23:05:38.154 UTC. No current pending count or defensible completion ETA was established. No new paid Job or source reset was launched during this verification.
115
+
116
+ ## September 9 request repair
117
+
118
+ Two read-only cpu-basic Jobs checked verified published database `2cae942abe77a575584fbdb709093324269b878c1bbca6a96e70e9b5e6b6444c`. Direct coverage took 8.611 seconds; the actual forked reader took 9.022 seconds. Saved-context loading took about 1.65 seconds. A separate local context-pin check took 6.555 seconds, including a 4.369-second metadata write and its read-back. A live history request completed, but full-selection and one-author bootstrap requests still hit the 30-second deadline. These checks do not identify a single proven cause on the live Space.
119
+
120
+ Deadline errors now name the current request stage, without adding a metrics API or exposing request data, so a remaining production failure identifies the operation rather than inviting another speculative query rewrite. An attempted unchanged-context reuse was rejected because it prevented the rolling history window from advancing. Existing context and history behavior remains unchanged.
121
+
122
+ Selection predicates now check author membership before public-content eligibility. Three local comparisons returned identical clocks and took 7.490, 7.421, and 7.488 seconds before the change, versus 6.546, 6.577, and 6.687 seconds after it. The absolute reduction was 0.801–0.944 seconds on 307,594 current units. A separate indexed-candidate approach needed 115,472 IDs but gave only a small additional benefit; it was dropped in favor of the simpler predicate order. A 64 MiB SQLite cache was also slower in all three comparisons and was not adopted. None of these local measurements establishes live request success; the saved-cursor production check remains mandatory.
123
+
124
+ ## Changed-source deployment
125
+
126
+ PR [#54](https://github.com/osolmaz/xtap-pool/pull/54) passed `npm run check`, SimpleDoc, Pi Reviewer with no findings, and CI run `34251140734`. It merged as `853d3da38cef549f506a65453e452597bb36e763`. The existing Space received that source as `92c4e167b482e979a21ad30aba4938625301e664` at 16:57 UTC. Live change-read verification remains pending while the Space restores its index; the first probe received a database-not-restored response, not a change-read deadline result.
127
+
128
+ The preceding Job `6aa02f9932d5d0c22c5adc0a` completed generation 89 and published verified index `31760d111d4790d90a3390c71d44467bda469b5efa630a1fa2dc6554525c037a`. It then processed 378 entries in generation 90 through 64 provider calls. Its final receipts report $0.698001 in inference; estimated CPU cost is $0.021. Unused reservations were released. Counted settled cost reached $4.6728952, excluding the active Our Models run.
129
+
130
+ Read-only handoff Job `6aa03b0b32d5d0c22c5adde5` and new-image restore Job `6aa03ee732d5d0c22c5ade3f` both completed. Each added an estimated $0.0015 in CPU cost and zero provider calls. The restore verified checkpoint 73, 308,510 of 308,861 completed queue entries, all 29,717 registry entries, 72 claimed output segments, and zero orphan segments. It preserved run `xtap-8ad4b8908b14c51d353a14f08b4a9c8d`; no plan was reset.
131
+
132
+ Canonical schedule `6aa03eb132d5d0c22c5ade37` is active after maintenance. Catch-up Job `6aa03fdb900620b5c77e2ee1` resumed that saved work with the unchanged $10 inference cap and 2,700-second physical limit. Settled cost before that Job is $4.6758952. Its maximum exposure is $10.0225, separate from the active Our Models cumulative $180 inference cap. All remain within the approved $200 task limit. Our Models Job `6aa01065900620b5c77e2385` remains active; these xTap results do not establish that its backlog is published.
133
+
134
+ ## September 9 live recovery
135
+
136
+ PR [#55](https://github.com/osolmaz/xtap-pool/pull/55) passed the full check, final review with no findings, and CI run `34309536404`. Source `8ea1007bc4f562addb9a8c176d123aa8af58160a` was deployed as Space revision `158529d2eabfa6a746e9801bded6334646f00e22`. The change reports the stage of a request deadline and preserves selection semantics while reducing SQL work. It does not raise the deadline or change history-window behavior.
137
+
138
+ Handoff Job `6aa0dcd4900620b5c77e516c` and new-image restore Job `6aa0de5332d5d0c22c5af3d0` passed without provider calls. The restore retained generation 94, checkpoint 131, 309,369 of 309,749 queue entries, all 29,788 registry entries, and 130 claimed segments with zero orphans. Canonical schedule `6aa0de4632d5d0c22c5af3ce` is active again. No extra manual enrichment Job was launched during this deployment.
139
+
140
+ After the Space restored its index, the actual saved Our Models cursor returned three successful change pages in 17.414, 12.411, and 4.979 seconds. Another 60 consecutive pages passed in 2.709–11.076 seconds each, returning 29 changed entries and 1,110 observations. The response source was complete through September 8 at 22:06:11.884 UTC. More pages remained at the bounded test limit; this does not establish that the whole backlog was read or published. These results establish live success after deployment, not that predicate ordering alone caused the recovery.
141
+
142
+ The responses contain repeat observations for 271 posts. For example, post `2097336073663684798` had 16 views and no replies at September 8 15:42:47.316 UTC, then 29 views and one reply at 22:33:35.137 UTC. This verifies delivery of real counter history, rather than only a history endpoint returning an empty response. The read-only checks made no provider calls and changed no remote source or consumer state.
143
+
144
+ The restore Job used 169.656 seconds. Its conservative CPU estimate is $0.0015; the unused $0.021 hold was released. The earlier timed-out Job's unresolved provider exposure remains counted in the task budget. Our Models publication is a separate gate and is not claimed complete here.
extension/.coveragerc ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [run]
2
+ include = native-host/xtap_core.py
3
+
4
+ [report]
5
+ show_missing = true
extension/.github/workflows/release.yml ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ release:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Build extension zip
18
+ run: |
19
+ VERSION="${GITHUB_REF_NAME#v}"
20
+ zip -r "xtap-pool-${VERSION}.zip" \
21
+ manifest.json \
22
+ background.js \
23
+ popup.html popup.js popup.css \
24
+ options.html options.js \
25
+ pool-connect.js \
26
+ reload.html reload.js \
27
+ cutover.html cutover.js \
28
+ debug.html debug.js debug.css \
29
+ icons/ \
30
+ lib/ \
31
+ native-host/xtap_core.py \
32
+ native-host/xtap_host.py \
33
+ native-host/xtap_daemon.py \
34
+ native-host/com.xtap.host.json \
35
+ native-host/com.xtap.daemon.plist \
36
+ native-host/com.xtap.daemon.service \
37
+ native-host/install.sh \
38
+ native-host/install.ps1 \
39
+ native-host/xtap_host.bat \
40
+ native-host/xtap_daemon.bat
41
+
42
+ - name: Publish release
43
+ env:
44
+ GH_TOKEN: ${{ github.token }}
45
+ run: |
46
+ VERSION="${GITHUB_REF_NAME#v}"
47
+ ASSET="xtap-pool-${VERSION}.zip#xTap Pool ${VERSION} (extension + native host)"
48
+ # Create the release if it doesn't exist yet (auto-generated notes
49
+ # from commits since the previous tag), otherwise just upload the
50
+ # zip into the release that was pre-created with hand-written notes.
51
+ if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
52
+ gh release upload "$GITHUB_REF_NAME" "$ASSET" --clobber
53
+ else
54
+ gh release create "$GITHUB_REF_NAME" \
55
+ --title "xTap Pool ${VERSION}" \
56
+ --generate-notes \
57
+ "$ASSET"
58
+ fi
extension/.github/workflows/test.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: '3.12'
18
+
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: '22'
22
+
23
+ - name: Install Python test dependencies
24
+ run: pip install pytest pytest-cov
25
+
26
+ - name: Run Python tests
27
+ run: python3 -m pytest tests/ -v --cov --cov-report=xml:coverage-python.xml
28
+
29
+ - name: Run JS tests with coverage
30
+ run: npx c8 --reporter=lcov --report-dir=coverage-js node --test tests/*.test.mjs
31
+
32
+ - name: Upload coverage to Codecov
33
+ uses: codecov/codecov-action@v5
34
+ with:
35
+ files: coverage-python.xml,coverage-js/lcov.info
extension/.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ .claude/
3
+ CLAUDE.md
4
+ *.crx
5
+ *.pem
6
+ tweets*.jsonl
7
+ node_modules/
8
+ __pycache__
9
+ .coverage
10
+ coverage/
11
+ coverage-*/
12
+ .pytest_cache/
13
+ tests/fixtures/private-raw/*
14
+ !tests/fixtures/private-raw/CAPTURE.md
15
+ tests/e2e/.extension-out/
16
+ tests/e2e/certs/
17
+ !tests/e2e/test-extension.pem
18
+ native-host/*.generated.json
extension/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xTap contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
extension/README.md ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <img src="icons/icon128.png" alt="xTap logo" width="96" />
3
+ </p>
4
+
5
+ <h1 align="center">xTap</h1>
6
+
7
+ <p align="center">
8
+ <strong>Passively capture tweets as you browse X/Twitter</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="#installation">Installation</a> &middot;
13
+ <a href="#how-it-works">How It Works</a> &middot;
14
+ <a href="#is-this-safe-to-use">Stealth</a> &middot;
15
+ <a href="#output-format">Output Format</a> &middot;
16
+ <a href="#configuration">Configuration</a> &middot;
17
+ <a href="LICENSE">License</a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue" alt="Platform" />
22
+ <img src="https://img.shields.io/badge/browser-Chrome-green" alt="Chrome" />
23
+ <img src="https://img.shields.io/badge/license-MIT-yellow" alt="MIT License" />
24
+ <a href="https://codecov.io/gh/mkubicek/xTap"><img src="https://codecov.io/gh/mkubicek/xTap/graph/badge.svg" alt="codecov" /></a>
25
+ </p>
26
+
27
+ ---
28
+
29
+ xTap is a Chrome extension that passively reads the GraphQL responses X/Twitter already sends to your browser and saves captured tweets as structured JSONL. It does not patch X's page code or make extra X requests for ordinary capture.
30
+
31
+ ## Features
32
+
33
+ - **Zero footprint** — no additional network requests; captures what your browser already receives
34
+ - **Structured output** — each tweet saved as a clean JSON object with author, metrics, media, and more
35
+ - **Article support** — long-form X articles are captured when X returns the full body, with inline image references and Draft.js block structure
36
+ - **Video download** — download videos from tweets using yt-dlp (or direct MP4 fallback) via the extension popup. Requires the HTTP daemon. **Note:** unlike passive capture, video downloads make additional network requests to X and are not stealth.
37
+ - **Image download** — opt-in toggle in the popup ("Download images automatically") fetches photos from `pbs.twimg.com` to `<output_dir>/media/<tweet_id>/<filename>` as you browse. If enabled after browsing, already-seen photo tweets are rechecked for media download without bumping the captured-tweet counter. Daemon-side; rate-limited; logs to `media-manifest.jsonl`. **Note:** also not stealth — adds requests to the Twitter CDN.
38
+ - **Pause / resume** — click the extension icon to toggle capture on the fly
39
+ - **Live counter** — badge on the extension icon shows tweets captured this session
40
+ - **Multi-tab aware** — multiple X tabs feed into the same service worker with shared deduplication
41
+ - **Debug logging** — optional toggle to write timestamped service worker logs to a date-rotated file
42
+ - **Debug dashboard** — internal extension page with live capture events, transport health, and a parser sandbox for testing GraphQL response parsing
43
+ - **Cross-platform** — works on macOS, Linux, and Windows
44
+
45
+ ## Scrape job receipts
46
+
47
+ xTap can provide capture receipts to the allowlisted [Infinite Feed Scroller](https://github.com/osolmaz/infinite-feed-scroller) extension. The scroller binds a run to a blank source tab before navigating that tab to X. xTap stores each post ID, post time, first-seen time, source endpoint, and ordered run observation in browser IndexedDB.
48
+
49
+ Live-list and search timelines use the same observation format. Up to two runs can be active. Each GraphQL response is routed only to the run bound to its Chrome tab, and finishing one run leaves the other running. The local index supports replay after an extension restart and records whether each list post was known before the run. The external port never exposes tweet text or the pool token.
50
+
51
+ ## How It Works
52
+
53
+ ```
54
+ X/Twitter GraphQL responses
55
+ │
56
+ ▼
57
+ ┌────────────────────────────┐
58
+ │ Chrome Debugger Network API│ Browser-owned
59
+ │ reads completed responses │
60
+ └──────────────┬─────────────┘
61
+ │
62
+ ▼
63
+ ┌────────────────────────────┐
64
+ │ background.js │ Service worker
65
+ │ parse, receipt, dedup, batch│
66
+ └──────────┬─────────┬───────┘
67
+ │ │
68
+ HTTP │ │ native messaging
69
+ (all data) │ │ (token bootstrap only)
70
+ │ │
71
+ ▼ ▼
72
+ ┌──────────────┐ ┌──────────────┐
73
+ │ xtap_daemon │ │ xtap_host.py │
74
+ │ (HTTP) │ │ (stdio) │
75
+ └──────┬───────┘ └──────────────┘
76
+ │
77
+ ▼
78
+ tweets-YYYY-MM-DD.jsonl
79
+ ```
80
+
81
+ 1. The service worker attaches Chrome's Debugger Network domain to open X tabs.
82
+ 2. It reads completed GraphQL response bodies without changing page JavaScript or the DOM.
83
+ 3. The existing parser normalizes tweets, records tab-bound scrape receipts, deduplicates normal capture, and builds upload batches.
84
+ 4. The HTTP daemon remains the primary disk transport. Native messaging bootstraps its token and provides the existing fallback.
85
+
86
+ ## Is This Safe to Use?
87
+
88
+ xTap does not post, like, follow, scroll, or request extra X data. It records responses that Chrome received because of browsing in the attached tab.
89
+
90
+ X can still enforce its rules based on account activity, browsing patterns, or automated scrolling performed by another tool. Review those rules and use a suitable account before running long scrapes.
91
+
92
+ ### Browser behavior
93
+
94
+ xTap reads responses that Chrome already received and does not send extra X requests for capture. The extension no longer replaces page fetch or XHR functions and leaves the page DOM unchanged.
95
+
96
+ - **No extra X requests** — debugger capture only reads responses the browser already received
97
+ - **No page hooks** — xTap does not patch page-owned `fetch` or XHR APIs, inject page code, or add DOM signaling
98
+ - **Extension-owned capture** — parsing, receipts, deduplication, staging, and transport stay in extension contexts
99
+
100
+ Chrome shows its standard debugger notice while xTap is attached to an X tab. Opening DevTools or another debugger can detach xTap from that tab. Reload or revisit the tab to attach again.
101
+
102
+ Capture still has account and platform risk. xTap can observe automated scrolling performed by another tool, and X can enforce its rules based on that browsing behavior.
103
+
104
+ ## Installation
105
+
106
+ ### Requirements
107
+
108
+ | | Requirement |
109
+ |---|---|
110
+ | **Browser** | Google Chrome |
111
+ | **Runtime** | Python 3 |
112
+ | **OS** | macOS, Linux, or Windows |
113
+ | [`yt-dlp`](https://github.com/yt-dlp/yt-dlp#installation) (optional) | For best-quality video downloads |
114
+
115
+ ### 1. Load the extension
116
+
117
+ **Chrome:**
118
+ 1. Open `chrome://extensions`
119
+ 2. Enable **Developer mode** (top right)
120
+ 3. Click **Load unpacked** and select the `xtap/` directory
121
+ 4. Copy the extension ID shown on the card (used by native host install)
122
+
123
+ ### 2. Install the native host
124
+
125
+ <details>
126
+ <summary><strong>macOS</strong></summary>
127
+
128
+ ```bash
129
+ cd native-host
130
+ ./install.sh <your-extension-id> chrome
131
+ ```
132
+
133
+ This installs the native messaging host (for auth token bootstrap) and an HTTP daemon (`xtap_daemon.py`) that runs via launchd. The daemon runs independently of the browser process tree and has its own TCC permissions, so it can write to protected paths like `~/Documents` and iCloud Drive. The installer captures your current `PATH` so the daemon can find tools like `yt-dlp`.
134
+
135
+ The extension automatically detects the daemon via the native host's auth token. If the daemon is not running, the extension will show a red "!" badge and an error in the popup.
136
+
137
+ </details>
138
+
139
+ <details>
140
+ <summary><strong>Linux</strong></summary>
141
+
142
+ ```bash
143
+ cd native-host
144
+ ./install.sh <your-extension-id> chrome
145
+ ```
146
+
147
+ This installs the native messaging host and an HTTP daemon (`xtap_daemon.py`) that runs as a systemd user service. The daemon enables video downloads and provides the same HTTP transport as macOS.
148
+
149
+ </details>
150
+
151
+ <details>
152
+ <summary><strong>Windows (PowerShell)</strong></summary>
153
+
154
+ ```powershell
155
+ cd native-host
156
+ .\install.ps1 -ExtensionId <your-extension-id> -Browser chrome
157
+ ```
158
+
159
+ This installs the native messaging host and an HTTP daemon (`xtap_daemon.py`) as a Windows Scheduled Task that starts at logon. The daemon enables video downloads and provides the same HTTP transport as macOS/Linux.
160
+
161
+ </details>
162
+
163
+ ### 3. Browse X
164
+
165
+ Open [x.com](https://x.com) and browse normally. The badge counter on the extension icon shows how many tweets have been captured this session. Click the icon to see stats and pause/resume capture.
166
+
167
+ > **After updating the extension:** Reload xTap at `chrome://extensions`, then reload any open X tabs. The extension must reattach its passive debugger capture to those tabs.
168
+
169
+ ### Upgrading from a previous version
170
+
171
+ After updating the extension files:
172
+ 1. Re-run the installer (`install.sh` on macOS/Linux, `install.ps1` on Windows) — this updates the daemon configuration and picks up new Python code
173
+ 2. Reload the extension in your browser extension manager
174
+ 3. Hard-reload any open X tabs (`Cmd+Shift+R` / `Ctrl+Shift+R`)
175
+
176
+ **From versions before v0.20.0 on macOS/Linux:** Re-running `install.sh` is **required** — the native messaging manifest now points to a wrapper script (`~/.xtap/xtap_host_wrapper.sh`) that uses an absolute Python path, fixing native host launch failures on macOS where Chrome's minimal environment couldn't find `python3`.
177
+
178
+ **From versions before v0.19.0:** The native messaging host (`xtap_host.py`) no longer handles tweet writing — all data now flows through the HTTP daemon exclusively. Re-running `install.sh` is required to update the daemon's service configuration (adds `XTAP_LOG_LEVEL` support). The extension will show a red "!" badge if the daemon is not running, instead of silently falling back to native messaging.
179
+
180
+ **From versions before v0.13.0 on macOS:** Re-running `install.sh` is **required** for video download support — the daemon needs an updated launchd configuration to find yt-dlp on your PATH.
181
+
182
+ ### Troubleshooting
183
+
184
+ If the extension shows "Not connected" or a red "!" badge:
185
+
186
+ 1. **Check if the daemon is running:**
187
+ ```bash
188
+ curl http://127.0.0.1:17381/status
189
+ # Should return: {"ok": true, "version": "..."}
190
+ ```
191
+
192
+ 2. **Check daemon logs:**
193
+ ```bash
194
+ cat ~/.xtap/daemon-stderr.log
195
+ ```
196
+ The daemon logs startup diagnostics (Python version, output directory, token status) on every start. Common issues:
197
+ - `FATAL: ~/.xtap/secret not found` — run `install.sh` first
198
+ - `FATAL: Cannot bind to 127.0.0.1:17381` — another instance is already running
199
+ - Import errors — check Python version (`python3 --version`, requires 3.x)
200
+
201
+ 3. **Check native host errors** (token bootstrap failures):
202
+ ```bash
203
+ cat ~/.xtap/host-error.log
204
+ ```
205
+ This file is created when the native messaging host crashes. It includes the Python version, script path, and full traceback.
206
+
207
+ 4. **Enable debug logging** for detailed request-level daemon logs:
208
+ ```bash
209
+ export XTAP_LOG_LEVEL=debug
210
+ cd native-host && ./install.sh <extension-id> chrome
211
+ ```
212
+ Then check `~/.xtap/daemon-stderr.log` for per-request details (method, path, duration, tweet counts).
213
+
214
+ 5. **Verify the native messaging manifest** points to the correct path:
215
+ ```bash
216
+ # Chrome (macOS):
217
+ cat ~/Library/Application\ Support/Google/Chrome/NativeMessagingHosts/com.xtap.host.json
218
+ ```
219
+ The `path` field should point to `~/.xtap/xtap_host_wrapper.sh` (macOS/Linux) or `xtap_host.bat` (Windows). The wrapper uses an absolute Python path so native messaging works even in Chrome's minimal environment. If it still points directly at `xtap_host.py`, re-run `install.sh`.
220
+
221
+ ## Configuration
222
+
223
+ ### Output directory
224
+
225
+ The easiest way to change where tweets are saved is through the extension popup — click the xTap icon and enter your preferred path in the **Output directory** field.
226
+
227
+ Alternatively, set the `XTAP_OUTPUT_DIR` environment variable and re-run the installer — the daemon runs as a system service (launchd/systemd/Scheduled Task), so the variable must be baked into the service definition; exporting it in a shell or before launching the browser has no effect:
228
+
229
+ ```bash
230
+ export XTAP_OUTPUT_DIR="$HOME/Documents/xtap-data"
231
+ ./native-host/install.sh <extension-id> [chrome|firefox]
232
+ ```
233
+
234
+ On Windows, set it as a *user* environment variable (`[Environment]::SetEnvironmentVariable('XTAP_OUTPUT_DIR', 'D:\\path', 'User')`) and re-run `install.ps1`.
235
+
236
+ | Setting | Default | Description |
237
+ |---|---|---|
238
+ | Popup "Output directory" | *(empty — uses default)* | Overrides the output path per-session |
239
+ | `XTAP_OUTPUT_DIR` env var | `~/Downloads/xtap` | Fallback when no popup setting is configured (set at install time) |
240
+ | Debug Dashboard | — | Accessible via popup link; shows live capture events, transport health, debug logging and discovery mode toggles, and parser sandbox |
241
+
242
+ > **macOS note:** On macOS, the HTTP daemon (installed via `install.sh`) runs outside browser TCC sandboxes and can write to protected paths like `~/Documents` and iCloud Drive after a one-time macOS permission prompt.
243
+
244
+ ### Download tuning
245
+
246
+ These daemon environment variables are optional. Re-run the installer after changing them so the service definition picks them up.
247
+
248
+ | Setting | Default | Description |
249
+ |---|---:|---|
250
+ | `XTAP_IMAGE_DELAY_MS` | `100` | Delay between background image requests |
251
+ | `XTAP_MAX_FILE_MB` | `50` | Max size for one downloaded image |
252
+ | `XTAP_MAX_MEDIA_MB` | *(unlimited)* | Max cumulative image bytes per daemon process |
253
+ | `XTAP_MAX_VIDEO_MB` | `500` | Max size for one direct MP4 fallback video download; set `0` or less to disable the cap |
254
+ | `XTAP_CONN_TIMEOUT_S` | `15` | Daemon per-connection socket timeout — drops idle/stalled connections; bad or `<=0` values fall back to the default (the timeout is never disabled) |
255
+ | `XTAP_SHUTDOWN_GRACE_S` | `10` | Max seconds the daemon waits for in-flight requests at shutdown before force-exiting; bad or `<=0` values fall back to the default |
256
+
257
+ ## Output Format
258
+
259
+ Output is written to daily files (`tweets-YYYY-MM-DD.jsonl`). Each line is a self-contained JSON object:
260
+
261
+ ```jsonc
262
+ {
263
+ "id": "1234567890",
264
+ "url": "https://x.com/handle/status/1234567890",
265
+ "created_at": "2024-01-01T00:00:00.000Z",
266
+ "author": {
267
+ "id": "987654321",
268
+ "username": "handle",
269
+ "display_name": "Display Name",
270
+ "verified": false,
271
+ "is_blue_verified": true,
272
+ "follower_count": 1234
273
+ },
274
+ "text": "Full tweet text...",
275
+ "lang": "en",
276
+ "metrics": {
277
+ "likes": 10,
278
+ "retweets": 5,
279
+ "replies": 2,
280
+ "views": 1000,
281
+ "bookmarks": 1,
282
+ "quotes": 0
283
+ },
284
+ "media": [],
285
+ "urls": [],
286
+ "hashtags": [],
287
+ "mentions": [],
288
+ "in_reply_to": null,
289
+ "quoted_tweet_id": null,
290
+ "conversation_id": "1234567890",
291
+ "is_retweet": false,
292
+ "retweeted_tweet_id": null,
293
+ "is_subscriber_only": false, // true for subscriber-only tweets
294
+ "is_article": true, // present only for long-form articles
295
+ "article": { // present only for long-form articles
296
+ "title": "Article Title",
297
+ "text": "Rendered plain text with ![img](media/<id>/file.png) refs",
298
+ "blocks": [], // raw Draft.js content_state blocks
299
+ "media": [{ // article image references
300
+ "id": "...",
301
+ "url": "https://pbs.twimg.com/...", // original CDN URL
302
+ "filename": "image.png",
303
+ "local_path": "media/<tweet_id>/image.png",
304
+ "width": 1200,
305
+ "height": 800
306
+ }]
307
+ },
308
+ "source_endpoint": "HomeTimeline", // which GraphQL endpoint
309
+ "captured_at": "2024-01-01T00:00:00.000Z"
310
+ }
311
+ ```
312
+
313
+ For regular tweets, `is_article` and `article` are absent. For articles, `text` contains a markdown-style rendering of the article with inline image references pointing to `media/<tweet_id>/`. Article stubs without `content_state.blocks` are skipped rather than saved as incomplete rows.
314
+
315
+ ### Media file convention
316
+
317
+ When the "Download images automatically" toggle is on, the daemon writes photos to:
318
+
319
+ ```
320
+ <output_dir>/media/<tweet_id>/<basename(media.url)>
321
+ ```
322
+
323
+ Top-level photo `media[]` entries do **not** carry a `local_path` field — the path is derived by convention so consumers can reconstruct it directly from `tweet.id` + the URL basename. Article media (`tweet.article.media[]`) does include `local_path` because that path is also embedded in the rendered article markdown (`![](media/<id>/file.png)`) so the article body works as a self-contained document. Download status (success / 404 / quota / oversize / blocked) is appended to `<output_dir>/media-manifest.jsonl`.
324
+
325
+ ## Project Structure
326
+
327
+ ```
328
+ xTap/
329
+ ├── manifest.json # Chrome MV3 extension manifest
330
+ ├── background.js # Service worker: capture, parsing, receipts, staging, transport
331
+ ├── lib/graphql-capture.js # Passive Chrome Debugger Network capture
332
+ ├── lib/dedup.js # Capture and image-backfill deduplication
333
+ ├── lib/tweet-parser.js # GraphQL response parser
334
+ ├── popup.html/js/css # Extension popup UI
335
+ ├── options.html/js # Pool connection settings
336
+ ├── pool-connect.js # Private pool token handoff
337
+ ├── reload.html/js # Safe extension reload page
338
+ ├── cutover.html/js # Coordinated active-run cutover page
339
+ ├── debug.html/js/css # Debug dashboard (live events, transport health, parser sandbox)
340
+ ├── icons/ # Extension icons
341
+ └── native-host/
342
+ ├── xtap_core.py # Shared file I/O logic
343
+ ├── xtap_host.py # Native messaging host — token bootstrap only (Python, stdio)
344
+ ├── xtap_daemon.py # HTTP daemon
345
+ ├── com.xtap.daemon.plist # launchd plist template (macOS)
346
+ ├── com.xtap.daemon.service # systemd unit template (Linux)
347
+ ├── com.xtap.host.json # Native host manifest template (Chrome)
348
+ ├── install.sh # Installer for macOS / Linux
349
+ ├── install.ps1 # Installer for Windows
350
+ ├── xtap_host.bat # Windows native host wrapper
351
+ └── xtap_daemon.bat # Windows daemon wrapper
352
+ ```
353
+
354
+ ## Development
355
+
356
+ After modifying extension files (`background.js`, `lib/`, or `popup.*`), reload the extension at `chrome://extensions` and hard-reload any open X tabs. Chrome asks for the Debugger permission and shows its standard notice while an X tab is attached.
357
+
358
+ **Debug dashboard:** Click "Debug Dashboard" in the popup to open a live view of capture events, transport health, and a parser sandbox for testing `extractTweets` against raw GraphQL JSON. Debug logging and discovery mode toggles are also here — enable debug logging to write timestamped service worker logs to `debug-YYYY-MM-DD.log`, or discovery mode to log endpoint response shapes to the console.
359
+
360
+ **Dev mode:** When loaded unpacked (developer mode), the extension prefers `chrome.storage.session` for the `seenIds` dedup cache, and falls back to `chrome.storage.local` if session storage APIs are unavailable. When session storage is available, reloading the extension automatically clears the cache — no need to manually clear storage between test runs.
361
+
362
+ After modifying Python host files (`xtap_core.py`, `xtap_host.py`, `xtap_daemon.py`), the native host picks up changes on next browser restart. To restart the HTTP daemon immediately:
363
+
364
+ **macOS (launchd):**
365
+ ```bash
366
+ launchctl kickstart -k gui/$(id -u)/com.xtap.daemon # restart
367
+ launchctl bootout gui/$(id -u)/com.xtap.daemon # stop
368
+ launchctl print gui/$(id -u)/com.xtap.daemon # status
369
+ tail -f ~/.xtap/daemon-stderr.log # logs
370
+ ```
371
+
372
+ **Linux (systemd):**
373
+ ```bash
374
+ systemctl --user restart com.xtap.daemon # restart
375
+ systemctl --user stop com.xtap.daemon # stop
376
+ systemctl --user status com.xtap.daemon # status
377
+ journalctl --user -u com.xtap.daemon -f # logs
378
+ ```
379
+
380
+ **Windows (Scheduled Task, PowerShell):**
381
+ ```powershell
382
+ Stop-ScheduledTask -TaskName xTapDaemon; Start-ScheduledTask -TaskName xTapDaemon # restart
383
+ Stop-ScheduledTask -TaskName xTapDaemon # stop
384
+ Get-ScheduledTask -TaskName xTapDaemon # status
385
+ Get-Content ~\.xtap\daemon-stderr.log -Tail 50 -Wait # logs
386
+ ```
387
+
388
+ ## Testing
389
+
390
+ ```bash
391
+ python3 -m pytest tests/ -v
392
+ node --test tests/*.test.mjs
393
+ ```
394
+
395
+ CI runs these on every push to `main` with coverage uploaded to [Codecov](https://codecov.io/gh/mkubicek/xTap).
396
+
397
+ Parser fixture packs live under `tests/fixtures/`. Raw captures stay local in
398
+ `tests/fixtures/private-raw/` (gitignored), while committed anonymized packs
399
+ live in `tests/fixtures/sanitized/`. The anonymization methodology and review
400
+ checklist are documented in `tests/fixtures/FIXTURES.md`.
401
+
402
+ ## License
403
+
404
+ [MIT](LICENSE) — use it however you like.
extension/VENDORED.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vendored xTap
2
+
3
+ This directory vendors the xTap Chrome extension.
4
+
5
+ - Upstream: https://github.com/mkubicek/xTap
6
+ - Vendored at commit: `9eba39a3c972649f07df98c3874cac6de38b383f` (`v0.24.0`)
7
+ - Local extension version: `0.26.0`
8
+ - License: MIT (see `LICENSE`, unchanged)
9
+
10
+ Keep upstream code style (vanilla JS, MV3, no bundler) so future re-syncs
11
+ stay cheap diffs. Re-syncing is a deliberate manual step: diff upstream against
12
+ this directory, excluding the modifications below.
13
+
14
+ ## Local modifications
15
+
16
+ - `manifest.json` — renamed to `xtap-pool`; added `debugger` and
17
+ `unlimitedStorage` permissions, the `https://*.hf.space/*` host permission,
18
+ and the `pool-connect.js` content script.
19
+ - Firefox manifests, build scripts, page-interception tests, and browser E2E
20
+ harness are omitted. Firefox does not implement Chrome's extension debugger
21
+ API, and this fork does not fall back to page-owned fetch/XHR interception.
22
+ - `lib/pool-sync.js` — **new**: persistent observation queue and batched flush to
23
+ the pool Space's `/api/ingest` with backoff. Queue admission and sampling state
24
+ are saved together. Storage errors and queue overflow leave staged work pending.
25
+ A batch leaves the queue only after the server's `added`, `duplicates`, and
26
+ `rejected` counts account for every record. No extra success flag is required.
27
+ Explicitly rejected records are saved in a bounded local rejection archive, so one invalid
28
+ post does not block valid work. A full rejection archive blocks removal instead
29
+ of discarding records.
30
+ - `lib/observations.js` — **new**: stable local observation identities, exact
31
+ nullable counters, and five-minute sampling for unchanged content. Later visits
32
+ and immediate content or visibility edits are independent of unique-post export
33
+ deduplication.
34
+ - `pool-connect.js` — **new**: content script for the Space's `/connect` page;
35
+ hands the pool token to the service worker (no copy-paste).
36
+ - `background.js` — imports `lib/pool-sync.js`; new captures also feed the pool
37
+ queue before unique-post deduplication; new `POOL_*` message handlers; a
38
+ `chrome.alarms` periodic pool flush and staged-response retry;
39
+ `initPoolSync()` during startup. Staging uses local storage and preserves the
40
+ original observation time through a browser restart. Upstream's coupled
41
+ dedup/buffer persistence, image backfill, bounded splitting, startup flush,
42
+ and HTTP-only transport remain in place around the pool path.
43
+ - `popup.html` / `popup.js` — added the "Pool sync" section (status, connect,
44
+ sync-now, pause) and an Options link.
45
+ - `options.html` / `options.js` — **new**: configure the pool Space URL and
46
+ paste a token manually (fallback for the automatic handoff).
47
+ - `tests/pool-sync.test.mjs` — **new**: node --test coverage for the queue,
48
+ flush, backoff and connect flows.
49
+ - `lib/scrape-receipts.js` and `lib/scrape-bridge.js` — **new**: durable
50
+ IndexedDB receipts for X list observations plus a cursor-based external port
51
+ used by the allowlisted Infinite Feed Scroller extension.
52
+ - `reload.html` and `reload.js` — **new**: extension-owned deployment page that
53
+ reloads the unpacked extension through `chrome.runtime.reload()`. The explicit
54
+ `?fail-active=1` cutover option gates new runs, fails active receipt runs,
55
+ and reloads; the new service worker clears the gate before accepting clients.
56
+ - `cutover.html` and `cutover.js` — **new**: one-shot fresh-state page with a
57
+ distinct URL so a cached older reload page cannot bypass active-run cleanup.
58
+ - `tests/graphql-capture.test.mjs` — **new**: debugger attachment, response-body
59
+ capture, operation extraction, and failed-request coverage. Receipt startup
60
+ retries passive debugger attachment for bounded transient failures.
61
+ - `tests/scrape-receipts.test.mjs` — **new**: receipt persistence, per-list
62
+ coverage, search and live-list normalization, replay, typed errors, active-run,
63
+ and sender-allowlist coverage.
64
+ - `lib/graphql-capture.js` — **new**: passively reads completed X GraphQL
65
+ responses through Chrome's Debugger Network domain without changing page-owned
66
+ JavaScript or the DOM. The old MAIN-world fetch/XHR patch and isolated bridge
67
+ were removed.
68
+ - `background.js` sends debugger-captured list and search responses through one
69
+ parser and receipt path before normal capture deduplication. It stages the
70
+ exact request URL and Chrome tab ID so recovery replays tab-bound receipts
71
+ before clearing the write-ahead record. Observations go only to the matching
72
+ run. Up to four leased receipt runs may be active. Heartbeats, source-tab
73
+ closure, and lease expiry reclaim runs that no longer have a live client.
74
+ - `lib/tweet-parser.js` — accepts object-shaped Draft.js `entityMap`s in
75
+ addition to X's array-of-pairs shape (+ regression test). New counter objects
76
+ carry `format: "exact-v1"`; absent, rounded, invalid, and unsafe counters are
77
+ null, while actual zero counts remain zero. Recovery passes the original response
78
+ time to `extractTweets`.
79
+ - `.github/workflows/release.yml` — packages the pool settings, connection,
80
+ reload, and cutover pages; omits unsupported Firefox artifacts.
81
+ - Removed upstream `AGENTS.md` / `CLAUDE.md` (superseded by the repo root's).
extension/background.js ADDED
@@ -0,0 +1,1111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // xTap — Service Worker (background)
2
+ import { GraphqlCapture } from './lib/graphql-capture.js';
3
+ import { extractTweets } from './lib/tweet-parser.js';
4
+ import {
5
+ initPoolSync,
6
+ poolEnqueue,
7
+ poolFlush,
8
+ poolConnect,
9
+ poolSetConfig,
10
+ poolTogglePause,
11
+ poolStatus,
12
+ } from './lib/pool-sync.js';
13
+ import { ScrapeReceiptBridge } from './lib/scrape-bridge.js';
14
+ import { dedupTweet } from './lib/dedup.js';
15
+
16
+ const NATIVE_HOST = 'com.xtap.host';
17
+ const BATCH_SIZE = 50;
18
+ const FLUSH_INTERVAL_MS = 30_000;
19
+ const MAX_SEEN_IDS = 50_000;
20
+ const HTTP_TIMEOUT_MS = 10_000;
21
+ const MAX_BUFFER_SIZE = 2000;
22
+ const IMAGE_BACKFILL_FLAG = '__xtap_image_backfill';
23
+
24
+ let captureEnabled = true;
25
+ let buffer = [];
26
+ let flushTimer = null;
27
+ let seenIds = new Set();
28
+ // Session-only set of tweet IDs already forwarded for image-backfill this SW
29
+ // lifetime. Lets us re-enter the daemon once per duplicate-with-photos so
30
+ // images skipped at original capture time get downloaded, without spamming
31
+ // the daemon on every scroll/re-navigation. Cleared on SW restart by design
32
+ // — the downloader's per-file os.path.exists is the source of truth.
33
+ let imageCheckedIds = new Set();
34
+ let sessionCount = 0;
35
+ let allTimeCount = 0;
36
+ let outputDir = '';
37
+ let imageDownload = false;
38
+ let debugLogging = false;
39
+ let verboseLogging = false;
40
+ let logBuffer = [];
41
+ const isDevMode = !chrome.runtime.getManifest().update_url;
42
+ const graphqlCapture = new GraphqlCapture({ onResponse: handleGraphqlResponse });
43
+ const scrapeReceiptBridge = new ScrapeReceiptBridge({
44
+ ensureSourceCapture: (tabId) => graphqlCapture.ensureAttached(tabId),
45
+ });
46
+ scrapeReceiptBridge.attach();
47
+ chrome.tabs?.onRemoved?.addListener((tabId) => {
48
+ void scrapeReceiptBridge.finishSourceTab(tabId).catch((error) => {
49
+ console.warn(`[xTap] Could not close scrape receipt for tab ${tabId}: ${error}`);
50
+ });
51
+ });
52
+ graphqlCapture.attach();
53
+ const hasSessionStorage = !!chrome.storage.session;
54
+ const traceStorage = chrome.storage.session || chrome.storage.local;
55
+ let stageSeq = 0;
56
+ let _saveChain = Promise.resolve();
57
+ let readyResolve;
58
+ const ready = new Promise(r => { readyResolve = r; });
59
+ const autoDumpedThisSession = new Set();
60
+
61
+ // --- Recent tweets cache (for video download lookup) ---
62
+ const MAX_RECENT_TWEETS = 1000;
63
+ const recentTweets = new Map();
64
+ // tweetId → downloadId for in-progress downloads (so popup can resume polling)
65
+ const activeDownloads = new Map();
66
+
67
+ // --- Transport state ---
68
+ // 'http' | 'none'
69
+ let transport = 'none';
70
+ let httpToken = null;
71
+ let httpPort = null;
72
+
73
+ // --- State persistence ---
74
+
75
+ function seenIdsStorage() {
76
+ return (isDevMode && hasSessionStorage) ? chrome.storage.session : chrome.storage.local;
77
+ }
78
+
79
+ // Staged observations must survive a browser restart as well as worker sleep.
80
+ function stagingStorage() {
81
+ return chrome.storage.local;
82
+ }
83
+ let recoveryRunning = false;
84
+ const activeStagedPayloads = new Set();
85
+
86
+ async function stagePayload(endpoint, data, metadata = {}) {
87
+ const key = `stg_${Date.now()}_${stageSeq++}`;
88
+ try {
89
+ await stagingStorage().set({
90
+ [key]: { endpoint, data, stagedAt: Date.now(), ...metadata },
91
+ });
92
+ return key;
93
+ } catch (e) {
94
+ console.warn('[xTap] Failed to stage payload (quota?):', e.message);
95
+ emitTraceEvent({ timestamp: Date.now(), endpoint, tweetId: null, status: 'STAGE_FAILED', reason: e.message });
96
+ throw e;
97
+ }
98
+ }
99
+
100
+ async function clearStagedPayload(key) {
101
+ if (!key) return;
102
+ try {
103
+ await stagingStorage().remove(key);
104
+ } catch (e) {
105
+ console.warn('[xTap] Failed to clear staged payload:', e.message);
106
+ }
107
+ }
108
+
109
+ async function recoverStagedPayloads() {
110
+ if (recoveryRunning) return;
111
+ recoveryRunning = true;
112
+ try { await replayStagedPayloads(); }
113
+ finally { recoveryRunning = false; }
114
+ }
115
+
116
+ async function replayStagedPayloads() {
117
+ let store;
118
+ try {
119
+ store = await stagingStorage().get(null);
120
+ } catch (e) {
121
+ console.warn('[xTap] Failed to read staging storage for recovery:', e.message);
122
+ return;
123
+ }
124
+ const keys = Object.keys(store).filter(k => k.startsWith('stg_')).sort((a, b) => {
125
+ const [, tsA, seqA] = a.split('_');
126
+ const [, tsB, seqB] = b.split('_');
127
+ return (tsA - tsB) || (seqA - seqB);
128
+ });
129
+ if (keys.length === 0) return;
130
+
131
+ let recoveredCount = 0;
132
+
133
+ for (const key of keys) {
134
+ if (activeStagedPayloads.has(key)) continue;
135
+ const entry = store[key];
136
+ let produced = false;
137
+ try {
138
+ if (!entry || !entry.data || !Number.isFinite(entry.stagedAt)) {
139
+ console.error(`[xTap] Invalid staged payload retained: ${key}`);
140
+ continue;
141
+ }
142
+ const tweets = extractTweets(entry.endpoint, entry.data, entry.stagedAt);
143
+ for (const tweet of tweets) tweet.source_endpoint = entry.endpoint;
144
+ if (tweets.length > 0) {
145
+ await recordScrapeReceipts({
146
+ endpoint: entry.endpoint,
147
+ url: entry.requestUrl,
148
+ sourceTabId: entry.sourceTabId,
149
+ }, tweets);
150
+ await enqueueTweets(tweets, entry.endpoint);
151
+ recoveredCount += tweets.length;
152
+ produced = true;
153
+ }
154
+ } catch (e) {
155
+ console.warn(`[xTap] Recovery failed for ${key}; staged data retained:`, e.message);
156
+ continue;
157
+ }
158
+ // Persist buffer before clearing WAL entry — if SW dies mid-recovery,
159
+ // already-cleared entries must have their tweets in durable storage.
160
+ // If saveState fails, keep the WAL entry for retry on next startup.
161
+ if (produced && !(await saveState())) continue;
162
+ await clearStagedPayload(key);
163
+ }
164
+
165
+ if (recoveredCount > 0) {
166
+ emitTraceEvent({ timestamp: Date.now(), endpoint: 'recovery', tweetId: null, status: 'RECOVERY_COMPLETE', reason: `recovered ${recoveredCount} tweets from ${keys.length} staged payloads` });
167
+ console.log(`[xTap] Recovery: ${recoveredCount} tweets from ${keys.length} staged payloads`);
168
+ }
169
+ }
170
+
171
+ // Serialized via _saveChain so concurrent callers can't interleave writes
172
+ // (back-to-back handlers would otherwise race, and an earlier snapshot could
173
+ // land after a later one, rolling back buffered tweets). Returns true on
174
+ // success, false on storage error — callers gate WAL clears on this.
175
+ function saveState() {
176
+ const p = _saveChain.then(() => _saveStateImpl());
177
+ _saveChain = p.catch(() => {});
178
+ return p;
179
+ }
180
+
181
+ async function _saveStateImpl() {
182
+ // seenIds and tweetBuffer are coupled in one write so a quota failure
183
+ // loses both rather than persisting seenIds without the buffer (which
184
+ // would create ghost-dedup entries that permanently block those tweets).
185
+ const seenArr = [...seenIds].slice(-MAX_SEEN_IDS);
186
+ try {
187
+ if (isDevMode && hasSessionStorage) {
188
+ await Promise.all([
189
+ chrome.storage.session.set({ seenIds: seenArr, tweetBuffer: buffer }),
190
+ chrome.storage.local.set({ allTimeCount, captureEnabled }),
191
+ ]);
192
+ } else {
193
+ await chrome.storage.local.set({ seenIds: seenArr, tweetBuffer: buffer, allTimeCount, captureEnabled });
194
+ }
195
+ return true;
196
+ } catch (e) {
197
+ console.warn('[xTap] Failed to persist state:', e.message);
198
+ return false;
199
+ }
200
+ }
201
+
202
+ async function restoreState() {
203
+ const [seenStored, stored] = await Promise.all([
204
+ seenIdsStorage().get(['seenIds', 'tweetBuffer']),
205
+ chrome.storage.local.get(['allTimeCount', 'captureEnabled', 'outputDir', 'imageDownload', 'debugLogging', 'verboseLogging']),
206
+ ]);
207
+ if (seenStored.seenIds) seenIds = new Set(seenStored.seenIds.filter(Boolean));
208
+ if (Array.isArray(seenStored.tweetBuffer)) buffer = seenStored.tweetBuffer;
209
+ if (typeof stored.allTimeCount === 'number') allTimeCount = stored.allTimeCount;
210
+ if (typeof stored.captureEnabled === 'boolean') captureEnabled = stored.captureEnabled;
211
+ if (typeof stored.outputDir === 'string') outputDir = stored.outputDir;
212
+ if (typeof stored.imageDownload === 'boolean') imageDownload = stored.imageDownload;
213
+ if (typeof stored.debugLogging === 'boolean') debugLogging = stored.debugLogging;
214
+ if (typeof stored.verboseLogging === 'boolean') verboseLogging = stored.verboseLogging;
215
+ }
216
+
217
+ // --- Debug logging ---
218
+
219
+ const _origLog = console.log;
220
+ const _origWarn = console.warn;
221
+ const _origError = console.error;
222
+
223
+ const MAX_LOG_BUFFER = 5000;
224
+
225
+ function debugLog(level, args) {
226
+ if (!debugLogging) return;
227
+ const ts = new Date().toISOString();
228
+ const text = args.map(a => {
229
+ if (typeof a === 'string') return a;
230
+ try { return JSON.stringify(a); } catch { return String(a); }
231
+ }).join(' ');
232
+ logBuffer.push(`${ts} [${level}] ${text}`);
233
+ // Cap so an unreachable daemon can't grow the buffer unboundedly.
234
+ if (logBuffer.length > MAX_LOG_BUFFER) {
235
+ logBuffer.splice(0, logBuffer.length - MAX_LOG_BUFFER);
236
+ }
237
+ }
238
+
239
+ console.log = (...args) => { _origLog(...args); debugLog('LOG', args); };
240
+ console.warn = (...args) => { _origWarn(...args); debugLog('WARN', args); };
241
+ console.error = (...args) => { _origError(...args); debugLog('ERROR', args); };
242
+
243
+ // --- HTTP transport ---
244
+
245
+ async function httpFetch(method, path, body) {
246
+ const url = `http://127.0.0.1:${httpPort}${path}`;
247
+ const opts = { method, headers: {} };
248
+ if (httpToken) {
249
+ opts.headers['Authorization'] = `Bearer ${httpToken}`;
250
+ }
251
+ if (body !== undefined) {
252
+ opts.headers['Content-Type'] = 'application/json';
253
+ opts.body = JSON.stringify(body);
254
+ }
255
+ const controller = new AbortController();
256
+ const timeout = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
257
+ opts.signal = controller.signal;
258
+ try {
259
+ const resp = await fetch(url, opts);
260
+ return { status: resp.status, data: await resp.json() };
261
+ } finally {
262
+ clearTimeout(timeout);
263
+ }
264
+ }
265
+
266
+ // AbortSignal.timeout may not exist in all MV3 runtimes (e.g. older Firefox)
267
+ function makeTimeoutSignal(ms) {
268
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
269
+ return AbortSignal.timeout(ms);
270
+ }
271
+ const controller = new AbortController();
272
+ setTimeout(() => controller.abort(), ms); // timer self-clears when SW terminates
273
+ return controller.signal;
274
+ }
275
+
276
+ async function probeHttp(port, token) {
277
+ try {
278
+ const headers = {};
279
+ if (token) headers['Authorization'] = `Bearer ${token}`;
280
+ const resp = await fetch(`http://127.0.0.1:${port}/status`, {
281
+ headers,
282
+ signal: makeTimeoutSignal(3000)
283
+ });
284
+ const data = await resp.json();
285
+ return data.ok === true;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ async function getTokenViaNative() {
292
+ return new Promise((resolve) => {
293
+ let port;
294
+ try {
295
+ port = chrome.runtime.connectNative(NATIVE_HOST);
296
+ } catch {
297
+ resolve(null);
298
+ return;
299
+ }
300
+ let settled = false;
301
+ function finish(value) {
302
+ if (settled) return;
303
+ settled = true;
304
+ clearTimeout(timer);
305
+ try { port.disconnect(); } catch {}
306
+ resolve(value);
307
+ }
308
+ const timer = setTimeout(() => finish(null), 5000);
309
+ port.onMessage.addListener((msg) => {
310
+ if (msg.ok && msg.token) {
311
+ finish({ token: msg.token, port: msg.port });
312
+ } else {
313
+ finish(null);
314
+ }
315
+ });
316
+ port.onDisconnect.addListener(() => {
317
+ const err = chrome.runtime.lastError;
318
+ if (err) console.warn('[xTap] Native host disconnected:', err.message);
319
+ finish(null);
320
+ });
321
+ try {
322
+ port.postMessage({ type: 'GET_TOKEN' });
323
+ } catch {
324
+ finish(null);
325
+ }
326
+ });
327
+ }
328
+
329
+ async function initTransport() {
330
+ // 1. Check cached token
331
+ const cached = await chrome.storage.local.get(['httpToken', 'httpPort']);
332
+ if (cached.httpToken && cached.httpPort) {
333
+ const alive = await probeHttp(cached.httpPort, cached.httpToken);
334
+ if (alive) {
335
+ httpToken = cached.httpToken;
336
+ httpPort = cached.httpPort;
337
+ transport = 'http';
338
+ updateBadge(); // clear a stale '!' from a previous daemon-down session
339
+ console.log('[xTap] Using HTTP transport (cached token)');
340
+ return;
341
+ }
342
+ }
343
+
344
+ // 2. Try to get token from native host
345
+ const result = await getTokenViaNative();
346
+ if (result) {
347
+ const alive = await probeHttp(result.port, result.token);
348
+ if (alive) {
349
+ httpToken = result.token;
350
+ httpPort = result.port;
351
+ transport = 'http';
352
+ await chrome.storage.local.set({ httpToken, httpPort });
353
+ updateBadge(); // clear a stale '!' from a previous daemon-down session
354
+ console.log('[xTap] Using HTTP transport (token from native host)');
355
+ return;
356
+ }
357
+ }
358
+
359
+ // 3. No transport available
360
+ transport = 'none';
361
+ console.warn('[xTap] No transport available — daemon may not be running');
362
+ updateTransportBadge();
363
+ }
364
+
365
+ // --- Unified send ---
366
+
367
+ async function sendToHost(msg) {
368
+ if (transport !== 'http') {
369
+ console.warn('[xTap] No transport available, message dropped');
370
+ return null;
371
+ }
372
+
373
+ let path, body;
374
+ if (msg.type === 'TEST_PATH') {
375
+ path = '/test-path';
376
+ body = { outputDir: msg.outputDir };
377
+ } else if (msg.type === 'LOG') {
378
+ path = '/log';
379
+ body = { lines: msg.lines };
380
+ if (msg.outputDir) body.outputDir = msg.outputDir;
381
+ } else if (msg.type === 'DUMP') {
382
+ path = '/dump';
383
+ body = { filename: msg.filename, content: msg.content };
384
+ if (msg.outputDir) body.outputDir = msg.outputDir;
385
+ } else if (msg.type === 'CHECK_YTDLP') {
386
+ path = '/check-ytdlp';
387
+ body = {};
388
+ } else if (msg.type === 'DOWNLOAD_VIDEO') {
389
+ path = '/download-video';
390
+ body = { tweetUrl: msg.tweetUrl, directUrl: msg.directUrl, postDate: msg.postDate };
391
+ if (msg.outputDir) body.outputDir = msg.outputDir;
392
+ } else if (msg.type === 'DOWNLOAD_STATUS') {
393
+ path = '/download-status';
394
+ body = { downloadId: msg.downloadId };
395
+ } else {
396
+ path = '/tweets';
397
+ body = { tweets: msg.tweets };
398
+ if (msg.outputDir) body.outputDir = msg.outputDir;
399
+ if (msg.imageDownload) body.image_download = true;
400
+ }
401
+
402
+ try {
403
+ const { status, data } = await httpFetch('POST', path, body);
404
+ if (status === 401 || status === 403) {
405
+ // The daemon is alive but our token is stale (e.g. rotated by a
406
+ // reinstall). Reset the transport so the next flush re-probes and
407
+ // fetches a fresh token via the native host — otherwise we'd retry
408
+ // the same dead credentials forever.
409
+ console.error(`[xTap] Daemon rejected credentials (HTTP ${status}), resetting transport`);
410
+ transport = 'none';
411
+ updateTransportBadge();
412
+ return null;
413
+ }
414
+ if (data && typeof data === 'object') data.httpStatus = status;
415
+ return data;
416
+ } catch (e) {
417
+ console.error('[xTap] HTTP send failed:', e.message);
418
+ transport = 'none';
419
+ updateTransportBadge();
420
+ return null;
421
+ }
422
+ }
423
+
424
+ // --- Batching & flushing ---
425
+
426
+ function scheduledFlush() {
427
+ if (buffer.length > 0 || logBuffer.length > 0) flush();
428
+ }
429
+
430
+ // The setTimeout flush timer dies with the service worker (MV3 kills it ~30s
431
+ // after the last event), stranding the final partial batch until a later
432
+ // session. A chrome.alarms backstop survives SW termination and wakes the SW
433
+ // to deliver whatever is still buffered, then clears itself.
434
+ const FLUSH_ALARM = 'xtap-flush';
435
+ let flushAlarmSet = false;
436
+
437
+ function ensureFlushAlarm() {
438
+ if (flushAlarmSet || !chrome.alarms) return;
439
+ flushAlarmSet = true;
440
+ chrome.alarms.create(FLUSH_ALARM, { periodInMinutes: 1 });
441
+ }
442
+
443
+ chrome.alarms?.onAlarm.addListener((alarm) => {
444
+ if (alarm.name !== FLUSH_ALARM) return;
445
+ (async () => {
446
+ await ready;
447
+ if (buffer.length > 0 || logBuffer.length > 0) await flush();
448
+ if (buffer.length === 0 && logBuffer.length === 0) {
449
+ chrome.alarms.clear(FLUSH_ALARM);
450
+ flushAlarmSet = false;
451
+ }
452
+ })();
453
+ });
454
+
455
+ async function flushLogs() {
456
+ if (logBuffer.length === 0) return;
457
+ if (transport === 'none') return;
458
+ const lines = logBuffer.splice(0);
459
+ const message = { type: 'LOG', lines };
460
+ if (outputDir) message.outputDir = outputDir;
461
+ await sendToHost(message);
462
+ }
463
+
464
+ let lastReprobe = 0;
465
+ const REPROBE_COOLDOWN_MS = 30_000;
466
+
467
+ async function reprobeTransport() {
468
+ const now = Date.now();
469
+ if (now - lastReprobe < REPROBE_COOLDOWN_MS) return false;
470
+ lastReprobe = now;
471
+ console.log('[xTap] Re-probing HTTP daemon...');
472
+ // Try cached credentials first (fast path)
473
+ if (httpToken && httpPort) {
474
+ const alive = await probeHttp(httpPort, httpToken);
475
+ if (alive) {
476
+ transport = 'http';
477
+ updateBadge();
478
+ console.log('[xTap] HTTP daemon recovered (cached token)');
479
+ return true;
480
+ }
481
+ }
482
+ // Try getting a fresh token via native host
483
+ const result = await getTokenViaNative();
484
+ if (result) {
485
+ const alive = await probeHttp(result.port, result.token);
486
+ if (alive) {
487
+ httpToken = result.token;
488
+ httpPort = result.port;
489
+ transport = 'http';
490
+ await chrome.storage.local.set({ httpToken, httpPort });
491
+ updateBadge();
492
+ console.log('[xTap] HTTP daemon recovered (fresh token)');
493
+ return true;
494
+ }
495
+ }
496
+ // Fallback: check chrome.storage.local (token may have been written by
497
+ // a prior session or injected externally for testing)
498
+ const stored = await chrome.storage.local.get(['httpToken', 'httpPort']);
499
+ if (stored.httpToken && stored.httpPort) {
500
+ const alive = await probeHttp(stored.httpPort, stored.httpToken);
501
+ if (alive) {
502
+ httpToken = stored.httpToken;
503
+ httpPort = stored.httpPort;
504
+ transport = 'http';
505
+ updateBadge();
506
+ console.log('[xTap] HTTP daemon recovered (stored token)');
507
+ return true;
508
+ }
509
+ }
510
+ return false;
511
+ }
512
+
513
+ // Cap per POST so a backlog (e.g. after a daemon outage) can't exceed the
514
+ // daemon's 10 MB body limit and wedge in a 413-retry loop.
515
+ const MAX_TWEETS_PER_POST = 200;
516
+ let flushInFlight = false;
517
+
518
+ function tweetForHost(tweet) {
519
+ if (!tweet || !tweet[IMAGE_BACKFILL_FLAG]) return tweet;
520
+ const { [IMAGE_BACKFILL_FLAG]: _ignored, ...clean } = tweet;
521
+ return clean;
522
+ }
523
+
524
+ function isBufferedImageBackfill(tweet) {
525
+ return !!(tweet && tweet[IMAGE_BACKFILL_FLAG]);
526
+ }
527
+
528
+ // Remove the delivered/dropped tweets by object identity, never by position.
529
+ // An overflow eviction in enqueueTweets can splice the buffer front while
530
+ // flush() awaits the POST, so buffer[0..batch.length) may no longer be the
531
+ // tweets we sent — a positional splice would then discard never-sent tweets
532
+ // (their ids are already in seenIds, so the loss is permanent). Reassigning
533
+ // `buffer` is safe: no other code runs between the await and here.
534
+ function removeDelivered(batch) {
535
+ const delivered = new Set(batch);
536
+ buffer = buffer.filter(t => !delivered.has(t));
537
+ }
538
+
539
+ async function flush() {
540
+ if (buffer.length === 0 && logBuffer.length === 0) return;
541
+
542
+ if (transport === 'none') {
543
+ if (!(await reprobeTransport())) return;
544
+ }
545
+
546
+ if (buffer.length > 0 && !flushInFlight) {
547
+ flushInFlight = true;
548
+ try {
549
+ // Send from the front without removing — the batch stays in `buffer`
550
+ // (and thus in persisted state) until the daemon acks, so SW death
551
+ // mid-POST can't lose it. The daemon dedups by ID, so a death after
552
+ // the POST but before the ack only costs a duplicate send, not data.
553
+ let postCap = MAX_TWEETS_PER_POST;
554
+ let splitRemaining = 0;
555
+ while (buffer.length > 0) {
556
+ const batch = buffer.slice(0, postCap);
557
+ const message = { tweets: batch.map(tweetForHost) };
558
+ if (outputDir) message.outputDir = outputDir;
559
+ if (imageDownload) message.imageDownload = true;
560
+
561
+ const resp = await sendToHost(message);
562
+ if (!resp || !resp.ok) {
563
+ // 413: the batch is too large in bytes (count cap doesn't bound
564
+ // huge article tweets). Retrying it unchanged would wedge the
565
+ // queue forever — halve and retry; a single tweet that alone
566
+ // exceeds the daemon's body limit can never be delivered, so
567
+ // drop it with a trace event.
568
+ if (resp?.httpStatus === 413) {
569
+ if (batch.length > 1) {
570
+ postCap = Math.max(1, Math.floor(batch.length / 2));
571
+ splitRemaining = batch.length;
572
+ continue;
573
+ }
574
+ const oversized = batch[0];
575
+ removeDelivered([oversized]);
576
+ console.error(`[xTap] Dropping tweet ${oversized.id}: exceeds daemon body limit`);
577
+ emitTraceEvent({ timestamp: Date.now(), endpoint: 'flush', tweetId: oversized.id, status: 'DROPPED_OVERSIZED', reason: 'single tweet exceeds daemon body limit' });
578
+ postCap = MAX_TWEETS_PER_POST; // only the fat batch pays the split cost
579
+ splitRemaining = 0;
580
+ await saveState();
581
+ continue;
582
+ }
583
+ // Anything else: batch stays buffered for the next flush.
584
+ console.error('[xTap] Host rejected tweets:', resp?.error || 'no response');
585
+ break;
586
+ }
587
+ removeDelivered(batch);
588
+ if (splitRemaining > 0) {
589
+ splitRemaining -= batch.length;
590
+ if (splitRemaining <= 0) {
591
+ postCap = MAX_TWEETS_PER_POST; // restore after a fat batch forced a split
592
+ splitRemaining = 0;
593
+ }
594
+ }
595
+ await saveState();
596
+ }
597
+ } finally {
598
+ flushInFlight = false;
599
+ }
600
+ }
601
+
602
+ if (debugLogging) await flushLogs();
603
+ }
604
+
605
+ // --- Trace events ---
606
+
607
+ const MAX_TRACE_EVENTS = 50;
608
+ let traceEvents = [];
609
+ let traceFlushTimer = null;
610
+
611
+ function emitTraceEvent(event) {
612
+ traceEvents.push(event);
613
+ if (traceEvents.length > MAX_TRACE_EVENTS) {
614
+ traceEvents = traceEvents.slice(-MAX_TRACE_EVENTS);
615
+ }
616
+ if (!traceFlushTimer) {
617
+ traceFlushTimer = setTimeout(() => {
618
+ traceFlushTimer = null;
619
+ traceStorage.set({ lastEvents: traceEvents });
620
+ }, 500);
621
+ }
622
+ }
623
+
624
+ async function enqueueTweets(tweets, endpoint = 'unknown') {
625
+ // Persist observation delivery before lifetime post-ID dedup or local state changes.
626
+ await poolEnqueue(tweets);
627
+ let newCount = 0;
628
+ let queuedCount = 0;
629
+ let backfillCount = 0;
630
+ let skippedCount = 0;
631
+ let droppedBackfillCount = 0;
632
+ for (const tweet of tweets) {
633
+ // Always cache for video lookup (even dupes — updates with latest data)
634
+ if (tweet.id) {
635
+ recentTweets.set(tweet.id, tweet);
636
+ // FIFO eviction
637
+ if (recentTweets.size > MAX_RECENT_TWEETS) {
638
+ const oldest = recentTweets.keys().next().value;
639
+ recentTweets.delete(oldest);
640
+ }
641
+ }
642
+
643
+ const wasSeen = !!(tweet.id && seenIds.has(tweet.id));
644
+ if (!dedupTweet(tweet, seenIds, { imageBackfill: imageDownload, imageCheckedIds })) {
645
+ skippedCount++;
646
+ emitTraceEvent({ timestamp: Date.now(), endpoint, tweetId: tweet.id, status: 'DEDUPLICATED', reason: 'seenIds' });
647
+ continue;
648
+ }
649
+
650
+ const isImageBackfill = wasSeen && !tweet.is_article;
651
+ if (isImageBackfill) {
652
+ backfillCount++;
653
+ tweet[IMAGE_BACKFILL_FLAG] = true;
654
+ } else {
655
+ newCount++;
656
+ }
657
+ buffer.push(tweet);
658
+ queuedCount++;
659
+ emitTraceEvent({
660
+ timestamp: Date.now(),
661
+ endpoint,
662
+ tweetId: tweet.id,
663
+ status: isImageBackfill ? 'IMAGE_BACKFILL' : 'ACCEPTED',
664
+ reason: null,
665
+ });
666
+ }
667
+
668
+ // FIFO eviction if seenIds grows too large
669
+ if (seenIds.size > MAX_SEEN_IDS) {
670
+ const arr = [...seenIds];
671
+ seenIds = new Set(arr.slice(arr.length - MAX_SEEN_IDS));
672
+ }
673
+ if (imageCheckedIds.size > MAX_SEEN_IDS) {
674
+ const arr = [...imageCheckedIds];
675
+ imageCheckedIds = new Set(arr.slice(arr.length - MAX_SEEN_IDS));
676
+ }
677
+
678
+ sessionCount += newCount;
679
+ allTimeCount += newCount;
680
+ updateBadge();
681
+ if (queuedCount > 0) ensureFlushAlarm();
682
+
683
+ if (buffer.length > MAX_BUFFER_SIZE) {
684
+ let droppedOldestCount = 0;
685
+ while (buffer.length > MAX_BUFFER_SIZE) {
686
+ const backfillIndex = buffer.findIndex(isBufferedImageBackfill);
687
+ if (backfillIndex !== -1) {
688
+ const [dropped] = buffer.splice(backfillIndex, 1);
689
+ if (dropped?.id) imageCheckedIds.delete(dropped.id);
690
+ droppedBackfillCount++;
691
+ } else {
692
+ buffer.shift();
693
+ droppedOldestCount++;
694
+ }
695
+ }
696
+ const droppedTotal = droppedOldestCount + droppedBackfillCount;
697
+ console.warn(`[xTap] Buffer overflow: dropped ${droppedTotal} tweets (${droppedBackfillCount} image backfill, ${droppedOldestCount} oldest; cap: ${MAX_BUFFER_SIZE})`);
698
+ emitTraceEvent({ timestamp: Date.now(), endpoint, tweetId: null, status: 'BUFFER_OVERFLOW', reason: `dropped ${droppedTotal}` });
699
+ }
700
+
701
+ if (skippedCount > 0 || backfillCount > 0 || droppedBackfillCount > 0) {
702
+ console.log(`[xTap] Dedup: ${newCount} new, ${backfillCount} image backfill, ${skippedCount} duplicates skipped, ${droppedBackfillCount} backfill dropped (seenIds: ${seenIds.size})`);
703
+ }
704
+ }
705
+
706
+ // --- Badge ---
707
+
708
+ function updateBadge() {
709
+ if (transport === 'none') return; // don't overwrite error badge
710
+ const text = sessionCount > 0 ? String(sessionCount) : '';
711
+ chrome.action.setBadgeText({ text });
712
+ chrome.action.setBadgeBackgroundColor({ color: '#1D9BF0' });
713
+ }
714
+
715
+ function updateTransportBadge() {
716
+ if (transport === 'none') {
717
+ chrome.action.setBadgeText({ text: '!' });
718
+ chrome.action.setBadgeBackgroundColor({ color: '#E0245E' });
719
+ }
720
+ }
721
+
722
+ // --- Verbose logging (discovery mode) ---
723
+
724
+ function summarizeShape(obj, depth = 0, maxDepth = 3) {
725
+ if (depth >= maxDepth) return typeof obj === 'object' && obj !== null ? (Array.isArray(obj) ? '[…]' : '{…}') : typeof obj;
726
+ if (obj === null) return 'null';
727
+ if (Array.isArray(obj)) {
728
+ if (obj.length === 0) return '[]';
729
+ return `[${obj.length}× ${summarizeShape(obj[0], depth + 1, maxDepth)}]`;
730
+ }
731
+ if (typeof obj === 'object') {
732
+ const keys = Object.keys(obj);
733
+ if (keys.length === 0) return '{}';
734
+ const entries = keys.slice(0, 12).map(k => `${k}: ${summarizeShape(obj[k], depth + 1, maxDepth)}`);
735
+ if (keys.length > 12) entries.push(`…+${keys.length - 12} more`);
736
+ return `{ ${entries.join(', ')} }`;
737
+ }
738
+ if (typeof obj === 'string') return obj.length > 80 ? `str(${obj.length})` : JSON.stringify(obj);
739
+ return String(obj);
740
+ }
741
+
742
+ function verboseLog(endpoint, data) {
743
+ if (!verboseLogging) return;
744
+ const shape = summarizeShape(data);
745
+ console.log(`[xTap:verbose] ${endpoint} response shape: ${shape}`);
746
+
747
+ // Auto-dump first response per endpoint per session (for agentic fixture creation)
748
+ if (!autoDumpedThisSession.has(endpoint)) {
749
+ autoDumpedThisSession.add(endpoint);
750
+ const ts = Date.now();
751
+ const filename = `dump-${endpoint}-${ts}.json`;
752
+ const content = JSON.stringify({ endpoint, data }, null, 2);
753
+ sendToHost({ type: 'DUMP', filename, content, outputDir: outputDir || undefined });
754
+ console.log(`[xTap:autodump] ${endpoint} → ${filename}`);
755
+ }
756
+
757
+ // Manual dump: target a specific endpoint or tweet IDs for multi-sample capture.
758
+ // Configure via console:
759
+ // chrome.storage.local.set({verboseDumpIds: ['1234567890']}) — dump responses containing these IDs
760
+ // chrome.storage.local.set({verboseDumpEndpoint: 'TweetDetail'}) — dump all responses for this endpoint
761
+ // Dumps are written to <outputDir>/dump-<endpoint>-<timestamp>.json
762
+ chrome.storage.local.get(['verboseDumpIds', 'verboseDumpEndpoint'], (cfg) => {
763
+ let shouldDump = false;
764
+ let reason = '';
765
+
766
+ if (cfg.verboseDumpEndpoint === endpoint) {
767
+ shouldDump = true;
768
+ reason = `endpoint=${endpoint}`;
769
+ }
770
+ if (!shouldDump && cfg.verboseDumpIds?.length) {
771
+ const json = JSON.stringify(data);
772
+ for (const id of cfg.verboseDumpIds) {
773
+ if (json.includes(id)) {
774
+ shouldDump = true;
775
+ reason = `id=${id}`;
776
+ break;
777
+ }
778
+ }
779
+ }
780
+
781
+ if (shouldDump) {
782
+ const ts = Date.now();
783
+ const filename = `dump-${endpoint}-${ts}.json`;
784
+ const content = JSON.stringify({ endpoint, data }, null, 2);
785
+ sendToHost({ type: 'DUMP', filename, content, outputDir: outputDir || undefined });
786
+ console.log(`[xTap:dump] ${endpoint} (${reason}) → ${filename} (${content.length} chars)`);
787
+ }
788
+ });
789
+ }
790
+
791
+ // --- Message handling ---
792
+
793
+ // Endpoints that use /i/api/graphql/ but never contain tweets
794
+ const IGNORED_ENDPOINTS = new Set([
795
+ 'DataSaverMode', 'getAltTextPromptPreference', 'useDirectCallSetupQuery',
796
+ 'XChatDmSettingsQuery', 'useTotalAdCampaignsForUserQuery', 'useStoryTopicQuery',
797
+ 'useSubscriptionsPaymentFailureQuery', 'PinnedTimelines', 'ExploreSidebar',
798
+ 'SidebarUserRecommendations', 'useFetchProductSubscriptionsQuery',
799
+ 'ExplorePage', 'UserByScreenName',
800
+ 'ProfileSpotlightsQuery', 'useFetchProfileSections_canViewExpandedProfileQuery',
801
+ 'UserSuperFollowTweets', 'NotificationsTimeline', 'AuthenticatePeriscope',
802
+ 'BookmarkFoldersSlice', 'EditBookmarkFolder', 'fetchPostQuery',
803
+ 'useReadableMessagesSnapshotMutation', 'UsersByRestIds',
804
+ 'CreatorStudioTabBarItemQuery', 'DelegatedAccountListQuery',
805
+ 'HandleShareBannerQuery', 'isEligibleForVoButtonUpsellQuery',
806
+ 'useEligibleForHandleShareBannerQuery', 'useRelayDelegateDataPendingQuery',
807
+ ]);
808
+
809
+ async function recordScrapeReceipts(msg, tweets) {
810
+ if (!Number.isInteger(msg.sourceTabId) || typeof msg.url !== 'string') return;
811
+ try {
812
+ await scrapeReceiptBridge.recordGraphqlResponse({
813
+ endpoint: msg.endpoint,
814
+ requestUrl: msg.url,
815
+ sourceTabId: msg.sourceTabId,
816
+ tweets,
817
+ });
818
+ } catch (error) {
819
+ console.error('[xTap] Failed to record scrape receipts:', error);
820
+ }
821
+ }
822
+
823
+ async function handleGraphqlResponse(msg) {
824
+ await ready;
825
+ verboseLog(msg.endpoint, msg.data);
826
+ if (!captureEnabled) return;
827
+ if (IGNORED_ENDPOINTS.has(msg.endpoint)) {
828
+ if (verboseLogging) console.log(`[xTap:verbose] ${msg.endpoint} (ignored)`);
829
+ return;
830
+ }
831
+
832
+ const observedAt = Date.now();
833
+ const stageKey = await stagePayload(msg.endpoint, msg.data, {
834
+ requestUrl: msg.url,
835
+ sourceTabId: msg.sourceTabId,
836
+ stagedAt: observedAt,
837
+ });
838
+ activeStagedPayloads.add(stageKey);
839
+ try {
840
+ const tweets = extractTweets(msg.endpoint, msg.data, observedAt);
841
+ for (const tweet of tweets) tweet.source_endpoint = msg.endpoint;
842
+ if (tweets.length > 0) {
843
+ const missingAuthor = tweets.filter(tweet => !tweet.author?.username).length;
844
+ const missingText = tweets.filter(tweet => !tweet.text).length;
845
+ let warning = '';
846
+ if (missingAuthor > 0) warning += ` | ${missingAuthor} missing username`;
847
+ if (missingText > 0) warning += ` | ${missingText} missing text`;
848
+ console.log(`[xTap] ${msg.endpoint}: ${tweets.length} tweets${warning}`);
849
+ await recordScrapeReceipts(msg, tweets);
850
+ await enqueueTweets(tweets, msg.endpoint);
851
+ if (await saveState()) await clearStagedPayload(stageKey);
852
+ } else {
853
+ await clearStagedPayload(stageKey);
854
+ }
855
+ if (buffer.length >= BATCH_SIZE) flush();
856
+ } catch (error) {
857
+ console.error(`[xTap] Parse error for ${msg.endpoint}:`, error, '| data keys:', Object.keys(msg.data || {}).join(', '));
858
+ emitTraceEvent({ timestamp: Date.now(), endpoint: msg.endpoint, tweetId: null, status: 'PARSER_ERROR', reason: error.message });
859
+ // Keep the original response and observation time until durable delivery succeeds.
860
+ } finally {
861
+ activeStagedPayloads.delete(stageKey);
862
+ }
863
+ }
864
+
865
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
866
+ if (msg.type === 'POOL_CONNECT') {
867
+ (async () => {
868
+ const result = await poolConnect(msg, sender && sender.url ? sender.url : '');
869
+ sendResponse(result);
870
+ })();
871
+ return true;
872
+ }
873
+
874
+ if (msg.type === 'POOL_STATUS') {
875
+ sendResponse(poolStatus());
876
+ return;
877
+ }
878
+
879
+ if (msg.type === 'POOL_SET_CONFIG') {
880
+ (async () => {
881
+ await poolSetConfig(msg);
882
+ sendResponse(poolStatus());
883
+ })();
884
+ return true;
885
+ }
886
+
887
+ if (msg.type === 'POOL_TOGGLE_PAUSE') {
888
+ (async () => {
889
+ await poolTogglePause();
890
+ sendResponse(poolStatus());
891
+ })();
892
+ return true;
893
+ }
894
+
895
+ if (msg.type === 'POOL_FLUSH_NOW') {
896
+ (async () => {
897
+ try {
898
+ await ready;
899
+ await poolFlush();
900
+ await recoverStagedPayloads();
901
+ sendResponse(poolStatus());
902
+ } catch (error) {
903
+ sendResponse({ ...poolStatus(), lastError: error.message });
904
+ }
905
+ })();
906
+ return true;
907
+ }
908
+
909
+ if (msg.type === 'GET_STATUS') {
910
+ (async () => {
911
+ await ready;
912
+ sendResponse({
913
+ captureEnabled,
914
+ sessionCount,
915
+ allTimeCount,
916
+ connected: transport !== 'none',
917
+ buffered: buffer.length,
918
+ outputDir,
919
+ imageDownload,
920
+ debugLogging,
921
+ verboseLogging,
922
+ transport,
923
+ transportError: transport === 'none'
924
+ ? 'Daemon not running. Check ~/.xtap/daemon-stderr.log'
925
+ : null,
926
+ discoveredEndpoints: [...autoDumpedThisSession],
927
+ });
928
+ })();
929
+ return true;
930
+ }
931
+
932
+ if (msg.type === 'SET_DEBUG') {
933
+ debugLogging = !!msg.debugLogging;
934
+ chrome.storage.local.set({ debugLogging });
935
+ if (debugLogging) {
936
+ console.log('[xTap] Debug logging enabled');
937
+ } else {
938
+ logBuffer = [];
939
+ }
940
+ sendResponse({ debugLogging });
941
+ return true;
942
+ }
943
+
944
+ if (msg.type === 'SET_VERBOSE') {
945
+ verboseLogging = !!msg.verboseLogging;
946
+ chrome.storage.local.set({ verboseLogging });
947
+ console.log(`[xTap] Verbose logging ${verboseLogging ? 'enabled' : 'disabled'}`);
948
+ sendResponse({ verboseLogging });
949
+ return true;
950
+ }
951
+
952
+ if (msg.type === 'SET_OUTPUT_DIR') {
953
+ const newDir = msg.outputDir || '';
954
+ if (newDir && transport === 'http') {
955
+ sendToHost({ type: 'TEST_PATH', outputDir: newDir }).then((resp) => {
956
+ if (resp?.ok) {
957
+ outputDir = newDir;
958
+ chrome.storage.local.set({ outputDir });
959
+ sendResponse({ outputDir });
960
+ } else {
961
+ sendResponse({ error: resp?.error || 'Cannot write to that directory' });
962
+ }
963
+ }).catch((e) => {
964
+ sendResponse({ error: e.message });
965
+ });
966
+ } else if (newDir && transport === 'none') {
967
+ sendResponse({ error: 'Daemon not running' });
968
+ } else {
969
+ outputDir = newDir;
970
+ chrome.storage.local.set({ outputDir });
971
+ sendResponse({ outputDir });
972
+ }
973
+ return true;
974
+ }
975
+
976
+ if (msg.type === 'SET_IMAGE_DOWNLOAD') {
977
+ const wasOff = !imageDownload;
978
+ imageDownload = !!msg.imageDownload;
979
+ if (wasOff && imageDownload) imageCheckedIds.clear();
980
+ chrome.storage.local.set({ imageDownload });
981
+ sendResponse({ imageDownload });
982
+ return true;
983
+ }
984
+
985
+ if (msg.type === 'TOGGLE_CAPTURE') {
986
+ captureEnabled = !captureEnabled;
987
+ saveState();
988
+ sendResponse({ captureEnabled });
989
+ return true;
990
+ }
991
+
992
+ if (msg.type === 'CHECK_VIDEO') {
993
+ const tweet = recentTweets.get(msg.tweetId);
994
+ if (!tweet || !tweet.media || tweet.media.length === 0) {
995
+ sendResponse({ hasVideo: false });
996
+ return true;
997
+ }
998
+ const videoMedia = tweet.media.find(m => m.type === 'video' || m.type === 'animated_gif');
999
+ if (!videoMedia) {
1000
+ sendResponse({ hasVideo: false });
1001
+ return true;
1002
+ }
1003
+ sendResponse({
1004
+ hasVideo: true,
1005
+ tweetUrl: tweet.url || `https://x.com/i/status/${msg.tweetId}`,
1006
+ directUrl: videoMedia.url || null,
1007
+ mediaType: videoMedia.type,
1008
+ durationMs: videoMedia.duration_ms || null,
1009
+ postDate: tweet.created_at || null,
1010
+ activeDownloadId: activeDownloads.get(msg.tweetId) || null,
1011
+ });
1012
+ return true;
1013
+ }
1014
+
1015
+ if (msg.type === 'CHECK_YTDLP') {
1016
+ (async () => {
1017
+ try {
1018
+ const resp = await sendToHost({ type: 'CHECK_YTDLP' });
1019
+ sendResponse(resp || { ok: false, error: 'No transport' });
1020
+ } catch (e) {
1021
+ sendResponse({ ok: false, error: e.message });
1022
+ }
1023
+ })();
1024
+ return true;
1025
+ }
1026
+
1027
+ if (msg.type === 'DOWNLOAD_VIDEO') {
1028
+ (async () => {
1029
+ try {
1030
+ const resp = await sendToHost({
1031
+ type: 'DOWNLOAD_VIDEO',
1032
+ tweetUrl: msg.tweetUrl,
1033
+ directUrl: msg.directUrl,
1034
+ postDate: msg.postDate,
1035
+ outputDir: outputDir || undefined,
1036
+ });
1037
+ // Track active download so popup can resume polling after close/reopen
1038
+ if (resp?.ok && resp.downloadId && msg.tweetId) {
1039
+ activeDownloads.set(msg.tweetId, resp.downloadId);
1040
+ }
1041
+ sendResponse(resp || { ok: false, error: 'No transport' });
1042
+ } catch (e) {
1043
+ sendResponse({ ok: false, error: e.message });
1044
+ }
1045
+ })();
1046
+ return true;
1047
+ }
1048
+
1049
+ if (msg.type === 'DOWNLOAD_STATUS') {
1050
+ (async () => {
1051
+ try {
1052
+ const resp = await sendToHost({
1053
+ type: 'DOWNLOAD_STATUS',
1054
+ downloadId: msg.downloadId,
1055
+ });
1056
+ // Clean up finished downloads from active map. 'unknown' means the
1057
+ // daemon restarted and lost the download — stop resuming it.
1058
+ if (resp?.status === 'done' || resp?.status === 'error' || resp?.status === 'unknown') {
1059
+ for (const [tid, did] of activeDownloads) {
1060
+ if (did === msg.downloadId) { activeDownloads.delete(tid); break; }
1061
+ }
1062
+ }
1063
+ sendResponse(resp || { ok: false, error: 'No transport' });
1064
+ } catch (e) {
1065
+ sendResponse({ ok: false, error: e.message });
1066
+ }
1067
+ })();
1068
+ return true;
1069
+ }
1070
+ });
1071
+
1072
+ // --- Init ---
1073
+
1074
+ if (typeof chrome.storage.session?.setAccessLevel === 'function') {
1075
+ chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' });
1076
+ }
1077
+
1078
+ // Periodic pool flush that survives service-worker sleep.
1079
+ chrome.alarms.create('xtap-pool-flush', { periodInMinutes: 1 });
1080
+ chrome.alarms.onAlarm.addListener((alarm) => {
1081
+ if (alarm.name === 'xtap-pool-flush') {
1082
+ ready.then(async () => {
1083
+ await poolFlush();
1084
+ await recoverStagedPayloads();
1085
+ }).catch(error => console.error('[xTap] Pool recovery failed:', error.message));
1086
+ }
1087
+ });
1088
+
1089
+ // Graceful degradation: if restoreState fails (e.g. storage unavailable), continue
1090
+ // with defaults so the extension still captures tweets.
1091
+ restoreState().catch((error) => {
1092
+ console.error('[xTap] Failed to restore state:', error);
1093
+ }).then(async () => {
1094
+ await initPoolSync();
1095
+ await recoverStagedPayloads();
1096
+ await initTransport();
1097
+ readyResolve();
1098
+ updateBadge();
1099
+ // Deliver anything restored or recovered before MV3 idles the worker.
1100
+ if (buffer.length > 0 || logBuffer.length > 0) {
1101
+ if (buffer.length > 0) ensureFlushAlarm();
1102
+ flush();
1103
+ }
1104
+ function scheduleNextFlush() {
1105
+ const jitter = Math.random() * FLUSH_INTERVAL_MS * 0.5;
1106
+ flushTimer = setTimeout(() => { scheduledFlush(); scheduleNextFlush(); }, FLUSH_INTERVAL_MS + jitter);
1107
+ }
1108
+ scheduleNextFlush();
1109
+ const seenStorageLabel = (isDevMode && hasSessionStorage) ? 'session' : 'local';
1110
+ console.log(`[xTap] Service worker started (${isDevMode ? 'dev' : 'production'} mode, seenIds in ${seenStorageLabel} storage)`);
1111
+ });
extension/cutover.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Cut over xTap</title>
7
+ </head>
8
+ <body>
9
+ <p>Ending active xTap runs and reloading…</p>
10
+ <script src="cutover.js" type="module"></script>
11
+ </body>
12
+ </html>
extension/cutover.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ScrapeReceiptStore } from './lib/scrape-receipts.js';
2
+
3
+ try {
4
+ const store = new ScrapeReceiptStore();
5
+ await store.beginCutover(Date.now());
6
+ await store.close();
7
+ chrome.runtime.reload();
8
+ } catch {
9
+ document.body.textContent = 'xTap cutover failed.';
10
+ }
extension/debug.css ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
9
+ background: #15202b;
10
+ color: #e7e9ea;
11
+ padding: 24px;
12
+ }
13
+
14
+ .container {
15
+ max-width: 960px;
16
+ margin: 0 auto;
17
+ }
18
+
19
+ h1 {
20
+ font-size: 20px;
21
+ font-weight: 700;
22
+ margin-bottom: 20px;
23
+ color: #1d9bf0;
24
+ }
25
+
26
+ h2 {
27
+ font-size: 14px;
28
+ font-weight: 600;
29
+ margin-bottom: 12px;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: space-between;
33
+ }
34
+
35
+ .panel {
36
+ background: #192734;
37
+ border: 1px solid #2f3336;
38
+ border-radius: 8px;
39
+ padding: 16px;
40
+ margin-bottom: 16px;
41
+ }
42
+
43
+ .panel-actions {
44
+ display: flex;
45
+ align-items: center;
46
+ gap: 12px;
47
+ font-weight: 400;
48
+ }
49
+
50
+ .auto-scroll-label {
51
+ font-size: 12px;
52
+ color: #8b98a5;
53
+ cursor: pointer;
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 4px;
57
+ }
58
+
59
+ .auto-scroll-label input {
60
+ accent-color: #1d9bf0;
61
+ }
62
+
63
+ .small-btn {
64
+ padding: 3px 10px;
65
+ font-size: 11px;
66
+ border: 1px solid #2f3336;
67
+ border-radius: 4px;
68
+ background: transparent;
69
+ color: #8b98a5;
70
+ cursor: pointer;
71
+ }
72
+
73
+ .small-btn:hover {
74
+ background: #2f3336;
75
+ color: #e7e9ea;
76
+ }
77
+
78
+ /* Health table */
79
+ .health-table {
80
+ width: 100%;
81
+ font-size: 13px;
82
+ }
83
+
84
+ .health-table td {
85
+ padding: 4px 0;
86
+ }
87
+
88
+ .health-table .label {
89
+ color: #8b98a5;
90
+ width: 140px;
91
+ }
92
+
93
+ .status-connected { color: #4ade80; }
94
+ .status-disconnected { color: #facc15; }
95
+ .status-error { color: #f87171; }
96
+
97
+ .toggle-row {
98
+ margin-top: 12px;
99
+ font-size: 13px;
100
+ color: #8b98a5;
101
+ }
102
+
103
+ .toggle-row label {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 8px;
107
+ cursor: pointer;
108
+ }
109
+
110
+ .toggle-row input[type="checkbox"] {
111
+ accent-color: #1d9bf0;
112
+ }
113
+
114
+ /* Discovered endpoints */
115
+ .discovered-list {
116
+ font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
117
+ font-size: 12px;
118
+ color: #8b98a5;
119
+ }
120
+
121
+ /* Events table */
122
+ .events-wrap {
123
+ max-height: 400px;
124
+ overflow-y: auto;
125
+ border: 1px solid #2f3336;
126
+ border-radius: 4px;
127
+ }
128
+
129
+ .events-table {
130
+ width: 100%;
131
+ border-collapse: collapse;
132
+ font-size: 12px;
133
+ font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
134
+ }
135
+
136
+ .events-table thead {
137
+ position: sticky;
138
+ top: 0;
139
+ background: #1e2a36;
140
+ }
141
+
142
+ .events-table th {
143
+ text-align: left;
144
+ padding: 6px 8px;
145
+ color: #8b98a5;
146
+ font-weight: 600;
147
+ border-bottom: 1px solid #2f3336;
148
+ }
149
+
150
+ .events-table td {
151
+ padding: 4px 8px;
152
+ border-bottom: 1px solid #1e2a36;
153
+ }
154
+
155
+ .events-table tr:hover {
156
+ background: #1e2a36;
157
+ }
158
+
159
+ .status-ACCEPTED { color: #4ade80; }
160
+ .status-IMAGE_BACKFILL { color: #60a5fa; }
161
+ .status-DEDUPLICATED { color: #8b98a5; }
162
+ .status-PARSER_ERROR { color: #f87171; }
163
+ .status-DROPPED_OVERSIZED { color: #f87171; }
164
+
165
+ /* Sandbox */
166
+ .sandbox-row {
167
+ margin-bottom: 10px;
168
+ }
169
+
170
+ .sandbox-row label {
171
+ display: block;
172
+ font-size: 12px;
173
+ color: #8b98a5;
174
+ margin-bottom: 4px;
175
+ }
176
+
177
+ .sandbox-row input,
178
+ .sandbox-row textarea {
179
+ width: 100%;
180
+ padding: 8px;
181
+ border: 1px solid #2f3336;
182
+ border-radius: 6px;
183
+ background: #1e2a36;
184
+ color: #e7e9ea;
185
+ font-size: 12px;
186
+ font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
187
+ outline: none;
188
+ resize: vertical;
189
+ }
190
+
191
+ .sandbox-row input:focus,
192
+ .sandbox-row textarea:focus {
193
+ border-color: #1d9bf0;
194
+ }
195
+
196
+ .action-btn {
197
+ padding: 8px 16px;
198
+ border: none;
199
+ border-radius: 6px;
200
+ background: #1d9bf0;
201
+ color: #fff;
202
+ font-size: 13px;
203
+ font-weight: 600;
204
+ cursor: pointer;
205
+ }
206
+
207
+ .action-btn:hover {
208
+ opacity: 0.85;
209
+ }
210
+
211
+ .sandbox-output {
212
+ margin-top: 12px;
213
+ padding: 12px;
214
+ background: #1e2a36;
215
+ border: 1px solid #2f3336;
216
+ border-radius: 6px;
217
+ font-size: 12px;
218
+ font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
219
+ white-space: pre-wrap;
220
+ word-break: break-all;
221
+ max-height: 400px;
222
+ overflow-y: auto;
223
+ display: none;
224
+ }
225
+
226
+ .sandbox-output.visible {
227
+ display: block;
228
+ }
229
+
230
+ .sandbox-output.error {
231
+ color: #f87171;
232
+ }
extension/debug.html ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>xTap Debug Dashboard</title>
6
+ <link rel="stylesheet" href="debug.css">
7
+ </head>
8
+ <body>
9
+ <div class="container">
10
+ <h1>xTap Debug Dashboard</h1>
11
+
12
+ <section class="panel">
13
+ <h2>Transport Health</h2>
14
+ <table class="health-table">
15
+ <tr><td class="label">Transport</td><td id="h-transport">—</td></tr>
16
+ <tr><td class="label">Status</td><td id="h-status">—</td></tr>
17
+ <tr><td class="label">Capture</td><td id="h-capture">—</td></tr>
18
+ <tr><td class="label">Session count</td><td id="h-session">—</td></tr>
19
+ <tr><td class="label">All-time count</td><td id="h-alltime">—</td></tr>
20
+ <tr><td class="label">Buffer size</td><td id="h-buffer">—</td></tr>
21
+ <tr id="h-error-row" style="display: none;"><td class="label">Error</td><td id="h-error" class="status-error">—</td></tr>
22
+ </table>
23
+ <div class="toggle-row">
24
+ <label><input type="checkbox" id="debug-toggle"> Debug logging to file</label>
25
+ </div>
26
+ <div class="toggle-row">
27
+ <label><input type="checkbox" id="verbose-toggle"> Discovery mode (verbose endpoint logging)</label>
28
+ </div>
29
+ </section>
30
+
31
+ <section class="panel" id="discovered-section" style="display: none;">
32
+ <h2>Discovered Endpoints</h2>
33
+ <div id="discovered-list" class="discovered-list"></div>
34
+ </section>
35
+
36
+ <section class="panel">
37
+ <h2>
38
+ Capture Events
39
+ <span class="panel-actions">
40
+ <label class="auto-scroll-label"><input type="checkbox" id="auto-scroll" checked> Auto-scroll</label>
41
+ <button id="clear-events" class="small-btn">Clear</button>
42
+ </span>
43
+ </h2>
44
+ <div class="events-wrap">
45
+ <table class="events-table">
46
+ <thead>
47
+ <tr>
48
+ <th>Time</th>
49
+ <th>Endpoint</th>
50
+ <th>Tweet ID</th>
51
+ <th>Status</th>
52
+ <th>Reason</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody id="events-body"></tbody>
56
+ </table>
57
+ </div>
58
+ </section>
59
+
60
+ <section class="panel">
61
+ <h2>Parser Sandbox</h2>
62
+ <div class="sandbox-row">
63
+ <label for="sandbox-endpoint">Endpoint</label>
64
+ <input type="text" id="sandbox-endpoint" placeholder="e.g. HomeTimeline" spellcheck="false">
65
+ </div>
66
+ <div class="sandbox-row">
67
+ <label for="sandbox-json">Response JSON</label>
68
+ <textarea id="sandbox-json" rows="10" placeholder="Paste raw GraphQL JSON here..." spellcheck="false"></textarea>
69
+ </div>
70
+ <button id="sandbox-run" class="action-btn">Run Parser</button>
71
+ <pre id="sandbox-output" class="sandbox-output"></pre>
72
+ </section>
73
+ </div>
74
+ <script type="module" src="debug.js"></script>
75
+ </body>
76
+ </html>
extension/debug.js ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { extractTweets } from './lib/tweet-parser.js';
2
+
3
+ // --- Health polling ---
4
+
5
+ const hTransport = document.getElementById('h-transport');
6
+ const hStatus = document.getElementById('h-status');
7
+ const hCapture = document.getElementById('h-capture');
8
+ const hSession = document.getElementById('h-session');
9
+ const hAlltime = document.getElementById('h-alltime');
10
+ const hBuffer = document.getElementById('h-buffer');
11
+ const hErrorRow = document.getElementById('h-error-row');
12
+ const hError = document.getElementById('h-error');
13
+ const debugToggle = document.getElementById('debug-toggle');
14
+ const verboseToggle = document.getElementById('verbose-toggle');
15
+ const discoveredSection = document.getElementById('discovered-section');
16
+ const discoveredList = document.getElementById('discovered-list');
17
+
18
+ function refreshHealth() {
19
+ chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (resp) => {
20
+ if (!resp) return;
21
+ hTransport.textContent = resp.transport || 'none';
22
+ hStatus.textContent = resp.connected ? 'Connected' : 'Disconnected';
23
+ hStatus.className = resp.connected ? 'status-connected' : 'status-disconnected';
24
+ hCapture.textContent = resp.captureEnabled ? 'Enabled' : 'Paused';
25
+ hSession.textContent = resp.sessionCount.toLocaleString();
26
+ hAlltime.textContent = resp.allTimeCount.toLocaleString();
27
+ hBuffer.textContent = resp.buffered;
28
+ if (resp.transportError) {
29
+ hErrorRow.style.display = '';
30
+ hError.textContent = resp.transportError;
31
+ } else {
32
+ hErrorRow.style.display = 'none';
33
+ }
34
+ debugToggle.checked = !!resp.debugLogging;
35
+ verboseToggle.checked = !!resp.verboseLogging;
36
+ // Discovered endpoints
37
+ if (resp.discoveredEndpoints && resp.discoveredEndpoints.length > 0) {
38
+ discoveredSection.style.display = '';
39
+ discoveredList.textContent = resp.discoveredEndpoints.join(', ');
40
+ } else {
41
+ discoveredSection.style.display = 'none';
42
+ }
43
+ });
44
+ }
45
+
46
+ refreshHealth();
47
+ setInterval(refreshHealth, 5000);
48
+
49
+ debugToggle.addEventListener('change', () => {
50
+ chrome.runtime.sendMessage({ type: 'SET_DEBUG', debugLogging: debugToggle.checked }, () => {
51
+ refreshHealth();
52
+ });
53
+ });
54
+
55
+ verboseToggle.addEventListener('change', () => {
56
+ chrome.runtime.sendMessage({ type: 'SET_VERBOSE', verboseLogging: verboseToggle.checked }, () => {
57
+ refreshHealth();
58
+ });
59
+ });
60
+
61
+ // --- Capture events ---
62
+
63
+ const eventsBody = document.getElementById('events-body');
64
+ const autoScrollCheckbox = document.getElementById('auto-scroll');
65
+ const clearBtn = document.getElementById('clear-events');
66
+ const eventsWrap = document.querySelector('.events-wrap');
67
+ const traceStorage = chrome.storage.session || chrome.storage.local;
68
+ const traceArea = chrome.storage.session ? 'session' : 'local';
69
+
70
+ let renderedCount = 0;
71
+
72
+ function formatTime(ts) {
73
+ const d = new Date(ts);
74
+ return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0');
75
+ }
76
+
77
+ function renderEvents(events) {
78
+ eventsBody.innerHTML = '';
79
+ renderedCount = 0;
80
+ for (const ev of events) {
81
+ appendEventRow(ev);
82
+ }
83
+ }
84
+
85
+ function appendEventRow(ev) {
86
+ const tr = document.createElement('tr');
87
+ const cells = [formatTime(ev.timestamp), ev.endpoint, ev.tweetId || '—', ev.status, ev.reason || ''];
88
+ for (const text of cells) {
89
+ const td = document.createElement('td');
90
+ td.textContent = text;
91
+ tr.appendChild(td);
92
+ }
93
+ tr.children[3].className = `status-${ev.status}`;
94
+ eventsBody.appendChild(tr);
95
+ renderedCount++;
96
+ if (autoScrollCheckbox.checked) {
97
+ eventsWrap.scrollTop = eventsWrap.scrollHeight;
98
+ }
99
+ }
100
+
101
+ // Load initial events
102
+ traceStorage.get(['lastEvents'], (result) => {
103
+ if (result.lastEvents) renderEvents(result.lastEvents);
104
+ });
105
+
106
+ // Live updates
107
+ chrome.storage.onChanged.addListener((changes, area) => {
108
+ if (area === traceArea && changes.lastEvents) {
109
+ const events = changes.lastEvents.newValue || [];
110
+ // Re-render if the new batch has fewer (was trimmed) or is a fresh set
111
+ if (events.length <= renderedCount || events.length === 0) {
112
+ renderEvents(events);
113
+ } else {
114
+ // Append only new events
115
+ const newEvents = events.slice(renderedCount);
116
+ for (const ev of newEvents) {
117
+ appendEventRow(ev);
118
+ }
119
+ }
120
+ }
121
+ });
122
+
123
+ clearBtn.addEventListener('click', () => {
124
+ eventsBody.innerHTML = '';
125
+ renderedCount = 0;
126
+ traceStorage.set({ lastEvents: [] });
127
+ });
128
+
129
+ // --- Parser sandbox ---
130
+
131
+ const sandboxEndpoint = document.getElementById('sandbox-endpoint');
132
+ const sandboxJson = document.getElementById('sandbox-json');
133
+ const sandboxRun = document.getElementById('sandbox-run');
134
+ const sandboxOutput = document.getElementById('sandbox-output');
135
+
136
+ sandboxRun.addEventListener('click', () => {
137
+ const endpoint = sandboxEndpoint.value.trim() || 'unknown';
138
+ const raw = sandboxJson.value.trim();
139
+ sandboxOutput.classList.add('visible');
140
+ sandboxOutput.classList.remove('error');
141
+
142
+ if (!raw) {
143
+ sandboxOutput.classList.add('error');
144
+ sandboxOutput.textContent = 'Paste JSON above first.';
145
+ return;
146
+ }
147
+
148
+ let data;
149
+ try {
150
+ data = JSON.parse(raw);
151
+ } catch (e) {
152
+ sandboxOutput.classList.add('error');
153
+ sandboxOutput.textContent = `JSON parse error: ${e.message}`;
154
+ return;
155
+ }
156
+
157
+ try {
158
+ const tweets = extractTweets(endpoint, data);
159
+ if (tweets.length === 0) {
160
+ sandboxOutput.textContent = 'No tweets extracted.';
161
+ } else {
162
+ sandboxOutput.textContent = `${tweets.length} tweet(s) extracted:\n\n${JSON.stringify(tweets, null, 2)}`;
163
+ }
164
+ } catch (e) {
165
+ sandboxOutput.classList.add('error');
166
+ sandboxOutput.textContent = `Parser error: ${e.message}\n\n${e.stack}`;
167
+ }
168
+ });
extension/icons/icon128.png ADDED
extension/icons/icon16.png ADDED
extension/icons/icon48.png ADDED
extension/lib/dedup.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tweet deduplication logic.
3
+ *
4
+ * Extracted so both background.js and tests can use the same code path.
5
+ */
6
+
7
+ /**
8
+ * Check whether a tweet is new and track it in seenIds.
9
+ *
10
+ * Returns true → tweet is new, should be enqueued.
11
+ * Returns false → tweet is a duplicate, should be skipped.
12
+ *
13
+ * - Tweets without an id are always new (and not tracked).
14
+ * - Article tweets may bypass tweet-ID dedup once — they may enrich a
15
+ * previously captured non-article row, but repeated full article payloads
16
+ * should not be appended forever.
17
+ * - When `options.imageBackfill` is set and the tweet has photo media,
18
+ * a duplicate is also let through once per session (tracked in
19
+ * `options.imageCheckedIds`) so the daemon can download images that
20
+ * were skipped when image-download was off at original capture time.
21
+ * The downloader's per-file `os.path.exists` check makes repeats cheap.
22
+ */
23
+ export function dedupTweet(tweet, seenIds, options = {}) {
24
+ const { imageBackfill = false, imageCheckedIds = null } = options;
25
+
26
+ const isDup = !!(tweet.id && seenIds.has(tweet.id));
27
+ const isArticle = !!tweet.is_article;
28
+ const articleKey = isArticle && tweet.id ? articleSeenKey(tweet.id) : null;
29
+ const isArticleDup = !!(articleKey && seenIds.has(articleKey));
30
+ const wantsImageBackfill = (
31
+ imageBackfill &&
32
+ !!tweet.id &&
33
+ imageCheckedIds &&
34
+ !imageCheckedIds.has(tweet.id) &&
35
+ hasPhotoMedia(tweet)
36
+ );
37
+
38
+ if (isDup && (!isArticle || isArticleDup) && !wantsImageBackfill) {
39
+ return false;
40
+ }
41
+
42
+ if (tweet.id) seenIds.add(tweet.id);
43
+ if (articleKey) seenIds.add(articleKey);
44
+ if (wantsImageBackfill) imageCheckedIds.add(tweet.id);
45
+
46
+ return true;
47
+ }
48
+
49
+ function articleSeenKey(id) {
50
+ return `article:${id}`;
51
+ }
52
+
53
+ function hasPhotoMedia(tweet) {
54
+ const media = tweet.media;
55
+ if (!Array.isArray(media)) return false;
56
+ for (const m of media) {
57
+ if (m && m.type === 'photo' && m.url) return true;
58
+ }
59
+ return false;
60
+ }
extension/lib/graphql-capture.js ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DEBUGGER_PROTOCOL_VERSION = '1.3';
2
+ const GRAPHQL_PATH = '/i/api/graphql/';
3
+ const MAX_PENDING_RESPONSES = 10_000;
4
+ const X_URL_PATTERNS = ['*://*.x.com/*', '*://*.twitter.com/*'];
5
+
6
+ export class GraphqlCapture {
7
+ constructor({
8
+ debuggerApi = globalThis.chrome?.debugger,
9
+ tabs = globalThis.chrome?.tabs,
10
+ onResponse,
11
+ logger = console,
12
+ } = {}) {
13
+ if (!debuggerApi || !tabs) throw new Error('Chrome debugger capture is unavailable');
14
+ if (typeof onResponse !== 'function') throw new Error('GraphQL response handler is required');
15
+ this.debuggerApi = debuggerApi;
16
+ this.tabs = tabs;
17
+ this.onResponse = onResponse;
18
+ this.logger = logger;
19
+ this.attachedTabs = new Set();
20
+ this.attachmentRevisions = new Map();
21
+ this.attachments = new Map();
22
+ this.pendingResponses = new Map();
23
+ }
24
+
25
+ attach() {
26
+ this.debuggerApi.onEvent.addListener((source, method, params) => {
27
+ void this.handleEvent(source, method, params).catch((error) => {
28
+ this.logger.error('[xTap] Passive GraphQL capture failed:', error);
29
+ });
30
+ });
31
+ this.debuggerApi.onDetach.addListener((source) => {
32
+ if (source.tabId === undefined) return;
33
+ this.attachedTabs.delete(source.tabId);
34
+ this.attachments.delete(source.tabId);
35
+ this.deletePendingForTab(source.tabId);
36
+ });
37
+ this.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
38
+ const url = changeInfo.url ?? tab.url;
39
+ if (isXUrl(url)) {
40
+ void this.ensureAttached(tabId);
41
+ } else if (changeInfo.url || changeInfo.status === 'loading') {
42
+ void this.detachTab(tabId);
43
+ }
44
+ });
45
+ this.tabs.onRemoved.addListener((tabId) => {
46
+ void this.detachTab(tabId);
47
+ });
48
+ void this.attachExistingTabs();
49
+ }
50
+
51
+ async attachExistingTabs() {
52
+ const tabs = await this.tabs.query({ url: X_URL_PATTERNS });
53
+ await Promise.all(
54
+ tabs
55
+ .map((tab) => tab.id)
56
+ .filter((tabId) => Number.isSafeInteger(tabId))
57
+ .map((tabId) => this.ensureAttached(tabId)),
58
+ );
59
+ }
60
+
61
+ async ensureAttached(tabId) {
62
+ if (!Number.isSafeInteger(tabId) || tabId < 0) return false;
63
+ if (this.attachedTabs.has(tabId)) return true;
64
+ const pending = this.attachments.get(tabId);
65
+ if (pending) return pending;
66
+ const revision = this.attachmentRevisions.get(tabId) ?? 0;
67
+ const attaching = this.attachTab(tabId, revision).finally(() => {
68
+ if (this.attachments.get(tabId) === attaching) this.attachments.delete(tabId);
69
+ });
70
+ this.attachments.set(tabId, attaching);
71
+ return attaching;
72
+ }
73
+
74
+ async attachTab(tabId, revision) {
75
+ let attachedHere = false;
76
+ try {
77
+ await this.debuggerApi.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
78
+ attachedHere = true;
79
+ } catch (error) {
80
+ if (!isAlreadyAttachedError(error)) {
81
+ this.logger.warn(`[xTap] Could not observe X tab ${tabId}: ${errorMessage(error)}`);
82
+ return false;
83
+ }
84
+ }
85
+ try {
86
+ await this.debuggerApi.sendCommand({ tabId }, 'Network.enable');
87
+ if ((this.attachmentRevisions.get(tabId) ?? 0) !== revision) {
88
+ if (attachedHere) await this.safeDetach(tabId);
89
+ return false;
90
+ }
91
+ this.attachedTabs.add(tabId);
92
+ return true;
93
+ } catch (error) {
94
+ if (attachedHere) await this.safeDetach(tabId);
95
+ this.logger.warn(`[xTap] Could not enable capture for X tab ${tabId}: ${errorMessage(error)}`);
96
+ return false;
97
+ }
98
+ }
99
+
100
+ async detachTab(tabId) {
101
+ const revision = (this.attachmentRevisions.get(tabId) ?? 0) + 1;
102
+ this.attachmentRevisions.set(tabId, revision);
103
+ const pending = this.attachments.get(tabId);
104
+ if (pending) await pending;
105
+ this.deletePendingForTab(tabId);
106
+ if (!this.attachedTabs.delete(tabId)) return;
107
+ await this.safeDetach(tabId);
108
+ }
109
+
110
+ async safeDetach(tabId) {
111
+ try {
112
+ await this.debuggerApi.detach({ tabId });
113
+ } catch {
114
+ // Chrome may have already detached a closed or reassigned tab.
115
+ }
116
+ }
117
+
118
+ async handleEvent(source, method, params) {
119
+ const tabId = source.tabId;
120
+ if (!Number.isSafeInteger(tabId)) return;
121
+ const requestId = typeof params?.requestId === 'string' ? params.requestId : undefined;
122
+ if (!requestId) return;
123
+ const key = responseKey(tabId, requestId);
124
+
125
+ if (method === 'Network.responseReceived') {
126
+ const url = params?.response?.url;
127
+ const endpoint = extractGraphqlEndpoint(url);
128
+ if (endpoint) this.rememberPending(key, { endpoint, requestId, tabId, url });
129
+ return;
130
+ }
131
+ if (method === 'Network.loadingFailed') {
132
+ this.pendingResponses.delete(key);
133
+ return;
134
+ }
135
+ if (method !== 'Network.loadingFinished') return;
136
+
137
+ const pending = this.pendingResponses.get(key);
138
+ if (!pending) return;
139
+ this.pendingResponses.delete(key);
140
+ const body = await this.debuggerApi.sendCommand(
141
+ { tabId },
142
+ 'Network.getResponseBody',
143
+ { requestId },
144
+ );
145
+ const text = decodeResponseBody(body);
146
+ const data = JSON.parse(text);
147
+ await this.onResponse({
148
+ data,
149
+ endpoint: pending.endpoint,
150
+ sourceTabId: tabId,
151
+ url: pending.url,
152
+ });
153
+ }
154
+
155
+ rememberPending(key, response) {
156
+ this.pendingResponses.set(key, response);
157
+ if (this.pendingResponses.size <= MAX_PENDING_RESPONSES) return;
158
+ const oldestKey = this.pendingResponses.keys().next().value;
159
+ if (oldestKey !== undefined) this.pendingResponses.delete(oldestKey);
160
+ }
161
+
162
+ deletePendingForTab(tabId) {
163
+ const prefix = `${tabId}:`;
164
+ for (const key of this.pendingResponses.keys()) {
165
+ if (key.startsWith(prefix)) this.pendingResponses.delete(key);
166
+ }
167
+ }
168
+ }
169
+
170
+ export function extractGraphqlEndpoint(url) {
171
+ if (typeof url !== 'string') return undefined;
172
+ try {
173
+ const parsed = new URL(url);
174
+ if (!isXHostname(parsed.hostname)) return undefined;
175
+ if (!parsed.pathname.includes(GRAPHQL_PATH)) return undefined;
176
+ const parts = parsed.pathname.split('/');
177
+ const graphqlIndex = parts.indexOf('graphql');
178
+ const endpoint = graphqlIndex >= 0 ? parts[graphqlIndex + 2] : undefined;
179
+ return typeof endpoint === 'string' && endpoint.length > 0 ? endpoint : undefined;
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ }
184
+
185
+ function decodeResponseBody(value) {
186
+ if (!value || typeof value.body !== 'string') throw new Error('GraphQL response body is unavailable');
187
+ return value.base64Encoded === true ? atob(value.body) : value.body;
188
+ }
189
+
190
+ function isXUrl(value) {
191
+ if (typeof value !== 'string') return false;
192
+ try {
193
+ return isXHostname(new URL(value).hostname);
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ function isXHostname(hostname) {
200
+ return hostname === 'x.com' || hostname.endsWith('.x.com') ||
201
+ hostname === 'twitter.com' || hostname.endsWith('.twitter.com');
202
+ }
203
+
204
+ function isAlreadyAttachedError(error) {
205
+ return errorMessage(error).toLowerCase().includes('another debugger is already attached');
206
+ }
207
+
208
+ function errorMessage(error) {
209
+ return error instanceof Error ? error.message : String(error);
210
+ }
211
+
212
+ function responseKey(tabId, requestId) {
213
+ return `${tabId}:${requestId}`;
214
+ }
extension/lib/observations.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Observation delivery is independent of the unique-post export filter.
2
+ // Hashes exclude transport fields. Retrying a saved sample preserves its time.
3
+ const SAMPLE_INTERVAL_MS = 5 * 60_000;
4
+ const MAX_SAMPLES = 50_000;
5
+ const OBSERVATION_FIELDS = new Set([
6
+ 'captured_at', 'pooled_at', 'contributed_by', 'metrics', 'source_endpoint',
7
+ 'observation_id', '__xtap_image_backfill',
8
+ ]);
9
+
10
+ export function exactCounter(value) {
11
+ if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
12
+ const converted = Number(value);
13
+ return Number.isSafeInteger(converted) ? converted : null;
14
+ }
15
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
16
+ }
17
+
18
+ function canonical(value) {
19
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
20
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
21
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined)
22
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
23
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
24
+ }
25
+ async function digest(value) {
26
+ const bytes = new TextEncoder().encode(canonical(value));
27
+ const hash = await globalThis.crypto.subtle.digest('SHA-256', bytes);
28
+ return [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('');
29
+ }
30
+
31
+ /** Pure preparation: callers commit samples and queued records in one write. */
32
+ export async function prepareObservations(tweets, previous = {}) {
33
+ const samples = new Map(Object.entries(previous));
34
+ const accepted = [];
35
+ let sampled = 0;
36
+ for (const tweet of tweets) {
37
+ const time = Date.parse(tweet.captured_at);
38
+ if (typeof tweet.id !== 'string' || !tweet.id || !Number.isFinite(time)) throw new Error('observation requires a post ID and time');
39
+ const content = Object.fromEntries(Object.entries(tweet).filter(([key]) => !OBSERVATION_FIELDS.has(key)));
40
+ if (tweet.author && typeof tweet.author === 'object') {
41
+ content.author = Object.fromEntries(Object.entries(tweet.author).filter(([key]) => key !== 'follower_count'));
42
+ }
43
+ const hash = await digest(content);
44
+ const before = samples.get(tweet.id);
45
+ // A later visit in another interval is useful even when every count is unchanged.
46
+ // Deliver late samples too; time ordering must not erase history.
47
+ if (before?.content === hash && Math.floor(time / SAMPLE_INTERVAL_MS) === Math.floor(before.time / SAMPLE_INTERVAL_MS)) {
48
+ sampled++;
49
+ continue;
50
+ }
51
+ const captured_at = new Date(time).toISOString();
52
+ const observation_id = await digest({ id: tweet.id, captured_at, content: hash, metrics: tweet.metrics ?? null });
53
+ accepted.push({ ...tweet, captured_at, observation_id });
54
+ if (!before || time >= before.time) samples.set(tweet.id, { time, content: hash });
55
+ }
56
+ // Eviction only permits extra sampling; it never deletes queued observations.
57
+ const retained = [...samples].sort((a, b) => b[1].time - a[1].time).slice(0, MAX_SAMPLES);
58
+ return { accepted, samples: Object.fromEntries(retained), sampled };
59
+ }
extension/lib/pool-sync.js ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // xtap-pool — background sync of captured tweets to the shared pool Space.
2
+ //
3
+ // Additive to xTap's local saving: tweets are appended to a persistent queue
4
+ // in chrome.storage.local and flushed in batches to POST <poolUrl>/api/ingest
5
+ // with the user's pool token. Delivery is at-least-once; the Space dedups.
6
+
7
+ import { prepareObservations } from './observations.js';
8
+
9
+ const QUEUE_KEY = 'poolQueue';
10
+ const SAMPLES_KEY = 'poolSamples';
11
+ const REJECTIONS_KEY = 'poolRejections';
12
+ const MAX_REJECTIONS = 500;
13
+ const CONFIG_KEYS = ['poolUrl', 'poolToken', 'poolUsername', 'poolPaused', 'poolStats'];
14
+ const MAX_QUEUE = 5000;
15
+ const MAX_BATCH = 500;
16
+ const FLUSH_DEBOUNCE_MS = 20_000;
17
+ const BACKOFF_BASE_MS = 30_000;
18
+ const BACKOFF_MAX_MS = 15 * 60_000;
19
+
20
+ // Set at build/packaging time so friends get a working default without setup.
21
+ export const DEFAULT_POOL_URL = 'https://osolmaz-xtap-pool.hf.space';
22
+
23
+ let queue = [];
24
+ let samples = {};
25
+ let rejections = [];
26
+ let queueWrites = Promise.resolve();
27
+ let config = {
28
+ poolUrl: DEFAULT_POOL_URL,
29
+ poolToken: '',
30
+ poolUsername: '',
31
+ poolPaused: false,
32
+ };
33
+ let stats = { synced: 0, observations: 0, sampled: 0, blocked: 0, lastError: null, lastSyncAt: null };
34
+ let flushTimer = null;
35
+ let backoffMs = 0;
36
+ let flushing = false;
37
+
38
+ function storage() {
39
+ return globalThis.chrome.storage.local;
40
+ }
41
+
42
+ function storageGet(keys) {
43
+ return new Promise((resolve, reject) => storage().get(keys, result => {
44
+ const error = globalThis.chrome.runtime?.lastError;
45
+ if (error) reject(new Error(error.message || 'pool storage read failed'));
46
+ else resolve(result);
47
+ }));
48
+ }
49
+
50
+ function storageSet(items) {
51
+ return new Promise((resolve, reject) => storage().set(items, () => {
52
+ const error = globalThis.chrome.runtime?.lastError;
53
+ if (error) reject(new Error(error.message || 'pool storage write failed'));
54
+ else resolve();
55
+ }));
56
+ }
57
+
58
+ function withQueueWrite(operation) {
59
+ const result = queueWrites.then(operation, operation);
60
+ queueWrites = result.catch(() => {});
61
+ return result;
62
+ }
63
+
64
+ export async function initPoolSync() {
65
+ const saved = await storageGet([QUEUE_KEY, SAMPLES_KEY, REJECTIONS_KEY, ...CONFIG_KEYS]);
66
+ queue = Array.isArray(saved[QUEUE_KEY]) ? saved[QUEUE_KEY] : [];
67
+ samples = saved[SAMPLES_KEY] && typeof saved[SAMPLES_KEY] === 'object' ? saved[SAMPLES_KEY] : {};
68
+ rejections = Array.isArray(saved[REJECTIONS_KEY]) ? saved[REJECTIONS_KEY] : [];
69
+ if (typeof saved.poolUrl === 'string' && saved.poolUrl) config.poolUrl = saved.poolUrl;
70
+ if (typeof saved.poolToken === 'string') config.poolToken = saved.poolToken;
71
+ if (typeof saved.poolUsername === 'string') config.poolUsername = saved.poolUsername;
72
+ config.poolPaused = saved.poolPaused === true;
73
+ if (saved.poolStats && typeof saved.poolStats === 'object') stats = { ...stats, ...saved.poolStats };
74
+ if (queue.length > 0) scheduleFlush(0);
75
+ }
76
+
77
+ async function persistStats() {
78
+ await storageSet({ poolStats: stats });
79
+ }
80
+
81
+ /** Persist samples and admission state together. Failure leaves staged work pending. */
82
+ export function poolEnqueue(tweets) {
83
+ if (!Array.isArray(tweets) || tweets.length === 0) return Promise.resolve();
84
+ return withQueueWrite(async () => {
85
+ const prepared = await prepareObservations(tweets, samples);
86
+ if (queue.length + prepared.accepted.length > MAX_QUEUE) {
87
+ stats = { ...stats, blocked: stats.blocked + 1, lastError: 'pool queue full — staged observations are waiting' };
88
+ await persistStats();
89
+ scheduleFlush(0);
90
+ throw new Error(stats.lastError);
91
+ }
92
+ const nextQueue = [...queue, ...prepared.accepted];
93
+ const nextStats = { ...stats, observations: stats.observations + prepared.accepted.length, sampled: stats.sampled + prepared.sampled };
94
+ await storageSet({ [QUEUE_KEY]: nextQueue, [SAMPLES_KEY]: prepared.samples, poolStats: nextStats });
95
+ queue = nextQueue;
96
+ samples = prepared.samples;
97
+ stats = nextStats;
98
+ if (queue.length > 0) scheduleFlush(queue.length >= MAX_BATCH ? 0 : FLUSH_DEBOUNCE_MS);
99
+ });
100
+ }
101
+
102
+ function scheduleFlush(delayMs) {
103
+ if (flushTimer) {
104
+ if (delayMs > 0) return;
105
+ // An immediate flush supersedes a pending debounce timer.
106
+ clearTimeout(flushTimer);
107
+ }
108
+ flushTimer = setTimeout(() => {
109
+ flushTimer = null;
110
+ poolFlush().catch(error => {
111
+ stats.lastError = `pool persistence failed: ${error.message}`;
112
+ scheduleFlush(BACKOFF_BASE_MS);
113
+ });
114
+ }, delayMs);
115
+ }
116
+
117
+ /** Push queued tweets to the Space, batch by batch. Returns when idle. */
118
+ export async function poolFlush() {
119
+ if (flushing || config.poolPaused || !config.poolToken || queue.length === 0) return;
120
+ flushing = true;
121
+ try {
122
+ while (queue.length > 0) {
123
+ const batch = queue.slice(0, MAX_BATCH);
124
+ const acknowledgment = await sendBatch(batch);
125
+ if (!acknowledgment) break;
126
+ await withQueueWrite(async () => {
127
+ const nextQueue = queue.slice(batch.length);
128
+ const nextRejections = [...rejections, ...acknowledgment.rejected.map(item => ({ tweet: batch[item.index], reason: item.reason }))];
129
+ if (nextRejections.length > MAX_REJECTIONS) throw new Error('pool rejection archive full — queued observations retained');
130
+ const nextStats = { ...stats, synced: stats.synced + batch.length - acknowledgment.rejected.length, lastError: nextRejections.length ? `${nextRejections.length} rejected observations saved for inspection` : null, lastSyncAt: new Date().toISOString() };
131
+ await storageSet({ [QUEUE_KEY]: nextQueue, [REJECTIONS_KEY]: nextRejections, poolStats: nextStats });
132
+ queue = nextQueue;
133
+ rejections = nextRejections;
134
+ stats = nextStats;
135
+ backoffMs = 0;
136
+ });
137
+ }
138
+ } finally {
139
+ flushing = false;
140
+ }
141
+ }
142
+
143
+ async function sendBatch(batch) {
144
+ let response;
145
+ try {
146
+ response = await globalThis.fetch(`${config.poolUrl}/api/ingest`, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'content-type': 'application/json',
150
+ authorization: `Bearer ${config.poolToken}`,
151
+ },
152
+ body: JSON.stringify({ tweets: batch }),
153
+ });
154
+ } catch (e) {
155
+ return retryLater(`network error: ${e && e.message ? e.message : e}`);
156
+ }
157
+ if (response.status === 401) {
158
+ // Token expired or revoked — needs a manual reconnect, retrying won't help.
159
+ await withQueueWrite(async () => {
160
+ stats.lastError = 'pool token rejected — reconnect from the popup';
161
+ await persistStats();
162
+ });
163
+ return false;
164
+ }
165
+ if (!response.ok) {
166
+ return retryLater(`pool responded ${response.status}`);
167
+ }
168
+ const body = await response.json().catch(() => null);
169
+ if (!body || !Number.isSafeInteger(body.added) || body.added < 0 ||
170
+ !Number.isSafeInteger(body.duplicates) || body.duplicates < 0 ||
171
+ !Array.isArray(body.rejected) ||
172
+ !body.rejected.every(item => item && Number.isSafeInteger(item.index) && item.index >= 0 && item.index < batch.length && typeof item.reason === 'string' && item.reason.length > 0) ||
173
+ new Set(body.rejected.map(item => item.index)).size !== body.rejected.length ||
174
+ body.added + body.duplicates + body.rejected.length !== batch.length) {
175
+ return retryLater('pool did not acknowledge the complete observation batch');
176
+ }
177
+ return { rejected: body.rejected };
178
+ }
179
+
180
+ async function retryLater(message) {
181
+ backoffMs = Math.min(backoffMs > 0 ? backoffMs * 2 : BACKOFF_BASE_MS, BACKOFF_MAX_MS);
182
+ await withQueueWrite(async () => {
183
+ stats.lastError = `${message} — retrying in ${Math.round(backoffMs / 1000)}s`;
184
+ await persistStats();
185
+ });
186
+ scheduleFlush(backoffMs);
187
+ return false;
188
+ }
189
+
190
+ /**
191
+ * Handle a token handed off by the /connect page content script.
192
+ *
193
+ * The token is only accepted when the sender page's origin matches the
194
+ * configured pool URL; otherwise any malicious HF Space rendering an
195
+ * `#xtap-pool-token` element could silently repoint the extension and
196
+ * receive all future captures. Switching pools requires setting the URL
197
+ * explicitly (options / POOL_SET_CONFIG) before connecting.
198
+ */
199
+ export async function poolConnect({ token, username }, senderUrl) {
200
+ if (typeof token !== 'string' || !token) return { ok: false, error: 'missing token' };
201
+ if (!senderMatchesPool(senderUrl)) {
202
+ console.warn(`[xtap-pool] refused pool token from unexpected origin: ${senderUrl}`);
203
+ return { ok: false, error: `refused token from unexpected origin (pool is ${config.poolUrl})` };
204
+ }
205
+ config.poolToken = token;
206
+ if (typeof username === 'string') config.poolUsername = username;
207
+ stats.lastError = null;
208
+ await storageSet({
209
+ poolToken: config.poolToken,
210
+ poolUsername: config.poolUsername,
211
+ poolStats: stats,
212
+ });
213
+ scheduleFlush(0);
214
+ return { ok: true, username: config.poolUsername };
215
+ }
216
+
217
+ function senderMatchesPool(senderUrl) {
218
+ if (typeof senderUrl !== 'string' || !senderUrl) return false;
219
+ try {
220
+ return new URL(senderUrl).origin === new URL(config.poolUrl).origin;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ export async function poolSetConfig({ url, token }) {
227
+ if (typeof url === 'string' && url) {
228
+ const normalized = url.replace(/\/+$/, '');
229
+ if (normalized !== config.poolUrl) {
230
+ config.poolUrl = normalized;
231
+ // A token is only valid for the pool that minted it. Keeping the old
232
+ // one would leak it (and queued captures) to whatever Space the new
233
+ // URL points at on the next flush.
234
+ config.poolToken = '';
235
+ config.poolUsername = '';
236
+ }
237
+ }
238
+ if (typeof token === 'string' && token) {
239
+ config.poolToken = token;
240
+ config.poolUsername = '';
241
+ }
242
+ await storageSet({
243
+ poolUrl: config.poolUrl,
244
+ poolToken: config.poolToken,
245
+ poolUsername: config.poolUsername,
246
+ });
247
+ if (config.poolToken) scheduleFlush(0);
248
+ }
249
+
250
+ export async function poolTogglePause() {
251
+ config.poolPaused = !config.poolPaused;
252
+ await storageSet({ poolPaused: config.poolPaused });
253
+ if (!config.poolPaused) scheduleFlush(0);
254
+ return config.poolPaused;
255
+ }
256
+
257
+ export function poolStatus() {
258
+ return {
259
+ connected: Boolean(config.poolToken),
260
+ username: config.poolUsername,
261
+ url: config.poolUrl,
262
+ paused: config.poolPaused,
263
+ queued: queue.length,
264
+ synced: stats.synced,
265
+ observations: stats.observations,
266
+ sampled: stats.sampled,
267
+ blocked: stats.blocked,
268
+ rejected: rejections.length,
269
+ lastError: stats.lastError,
270
+ lastSyncAt: stats.lastSyncAt,
271
+ };
272
+ }
273
+
274
+ // Test-only hook: reset module state between node --test cases.
275
+ export function _resetForTests() {
276
+ queue = [];
277
+ samples = {};
278
+ rejections = [];
279
+ queueWrites = Promise.resolve();
280
+ config = { poolUrl: DEFAULT_POOL_URL, poolToken: '', poolUsername: '', poolPaused: false };
281
+ stats = { synced: 0, observations: 0, sampled: 0, blocked: 0, lastError: null, lastSyncAt: null };
282
+ if (flushTimer) clearTimeout(flushTimer);
283
+ flushTimer = null;
284
+ backoffMs = 0;
285
+ flushing = false;
286
+ }
extension/lib/scrape-bridge.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ SCRAPE_PORT_NAME,
3
+ SCRAPE_PROTOCOL_VERSION,
4
+ ScrapeReceiptError,
5
+ ScrapeReceiptStore,
6
+ } from './scrape-receipts.js';
7
+
8
+ export const SCROLLER_EXTENSION_ID = 'aahdialpkbjlbfjkpamfclbnlbinekal';
9
+
10
+ const CAPTURE_ATTACH_ATTEMPTS = 5;
11
+ const CAPTURE_ATTACH_RETRY_MS = 200;
12
+
13
+ export class ScrapeReceiptBridge {
14
+ constructor({
15
+ allowedExtensionId = SCROLLER_EXTENSION_ID,
16
+ ensureSourceCapture,
17
+ runtime = globalThis.chrome?.runtime,
18
+ store = new ScrapeReceiptStore(),
19
+ wait = defaultWait,
20
+ } = {}) {
21
+ if (!runtime) throw new Error('Chrome runtime is unavailable');
22
+ this.allowedExtensionId = allowedExtensionId;
23
+ this.ensureSourceCapture = ensureSourceCapture;
24
+ this.runtime = runtime;
25
+ this.store = store;
26
+ this.wait = wait;
27
+ this.connections = new Set();
28
+ this.cutoverClearPromise = null;
29
+ }
30
+
31
+ attach() {
32
+ this.runtime.onConnectExternal.addListener((port) => {
33
+ this.accept(port);
34
+ });
35
+ void this.ensureCutoverCleared().catch(() => {
36
+ // A later handshake retries and reports a persistent IndexedDB failure.
37
+ });
38
+ }
39
+
40
+ async ensureCutoverCleared() {
41
+ if (this.cutoverClearPromise) return this.cutoverClearPromise;
42
+ const clearing = this.store.finishCutover();
43
+ this.cutoverClearPromise = clearing;
44
+ try {
45
+ await clearing;
46
+ } catch (error) {
47
+ if (this.cutoverClearPromise === clearing) this.cutoverClearPromise = null;
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ async finishSourceTab(sourceTabId, finishedAtMs = Date.now()) {
53
+ return this.store.finishRunsForSourceTab(sourceTabId, finishedAtMs);
54
+ }
55
+
56
+ async recordGraphqlResponse({ endpoint, requestUrl, sourceTabId, tweets }) {
57
+ const observations = await this.store.recordTimeline({
58
+ endpoint,
59
+ observedAtMs: Date.now(),
60
+ requestUrl,
61
+ sourceTabId,
62
+ tweets,
63
+ });
64
+ if (observations.length === 0) return observations;
65
+
66
+ const runId = observations[0].runId;
67
+ const message = {
68
+ observations,
69
+ protocolVersion: SCRAPE_PROTOCOL_VERSION,
70
+ runId,
71
+ type: 'scrape:observations',
72
+ };
73
+ for (const connection of this.connections) {
74
+ if (connection.runId === runId) post(connection.port, message);
75
+ }
76
+ return observations;
77
+ }
78
+
79
+ accept(port) {
80
+ if (
81
+ port.name !== SCRAPE_PORT_NAME ||
82
+ port.sender?.id !== this.allowedExtensionId
83
+ ) {
84
+ port.disconnect();
85
+ return;
86
+ }
87
+
88
+ const connection = { port, runId: undefined };
89
+ this.connections.add(connection);
90
+ port.onDisconnect.addListener(() => {
91
+ this.connections.delete(connection);
92
+ });
93
+ port.onMessage.addListener((message) => {
94
+ void this.handleMessageAfterCutover(connection, message);
95
+ });
96
+ }
97
+
98
+ async handleMessageAfterCutover(connection, message) {
99
+ try {
100
+ await this.ensureCutoverCleared();
101
+ } catch (error) {
102
+ post(connection.port, scrapeError(message, error));
103
+ return;
104
+ }
105
+ await this.handleMessage(connection, message);
106
+ }
107
+
108
+ async handleMessage(connection, message) {
109
+ try {
110
+ if (message?.protocolVersion !== SCRAPE_PROTOCOL_VERSION) {
111
+ throw new Error('unsupported scrape protocol version');
112
+ }
113
+ if (message.type === 'scrape:open') {
114
+ if (
115
+ !Array.isArray(message.capabilities) ||
116
+ !message.capabilities.includes('run-leases')
117
+ ) {
118
+ throw new ScrapeReceiptError(
119
+ 'invalid-request',
120
+ 'scrape client must support run leases',
121
+ );
122
+ }
123
+ connection.runId = message.runId;
124
+ if (!(await this.ensureCapture(message.sourceTabId))) {
125
+ throw new ScrapeReceiptError(
126
+ 'internal-error',
127
+ `xTap passive capture is unavailable for source tab ${message.sourceTabId}`,
128
+ );
129
+ }
130
+ const run = await this.store.beginRun({
131
+ listId: message.listId,
132
+ runId: message.runId,
133
+ sourceTabId: message.sourceTabId,
134
+ startedAtMs: message.startedAtMs,
135
+ });
136
+ const observations = await this.store.readObservations(
137
+ run.runId,
138
+ normalizeCursor(message.afterCursor),
139
+ );
140
+ post(connection.port, {
141
+ capabilities: [
142
+ 'search-timeline-observations',
143
+ 'typed-errors',
144
+ 'run-leases',
145
+ ],
146
+ observations,
147
+ protocolVersion: SCRAPE_PROTOCOL_VERSION,
148
+ run,
149
+ runId: run.runId,
150
+ type: 'scrape:opened',
151
+ });
152
+ return;
153
+ }
154
+
155
+ if (message.type === 'scrape:heartbeat') {
156
+ await this.store.renewRun(
157
+ message.runId,
158
+ message.sourceTabId,
159
+ message.renewedAtMs,
160
+ );
161
+ return;
162
+ }
163
+
164
+ if (message.type === 'scrape:finish') {
165
+ const run = await this.store.finishRun(
166
+ message.runId,
167
+ message.state,
168
+ message.finishedAtMs,
169
+ );
170
+ post(connection.port, {
171
+ protocolVersion: SCRAPE_PROTOCOL_VERSION,
172
+ run,
173
+ runId: run.runId,
174
+ type: 'scrape:finished',
175
+ });
176
+ return;
177
+ }
178
+
179
+ throw new Error('unknown scrape protocol message');
180
+ } catch (error) {
181
+ post(connection.port, scrapeError(message, error));
182
+ }
183
+ }
184
+
185
+ async ensureCapture(sourceTabId) {
186
+ if (!this.ensureSourceCapture) return true;
187
+ for (let attempt = 1; attempt <= CAPTURE_ATTACH_ATTEMPTS; attempt += 1) {
188
+ if (await this.ensureSourceCapture(sourceTabId)) return true;
189
+ if (attempt < CAPTURE_ATTACH_ATTEMPTS) {
190
+ await this.wait(CAPTURE_ATTACH_RETRY_MS);
191
+ }
192
+ }
193
+ return false;
194
+ }
195
+ }
196
+
197
+ function defaultWait(delayMs) {
198
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
199
+ }
200
+
201
+ function normalizeCursor(value) {
202
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
203
+ }
204
+
205
+ function scrapeError(message, error) {
206
+ const failure = protocolFailure(error);
207
+ return {
208
+ error: failure.message,
209
+ errorCode: failure.code,
210
+ protocolVersion: SCRAPE_PROTOCOL_VERSION,
211
+ runId: typeof message?.runId === 'string' ? message.runId : '',
212
+ type: 'scrape:error',
213
+ };
214
+ }
215
+
216
+ function protocolFailure(error) {
217
+ if (error instanceof ScrapeReceiptError) {
218
+ return { code: error.code, message: error.message };
219
+ }
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ const code = message.startsWith('invalid ') ||
222
+ message === 'unsupported scrape protocol version' ||
223
+ message === 'unknown scrape protocol message'
224
+ ? 'invalid-request'
225
+ : 'internal-error';
226
+ return { code, message };
227
+ }
228
+
229
+ function post(port, message) {
230
+ try {
231
+ port.postMessage(message);
232
+ } catch {
233
+ // A disconnected client will reconnect and replay from its cursor.
234
+ }
235
+ }
extension/lib/scrape-receipts.js ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DATABASE_NAME = 'xtap-scrape-receipts';
2
+ const DATABASE_VERSION = 1;
3
+ const META_CUTOVER = 'cutoverInProgress';
4
+ const META_SEQUENCE = 'captureSequence';
5
+ const MAX_ACTIVE_RUNS = 4;
6
+ export const SCRAPE_RUN_LEASE_MS = 90_000;
7
+ const TIMELINE_ENDPOINTS = new Set([
8
+ 'ListLatestTweetsTimeline',
9
+ 'SearchTimeline',
10
+ ]);
11
+
12
+ export const SCRAPE_PROTOCOL_VERSION = 1;
13
+ export const SCRAPE_PORT_NAME = 'xtap-scrape-v1';
14
+
15
+ export class ScrapeReceiptError extends Error {
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.code = code;
19
+ this.name = 'ScrapeReceiptError';
20
+ }
21
+ }
22
+
23
+ export class ScrapeReceiptStore {
24
+ constructor(indexedDBFactory = globalThis.indexedDB, databaseName = DATABASE_NAME) {
25
+ if (!indexedDBFactory) throw new Error('IndexedDB is unavailable');
26
+ this.indexedDB = indexedDBFactory;
27
+ this.databaseName = databaseName;
28
+ this.databasePromise = null;
29
+ }
30
+
31
+ async beginRun({ runId, listId, sourceTabId, startedAtMs }, openedAtMs = Date.now()) {
32
+ validateRunInput({ runId, listId, sourceTabId, startedAtMs });
33
+ if (!Number.isFinite(openedAtMs)) throw new Error('invalid open time');
34
+ const database = await this.open();
35
+ const transaction = database.transaction(
36
+ ['listReceipts', 'meta', 'runs'],
37
+ 'readwrite',
38
+ );
39
+ const listReceipts = transaction.objectStore('listReceipts');
40
+ const meta = transaction.objectStore('meta');
41
+ const runs = transaction.objectStore('runs');
42
+
43
+ if ((await readMeta(meta, META_CUTOVER)) === true) {
44
+ transaction.abort();
45
+ throw new ScrapeReceiptError(
46
+ 'cutover-active',
47
+ 'xTap scrape cutover is in progress',
48
+ );
49
+ }
50
+
51
+ const allRuns = await request(runs.getAll());
52
+ const activeRuns = [];
53
+ for (const run of allRuns) {
54
+ if (run.state !== 'running') continue;
55
+ if (!hasLiveLease(run, openedAtMs)) {
56
+ expireRun(run, openedAtMs);
57
+ runs.put(run);
58
+ continue;
59
+ }
60
+ activeRuns.push(run);
61
+ }
62
+
63
+ const existing = allRuns.find((run) => run.runId === runId);
64
+ if (existing) {
65
+ if (existing.listId !== listId || existing.startedAtMs !== startedAtMs) {
66
+ transaction.abort();
67
+ throw new ScrapeReceiptError(
68
+ 'run-conflict',
69
+ 'run ID already belongs to different parameters',
70
+ );
71
+ }
72
+ if (existing.state === 'running') {
73
+ if (existing.sourceTabId !== sourceTabId) {
74
+ transaction.abort();
75
+ throw new ScrapeReceiptError(
76
+ 'run-conflict',
77
+ 'run ID already belongs to a different source tab',
78
+ );
79
+ }
80
+ renewLease(existing, openedAtMs);
81
+ runs.put(existing);
82
+ await transactionDone(transaction);
83
+ return existing;
84
+ }
85
+ if (isRecoverableStop(existing.stopReason)) {
86
+ if (activeRuns.some((run) => run.sourceTabId === sourceTabId)) {
87
+ transaction.abort();
88
+ throw new ScrapeReceiptError(
89
+ 'source-tab-active',
90
+ `source tab ${sourceTabId} already belongs to an active scrape run`,
91
+ );
92
+ }
93
+ if (activeRuns.length >= MAX_ACTIVE_RUNS) {
94
+ transaction.abort();
95
+ throw new ScrapeReceiptError(
96
+ 'capacity-full',
97
+ 'xTap already has four active scrape runs',
98
+ );
99
+ }
100
+ delete existing.finishedAtMs;
101
+ delete existing.stopReason;
102
+ existing.sourceTabId = sourceTabId;
103
+ existing.state = 'running';
104
+ renewLease(existing, openedAtMs);
105
+ runs.put(existing);
106
+ await transactionDone(transaction);
107
+ return existing;
108
+ }
109
+ transaction.abort();
110
+ throw new ScrapeReceiptError(
111
+ 'run-terminal',
112
+ `scrape run already ended as ${existing.state}`,
113
+ );
114
+ }
115
+ if (activeRuns.some((run) => run.sourceTabId === sourceTabId)) {
116
+ transaction.abort();
117
+ throw new ScrapeReceiptError(
118
+ 'source-tab-active',
119
+ `source tab ${sourceTabId} already belongs to an active scrape run`,
120
+ );
121
+ }
122
+ if (activeRuns.length >= MAX_ACTIVE_RUNS) {
123
+ transaction.abort();
124
+ throw new ScrapeReceiptError(
125
+ 'capacity-full',
126
+ 'xTap already has four active scrape runs',
127
+ );
128
+ }
129
+
130
+ const baselineSequence = (await readMeta(meta, META_SEQUENCE)) ?? 0;
131
+ const knownListCount = await request(
132
+ listReceipts.index('listId').count(listId),
133
+ );
134
+ const run = {
135
+ baselineSequence,
136
+ knownListCount,
137
+ lastCursor: 0,
138
+ listId,
139
+ protocolVersion: SCRAPE_PROTOCOL_VERSION,
140
+ runId,
141
+ sourceTabId,
142
+ startedAtMs,
143
+ state: 'running',
144
+ leaseExpiresAtMs: openedAtMs + SCRAPE_RUN_LEASE_MS,
145
+ updatedAtMs: openedAtMs,
146
+ };
147
+ runs.put(run);
148
+ await transactionDone(transaction);
149
+ return run;
150
+ }
151
+
152
+ async recordTimeline({ endpoint, requestUrl, observedAtMs, sourceTabId, tweets }) {
153
+ if (!TIMELINE_ENDPOINTS.has(endpoint)) return [];
154
+ if (!Number.isFinite(observedAtMs) || !isSourceTabId(sourceTabId)) return [];
155
+ if (!Array.isArray(tweets) || tweets.length === 0) return [];
156
+
157
+ const database = await this.open();
158
+ const transaction = database.transaction(
159
+ ['listReceipts', 'meta', 'observations', 'receipts', 'runs'],
160
+ 'readwrite',
161
+ );
162
+ const listReceipts = transaction.objectStore('listReceipts');
163
+ const meta = transaction.objectStore('meta');
164
+ const observations = transaction.objectStore('observations');
165
+ const receipts = transaction.objectStore('receipts');
166
+ const runs = transaction.objectStore('runs');
167
+
168
+ let sequence = (await readMeta(meta, META_SEQUENCE)) ?? 0;
169
+ let run = (await request(runs.getAll())).find(
170
+ (candidate) =>
171
+ candidate.state === 'running' && candidate.sourceTabId === sourceTabId,
172
+ );
173
+ if (run && !hasLiveLease(run, observedAtMs)) {
174
+ expireRun(run, observedAtMs);
175
+ runs.put(run);
176
+ run = undefined;
177
+ }
178
+ const listId = run?.listId ?? extractListId(endpoint, requestUrl);
179
+ if (!isListId(listId)) {
180
+ await transactionDone(transaction);
181
+ return [];
182
+ }
183
+ const emitted = [];
184
+ const batchIds = new Set();
185
+
186
+ for (const tweet of tweets) {
187
+ if (!isTweet(tweet) || batchIds.has(tweet.id)) continue;
188
+ batchIds.add(tweet.id);
189
+
190
+ let receipt = await request(receipts.get(tweet.id));
191
+ let listReceipt = await request(listReceipts.get([listId, tweet.id]));
192
+ if (!receipt || !listReceipt) sequence += 1;
193
+
194
+ if (!receipt) {
195
+ receipt = {
196
+ endpoint,
197
+ firstSeenAt: normalizeTimestamp(tweet.captured_at, observedAtMs),
198
+ id: tweet.id,
199
+ listId,
200
+ postAt: tweet.created_at,
201
+ sequence,
202
+ };
203
+ receipts.put(receipt);
204
+ }
205
+
206
+ if (!listReceipt) {
207
+ listReceipt = {
208
+ firstSeenAt: normalizeTimestamp(tweet.captured_at, observedAtMs),
209
+ listId,
210
+ postAt: tweet.created_at,
211
+ sequence,
212
+ tweetId: tweet.id,
213
+ };
214
+ listReceipts.put(listReceipt);
215
+ }
216
+
217
+ if (run) {
218
+ run.lastCursor += 1;
219
+ const observation = {
220
+ captureSequence: listReceipt.sequence,
221
+ cursor: run.lastCursor,
222
+ knownBeforeRun: listReceipt.sequence <= run.baselineSequence,
223
+ observedAtMs,
224
+ postAt: tweet.created_at,
225
+ runId: run.runId,
226
+ sourceEndpoint: endpoint,
227
+ tweetId: tweet.id,
228
+ };
229
+ observations.put(observation);
230
+ emitted.push(observation);
231
+ }
232
+ }
233
+
234
+ writeMeta(meta, META_SEQUENCE, sequence);
235
+ if (run && emitted.length > 0) {
236
+ run.updatedAtMs = observedAtMs;
237
+ runs.put(run);
238
+ }
239
+ await transactionDone(transaction);
240
+ return emitted;
241
+ }
242
+
243
+ async renewRun(runId, sourceTabId, renewedAtMs) {
244
+ if (!isRunId(runId)) throw new Error('invalid run ID');
245
+ if (!isSourceTabId(sourceTabId)) throw new Error('invalid source tab ID');
246
+ if (!Number.isFinite(renewedAtMs)) throw new Error('invalid renewal time');
247
+
248
+ const database = await this.open();
249
+ const transaction = database.transaction('runs', 'readwrite');
250
+ const runs = transaction.objectStore('runs');
251
+ const run = await request(runs.get(runId));
252
+ if (!run) {
253
+ transaction.abort();
254
+ throw new ScrapeReceiptError('unknown-run', 'unknown scrape run');
255
+ }
256
+ if (run.state !== 'running') {
257
+ transaction.abort();
258
+ throw new ScrapeReceiptError(
259
+ 'run-terminal',
260
+ `scrape run already ended as ${run.state}`,
261
+ );
262
+ }
263
+ if (run.sourceTabId !== sourceTabId) {
264
+ transaction.abort();
265
+ throw new ScrapeReceiptError(
266
+ 'run-conflict',
267
+ 'run ID already belongs to a different source tab',
268
+ );
269
+ }
270
+ renewLease(run, renewedAtMs);
271
+ runs.put(run);
272
+ await transactionDone(transaction);
273
+ return run;
274
+ }
275
+
276
+ async finishRunsForSourceTab(sourceTabId, finishedAtMs) {
277
+ if (!isSourceTabId(sourceTabId)) throw new Error('invalid source tab ID');
278
+ if (!Number.isFinite(finishedAtMs)) throw new Error('invalid finish time');
279
+
280
+ const database = await this.open();
281
+ const transaction = database.transaction('runs', 'readwrite');
282
+ const runs = transaction.objectStore('runs');
283
+ const activeRuns = (await request(runs.getAll())).filter(
284
+ (run) => run.state === 'running' && run.sourceTabId === sourceTabId,
285
+ );
286
+ for (const run of activeRuns) {
287
+ run.finishedAtMs = finishedAtMs;
288
+ run.state = 'aborted';
289
+ run.stopReason = 'source-tab-closed';
290
+ run.updatedAtMs = finishedAtMs;
291
+ runs.put(run);
292
+ }
293
+ await transactionDone(transaction);
294
+ return activeRuns.length;
295
+ }
296
+
297
+ async readObservations(runId, afterCursor = 0) {
298
+ if (!isRunId(runId) || !Number.isSafeInteger(afterCursor) || afterCursor < 0) {
299
+ throw new Error('invalid observation cursor');
300
+ }
301
+ const database = await this.open();
302
+ const transaction = database.transaction('observations', 'readonly');
303
+ const values = await request(
304
+ transaction.objectStore('observations').index('runId').getAll(runId),
305
+ );
306
+ await transactionDone(transaction);
307
+ return values
308
+ .filter((entry) => entry.cursor > afterCursor)
309
+ .sort((left, right) => left.cursor - right.cursor);
310
+ }
311
+
312
+ async beginCutover(finishedAtMs) {
313
+ if (!Number.isFinite(finishedAtMs)) throw new Error('invalid finish time');
314
+
315
+ const database = await this.open();
316
+ const transaction = database.transaction(['meta', 'runs'], 'readwrite');
317
+ const meta = transaction.objectStore('meta');
318
+ const runs = transaction.objectStore('runs');
319
+ writeMeta(meta, META_CUTOVER, true);
320
+ const activeRuns = (await request(runs.getAll())).filter(
321
+ (run) => run.state === 'running',
322
+ );
323
+ for (const run of activeRuns) {
324
+ run.finishedAtMs = finishedAtMs;
325
+ run.state = 'failed';
326
+ run.updatedAtMs = finishedAtMs;
327
+ runs.put(run);
328
+ }
329
+ await transactionDone(transaction);
330
+ return activeRuns.length;
331
+ }
332
+
333
+ async finishCutover() {
334
+ const database = await this.open();
335
+ const transaction = database.transaction('meta', 'readwrite');
336
+ writeMeta(transaction.objectStore('meta'), META_CUTOVER, false);
337
+ await transactionDone(transaction);
338
+ }
339
+
340
+ async finishRun(runId, state, finishedAtMs) {
341
+ if (!isRunId(runId)) throw new Error('invalid run ID');
342
+ if (!['aborted', 'completed', 'failed'].includes(state)) {
343
+ throw new Error('invalid terminal run state');
344
+ }
345
+ if (!Number.isFinite(finishedAtMs)) throw new Error('invalid finish time');
346
+
347
+ const database = await this.open();
348
+ const transaction = database.transaction('runs', 'readwrite');
349
+ const runs = transaction.objectStore('runs');
350
+ const run = await request(runs.get(runId));
351
+ if (!run) {
352
+ transaction.abort();
353
+ throw new ScrapeReceiptError('unknown-run', 'unknown scrape run');
354
+ }
355
+ if (run.state !== 'running' && run.state !== state) {
356
+ transaction.abort();
357
+ throw new ScrapeReceiptError(
358
+ 'run-terminal',
359
+ `scrape run already ended as ${run.state}`,
360
+ );
361
+ }
362
+
363
+ run.finishedAtMs = finishedAtMs;
364
+ run.state = state;
365
+ run.updatedAtMs = finishedAtMs;
366
+ runs.put(run);
367
+ await transactionDone(transaction);
368
+ return run;
369
+ }
370
+
371
+ async getRun(runId) {
372
+ const database = await this.open();
373
+ const transaction = database.transaction('runs', 'readonly');
374
+ const run = await request(transaction.objectStore('runs').get(runId));
375
+ await transactionDone(transaction);
376
+ return run;
377
+ }
378
+
379
+ async open() {
380
+ if (this.databasePromise) return this.databasePromise;
381
+ const opening = new Promise((resolve, reject) => {
382
+ const openRequest = this.indexedDB.open(this.databaseName, DATABASE_VERSION);
383
+ let settled = false;
384
+ openRequest.onupgradeneeded = () => createSchema(openRequest.result);
385
+ openRequest.onsuccess = () => {
386
+ if (settled) {
387
+ openRequest.result.close();
388
+ return;
389
+ }
390
+ settled = true;
391
+ resolve(openRequest.result);
392
+ };
393
+ openRequest.onerror = () => {
394
+ settled = true;
395
+ reject(openRequest.error ?? new Error('failed to open receipt database'));
396
+ };
397
+ openRequest.onblocked = () => {
398
+ settled = true;
399
+ reject(new Error('receipt database upgrade is blocked'));
400
+ };
401
+ });
402
+ this.databasePromise = opening;
403
+ opening.catch(() => {
404
+ if (this.databasePromise === opening) this.databasePromise = null;
405
+ });
406
+ return opening;
407
+ }
408
+
409
+ async close() {
410
+ if (!this.databasePromise) return;
411
+ const database = await this.databasePromise;
412
+ database.close();
413
+ this.databasePromise = null;
414
+ }
415
+ }
416
+
417
+ export function extractListId(endpoint, requestUrl) {
418
+ if (!TIMELINE_ENDPOINTS.has(endpoint) || typeof requestUrl !== 'string') {
419
+ return undefined;
420
+ }
421
+ try {
422
+ const variables = new URL(requestUrl).searchParams.get('variables');
423
+ if (!variables) return undefined;
424
+ const parsed = JSON.parse(variables);
425
+ if (endpoint === 'ListLatestTweetsTimeline') {
426
+ return isListId(parsed?.listId) ? parsed.listId : undefined;
427
+ }
428
+ const rawQuery = typeof parsed?.rawQuery === 'string' ? parsed.rawQuery : '';
429
+ return rawQuery.match(/(?:^|\s)list:(\d{4,25})(?:\s|$)/)?.[1];
430
+ } catch {
431
+ return undefined;
432
+ }
433
+ }
434
+
435
+ function renewLease(run, renewedAtMs) {
436
+ run.leaseExpiresAtMs = renewedAtMs + SCRAPE_RUN_LEASE_MS;
437
+ run.updatedAtMs = renewedAtMs;
438
+ }
439
+
440
+ function hasLiveLease(run, atMs) {
441
+ return Number.isFinite(run.leaseExpiresAtMs) && run.leaseExpiresAtMs > atMs;
442
+ }
443
+
444
+ function expireRun(run, finishedAtMs) {
445
+ run.finishedAtMs = finishedAtMs;
446
+ run.state = 'failed';
447
+ run.stopReason = 'lease-expired';
448
+ run.updatedAtMs = finishedAtMs;
449
+ }
450
+
451
+ function isRecoverableStop(value) {
452
+ return value === 'lease-expired' || value === 'source-tab-closed';
453
+ }
454
+
455
+ function createSchema(database) {
456
+ if (!database.objectStoreNames.contains('receipts')) {
457
+ const receipts = database.createObjectStore('receipts', { keyPath: 'id' });
458
+ receipts.createIndex('sequence', 'sequence', { unique: true });
459
+ }
460
+ if (!database.objectStoreNames.contains('listReceipts')) {
461
+ const listReceipts = database.createObjectStore('listReceipts', {
462
+ keyPath: ['listId', 'tweetId'],
463
+ });
464
+ listReceipts.createIndex('listId', 'listId', { unique: false });
465
+ }
466
+ if (!database.objectStoreNames.contains('runs')) {
467
+ database.createObjectStore('runs', { keyPath: 'runId' });
468
+ }
469
+ if (!database.objectStoreNames.contains('observations')) {
470
+ const observations = database.createObjectStore('observations', {
471
+ keyPath: ['runId', 'cursor'],
472
+ });
473
+ observations.createIndex('runId', 'runId', { unique: false });
474
+ }
475
+ if (!database.objectStoreNames.contains('meta')) {
476
+ database.createObjectStore('meta', { keyPath: 'key' });
477
+ }
478
+ }
479
+
480
+ function request(idbRequest) {
481
+ return new Promise((resolve, reject) => {
482
+ idbRequest.onsuccess = () => resolve(idbRequest.result);
483
+ idbRequest.onerror = () => reject(idbRequest.error ?? new Error('IndexedDB request failed'));
484
+ });
485
+ }
486
+
487
+ function transactionDone(transaction) {
488
+ return new Promise((resolve, reject) => {
489
+ transaction.oncomplete = () => resolve();
490
+ transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
491
+ transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
492
+ });
493
+ }
494
+
495
+ async function readMeta(store, key) {
496
+ const entry = await request(store.get(key));
497
+ return entry?.value;
498
+ }
499
+
500
+ function writeMeta(store, key, value) {
501
+ store.put({ key, value });
502
+ }
503
+
504
+ function validateRunInput({ runId, listId, sourceTabId, startedAtMs }) {
505
+ if (!isRunId(runId)) throw new Error('invalid run ID');
506
+ if (!isListId(listId)) throw new Error('invalid list ID');
507
+ if (!isSourceTabId(sourceTabId)) throw new Error('invalid source tab ID');
508
+ if (!Number.isFinite(startedAtMs)) throw new Error('invalid start time');
509
+ }
510
+
511
+ function isRunId(value) {
512
+ return typeof value === 'string' && /^[0-9a-f-]{16,64}$/i.test(value);
513
+ }
514
+
515
+ function isListId(value) {
516
+ return typeof value === 'string' && /^\d{4,25}$/.test(value);
517
+ }
518
+
519
+ function isSourceTabId(value) {
520
+ return Number.isSafeInteger(value) && value >= 0;
521
+ }
522
+
523
+ function isTweet(value) {
524
+ return (
525
+ value &&
526
+ typeof value.id === 'string' &&
527
+ value.id.length > 0 &&
528
+ typeof value.created_at === 'string' &&
529
+ Number.isFinite(Date.parse(value.created_at))
530
+ );
531
+ }
532
+
533
+ function normalizeTimestamp(value, fallbackMs) {
534
+ return typeof value === 'string' && Number.isFinite(Date.parse(value))
535
+ ? value
536
+ : new Date(fallbackMs).toISOString();
537
+ }
extension/lib/tweet-parser.js ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // xTap — Tweet parser
2
+ // Extracts and normalizes tweets from X/Twitter GraphQL API responses.
3
+ import { exactCounter } from './observations.js';
4
+
5
+ /**
6
+ * Main entry point. Given an endpoint name and the raw GraphQL response data,
7
+ * returns an array of normalized tweet objects.
8
+ */
9
+ export function extractTweets(endpoint, data, observedAt = new Date().toISOString()) {
10
+ const capturedAt = new Date(observedAt).toISOString();
11
+ return parseTweets(endpoint, data).map(tweet => ({ ...tweet, captured_at: capturedAt }));
12
+ }
13
+
14
+ function parseTweets(endpoint, data) {
15
+ if (!data) return [];
16
+
17
+ // TweetResultByRestId returns a single tweet, not a timeline with instructions.
18
+ // Always extract it — dedup in enqueueTweets prevents duplicates if the tweet
19
+ // also appears in TweetDetail. The root tweet on a tweet detail page is often
20
+ // delivered only via this endpoint and omitted from TweetDetail.
21
+ if (endpoint === 'TweetResultByRestId') {
22
+ const result = data?.data?.tweetResult?.result;
23
+ if (!result) return [];
24
+ const raw = unwrapTweetResult(result);
25
+ if (!raw) return [];
26
+ return normalizeWithQuoted(raw);
27
+ }
28
+
29
+ const instructions = findInstructions(endpoint, data);
30
+ if (!instructions || !Array.isArray(instructions)) {
31
+ // Log top-level keys so we can find where instructions moved
32
+ const topKeys = data?.data ? Object.keys(data.data) : Object.keys(data || {});
33
+ console.warn(`[xTap:parser] No instructions found for ${endpoint} | top-level keys: [${topKeys.join(', ')}]`);
34
+ return [];
35
+ }
36
+
37
+ const tweets = [];
38
+ let skippedEntries = 0;
39
+ const skippedTypes = {};
40
+
41
+ for (const instruction of instructions) {
42
+ const entries = instruction.entries || instruction.moduleItems || [];
43
+
44
+ for (const entry of entries) {
45
+ const extracted = extractTweetsFromEntry(entry, skippedTypes);
46
+ tweets.push(...extracted);
47
+ if (extracted.length === 0 && !entry.content?.cursorType && !entry.content?.entryType?.includes('Cursor')) {
48
+ skippedEntries++;
49
+ }
50
+ }
51
+
52
+ if (instruction.entry) {
53
+ tweets.push(...extractTweetsFromEntry(instruction.entry, skippedTypes));
54
+ }
55
+ }
56
+
57
+ if (skippedEntries > 0 || Object.keys(skippedTypes).length > 0) {
58
+ console.log(`[xTap:parser] ${endpoint}: ${tweets.length} tweets extracted, ${skippedEntries} entries skipped | skipped types: ${JSON.stringify(skippedTypes)}`);
59
+ }
60
+
61
+ return tweets;
62
+ }
63
+
64
+ // --- Known instruction paths per endpoint ---
65
+ const INSTRUCTION_PATHS = {
66
+ HomeTimeline: ['data', 'home', 'home_timeline_urt', 'instructions'],
67
+ HomeLatestTimeline: ['data', 'home', 'home_timeline_urt', 'instructions'],
68
+ UserTweets: ['data', 'user', 'result', 'timeline', 'timeline', 'instructions'],
69
+ UserTweetsAndReplies: ['data', 'user', 'result', 'timeline', 'timeline', 'instructions'],
70
+ UserMedia: ['data', 'user', 'result', 'timeline', 'timeline', 'instructions'],
71
+ UserLikes: ['data', 'user', 'result', 'timeline', 'timeline', 'instructions'],
72
+ UserArticlesTweets: ['data', 'user', 'result', 'timeline', 'timeline', 'instructions'],
73
+ UserHighlightsTweets: ['data', 'user', 'result', 'timeline', 'timeline', 'instructions'],
74
+ TweetDetail: ['data', 'threaded_conversation_with_injections_v2', 'instructions'],
75
+ SearchTimeline: ['data', 'search_by_raw_query', 'search_timeline', 'timeline', 'instructions'],
76
+ ListLatestTweetsTimeline: ['data', 'list', 'tweets_timeline', 'timeline', 'instructions'],
77
+ Bookmarks: ['data', 'bookmark_timeline_v2', 'timeline', 'instructions'],
78
+ Likes: ['data', 'user', 'result', 'timeline', 'timeline', 'instructions'],
79
+ CommunityTweetsTimeline: ['data', 'communityResults', 'result', 'ranked_community_timeline', 'timeline', 'instructions'],
80
+ BookmarkFolderTimeline: ['data', 'bookmark_collection_timeline', 'timeline', 'instructions'],
81
+ };
82
+
83
+ /**
84
+ * Navigate to the instructions[] array. Different endpoints nest it at different paths.
85
+ */
86
+ function findInstructions(endpoint, data) {
87
+ const path = INSTRUCTION_PATHS[endpoint];
88
+ if (path) {
89
+ const result = navigatePath(data, path);
90
+ if (result) return result;
91
+ // Known endpoint but path failed — log where it broke and what's there instead
92
+ let current = data;
93
+ for (let i = 0; i < path.length; i++) {
94
+ if (current == null || typeof current !== 'object') {
95
+ const parent = navigatePath(data, path.slice(0, i));
96
+ console.warn(`[xTap:parser] Path broke for ${endpoint} at step ${i} ("${path[i]}") | value is: ${typeof current} | parent keys: [${Object.keys(parent || {}).join(', ')}]`);
97
+ break;
98
+ }
99
+ if (current[path[i]] === undefined) {
100
+ console.warn(`[xTap:parser] Path broke for ${endpoint} at step ${i} ("${path[i]}") | key not found | available keys: [${Object.keys(current).join(', ')}]`);
101
+ break;
102
+ }
103
+ current = current[path[i]];
104
+ }
105
+ } else {
106
+ console.log(`[xTap:parser] Unknown endpoint "${endpoint}", using recursive fallback`);
107
+ }
108
+
109
+ // Generic fallback: recursively search for an instructions array
110
+ const fallback = findInstructionsRecursive(data, 5);
111
+ if (fallback) {
112
+ console.log(`[xTap:parser] Recursive fallback found instructions for ${endpoint}`);
113
+ }
114
+ return fallback;
115
+ }
116
+
117
+ function navigatePath(obj, path) {
118
+ let current = obj;
119
+ for (const key of path) {
120
+ if (current == null || typeof current !== 'object') return null;
121
+ current = current[key];
122
+ }
123
+ return current;
124
+ }
125
+
126
+ function findInstructionsRecursive(obj, maxDepth) {
127
+ if (maxDepth <= 0 || obj == null || typeof obj !== 'object') return null;
128
+
129
+ if (Array.isArray(obj.instructions)) {
130
+ const hasEntries = obj.instructions.some(i =>
131
+ i.type === 'TimelineAddEntries' || i.entries || i.type === 'TimelineAddToModule'
132
+ );
133
+ if (hasEntries) return obj.instructions;
134
+ }
135
+
136
+ for (const key of Object.keys(obj)) {
137
+ if (key === 'instructions') continue;
138
+ const result = findInstructionsRecursive(obj[key], maxDepth - 1);
139
+ if (result) return result;
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Extract tweets from a single timeline entry.
147
+ */
148
+ function extractTweetsFromEntry(entry, skippedTypes) {
149
+ const tweets = [];
150
+ const content = entry.content || entry;
151
+
152
+ // Cursor entries — skip silently
153
+ if (content.entryType === 'TimelineTimelineCursor' || content.cursorType) {
154
+ return tweets;
155
+ }
156
+
157
+ // Single tweet item
158
+ if (content.entryType === 'TimelineTimelineItem' || content.__typename === 'TimelineTimelineItem') {
159
+ tweets.push(...extractFromItemContent(content.itemContent));
160
+ return tweets;
161
+ }
162
+
163
+ // Thread / conversation module
164
+ if (content.entryType === 'TimelineTimelineModule' || content.__typename === 'TimelineTimelineModule') {
165
+ const items = content.items || [];
166
+ for (const item of items) {
167
+ const itemContent = item.item?.itemContent || item.itemContent;
168
+ tweets.push(...extractFromItemContent(itemContent));
169
+ }
170
+ return tweets;
171
+ }
172
+
173
+ // Fallback: try itemContent directly (may be at content.itemContent or content.item.itemContent)
174
+ const fallbackItemContent = content.itemContent || content.item?.itemContent;
175
+ if (fallbackItemContent) {
176
+ tweets.push(...extractFromItemContent(fallbackItemContent));
177
+ return tweets;
178
+ }
179
+
180
+ // Unknown entry type — log it so we can add support
181
+ const entryType = content.entryType || content.__typename || 'no_type';
182
+ skippedTypes[entryType] = (skippedTypes[entryType] || 0) + 1;
183
+ if (!skippedTypes['_logged_' + entryType]) {
184
+ skippedTypes['_logged_' + entryType] = true;
185
+ console.warn(`[xTap:parser] Unknown entry type: "${entryType}" | keys: [${Object.keys(content).join(', ')}]`);
186
+ }
187
+
188
+ return tweets;
189
+ }
190
+
191
+ function extractFromItemContent(itemContent) {
192
+ if (!itemContent) return [];
193
+
194
+ // Track non-tweet item types we encounter
195
+ if (itemContent.itemType !== 'TimelineTweet' && itemContent.__typename !== 'TimelineTweet') {
196
+ const itemType = itemContent.itemType || itemContent.__typename || 'unknown';
197
+ if (!extractFromItemContent._seenTypes) extractFromItemContent._seenTypes = new Set();
198
+ if (!extractFromItemContent._seenTypes.has(itemType)) {
199
+ extractFromItemContent._seenTypes.add(itemType);
200
+ console.log(`[xTap:parser] Skipping non-tweet itemType: "${itemType}"`);
201
+ }
202
+ return [];
203
+ }
204
+
205
+ const tweetResults = itemContent.tweet_results;
206
+ if (!tweetResults?.result) {
207
+ console.warn('[xTap:parser] TimelineTweet has no tweet_results.result | keys:', Object.keys(itemContent).join(', '));
208
+ return [];
209
+ }
210
+
211
+ const raw = unwrapTweetResult(tweetResults.result);
212
+ if (!raw) return [];
213
+
214
+ return normalizeWithQuoted(raw);
215
+ }
216
+
217
+ /**
218
+ * Normalize a tweet plus any inline quoted tweet it carries. X delivers the
219
+ * full quoted payload at quoted_status_result.result — capturing only
220
+ * legacy.quoted_status_id_str would discard the quoted content entirely.
221
+ */
222
+ function normalizeWithQuoted(raw) {
223
+ const out = [];
224
+ const tweet = normalizeTweet(raw);
225
+ if (!tweet) return out;
226
+ out.push(tweet);
227
+
228
+ // For retweets the quoted payload sits on the retweeted original.
229
+ const sources = [raw];
230
+ const rtResult = raw.legacy?.retweeted_status_result?.result;
231
+ if (rtResult) {
232
+ const rtRaw = unwrapTweetResult(rtResult);
233
+ if (rtRaw) sources.push(rtRaw);
234
+ }
235
+ for (const source of sources) {
236
+ const quotedResult = source.quoted_status_result?.result;
237
+ if (!quotedResult) continue;
238
+ const quotedRaw = unwrapTweetResult(quotedResult);
239
+ if (!quotedRaw) continue;
240
+ const quoted = normalizeTweet(quotedRaw);
241
+ // Dedupe by id — a retweet-of-a-quote can carry the same quoted payload
242
+ // on both the wrapper and the original, and article tweets bypass all
243
+ // downstream dedup layers.
244
+ if (quoted && !out.some(t => t.id === quoted.id)) out.push(quoted);
245
+ }
246
+ return out;
247
+ }
248
+
249
+ /**
250
+ * Unwrap tweet result — handles Tweet, TweetWithVisibilityResults, tombstones.
251
+ */
252
+ function unwrapTweetResult(result) {
253
+ if (!result) return null;
254
+
255
+ const typename = result.__typename;
256
+
257
+ if (typename === 'Tweet') return result;
258
+
259
+ if (typename === 'TweetWithVisibilityResults') {
260
+ const tweet = result.tweet;
261
+ if (!tweet) return null;
262
+ tweet._visibilityResults = result.tweetInterstitial || result.visibility_results || null;
263
+ return tweet;
264
+ }
265
+
266
+ // Tombstones, unavailable tweets — skip silently
267
+ if (typename === 'TweetTombstone' || typename === 'TweetUnavailable') {
268
+ return null;
269
+ }
270
+
271
+ // Unknown type but has legacy data — try anyway
272
+ if (result.legacy && result.core) {
273
+ console.warn(`[xTap:parser] Unknown tweet __typename "${typename}", attempting extraction (has legacy+core)`);
274
+ return result;
275
+ }
276
+
277
+ console.warn(`[xTap:parser] Unhandled tweet result __typename: "${typename}" | keys: [${Object.keys(result).join(', ')}]`);
278
+ return null;
279
+ }
280
+
281
+ /**
282
+ * Normalize a raw tweet into our output schema.
283
+ */
284
+ export function normalizeTweet(raw) {
285
+ const legacy = raw.legacy;
286
+ if (!legacy) {
287
+ console.warn('[xTap:parser] Tweet missing legacy | keys:', Object.keys(raw).join(', '));
288
+ return null;
289
+ }
290
+
291
+ const tweetId = legacy.id_str || raw.rest_id;
292
+ if (isArticleStub(raw)) {
293
+ console.log(`[xTap:parser] Skipping article tweet ${tweetId || '(no id)'} without content_state.blocks`);
294
+ return null;
295
+ }
296
+
297
+ const userResult = raw.core?.user_results?.result;
298
+ const userCore = userResult?.core; // X nests name/screen_name here
299
+ const userLegacy = userResult?.legacy; // follower_count, verified, etc.
300
+
301
+ // Warn if user data paths have changed
302
+ if (!userResult) {
303
+ console.warn(`[xTap:parser] No user data at raw.core.user_results.result | raw.core keys: [${Object.keys(raw.core || {}).join(', ')}]`);
304
+ } else if (!userCore?.screen_name && !userLegacy?.screen_name) {
305
+ console.warn(`[xTap:parser] No screen_name found | userResult keys: [${Object.keys(userResult).join(', ')}] | userResult.core keys: [${Object.keys(userCore || {}).join(', ')}] | userResult.legacy keys: [${Object.keys(userLegacy || {}).join(', ')}]`);
306
+ }
307
+
308
+ const text = extractFullText(raw);
309
+ const media = extractMedia(legacy);
310
+ const urls = extractUrls(legacy);
311
+
312
+ const username = userCore?.screen_name || userLegacy?.screen_name;
313
+
314
+ const tweet = {
315
+ id: tweetId,
316
+ url: username ? `https://x.com/${username}/status/${tweetId}` : null,
317
+ created_at: toISO(legacy.created_at),
318
+ author: {
319
+ id: userResult?.rest_id || legacy.user_id_str,
320
+ username: userCore?.screen_name || userLegacy?.screen_name,
321
+ display_name: userCore?.name || userLegacy?.name,
322
+ verified: userLegacy?.verified || false,
323
+ is_blue_verified: userResult?.is_blue_verified || false,
324
+ follower_count: userLegacy?.followers_count
325
+ },
326
+ text,
327
+ lang: legacy.lang,
328
+ metrics: {
329
+ format: 'exact-v1',
330
+ likes: exactCounter(legacy.favorite_count),
331
+ retweets: exactCounter(legacy.retweet_count),
332
+ replies: exactCounter(legacy.reply_count),
333
+ views: exactCounter(raw.views?.count),
334
+ bookmarks: exactCounter(legacy.bookmark_count),
335
+ quotes: exactCounter(legacy.quote_count)
336
+ },
337
+ media,
338
+ urls,
339
+ hashtags: (legacy.entities?.hashtags || []).map(h => h.text),
340
+ mentions: (legacy.entities?.user_mentions || []).map(m => ({
341
+ id: m.id_str,
342
+ username: m.screen_name
343
+ })),
344
+ in_reply_to: legacy.in_reply_to_status_id_str || null,
345
+ quoted_tweet_id: legacy.quoted_status_id_str || null,
346
+ conversation_id: legacy.conversation_id_str || null,
347
+ is_retweet: !!legacy.retweeted_status_result,
348
+ retweeted_tweet_id: legacy.retweeted_status_result?.result?.legacy?.id_str || null,
349
+ is_subscriber_only: !!raw.exclusivityInfo,
350
+ source_endpoint: null, // Set by caller
351
+ captured_at: new Date().toISOString()
352
+ };
353
+
354
+ // Clean up temporary property from unwrapTweetResult
355
+ delete raw._visibilityResults;
356
+
357
+ // If this is a retweet, also normalize the retweeted tweet
358
+ // (the text of a retweet is often truncated with "RT @user:")
359
+ if (legacy.retweeted_status_result?.result) {
360
+ const rtRaw = unwrapTweetResult(legacy.retweeted_status_result.result);
361
+ if (rtRaw) {
362
+ tweet.text = extractFullText(rtRaw);
363
+ }
364
+ }
365
+
366
+ // Extract article content (long-form posts)
367
+ const article = extractArticle(raw);
368
+ if (article) {
369
+ tweet.text = article.text;
370
+ tweet.is_article = true;
371
+ tweet.article = article;
372
+ }
373
+
374
+ return tweet;
375
+ }
376
+
377
+ /**
378
+ * Extract article data from a raw tweet result.
379
+ * Articles use Draft.js block format in content_state.
380
+ */
381
+ export function extractArticle(raw) {
382
+ const articleResult = getArticleResult(raw);
383
+ if (!articleResult) return null;
384
+
385
+ // Timeline endpoints return article stubs with only title/preview_text.
386
+ // Require content_state to be present — full data comes from TweetResultByRestId.
387
+ if (!hasArticleContent(articleResult)) return null;
388
+
389
+ const tweetId = raw.legacy?.id_str || raw.rest_id;
390
+ const title = articleResult.title || null;
391
+ const blocks = articleResult.content_state.blocks;
392
+ const entityMap = articleResult.content_state.entityMap || [];
393
+
394
+ // Build mediaId → {url, filename} from media_entities
395
+ const mediaEntities = articleResult.media_entities || [];
396
+ const mediaById = new Map();
397
+ for (const e of mediaEntities) {
398
+ const url = e.media_info?.original_img_url || null;
399
+ const filename = url ? url.split('/').pop() : null;
400
+ mediaById.set(e.media_id, {
401
+ id: e.media_id,
402
+ url,
403
+ filename,
404
+ local_path: filename ? `media/${tweetId}/${filename}` : null,
405
+ width: e.media_info?.original_img_width || null,
406
+ height: e.media_info?.original_img_height || null
407
+ });
408
+ }
409
+
410
+ // Build entity key → mediaId from entityMap. X emits an array of
411
+ // {key, value} pairs, but standard Draft.js raw content uses an object
412
+ // keyed by entity id — accept both shapes.
413
+ const entityMapEntries = Array.isArray(entityMap)
414
+ ? entityMap.map((ent) => [ent.key, ent.value])
415
+ : Object.entries(entityMap);
416
+ const entityMediaId = new Map();
417
+ for (const [key, value] of entityMapEntries) {
418
+ const items = value?.data?.mediaItems || [];
419
+ if (items.length > 0) {
420
+ entityMediaId.set(String(key), items[0].mediaId);
421
+ }
422
+ }
423
+
424
+ // Build plain text from Draft.js blocks, inserting image references for atomic blocks
425
+ const lines = [];
426
+ for (const block of blocks) {
427
+ const blockText = block.text || '';
428
+ const type = block.type;
429
+ if (type === 'atomic') {
430
+ const eKey = String(block.entityRanges?.[0]?.key);
431
+ const mid = entityMediaId.get(eKey);
432
+ const m = mid ? mediaById.get(mid) : null;
433
+ if (m) lines.push(`![${m.filename}](${m.local_path})`);
434
+ continue;
435
+ }
436
+ if (type === 'header-one') { lines.push(`# ${blockText}`); continue; }
437
+ if (type === 'header-two') { lines.push(`## ${blockText}`); continue; }
438
+ if (type === 'header-three') { lines.push(`### ${blockText}`); continue; }
439
+ if (type === 'ordered-list-item') { lines.push(`1. ${blockText}`); continue; }
440
+ if (type === 'unordered-list-item') { lines.push(`- ${blockText}`); continue; }
441
+ if (type === 'blockquote') { lines.push(`> ${blockText}`); continue; }
442
+ lines.push(blockText);
443
+ }
444
+ const text = lines.join('\n');
445
+
446
+ const media = [...mediaById.values()];
447
+
448
+ return { title, text, blocks, media };
449
+ }
450
+
451
+ function getArticleResult(raw) {
452
+ return raw.article?.article_results?.result || null;
453
+ }
454
+
455
+ function hasArticleContent(articleResult) {
456
+ return Array.isArray(articleResult?.content_state?.blocks) &&
457
+ articleResult.content_state.blocks.length > 0;
458
+ }
459
+
460
+ function isArticleStub(raw) {
461
+ const articleResult = getArticleResult(raw);
462
+ return !!articleResult && !hasArticleContent(articleResult);
463
+ }
464
+
465
+ /**
466
+ * Extract full text, preferring note_tweet (long-form) over legacy.full_text.
467
+ */
468
+ function extractFullText(raw) {
469
+ const noteText = raw.note_tweet?.note_tweet_results?.result?.text;
470
+ if (noteText) return noteText;
471
+ return raw.legacy?.full_text || '';
472
+ }
473
+
474
+ /**
475
+ * Extract media from extended_entities.
476
+ */
477
+ export function extractMedia(legacy) {
478
+ const mediaList = legacy.extended_entities?.media || legacy.entities?.media || [];
479
+ return mediaList.map(m => {
480
+ const item = {
481
+ type: m.type, // photo, video, animated_gif
482
+ url: null,
483
+ alt_text: m.ext_alt_text || null
484
+ };
485
+
486
+ if (m.type === 'photo') {
487
+ item.url = m.media_url_https ? m.media_url_https + ':orig' : m.media_url_https;
488
+ } else if (m.type === 'video' || m.type === 'animated_gif') {
489
+ const variants = m.video_info?.variants || [];
490
+ const mp4s = variants
491
+ .filter(v => v.content_type === 'video/mp4')
492
+ .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
493
+ item.url = mp4s[0]?.url || null;
494
+ if (m.type === 'video') {
495
+ item.duration_ms = m.video_info?.duration_millis || null;
496
+ }
497
+ } else {
498
+ console.warn(`[xTap:parser] Unknown media type: "${m.type}" | keys: [${Object.keys(m).join(', ')}]`);
499
+ }
500
+
501
+ return item;
502
+ });
503
+ }
504
+
505
+ /**
506
+ * Extract URLs from entities.
507
+ */
508
+ function extractUrls(legacy) {
509
+ const urlList = legacy.entities?.urls || [];
510
+ return urlList.map(u => ({
511
+ display: u.display_url,
512
+ expanded: u.expanded_url,
513
+ shortened: u.url
514
+ }));
515
+ }
516
+
517
+ /**
518
+ * Convert Twitter's legacy date format to ISO 8601.
519
+ * Falls back to the original string if parsing fails.
520
+ */
521
+ function toISO(dateStr) {
522
+ if (!dateStr) return dateStr;
523
+ const d = new Date(dateStr);
524
+ return isNaN(d.getTime()) ? dateStr : d.toISOString();
525
+ }
extension/manifest.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "xtap-pool",
4
+ "version": "0.26.0",
5
+ "description": "Passively captures tweets from X/Twitter as you browse and pools them with friends via a private Hugging Face Space",
6
+ "permissions": [
7
+ "alarms",
8
+ "debugger",
9
+ "nativeMessaging",
10
+ "storage",
11
+ "unlimitedStorage"
12
+ ],
13
+ "host_permissions": [
14
+ "*://*.x.com/*",
15
+ "*://*.twitter.com/*",
16
+ "http://127.0.0.1/*",
17
+ "https://*.hf.space/*"
18
+ ],
19
+ "background": {
20
+ "service_worker": "background.js",
21
+ "type": "module"
22
+ },
23
+ "content_scripts": [
24
+ {
25
+ "matches": ["https://*.hf.space/*"],
26
+ "js": ["pool-connect.js"],
27
+ "world": "ISOLATED",
28
+ "run_at": "document_idle"
29
+ }
30
+ ],
31
+ "options_ui": {
32
+ "page": "options.html",
33
+ "open_in_tab": false
34
+ },
35
+ "action": {
36
+ "default_popup": "popup.html",
37
+ "default_icon": {
38
+ "16": "icons/icon16.png",
39
+ "48": "icons/icon48.png",
40
+ "128": "icons/icon128.png"
41
+ }
42
+ },
43
+ "icons": {
44
+ "16": "icons/icon16.png",
45
+ "48": "icons/icon48.png",
46
+ "128": "icons/icon128.png"
47
+ }
48
+ }
extension/native-host/com.xtap.daemon.plist ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.xtap.daemon</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>__PYTHON_PATH__</string>
10
+ <string>__DAEMON_PATH__</string>
11
+ </array>
12
+ <key>EnvironmentVariables</key>
13
+ <dict>
14
+ <key>HOME</key>
15
+ <string>__HOME_DIR__</string>
16
+ <key>PATH</key>
17
+ <string>__PATH__</string>
18
+ <key>XTAP_LOG_LEVEL</key>
19
+ <string>__LOG_LEVEL__</string>
20
+ <key>XTAP_OUTPUT_DIR</key>
21
+ <string>__OUTPUT_DIR__</string>
22
+ </dict>
23
+ <key>RunAtLoad</key>
24
+ <true/>
25
+ <key>KeepAlive</key>
26
+ <true/>
27
+ <key>StandardOutPath</key>
28
+ <string>__HOME_DIR__/.xtap/daemon-stdout.log</string>
29
+ <key>StandardErrorPath</key>
30
+ <string>__HOME_DIR__/.xtap/daemon-stderr.log</string>
31
+ </dict>
32
+ </plist>
extension/native-host/com.xtap.daemon.service ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Unit]
2
+ Description=xTap HTTP daemon
3
+ After=network.target
4
+
5
+ [Service]
6
+ ExecStart=__PYTHON_PATH__ __DAEMON_PATH__
7
+ Environment=HOME=__HOME_DIR__
8
+ Environment=PATH=__PATH__
9
+ Environment=XTAP_LOG_LEVEL=__LOG_LEVEL__
10
+ Environment="XTAP_OUTPUT_DIR=__OUTPUT_DIR__"
11
+ Restart=always
12
+ RestartSec=5
13
+
14
+ [Install]
15
+ WantedBy=default.target
extension/native-host/com.xtap.host.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "com.xtap.host",
3
+ "description": "xTap native messaging host — passes the HTTP daemon auth token to the extension",
4
+ "path": "/path/to/xtap_host.py",
5
+ "type": "stdio",
6
+ "allowed_origins": ["chrome-extension://EXTENSION_ID/"]
7
+ }
extension/native-host/install.ps1 ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # xTap — Windows installer for the native messaging host (PowerShell).
2
+ # Usage:
3
+ # .\install.ps1 -ExtensionId <id> [-Browser chrome|firefox]
4
+ # .\install.ps1 -Browser firefox
5
+
6
+ param(
7
+ [Parameter(Mandatory=$false, Position=0)]
8
+ [string]$ExtensionId,
9
+
10
+ [Parameter(Mandatory=$false)]
11
+ [ValidateSet("chrome", "firefox")]
12
+ [string]$Browser = "chrome"
13
+ )
14
+
15
+ $ErrorActionPreference = "Stop"
16
+
17
+ $HostName = "com.xtap.host"
18
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
19
+ $HostPy = Join-Path $ScriptDir "xtap_host.py"
20
+ $BatPath = Join-Path $ScriptDir "xtap_host.bat"
21
+ # Per-browser file: Chrome needs allowed_origins, Firefox allowed_extensions.
22
+ # A shared file would let the second install break the first browser (both
23
+ # registry keys would point at a manifest missing their key). The generated
24
+ # name also avoids clobbering the repo's template JSON files.
25
+ $ManifestPath = Join-Path $ScriptDir "$HostName.$Browser.generated.json"
26
+
27
+ if (-not $ExtensionId -and $Browser -eq "firefox") {
28
+ $ExtensionId = "xtap@mkubicek.dev"
29
+ }
30
+ if (-not $ExtensionId) {
31
+ Write-Error "ExtensionId is required for Chrome installs (find it at chrome://extensions)."
32
+ exit 1
33
+ }
34
+
35
+ $RegKey = if ($Browser -eq "firefox") {
36
+ "HKCU:\Software\Mozilla\NativeMessagingHosts\$HostName"
37
+ } else {
38
+ "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$HostName"
39
+ }
40
+
41
+ # Verify python
42
+ if (-not (Get-Command python -ErrorAction SilentlyContinue)) {
43
+ Write-Error "python is required but not found in PATH"
44
+ exit 1
45
+ }
46
+
47
+ # Write manifest (path must point to the .bat wrapper)
48
+ $manifestData = @{
49
+ name = $HostName
50
+ description = "xTap native messaging host -- passes the HTTP daemon auth token to the extension"
51
+ path = $BatPath
52
+ type = "stdio"
53
+ }
54
+ if ($Browser -eq "firefox") {
55
+ $manifestData.allowed_extensions = @($ExtensionId)
56
+ } else {
57
+ $manifestData.allowed_origins = @("chrome-extension://$ExtensionId/")
58
+ }
59
+ $manifest = $manifestData | ConvertTo-Json -Depth 2
60
+
61
+ Set-Content -Path $ManifestPath -Value $manifest -Encoding UTF8
62
+
63
+ # Create registry key pointing to manifest
64
+ if (-not (Test-Path (Split-Path $RegKey))) {
65
+ New-Item -Path (Split-Path $RegKey) -Force | Out-Null
66
+ }
67
+ New-Item -Path $RegKey -Force | Out-Null
68
+ Set-ItemProperty -Path $RegKey -Name "(Default)" -Value $ManifestPath
69
+
70
+ Write-Host "Installed native messaging host:"
71
+ Write-Host " Manifest: $ManifestPath"
72
+ Write-Host " Registry: $RegKey"
73
+ Write-Host " Browser: $Browser"
74
+ Write-Host " Host script: $HostPy"
75
+ Write-Host " Extension ID: $ExtensionId"
76
+
77
+ # --- Install HTTP daemon via Scheduled Task ---
78
+ $DaemonPy = Join-Path $ScriptDir "xtap_daemon.py"
79
+ $XtapDir = Join-Path $HOME ".xtap"
80
+ $XtapSecret = Join-Path $XtapDir "secret"
81
+ $TaskName = "xTapDaemon"
82
+
83
+ # Create ~/.xtap/ directory
84
+ if (-not (Test-Path $XtapDir)) {
85
+ New-Item -ItemType Directory -Path $XtapDir -Force | Out-Null
86
+ }
87
+
88
+ # Generate auth token if not exists
89
+ if (-not (Test-Path $XtapSecret)) {
90
+ $token = python -c "import secrets; print(secrets.token_urlsafe(32))"
91
+ Set-Content -Path $XtapSecret -Value $token -Encoding ASCII
92
+ Write-Host "Generated auth token: $XtapSecret"
93
+ } else {
94
+ Write-Host "Auth token already exists: $XtapSecret"
95
+ }
96
+
97
+ # Find pythonw (preferred — no console window) or fall back to python
98
+ $PythonW = Get-Command pythonw -ErrorAction SilentlyContinue
99
+ if ($PythonW) {
100
+ $PythonExe = $PythonW.Source
101
+ } else {
102
+ $PythonExe = (Get-Command python).Source
103
+ }
104
+
105
+ # Remove existing scheduled task if present
106
+ if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
107
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
108
+ }
109
+
110
+ # Create scheduled task: run at logon, restart on failure
111
+ $Action = New-ScheduledTaskAction -Execute $PythonExe -Argument "`"$DaemonPy`""
112
+ $Trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
113
+ $Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
114
+ -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval ([TimeSpan]::FromMinutes(1))
115
+ Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger `
116
+ -Settings $Settings -Description "xTap HTTP daemon" | Out-Null
117
+
118
+ # Start the task now
119
+ Start-ScheduledTask -TaskName $TaskName
120
+
121
+ Write-Host ""
122
+ Write-Host "HTTP daemon installed:"
123
+ Write-Host " Scheduled Task: $TaskName"
124
+ Write-Host " Daemon: $DaemonPy"
125
+ Write-Host " Python: $PythonExe"
126
+ Write-Host " Listening on: 127.0.0.1:17381"
127
+ Write-Host ""
128
+ Write-Host "Useful commands:"
129
+ Write-Host " Get-ScheduledTask -TaskName $TaskName"
130
+ Write-Host " Start-ScheduledTask -TaskName $TaskName"
131
+ Write-Host " Stop-ScheduledTask -TaskName $TaskName"
132
+
133
+ Write-Host ""
134
+ $outputDir = if ($env:XTAP_OUTPUT_DIR) { $env:XTAP_OUTPUT_DIR } else { Join-Path $HOME "Downloads\xtap" }
135
+ Write-Host "Output directory:"
136
+ Write-Host " $outputDir"
137
+ Write-Host "To change it, set XTAP_OUTPUT_DIR as a *user* environment variable"
138
+ Write-Host "(scheduled tasks do not see shell-session variables), then re-run:"
139
+ Write-Host " [Environment]::SetEnvironmentVariable('XTAP_OUTPUT_DIR', 'D:\path', 'User')"
extension/native-host/install.sh ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # xTap — installer for the native messaging host and HTTP daemon (macOS / Linux).
3
+ # Usage:
4
+ # ./install.sh <extension-id> [chrome|firefox]
5
+ # ./install.sh firefox [extension-id]
6
+
7
+ set -euo pipefail
8
+
9
+ if [ "$(id -u)" -eq 0 ]; then
10
+ echo "Error: Do not run this script with sudo. It installs to user-level paths."
11
+ exit 1
12
+ fi
13
+
14
+ if [ $# -lt 1 ]; then
15
+ echo "Usage:"
16
+ echo " $0 <extension-id> [chrome|firefox]"
17
+ echo " $0 firefox [extension-id]"
18
+ echo ""
19
+ echo "Chrome ID: chrome://extensions (Developer mode)"
20
+ echo "Firefox ID default: xtap@mkubicek.dev (from manifest.firefox.json)"
21
+ exit 1
22
+ fi
23
+
24
+ BROWSER="chrome"
25
+ EXT_ID=""
26
+ if [ "$1" = "chrome" ] || [ "$1" = "firefox" ]; then
27
+ BROWSER="$1"
28
+ EXT_ID="${2:-}"
29
+ else
30
+ EXT_ID="$1"
31
+ if [ $# -ge 2 ]; then
32
+ BROWSER="$2"
33
+ fi
34
+ fi
35
+
36
+ if [ "$BROWSER" != "chrome" ] && [ "$BROWSER" != "firefox" ]; then
37
+ echo "Error: browser must be 'chrome' or 'firefox'"
38
+ exit 1
39
+ fi
40
+
41
+ if [ "$BROWSER" = "firefox" ] && [ -z "$EXT_ID" ]; then
42
+ EXT_ID="xtap@mkubicek.dev"
43
+ fi
44
+
45
+ if [ -z "$EXT_ID" ]; then
46
+ echo "Error: extension ID is required for Chrome installs."
47
+ echo "Find it at chrome://extensions (enable Developer mode)."
48
+ exit 1
49
+ fi
50
+
51
+ HOST_NAME="com.xtap.host"
52
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
53
+ HOST_PATH="${SCRIPT_DIR}/xtap_host.py"
54
+
55
+ OS="$(uname)"
56
+ case "$OS" in
57
+ Darwin)
58
+ if [ "$BROWSER" = "firefox" ]; then
59
+ TARGET_DIR="$HOME/Library/Application Support/Mozilla/NativeMessagingHosts"
60
+ else
61
+ TARGET_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
62
+ fi
63
+ ;;
64
+ Linux)
65
+ if [ "$BROWSER" = "firefox" ]; then
66
+ TARGET_DIR="$HOME/.mozilla/native-messaging-hosts"
67
+ else
68
+ TARGET_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
69
+ fi
70
+ ;;
71
+ *)
72
+ echo "Error: Unsupported OS '$OS'. Use install.ps1 on Windows."
73
+ exit 1
74
+ ;;
75
+ esac
76
+ MANIFEST_PATH="${TARGET_DIR}/${HOST_NAME}.json"
77
+
78
+ # Verify python3
79
+ PYTHON_PATH="$(command -v python3 2>/dev/null || true)"
80
+ if [ -z "$PYTHON_PATH" ]; then
81
+ echo "Error: python3 is required but not found in PATH"
82
+ exit 1
83
+ fi
84
+
85
+ # Make host executable
86
+ chmod +x "$HOST_PATH"
87
+
88
+ # --- Generate wrapper script with absolute Python path ---
89
+ # Chrome and Firefox launch native messaging hosts with a minimal environment
90
+ # (typically PATH=/usr/bin:/bin:/usr/sbin:/sbin). The shebang
91
+ # #!/usr/bin/env python3 can fail if python3 lives in /usr/local/bin,
92
+ # Homebrew, pyenv, or a framework install. A thin wrapper with absolute
93
+ # paths avoids this entirely.
94
+ XTAP_DIR_WRAPPER="$HOME/.xtap"
95
+ WRAPPER_PATH="${XTAP_DIR_WRAPPER}/xtap_host_wrapper.sh"
96
+
97
+ mkdir -p "$XTAP_DIR_WRAPPER"
98
+
99
+ cat > "$WRAPPER_PATH" <<WRAPPER
100
+ #!/bin/sh
101
+ # xTap native messaging host wrapper — generated by install.sh
102
+ # Launches xtap_host.py with an absolute Python path so the native
103
+ # messaging protocol works even in Chrome/Firefox's minimal environment.
104
+ exec "${PYTHON_PATH}" "${HOST_PATH}" "\$@"
105
+ WRAPPER
106
+
107
+ chmod +x "$WRAPPER_PATH"
108
+
109
+ # Create target directory
110
+ mkdir -p "$TARGET_DIR"
111
+
112
+ # Write native messaging manifest (pointing at the wrapper, not the .py)
113
+ if [ "$BROWSER" = "firefox" ]; then
114
+ cat > "$MANIFEST_PATH" <<EOF
115
+ {
116
+ "name": "${HOST_NAME}",
117
+ "description": "xTap native messaging host — passes the HTTP daemon auth token to the extension",
118
+ "path": "${WRAPPER_PATH}",
119
+ "type": "stdio",
120
+ "allowed_extensions": ["${EXT_ID}"]
121
+ }
122
+ EOF
123
+ else
124
+ cat > "$MANIFEST_PATH" <<EOF
125
+ {
126
+ "name": "${HOST_NAME}",
127
+ "description": "xTap native messaging host — passes the HTTP daemon auth token to the extension",
128
+ "path": "${WRAPPER_PATH}",
129
+ "type": "stdio",
130
+ "allowed_origins": ["chrome-extension://${EXT_ID}/"]
131
+ }
132
+ EOF
133
+ fi
134
+
135
+ echo "Installed native messaging host manifest to:"
136
+ echo " $MANIFEST_PATH"
137
+ echo ""
138
+ echo "Browser: $BROWSER"
139
+ echo "Host wrapper: $WRAPPER_PATH"
140
+ echo "Host script: $HOST_PATH"
141
+ echo "Python: $PYTHON_PATH"
142
+ echo "Extension ID: $EXT_ID"
143
+
144
+ # --- macOS: install HTTP daemon via launchd ---
145
+ if [ "$OS" = "Darwin" ]; then
146
+ DAEMON_PATH="${SCRIPT_DIR}/xtap_daemon.py"
147
+ XTAP_DIR="$HOME/.xtap"
148
+ XTAP_SECRET="${XTAP_DIR}/secret"
149
+ PLIST_LABEL="com.xtap.daemon"
150
+ PLIST_DEST="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist"
151
+ PLIST_TEMPLATE="${SCRIPT_DIR}/com.xtap.daemon.plist"
152
+
153
+ chmod +x "$DAEMON_PATH"
154
+
155
+ # Create ~/.xtap/ with restricted permissions
156
+ mkdir -p "$XTAP_DIR"
157
+ chmod 700 "$XTAP_DIR"
158
+
159
+ # Generate auth token if not exists
160
+ if [ ! -f "$XTAP_SECRET" ]; then
161
+ python3 -c "import secrets; print(secrets.token_urlsafe(32))" > "$XTAP_SECRET"
162
+ chmod 600 "$XTAP_SECRET"
163
+ echo "Generated auth token: $XTAP_SECRET"
164
+ else
165
+ echo "Auth token already exists: $XTAP_SECRET"
166
+ fi
167
+
168
+ # Unload existing daemon if loaded (ignore errors)
169
+ launchctl bootout "gui/$(id -u)/${PLIST_LABEL}" 2>/dev/null || true
170
+ # Wait for the service to fully unload before re-bootstrapping
171
+ for i in 1 2 3 4 5; do
172
+ launchctl print "gui/$(id -u)/${PLIST_LABEL}" 2>/dev/null || break
173
+ sleep 0.5
174
+ done
175
+
176
+ # Capture user's PATH so the daemon can find yt-dlp and other tools
177
+ USER_PATH="$PATH"
178
+ XTAP_LOG_LEVEL="${XTAP_LOG_LEVEL:-info}"
179
+ # Bake the output dir into the service environment — launchd does not
180
+ # inherit shell exports, so this is the only way XTAP_OUTPUT_DIR reaches
181
+ # the daemon (re-run install.sh after changing it).
182
+ XTAP_OUTPUT_DIR="${XTAP_OUTPUT_DIR:-$HOME/Downloads/xtap}"
183
+ # XML-escape for the plist, then sed-escape (&, \, |) so paths containing
184
+ # those characters can't corrupt the substitution or the plist.
185
+ _OUT_XML=$(printf '%s' "$XTAP_OUTPUT_DIR" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g')
186
+ _OUT_SED=$(printf '%s' "$_OUT_XML" | sed -e 's/[\\&|]/\\&/g')
187
+
188
+ # Substitute plist template
189
+ mkdir -p "$HOME/Library/LaunchAgents"
190
+ sed \
191
+ -e "s|__PYTHON_PATH__|${PYTHON_PATH}|g" \
192
+ -e "s|__DAEMON_PATH__|${DAEMON_PATH}|g" \
193
+ -e "s|__HOME_DIR__|${HOME}|g" \
194
+ -e "s|__PATH__|${USER_PATH}|g" \
195
+ -e "s|__LOG_LEVEL__|${XTAP_LOG_LEVEL}|g" \
196
+ -e "s|__OUTPUT_DIR__|${_OUT_SED}|g" \
197
+ "$PLIST_TEMPLATE" > "$PLIST_DEST"
198
+
199
+ # Load daemon
200
+ launchctl bootstrap "gui/$(id -u)" "$PLIST_DEST"
201
+
202
+ echo ""
203
+ echo "HTTP daemon installed:"
204
+ echo " Plist: $PLIST_DEST"
205
+ echo " Daemon: $DAEMON_PATH"
206
+ echo " Listening on: 127.0.0.1:17381"
207
+ echo " Logs: ${XTAP_DIR}/daemon-stderr.log"
208
+ fi
209
+
210
+ # --- Linux: install HTTP daemon via systemd user service ---
211
+ if [ "$OS" = "Linux" ]; then
212
+ DAEMON_PATH="${SCRIPT_DIR}/xtap_daemon.py"
213
+ XTAP_DIR="$HOME/.xtap"
214
+ XTAP_SECRET="${XTAP_DIR}/secret"
215
+ SERVICE_NAME="com.xtap.daemon"
216
+ SERVICE_TEMPLATE="${SCRIPT_DIR}/${SERVICE_NAME}.service"
217
+ SERVICE_DIR="$HOME/.config/systemd/user"
218
+ SERVICE_DEST="${SERVICE_DIR}/${SERVICE_NAME}.service"
219
+
220
+ chmod +x "$DAEMON_PATH"
221
+
222
+ # Create ~/.xtap/ with restricted permissions
223
+ mkdir -p "$XTAP_DIR"
224
+ chmod 700 "$XTAP_DIR"
225
+
226
+ # Generate auth token if not exists
227
+ if [ ! -f "$XTAP_SECRET" ]; then
228
+ python3 -c "import secrets; print(secrets.token_urlsafe(32))" > "$XTAP_SECRET"
229
+ chmod 600 "$XTAP_SECRET"
230
+ echo "Generated auth token: $XTAP_SECRET"
231
+ else
232
+ echo "Auth token already exists: $XTAP_SECRET"
233
+ fi
234
+
235
+ # Capture user's PATH so the daemon can find yt-dlp and other tools
236
+ USER_PATH="$PATH"
237
+ XTAP_LOG_LEVEL="${XTAP_LOG_LEVEL:-info}"
238
+ # Bake the output dir into the service environment — systemd does not
239
+ # inherit shell exports, so this is the only way XTAP_OUTPUT_DIR reaches
240
+ # the daemon (re-run install.sh after changing it). The template quotes the
241
+ # Environment= value (paths with spaces); escape sed metachars and any
242
+ # embedded double quotes so the substitution can't corrupt the unit file.
243
+ XTAP_OUTPUT_DIR="${XTAP_OUTPUT_DIR:-$HOME/Downloads/xtap}"
244
+ _OUT_SED=$(printf '%s' "$XTAP_OUTPUT_DIR" | sed -e 's/[\\&|]/\\&/g' -e 's/"/\\\\"/g')
245
+
246
+ # Substitute service template
247
+ mkdir -p "$SERVICE_DIR"
248
+ sed \
249
+ -e "s|__PYTHON_PATH__|${PYTHON_PATH}|g" \
250
+ -e "s|__DAEMON_PATH__|${DAEMON_PATH}|g" \
251
+ -e "s|__HOME_DIR__|${HOME}|g" \
252
+ -e "s|__PATH__|${USER_PATH}|g" \
253
+ -e "s|__LOG_LEVEL__|${XTAP_LOG_LEVEL}|g" \
254
+ -e "s|__OUTPUT_DIR__|${_OUT_SED}|g" \
255
+ "$SERVICE_TEMPLATE" > "$SERVICE_DEST"
256
+
257
+ # Reload and enable
258
+ systemctl --user daemon-reload
259
+ systemctl --user enable --now "$SERVICE_NAME"
260
+ # enable --now does not restart an already-running unit — restart so a
261
+ # re-install actually applies the freshly baked environment.
262
+ systemctl --user try-restart "$SERVICE_NAME" 2>/dev/null || true
263
+
264
+ echo ""
265
+ echo "HTTP daemon installed:"
266
+ echo " Service: $SERVICE_DEST"
267
+ echo " Daemon: $DAEMON_PATH"
268
+ echo " Listening on: 127.0.0.1:17381"
269
+ echo ""
270
+ echo "Useful commands:"
271
+ echo " systemctl --user status $SERVICE_NAME"
272
+ echo " journalctl --user -u $SERVICE_NAME -f"
273
+ fi
274
+
275
+ echo ""
276
+ echo "Output directory (set XTAP_OUTPUT_DIR and re-run install to change):"
277
+ echo " ${XTAP_OUTPUT_DIR:-$HOME/Downloads/xtap}"
278
+ echo ""
279
+ echo "Debug logging (re-run install after setting):"
280
+ echo " export XTAP_LOG_LEVEL=debug"
extension/native-host/xtap_core.py ADDED
@@ -0,0 +1,836 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """xTap Core — shared file I/O logic used by both native host and HTTP daemon."""
2
+
3
+ import glob
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ from datetime import date, datetime, timezone
16
+ from urllib.parse import urlparse
17
+
18
+
19
+ # realpath so the default dir and an explicit outputDir naming the same
20
+ # physical directory (e.g. via a symlinked home) share one seen-ID set —
21
+ # resolve_output_dir keys per-directory state by this string.
22
+ DEFAULT_OUTPUT_DIR = os.path.realpath(
23
+ os.environ.get('XTAP_OUTPUT_DIR', os.path.expanduser('~/Downloads/xtap')))
24
+
25
+ # Allowed roots for outputDir validation: user's home + DEFAULT_OUTPUT_DIR
26
+ # (the latter covers XTAP_OUTPUT_DIR pointing outside home, e.g. /data/xtap)
27
+ _ALLOWED_ROOTS = tuple(dict.fromkeys([
28
+ os.path.realpath(os.path.expanduser('~')),
29
+ DEFAULT_OUTPUT_DIR, # already realpath-resolved at definition
30
+ ]))
31
+
32
+
33
+ def _article_seen_key(tweet_id):
34
+ return f'article:{tweet_id}'
35
+
36
+
37
+ def validate_output_dir(path):
38
+ """Validate that a resolved path is under an allowed root directory.
39
+
40
+ Args:
41
+ path: The path to validate (should already be expanduser'd).
42
+
43
+ Returns:
44
+ The realpath-resolved path.
45
+
46
+ Raises:
47
+ ValueError: If the path resolves outside all allowed roots.
48
+ """
49
+ resolved = os.path.realpath(path)
50
+ for root in _ALLOWED_ROOTS:
51
+ # Append os.sep so '/home/userX' doesn't match root '/home/user'
52
+ if resolved == root or resolved.startswith(root + os.sep):
53
+ return resolved
54
+ raise ValueError(
55
+ f'outputDir resolves outside allowed directories: {resolved}'
56
+ )
57
+
58
+
59
+ def load_seen_ids(out_dir):
60
+ """Build a set of tweet IDs from all existing JSONL files in the output directory."""
61
+ seen = set()
62
+ escaped_dir = glob.escape(out_dir)
63
+ # Files evicted by iCloud ("Optimize Mac Storage") become hidden
64
+ # .name.icloud placeholders the glob below can't read — dedup silently
65
+ # loses their IDs, so at least make the degradation visible.
66
+ placeholders = glob.glob(os.path.join(escaped_dir, '.tweets-*.jsonl.icloud'))
67
+ if placeholders:
68
+ names = ', '.join(os.path.basename(p) for p in placeholders[:5])
69
+ print(f'[xtap] WARNING: {len(placeholders)} tweets file(s) evicted by '
70
+ f'iCloud — their IDs cannot be deduplicated against: {names}',
71
+ file=sys.stderr)
72
+ for path in glob.glob(os.path.join(escaped_dir, 'tweets-*.jsonl')):
73
+ try:
74
+ # errors='replace': a corrupt file must not crash the daemon at
75
+ # startup — unreadable lines just fail JSON parsing and are skipped.
76
+ with open(path, 'r', encoding='utf-8', errors='replace') as f:
77
+ for line in f:
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ tweet = json.loads(line)
83
+ tweet_id = tweet.get('id')
84
+ if tweet_id:
85
+ seen.add(tweet_id)
86
+ if tweet.get('is_article'):
87
+ seen.add(_article_seen_key(tweet_id))
88
+ except (json.JSONDecodeError, KeyError):
89
+ continue
90
+ except OSError as e:
91
+ print(f'[xtap] WARNING: cannot read {path}: {e}', file=sys.stderr)
92
+ return seen
93
+
94
+
95
+ def resolve_output_dir(msg_dir, default_dir, seen_ids_by_dir):
96
+ """Resolve output directory from message, scoping seen IDs per directory.
97
+
98
+ seen_ids_by_dir maps out_dir -> set of tweet IDs already written there.
99
+ Per-directory scoping matters: after switching to a new output directory,
100
+ tweets already captured into the old one must still be written to the new
101
+ archive (a shared set would silently suppress them).
102
+
103
+ Returns (out_dir, seen_ids) for the resolved directory.
104
+ """
105
+ if msg_dir:
106
+ out_dir = validate_output_dir(os.path.expanduser(msg_dir))
107
+ os.makedirs(out_dir, exist_ok=True)
108
+ else:
109
+ out_dir = default_dir
110
+ if out_dir not in seen_ids_by_dir:
111
+ seen_ids_by_dir[out_dir] = load_seen_ids(out_dir)
112
+ return out_dir, seen_ids_by_dir[out_dir]
113
+
114
+
115
+ def write_tweets(tweets, out_dir, seen_ids):
116
+ """Write tweets to JSONL, deduplicating against seen_ids. Returns (count, dupes).
117
+
118
+ seen_ids is committed only after the file has been flushed and closed —
119
+ if the write fails, the extension retries the batch, and pre-committed IDs
120
+ would make the retry look like all-duplicates (silent data loss). A partial
121
+ flush before a failure can yield duplicate lines on retry; duplicates are
122
+ recoverable, dropped tweets are not.
123
+ """
124
+ out_file = os.path.join(out_dir, f'tweets-{date.today().isoformat()}.jsonl')
125
+ count = 0
126
+ dupes = 0
127
+ written_ids = set()
128
+ with open(out_file, 'a', encoding='utf-8') as f:
129
+ for tweet in tweets:
130
+ tid = tweet.get('id')
131
+ article_key = _article_seen_key(tid) if tid and tweet.get('is_article') else None
132
+ if tid and (tid in seen_ids or tid in written_ids):
133
+ if not article_key or article_key in seen_ids or article_key in written_ids:
134
+ dupes += 1
135
+ continue
136
+ f.write(json.dumps(tweet, ensure_ascii=False) + '\n')
137
+ if tid:
138
+ written_ids.add(tid)
139
+ if article_key:
140
+ written_ids.add(article_key)
141
+ count += 1
142
+ seen_ids |= written_ids
143
+ return count, dupes
144
+
145
+
146
+ def write_log(lines, out_dir):
147
+ """Append debug log lines to daily log file. Returns logged count."""
148
+ log_file = os.path.join(out_dir, f'debug-{date.today().isoformat()}.log')
149
+ with open(log_file, 'a', encoding='utf-8') as f:
150
+ for line in lines:
151
+ f.write(line + '\n')
152
+ return len(lines)
153
+
154
+
155
+ def write_dump(filename, content, out_dir):
156
+ """Write a raw JSON dump file for discovery/debugging."""
157
+ # Strip path components — only the basename is allowed
158
+ safe_name = os.path.basename(filename)
159
+ if not safe_name or safe_name in ('.', '..'):
160
+ raise ValueError(f'Invalid dump filename: {filename!r}')
161
+ dump_file = os.path.join(out_dir, safe_name)
162
+ with open(dump_file, 'w', encoding='utf-8') as f:
163
+ f.write(content)
164
+ return dump_file
165
+
166
+
167
+ def test_path(out_dir):
168
+ """Test that we can write to the output directory. Raises on failure."""
169
+ os.makedirs(out_dir, exist_ok=True)
170
+ test_file = os.path.join(out_dir, f'.xtap-write-test-{threading.get_ident()}')
171
+ try:
172
+ with open(test_file, 'w', encoding='utf-8') as f:
173
+ f.write('ok')
174
+ finally:
175
+ try:
176
+ os.remove(test_file)
177
+ except FileNotFoundError:
178
+ pass
179
+
180
+
181
+ # --- Video download ---
182
+
183
+ VIDEO_REQUEST_TIMEOUT_S = 60
184
+ VIDEO_CHUNK_SIZE = 64 * 1024
185
+ DEFAULT_MAX_VIDEO_MB = 500
186
+ TWEET_STATUS_HOSTS = frozenset({'x.com', 'twitter.com'})
187
+
188
+ _ytdlp_path = None
189
+ _ytdlp_checked = False
190
+ _downloads = {}
191
+ _downloads_lock = threading.Lock()
192
+
193
+
194
+ def check_ytdlp(): # pragma: no cover
195
+ """Check if yt-dlp is available on PATH. Cached after first call."""
196
+ global _ytdlp_path, _ytdlp_checked
197
+ if not _ytdlp_checked:
198
+ _ytdlp_path = shutil.which('yt-dlp')
199
+ _ytdlp_checked = True
200
+ return _ytdlp_path is not None
201
+
202
+
203
+ def get_download_status(download_id):
204
+ """Return current state of a download."""
205
+ with _downloads_lock:
206
+ info = _downloads.get(download_id)
207
+ if not info:
208
+ return {'status': 'unknown'}
209
+ return {
210
+ 'status': info['status'],
211
+ 'progress': info.get('progress'),
212
+ 'path': info.get('path'),
213
+ 'error': info.get('error'),
214
+ }
215
+
216
+
217
+ def _date_prefix(post_date):
218
+ """Convert ISO date string to yyyy.mm.dd prefix, or empty string on failure."""
219
+ if not isinstance(post_date, str):
220
+ return ''
221
+ match = re.match(r'^(\d{4})-(\d{2})-(\d{2})(?:T|$)', post_date)
222
+ if not match:
223
+ return ''
224
+ year, month, day = match.groups()
225
+ try:
226
+ datetime.strptime(f'{year}-{month}-{day}', '%Y-%m-%d')
227
+ except ValueError:
228
+ return ''
229
+ return f'{year}.{month}.{day}_'
230
+
231
+
232
+ def _tweet_status_id(tweet_url):
233
+ """Validate an X/Twitter status URL and return its numeric tweet ID."""
234
+ if not isinstance(tweet_url, str):
235
+ raise ValueError('tweetUrl must be an X/Twitter status URL')
236
+ parsed = urlparse(tweet_url)
237
+ host = (parsed.hostname or '').lower()
238
+ if parsed.scheme != 'https' or host not in TWEET_STATUS_HOSTS:
239
+ raise ValueError(f'tweetUrl not allowed: {tweet_url!r}')
240
+ match = re.search(r'/(?:[^/?#]+/)*status/(\d+)(?:/|$)', parsed.path)
241
+ if not match:
242
+ raise ValueError(f'tweetUrl must be a status URL: {tweet_url!r}')
243
+ return match.group(1)
244
+
245
+
246
+ def validate_tweet_url(tweet_url):
247
+ """Public validation hook for daemon request handling."""
248
+ _tweet_status_id(tweet_url)
249
+ return tweet_url
250
+
251
+
252
+ def _safe_video_path(video_dir, filename):
253
+ if (not filename or os.path.basename(filename) != filename or
254
+ '/' in filename or '\\' in filename):
255
+ raise ValueError(f'unsafe video filename: {filename!r}')
256
+ base = os.path.realpath(video_dir)
257
+ candidate = os.path.realpath(os.path.join(video_dir, filename))
258
+ try:
259
+ if os.path.commonpath([base, candidate]) != base:
260
+ raise ValueError(f'video path escapes output directory: {filename!r}')
261
+ except ValueError:
262
+ raise ValueError(f'video path escapes output directory: {filename!r}')
263
+ return candidate
264
+
265
+
266
+ def _video_max_bytes():
267
+ max_video_mb = _env_float('XTAP_MAX_VIDEO_MB', float(DEFAULT_MAX_VIDEO_MB), label='video')
268
+ if max_video_mb <= 0:
269
+ return None
270
+ return int(max_video_mb * 1024 * 1024)
271
+
272
+
273
+ def download_direct(direct_url, tweet_id, video_dir, post_date=''):
274
+ """Download video via direct CDN URL. Returns the file path."""
275
+ # Same trust boundary as the image downloader: only fetch from Twitter's
276
+ # CDN over https — the URL crosses the daemon's HTTP API and must not be
277
+ # able to point the daemon at file:// or internal hosts.
278
+ parsed = urlparse(direct_url)
279
+ host = (parsed.hostname or '').lower()
280
+ if parsed.scheme != 'https' or not (host == 'twimg.com' or host.endswith('.twimg.com')):
281
+ raise ValueError(f'direct URL not allowed: {direct_url!r}')
282
+ os.makedirs(video_dir, exist_ok=True)
283
+ prefix = _date_prefix(post_date)
284
+ filename = f'{prefix}{tweet_id}.mp4'
285
+ filepath = _safe_video_path(video_dir, filename)
286
+ tmp_path = filepath + '.part'
287
+ max_bytes = _video_max_bytes()
288
+ # _NO_REDIRECT_OPENER (not bare urlopen) so a redirect can't bounce the
289
+ # daemon off the allowlisted host — the host check only validates the
290
+ # initial URL. Socket-level timeout so a stalled CDN can't hang the
291
+ # download thread (and the popup's polling) forever.
292
+ try:
293
+ with _NO_REDIRECT_OPENER.open(direct_url, timeout=VIDEO_REQUEST_TIMEOUT_S) as resp, \
294
+ open(tmp_path, 'wb') as f:
295
+ headers = getattr(resp, 'headers', {}) or {}
296
+ content_length = headers.get('Content-Length')
297
+ if max_bytes is not None and content_length and content_length.isdigit():
298
+ if int(content_length) > max_bytes:
299
+ raise ValueError('video exceeds XTAP_MAX_VIDEO_MB')
300
+ written = 0
301
+ while True:
302
+ chunk = resp.read(VIDEO_CHUNK_SIZE)
303
+ if not chunk:
304
+ break
305
+ written += len(chunk)
306
+ if max_bytes is not None and written > max_bytes:
307
+ raise ValueError('video exceeds XTAP_MAX_VIDEO_MB')
308
+ f.write(chunk)
309
+ os.replace(tmp_path, filepath)
310
+ except Exception:
311
+ _safe_unlink(tmp_path)
312
+ raise
313
+ return filepath
314
+
315
+
316
+ def start_download(download_id, tweet_url, direct_url, out_dir, post_date=''): # pragma: no cover
317
+ """Start a background download. Returns immediately; poll get_download_status()."""
318
+ tweet_id = _tweet_status_id(tweet_url)
319
+ video_dir = os.path.join(out_dir, 'videos')
320
+ os.makedirs(video_dir, exist_ok=True)
321
+
322
+ with _downloads_lock:
323
+ _downloads[download_id] = {
324
+ 'status': 'downloading',
325
+ 'progress': None,
326
+ 'path': None,
327
+ 'error': None,
328
+ }
329
+
330
+ def run():
331
+ try:
332
+ if check_ytdlp():
333
+ _download_with_ytdlp(download_id, tweet_url, video_dir, post_date)
334
+ elif direct_url:
335
+ with _downloads_lock:
336
+ _downloads[download_id]['progress'] = 0
337
+ path = download_direct(direct_url, tweet_id, video_dir, post_date)
338
+ with _downloads_lock:
339
+ _downloads[download_id].update(
340
+ progress=100, status='done', path=path)
341
+ else:
342
+ with _downloads_lock:
343
+ _downloads[download_id].update(
344
+ status='error',
345
+ error='yt-dlp not found and no direct URL available')
346
+ except Exception as e:
347
+ with _downloads_lock:
348
+ _downloads[download_id].update(
349
+ status='error', error=str(e))
350
+
351
+ t = threading.Thread(target=run, daemon=True)
352
+ t.start()
353
+
354
+
355
+ class _YtdlpProgress:
356
+ """Parse yt-dlp stdout and scale progress across multiple HLS streams.
357
+
358
+ Call feed(line) for each stripped stdout line. Read .progress for the
359
+ current 0-100 value and .final_path for the last detected output path.
360
+ """
361
+
362
+ _progress_re = re.compile(r'(\d+\.?\d*)%')
363
+ _format_re = re.compile(r'Downloading \d+ format\(s\): (.+)')
364
+
365
+ def __init__(self):
366
+ self.total_streams = 1
367
+ self.stream_index = 0
368
+ self.dest_count = 0
369
+ self.progress = 0.0
370
+ self.final_path = None
371
+
372
+ def feed(self, line: str):
373
+ # Detect multi-stream downloads by counting '+' in format string
374
+ # ("Downloading 1 format(s): hls-707+hls-audio" -> 2 streams)
375
+ if line.startswith('[info]'):
376
+ fm = self._format_re.search(line)
377
+ if fm:
378
+ self.total_streams = max(1, fm.group(1).count('+') + 1)
379
+
380
+ # Parse progress percentage, scaled across streams.
381
+ # Exclude Destination / already-downloaded lines so a tweet title
382
+ # containing "%" (e.g. "100% agree") doesn't poison progress.
383
+ if line.startswith('[download]') and 'Destination:' not in line and 'has already been downloaded' not in line:
384
+ m = self._progress_re.search(line)
385
+ if m:
386
+ raw_pct = float(m.group(1))
387
+ pct = min((self.stream_index * 100 + raw_pct) / self.total_streams, 100.0)
388
+ # Never report backward progress (safety net if stream
389
+ # count detection is wrong or yt-dlp format changes)
390
+ if pct >= self.progress:
391
+ self.progress = pct
392
+
393
+ # Capture output filename from [download] or [Merger] lines
394
+ if 'Destination:' in line:
395
+ self.dest_count += 1
396
+ if self.dest_count > self.total_streams:
397
+ # More streams than the format line indicated (or format
398
+ # line was missing). Recalibrate so the monotonic guard
399
+ # doesn't lock progress at 100% for the rest of the download.
400
+ self.total_streams = self.dest_count
401
+ self.progress = min(
402
+ self.progress,
403
+ (self.dest_count - 1) * 100.0 / self.total_streams)
404
+ self.stream_index = min(self.dest_count - 1, self.total_streams - 1)
405
+ self.final_path = line.split('Destination:', 1)[1].strip()
406
+ elif 'has already been downloaded' in line:
407
+ self.dest_count += 1
408
+ if self.dest_count > self.total_streams:
409
+ self.total_streams = self.dest_count
410
+ self.stream_index = min(self.dest_count - 1, self.total_streams - 1)
411
+ # Count cached stream as complete for progress
412
+ pct = min((self.stream_index + 1) * 100.0 / self.total_streams, 100.0)
413
+ if pct >= self.progress:
414
+ self.progress = pct
415
+ # "[download] <path> has already been downloaded"
416
+ part = line.split(']', 1)[1].strip() if ']' in line else line
417
+ self.final_path = part.replace(' has already been downloaded', '').strip()
418
+ elif '[Merger]' in line and 'Merging formats into' in line:
419
+ self.final_path = line.split('Merging formats into "', 1)[1].rstrip('"').strip() if '"' in line else self.final_path
420
+
421
+
422
+ def _download_with_ytdlp(download_id, tweet_url, video_dir, post_date=''): # pragma: no cover
423
+ """Download using yt-dlp with progress parsing.
424
+
425
+ Downloads into a .downloading/ staging subdirectory so partial files
426
+ are not visible in video_dir until the download is fully complete.
427
+ """
428
+ tweet_id = _tweet_status_id(tweet_url)
429
+ staging_dir = os.path.join(video_dir, '.downloading')
430
+ os.makedirs(staging_dir, exist_ok=True)
431
+ prefix = _date_prefix(post_date)
432
+ # Pin the tweet status ID in the filename rather than relying on %(id)s,
433
+ # which some yt-dlp Twitter sub-extractors (amplify/broadcast/card) fill
434
+ # with a media or broadcast ID instead of the tweet ID.
435
+ output_template = _safe_video_path(
436
+ staging_dir,
437
+ prefix + '%(title)s [' + tweet_id + '].%(ext)s')
438
+ cmd = [
439
+ _ytdlp_path,
440
+ '--newline', '--progress',
441
+ '--cookies-from-browser', 'chrome',
442
+ '-o', output_template,
443
+ tweet_url,
444
+ ]
445
+ proc = subprocess.Popen(
446
+ cmd,
447
+ stdout=subprocess.PIPE,
448
+ stderr=subprocess.STDOUT,
449
+ text=True,
450
+ )
451
+ tracker = _YtdlpProgress()
452
+ last_lines = []
453
+ for line in proc.stdout:
454
+ line = line.strip()
455
+ if line:
456
+ last_lines.append(line)
457
+ if len(last_lines) > 20:
458
+ last_lines.pop(0)
459
+ print(f'[yt-dlp] {line}', file=sys.stderr)
460
+ tracker.feed(line)
461
+ with _downloads_lock:
462
+ _downloads[download_id]['progress'] = tracker.progress
463
+ proc.wait()
464
+ if proc.returncode != 0:
465
+ # Include yt-dlp's error output in the exception
466
+ error_lines = [l for l in last_lines if 'ERROR' in l]
467
+ detail = error_lines[-1] if error_lines else (last_lines[-1] if last_lines else '')
468
+ raise RuntimeError(f'yt-dlp failed: {detail}' if detail else f'yt-dlp exited with code {proc.returncode}')
469
+ # Move completed file from staging dir to final video_dir
470
+ final_path = tracker.final_path
471
+ if final_path and os.path.isfile(final_path):
472
+ staging_real = os.path.realpath(staging_dir)
473
+ final_real = os.path.realpath(final_path)
474
+ try:
475
+ if os.path.commonpath([staging_real, final_real]) != staging_real:
476
+ raise RuntimeError('yt-dlp output escaped staging directory')
477
+ except ValueError:
478
+ raise RuntimeError('yt-dlp output escaped staging directory')
479
+ dest_path = _safe_video_path(video_dir, os.path.basename(final_path))
480
+ shutil.move(final_real, dest_path)
481
+ final_path = dest_path
482
+ with _downloads_lock:
483
+ _downloads[download_id].update(
484
+ progress=100, status='done', path=final_path)
485
+
486
+
487
+ # --- Image download ---
488
+
489
+ # Strip a trailing :orig / :large / :medium etc. suffix Twitter appends to media URLs.
490
+ _TWIMG_SIZE_SUFFIX_RE = re.compile(r':(orig|large|medium|small|thumb|tiny)$')
491
+
492
+ # Twitter snowflake IDs are numeric; reject anything else to block path traversal.
493
+ _TWEET_ID_RE = re.compile(r'^[0-9]+$')
494
+
495
+ # Only fetch from these CDN hosts. Anything else (including redirects) is rejected.
496
+ ALLOWED_IMAGE_HOSTS = frozenset({'pbs.twimg.com'})
497
+
498
+
499
+ def _env_int(name, default):
500
+ """Parse an int env var, falling back to default on missing/empty/invalid."""
501
+ raw = (os.environ.get(name) or '').strip()
502
+ if not raw:
503
+ return default
504
+ try:
505
+ return int(raw)
506
+ except ValueError:
507
+ print(f'[xtap:image] invalid {name}={raw!r}, using {default}', file=sys.stderr)
508
+ return default
509
+
510
+
511
+ def _env_float(name, default, label='image'):
512
+ raw = (os.environ.get(name) or '').strip()
513
+ if not raw:
514
+ return default
515
+ try:
516
+ return float(raw)
517
+ except ValueError:
518
+ print(f'[xtap:{label}] invalid {name}={raw!r}, using {default}', file=sys.stderr)
519
+ return default
520
+
521
+
522
+ def _photo_filename(url):
523
+ """Extract the CDN filename from a pbs.twimg.com photo URL.
524
+
525
+ Returns the basename without any trailing :size suffix, or None if the URL
526
+ has no usable filename or contains traversal components.
527
+ """
528
+ if not url:
529
+ return None
530
+ cleaned = _TWIMG_SIZE_SUFFIX_RE.sub('', url)
531
+ path = urlparse(cleaned).path or cleaned
532
+ name = os.path.basename(path)
533
+ if not name or name in ('.', '..'):
534
+ return None
535
+ # Reject anything with path separators or that would still resolve to a
536
+ # parent directory after basename (defense in depth).
537
+ if '/' in name or '\\' in name or name.startswith('.'):
538
+ return None
539
+ return name
540
+
541
+
542
+ def _is_safe_rel_path(out_dir, rel_path):
543
+ """Verify joining rel_path with out_dir stays under out_dir.
544
+
545
+ Catches absolute paths (`/etc/...`), traversal (`../foo`), and Windows
546
+ drive-letter paths.
547
+ """
548
+ if not rel_path or os.path.isabs(rel_path):
549
+ return False
550
+ out_real = os.path.realpath(out_dir)
551
+ candidate = os.path.realpath(os.path.join(out_real, rel_path))
552
+ try:
553
+ return os.path.commonpath([out_real, candidate]) == out_real
554
+ except ValueError: # pragma: no cover — Windows-only: different drives
555
+ return False
556
+
557
+
558
+ def _strip_article_text_local_path(article, rel_path):
559
+ text = article.get('text')
560
+ if not isinstance(text, str):
561
+ return
562
+ pattern = re.compile(r'!\[[^\]\n]*\]\(' + re.escape(rel_path) + r'\)')
563
+ article['text'] = pattern.sub('', text)
564
+
565
+
566
+ def collect_image_jobs(tweets, out_dir):
567
+ """Compute the list of image download jobs for a batch of tweets.
568
+
569
+ For top-level photo media the rel_path is derived by convention
570
+ (`media/<tweet_id>/<cdn_filename>`) — the JSONL never carries a
571
+ redundant `local_path` field for these. Consumers who need the path
572
+ can reconstruct it from `id` + `basename(url)`.
573
+
574
+ Article media items (under `tweet.article.media[]`) already have
575
+ `local_path` set by the JS parser because that path is also embedded
576
+ in the article's rendered Markdown text (`![](media/<id>/file.png)`).
577
+ Those entries are validated here; if the supplied `local_path` would
578
+ escape `out_dir`, the field and its rendered Markdown image reference
579
+ are stripped so the unsafe path never lands in the JSONL.
580
+
581
+ Path components are validated against `out_dir`: tweet IDs must match
582
+ [0-9]+, filenames must be plain basenames, and the final resolved
583
+ path must stay under `out_dir`. Anything that fails validation is
584
+ skipped.
585
+
586
+ Returns: list of {tweet_id, url, rel_path} ready for the downloader.
587
+ """
588
+ pending = []
589
+ for tweet in tweets:
590
+ tweet_id = tweet.get('id')
591
+ if not tweet_id or not isinstance(tweet_id, str) or not _TWEET_ID_RE.match(tweet_id):
592
+ continue
593
+
594
+ # Top-level photo media (regular tweets) — derived path, no mutation.
595
+ for item in tweet.get('media') or []:
596
+ if not isinstance(item, dict) or item.get('type') != 'photo':
597
+ continue
598
+ url = item.get('url')
599
+ filename = _photo_filename(url)
600
+ if not filename:
601
+ continue
602
+ rel_path = f'media/{tweet_id}/{filename}'
603
+ if not _is_safe_rel_path(out_dir, rel_path):
604
+ continue
605
+ pending.append({'tweet_id': tweet_id, 'url': url, 'rel_path': rel_path})
606
+
607
+ # Article media — local_path crosses the trust boundary in the
608
+ # request body, so re-validate it here.
609
+ article = tweet.get('article') or {}
610
+ for item in article.get('media') or []:
611
+ if not isinstance(item, dict):
612
+ continue
613
+ url = item.get('url')
614
+ rel_path = item.get('local_path')
615
+ if not url or not isinstance(rel_path, str):
616
+ continue
617
+ if not _is_safe_rel_path(out_dir, rel_path):
618
+ # Strip the unsafe path so it doesn't end up in the JSONL.
619
+ item.pop('local_path', None)
620
+ _strip_article_text_local_path(article, rel_path)
621
+ continue
622
+ pending.append({'tweet_id': tweet_id, 'url': url, 'rel_path': rel_path})
623
+
624
+ return pending
625
+
626
+
627
+ _image_downloader = None
628
+ _image_downloader_lock = threading.Lock()
629
+
630
+
631
+ def get_image_downloader():
632
+ """Lazily construct the singleton ImageDownloader."""
633
+ global _image_downloader
634
+ with _image_downloader_lock:
635
+ if _image_downloader is None:
636
+ _image_downloader = ImageDownloader()
637
+ return _image_downloader
638
+
639
+
640
+ def reset_image_downloader():
641
+ """Reset the singleton (test-only — leaks the previous worker thread)."""
642
+ global _image_downloader
643
+ with _image_downloader_lock:
644
+ _image_downloader = None
645
+
646
+
647
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
648
+ """Treat any redirect as an error so we never fetch beyond the allowlisted host."""
649
+
650
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
651
+ raise urllib.error.HTTPError(req.full_url, code, f'redirect to {newurl} blocked', headers, fp)
652
+
653
+
654
+ _NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler())
655
+
656
+
657
+ class ImageDownloader:
658
+ """Single background-thread image downloader with hardened HTTP fetch.
659
+
660
+ - One worker, one queue. Per-job cost is small so a single thread is fine
661
+ and avoids hammering the CDN.
662
+ - Idempotent: if the destination file already exists, skip the network call
663
+ and log status='exists'.
664
+ - URL hostname is checked against ALLOWED_IMAGE_HOSTS before every request.
665
+ Redirects are blocked entirely (so an attacker can't 302 us off-allowlist).
666
+ - Per-file size cap (XTAP_MAX_FILE_MB, default 50) bounds disk impact even
667
+ for adversarial responses. Optional cumulative cap via XTAP_MAX_MEDIA_MB.
668
+ - 429 responses trigger exponential backoff (capped at MAX_BACKOFF_S).
669
+ Other HTTP errors and network failures log status='error'.
670
+ """
671
+
672
+ DEFAULT_DELAY_MS = 100
673
+ DEFAULT_MAX_FILE_MB = 50
674
+ USER_AGENT = 'xTap/1.0 (+https://github.com/mkubicek/xTap)'
675
+ MAX_BACKOFF_S = 30
676
+ REQUEST_TIMEOUT_S = 30
677
+ CHUNK_SIZE = 64 * 1024
678
+
679
+ def __init__(self):
680
+ self.queue = queue.Queue()
681
+ self.delay_s = max(0.0, _env_int('XTAP_IMAGE_DELAY_MS', self.DEFAULT_DELAY_MS) / 1000.0)
682
+ max_total_mb = _env_float('XTAP_MAX_MEDIA_MB', 0.0)
683
+ self.max_bytes = int(max_total_mb * 1024 * 1024) if max_total_mb > 0 else None
684
+ max_file_mb = _env_float('XTAP_MAX_FILE_MB', float(self.DEFAULT_MAX_FILE_MB))
685
+ self.max_file_bytes = int(max_file_mb * 1024 * 1024) if max_file_mb > 0 else None
686
+ self._bytes_lock = threading.Lock()
687
+ self.bytes_downloaded = 0
688
+ self._last_request_at = 0.0
689
+ self._thread = threading.Thread(target=self._run, daemon=True, name='xtap-image-downloader')
690
+ self._thread.start()
691
+
692
+ def enqueue(self, jobs, out_dir):
693
+ """Enqueue a batch of pending downloads against the given output dir."""
694
+ for job in jobs:
695
+ self.queue.put((job, out_dir))
696
+
697
+ def _run(self):
698
+ while True:
699
+ try:
700
+ job, out_dir = self.queue.get()
701
+ except Exception: # pragma: no cover — only fires at interpreter shutdown
702
+ continue
703
+ try:
704
+ self._process(job, out_dir)
705
+ except Exception as e: # pragma: no cover — defensive
706
+ print(f'[xtap:image] worker exception: {e}', file=sys.stderr)
707
+ finally:
708
+ self.queue.task_done()
709
+
710
+ def _process(self, job, out_dir):
711
+ tweet_id = job['tweet_id']
712
+ url = job['url']
713
+ rel_path = job['rel_path']
714
+
715
+ # Re-validate the rel_path defensively: enqueue() is exported and a
716
+ # future caller could skip inject_image_local_paths.
717
+ if not _is_safe_rel_path(out_dir, rel_path):
718
+ self._log(out_dir, tweet_id, url, rel_path, 'error:unsafe_path', 0)
719
+ return
720
+
721
+ dest_path = os.path.join(out_dir, rel_path)
722
+
723
+ if os.path.exists(dest_path):
724
+ self._log(out_dir, tweet_id, url, dest_path, 'exists', os.path.getsize(dest_path))
725
+ return
726
+
727
+ if not _is_allowed_url(url):
728
+ self._log(out_dir, tweet_id, url, dest_path, 'error:host_not_allowed', 0)
729
+ return
730
+
731
+ if self.max_bytes is not None:
732
+ with self._bytes_lock:
733
+ over_quota = self.bytes_downloaded >= self.max_bytes
734
+ if over_quota:
735
+ self._log(out_dir, tweet_id, url, dest_path, 'skipped:quota', 0)
736
+ return
737
+
738
+ # Simple rate limiter: enforce delay_s between requests.
739
+ if self.delay_s > 0:
740
+ wait = self.delay_s - (time.monotonic() - self._last_request_at)
741
+ if wait > 0:
742
+ time.sleep(wait)
743
+
744
+ size, err = self._download(url, dest_path)
745
+ self._last_request_at = time.monotonic()
746
+
747
+ if err:
748
+ self._log(out_dir, tweet_id, url, dest_path, f'error:{err}', 0)
749
+ return
750
+
751
+ with self._bytes_lock:
752
+ self.bytes_downloaded += size
753
+ self._log(out_dir, tweet_id, url, dest_path, 'ok', size)
754
+
755
+ def _download(self, url, dest_path):
756
+ """Download to a .part file and rename atomically. Returns (bytes, error)."""
757
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
758
+ tmp_path = dest_path + '.part'
759
+ backoff = 1.0
760
+ attempts = 0
761
+ while True:
762
+ attempts += 1
763
+ req = urllib.request.Request(url, headers={'User-Agent': self.USER_AGENT})
764
+ try:
765
+ with _NO_REDIRECT_OPENER.open(req, timeout=self.REQUEST_TIMEOUT_S) as resp:
766
+ # Reject up-front when Content-Length advertises an oversize body.
767
+ if self.max_file_bytes is not None:
768
+ cl = resp.headers.get('Content-Length')
769
+ if cl and cl.isdigit() and int(cl) > self.max_file_bytes:
770
+ return 0, 'too_large'
771
+ written = 0
772
+ with open(tmp_path, 'wb') as f:
773
+ while True:
774
+ chunk = resp.read(self.CHUNK_SIZE)
775
+ if not chunk:
776
+ break
777
+ written += len(chunk)
778
+ if self.max_file_bytes is not None and written > self.max_file_bytes:
779
+ _safe_unlink(tmp_path)
780
+ return 0, 'too_large'
781
+ f.write(chunk)
782
+ size = os.path.getsize(tmp_path)
783
+ os.replace(tmp_path, dest_path)
784
+ return size, None
785
+ except urllib.error.HTTPError as e:
786
+ _safe_unlink(tmp_path)
787
+ if e.code == 429 and backoff <= self.MAX_BACKOFF_S and attempts < 4:
788
+ time.sleep(backoff)
789
+ backoff *= 2
790
+ continue
791
+ return 0, f'http_{e.code}'
792
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
793
+ _safe_unlink(tmp_path)
794
+ return 0, type(e).__name__
795
+
796
+ def _log(self, out_dir, tweet_id, url, dest_path, status, size):
797
+ try:
798
+ local_path = os.path.relpath(dest_path, out_dir)
799
+ except ValueError: # pragma: no cover — Windows-only: different drives
800
+ local_path = dest_path
801
+ entry = {
802
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
803
+ 'tweet_id': tweet_id,
804
+ 'url': url,
805
+ 'local_path': local_path,
806
+ 'status': status,
807
+ 'bytes': size,
808
+ }
809
+ manifest_path = os.path.join(out_dir, 'media-manifest.jsonl')
810
+ try:
811
+ os.makedirs(out_dir, exist_ok=True)
812
+ with open(manifest_path, 'a', encoding='utf-8') as f:
813
+ f.write(json.dumps(entry, ensure_ascii=False) + '\n')
814
+ except OSError as e:
815
+ print(f'[xtap:image] manifest write failed: {e}', file=sys.stderr)
816
+
817
+
818
+ def _is_allowed_url(url):
819
+ """True iff url is https and the hostname is in ALLOWED_IMAGE_HOSTS."""
820
+ if not url or not isinstance(url, str):
821
+ return False
822
+ try:
823
+ parsed = urlparse(url)
824
+ except ValueError: # pragma: no cover — urlparse rarely raises in practice
825
+ return False
826
+ if parsed.scheme != 'https':
827
+ return False
828
+ host = (parsed.hostname or '').lower()
829
+ return host in ALLOWED_IMAGE_HOSTS
830
+
831
+
832
+ def _safe_unlink(path):
833
+ try:
834
+ os.unlink(path)
835
+ except OSError:
836
+ pass
extension/native-host/xtap_daemon.bat ADDED
@@ -0,0 +1 @@
 
 
1
+ @pythonw "%~dp0xtap_daemon.py" %*
extension/native-host/xtap_daemon.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """xTap HTTP Daemon — runs as a system service (launchd/systemd/Scheduled Task)."""
3
+
4
+ import hmac
5
+ import json
6
+ import os
7
+ import platform
8
+ import signal
9
+ import socket
10
+ import sys
11
+ import time
12
+ import uuid
13
+ import threading
14
+ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
15
+
16
+ from xtap_core import (DEFAULT_OUTPUT_DIR, load_seen_ids, resolve_output_dir,
17
+ validate_output_dir, write_tweets, write_log,
18
+ write_dump, test_path,
19
+ check_ytdlp, start_download, get_download_status,
20
+ collect_image_jobs, get_image_downloader,
21
+ validate_tweet_url)
22
+
23
+ VERSION = '0.24.0'
24
+ BIND_HOST = '127.0.0.1'
25
+ BIND_PORT = int(os.environ.get('XTAP_DAEMON_PORT', 17381))
26
+ MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB (extension caps POSTs via MAX_TWEETS_PER_POST in background.js)
27
+ MAX_DRAIN_SIZE = 64 * 1024 * 1024 # cap bytes drained from an oversized request before replying 413
28
+ MAX_DRAIN_IDLE_TIMEOUT_S = 0.25 # don't hang on clients that declare a body and send none
29
+
30
+
31
+ def _env_timeout_s(name, default):
32
+ # Always finite — bad/<=0 values fall back to the default rather than
33
+ # disabling the timeout (disabling would reintroduce the shutdown-hang
34
+ # these guards exist to prevent).
35
+ raw = (os.environ.get(name) or '').strip()
36
+ if not raw:
37
+ return default
38
+ try:
39
+ value = float(raw)
40
+ except ValueError:
41
+ return default
42
+ return value if value > 0 else default
43
+
44
+
45
+ # Per-connection socket timeout (see DaemonHandler.timeout). Default 15s:
46
+ # above the extension's 10s client timeout (HTTP_TIMEOUT_MS) so a legit
47
+ # localhost request is never cut off, and below launchd's ~20s SIGTERM->
48
+ # SIGKILL window so an idle connection can't push shutdown past it.
49
+ CONN_TIMEOUT_S = _env_timeout_s('XTAP_CONN_TIMEOUT_S', 15.0)
50
+
51
+ # Bounded shutdown join. The socket timeout above is per blocking read, not a
52
+ # total request deadline — a trickle peer sending one byte per read window
53
+ # keeps its handler thread alive forever, and server_close() would join it
54
+ # indefinitely. After this grace period the daemon force-exits: legitimate
55
+ # JSONL writes finish in well under a second, so only stuck peers are cut off,
56
+ # and 10s stays below launchd's ~20s SIGTERM->SIGKILL escalation.
57
+ SHUTDOWN_GRACE_S = _env_timeout_s('XTAP_SHUTDOWN_GRACE_S', 10.0)
58
+ XTAP_DIR = os.path.expanduser('~/.xtap')
59
+ XTAP_SECRET = os.path.join(XTAP_DIR, 'secret')
60
+
61
+ # Log level: 'info' (default) or 'debug'
62
+ LOG_LEVEL = os.environ.get('XTAP_LOG_LEVEL', 'info').lower()
63
+
64
+
65
+ def log_info(msg):
66
+ print(msg, file=sys.stderr)
67
+
68
+
69
+ def log_debug(msg):
70
+ if LOG_LEVEL == 'debug':
71
+ print(f'[DEBUG] {msg}', file=sys.stderr)
72
+
73
+
74
+ def load_token():
75
+ try:
76
+ with open(XTAP_SECRET, 'r', encoding='utf-8') as f:
77
+ return f.read().strip()
78
+ except FileNotFoundError:
79
+ log_info(f'FATAL: {XTAP_SECRET} not found. Run install.sh first.')
80
+ sys.exit(1)
81
+
82
+
83
+ # Module-level state shared across requests
84
+ _token = None
85
+ _seen_ids_by_dir = {}
86
+ _state_lock = threading.Lock()
87
+
88
+
89
+ class XtapHTTPServer(ThreadingHTTPServer):
90
+ # daemon_threads defaults to True on ThreadingHTTPServer, which makes
91
+ # server_close()'s thread-join a no-op (daemon threads aren't tracked) —
92
+ # an in-flight handler is then killed mid-JSONL-write at interpreter exit.
93
+ # False + block_on_close (default True) makes server_close() actually wait
94
+ # for in-flight writes to finish before the process exits.
95
+ daemon_threads = False
96
+
97
+
98
+ class DaemonHandler(BaseHTTPRequestHandler):
99
+ # Socket timeout for stalled/idle connections. Required by the
100
+ # daemon_threads = False change above: without it, a peer that opens a
101
+ # connection to 127.0.0.1 and never finishes sending its request line/body
102
+ # (a port scanner, a stalled fetch — this is pre-auth, so the token can't
103
+ # gate it) parks a non-daemon handler thread in a blocking read forever.
104
+ # server_close() then blocks joining that thread on shutdown until
105
+ # launchd/systemd escalates to SIGKILL, killing in-flight JSONL writes —
106
+ # the exact failure daemon_threads = False exists to prevent. The timeout
107
+ # makes each blocking read raise so idle threads exit. It is per-read, not
108
+ # a total deadline — a trickle sender can still hold a thread, which is
109
+ # why shutdown is additionally bounded by SHUTDOWN_GRACE_S in main().
110
+ timeout = CONN_TIMEOUT_S
111
+
112
+ def log_message(self, format, *args):
113
+ # Log to stderr (captured by launchd/systemd)
114
+ log_info(f'{self.client_address[0]} - {format % args}')
115
+
116
+ def _send_json(self, obj, status=200):
117
+ body = json.dumps(obj).encode('utf-8')
118
+ self.send_response(status)
119
+ self.send_header('Content-Type', 'application/json')
120
+ self.send_header('Content-Length', len(body))
121
+ self.end_headers()
122
+ self.wfile.write(body)
123
+ log_debug(f' -> {status} ({len(body)} bytes)')
124
+
125
+ def _read_json(self, length):
126
+ if length == 0:
127
+ return {}
128
+ return json.loads(self.rfile.read(length))
129
+
130
+ def _check_auth(self):
131
+ auth = self.headers.get('Authorization', '')
132
+ if not auth.startswith('Bearer ') or not hmac.compare_digest(auth[7:], _token):
133
+ log_debug(f' Auth failed (header {"present" if auth else "missing"})')
134
+ self._send_json({'ok': False, 'error': 'Unauthorized'}, 401)
135
+ return False
136
+ return True
137
+
138
+ def do_GET(self):
139
+ log_debug(f'GET {self.path}')
140
+ if self.path == '/status':
141
+ # Validate token when provided (allows probeHttp to detect stale credentials)
142
+ auth = self.headers.get('Authorization', '')
143
+ if auth and (not auth.startswith('Bearer ') or not hmac.compare_digest(auth[7:], _token)):
144
+ self._send_json({'ok': False, 'error': 'Unauthorized'}, 401)
145
+ return
146
+ self._send_json({'ok': True, 'version': VERSION})
147
+ return
148
+ self._send_json({'ok': False, 'error': 'Not found'}, 404)
149
+
150
+ def _validate_content_length(self):
151
+ """Validate Content-Length header. Returns length or -1 on error (response already sent)."""
152
+ raw = self.headers.get('Content-Length')
153
+ if raw is None:
154
+ self._send_json({'ok': False, 'error': 'Missing Content-Length header'}, 400)
155
+ return -1
156
+ try:
157
+ length = int(raw)
158
+ except ValueError:
159
+ self._send_json({'ok': False, 'error': f'Invalid Content-Length: {raw!r}'}, 400)
160
+ return -1
161
+ if length < 0:
162
+ self._send_json({'ok': False, 'error': 'Content-Length must not be negative'}, 400)
163
+ return -1
164
+ if length > MAX_BODY_SIZE:
165
+ # Drain the (bounded) body before replying. An unread request body
166
+ # on a closed HTTP/1.0 socket triggers a TCP RST, which surfaces to
167
+ # the extension's fetch() as a network error rather than a 413 — so
168
+ # the client's batch-splitting recovery never sees the 413 and the
169
+ # oversized batch wedges the queue. Draining lets the 413 arrive
170
+ # cleanly. Capped so a bogus huge Content-Length can't tie us up.
171
+ self._drain_body(min(length, MAX_DRAIN_SIZE))
172
+ self._send_json({'ok': False, 'error': 'Payload too large'}, 413)
173
+ return -1
174
+ return length
175
+
176
+ def _drain_body(self, n):
177
+ old_timeout = self.connection.gettimeout()
178
+ self.connection.settimeout(MAX_DRAIN_IDLE_TIMEOUT_S)
179
+ remaining = n
180
+ try:
181
+ while remaining > 0:
182
+ try:
183
+ chunk = self.rfile.read(min(65536, remaining))
184
+ except socket.timeout:
185
+ break
186
+ if not chunk:
187
+ break
188
+ remaining -= len(chunk)
189
+ finally:
190
+ self.connection.settimeout(old_timeout)
191
+
192
+ def do_POST(self):
193
+ log_debug(f'POST {self.path} (Content-Length: {self.headers.get("Content-Length", "?")})')
194
+
195
+ if not self._check_auth():
196
+ return
197
+
198
+ length = self._validate_content_length()
199
+ if length < 0:
200
+ return
201
+
202
+ try:
203
+ body = self._read_json(length)
204
+ except (json.JSONDecodeError, ValueError) as e:
205
+ self._send_json({'ok': False, 'error': f'Invalid JSON: {e}'}, 400)
206
+ return
207
+
208
+ t0 = time.monotonic()
209
+ if self.path == '/tweets':
210
+ self._handle_tweets(body)
211
+ elif self.path == '/log':
212
+ self._handle_log(body)
213
+ elif self.path == '/dump':
214
+ self._handle_dump(body)
215
+ elif self.path == '/test-path':
216
+ self._handle_test_path(body)
217
+ elif self.path == '/check-ytdlp':
218
+ self._handle_check_ytdlp(body)
219
+ elif self.path == '/download-video':
220
+ self._handle_download_video(body)
221
+ elif self.path == '/download-status':
222
+ self._handle_download_status(body)
223
+ else:
224
+ self._send_json({'ok': False, 'error': 'Not found'}, 404)
225
+ elapsed = (time.monotonic() - t0) * 1000
226
+ log_debug(f' Completed in {elapsed:.1f}ms')
227
+
228
+ def _handle_tweets(self, body):
229
+ try:
230
+ msg_dir = body.get('outputDir', '').strip()
231
+ # Strict boolean: only `true` enables. Avoids string 'false' / number 1
232
+ # silently turning the feature on.
233
+ image_download = body.get('image_download') is True
234
+ with _state_lock:
235
+ out_dir, seen_ids = resolve_output_dir(msg_dir, DEFAULT_OUTPUT_DIR, _seen_ids_by_dir)
236
+ tweets = body.get('tweets', [])
237
+ # Always collect before write_tweets: collect_image_jobs strips
238
+ # unsafe article local_paths from the tweets so traversal paths
239
+ # never land in the JSONL — sanitization must not depend on the
240
+ # image_download toggle.
241
+ pending_images = collect_image_jobs(tweets, out_dir)
242
+ count, dupes = write_tweets(tweets, out_dir, seen_ids)
243
+ queued = 0
244
+ if image_download and pending_images:
245
+ get_image_downloader().enqueue(pending_images, out_dir)
246
+ queued = len(pending_images)
247
+ log_debug(f' Tweets: {count} written, {dupes} dupes, {queued} images queued -> {out_dir}')
248
+ self._send_json({'ok': True, 'count': count, 'dupes': dupes, 'images_queued': queued})
249
+ except ValueError as e:
250
+ self._send_json({'ok': False, 'error': str(e)}, 400)
251
+ except Exception as e:
252
+ log_info(f'ERROR /tweets: {e}')
253
+ log_debug(f' Traceback: {_format_exc()}')
254
+ self._send_json({'ok': False, 'error': str(e)}, 500)
255
+
256
+ def _handle_log(self, body):
257
+ try:
258
+ msg_dir = body.get('outputDir', '').strip()
259
+ lines = body.get('lines', [])
260
+ with _state_lock:
261
+ out_dir, _ = resolve_output_dir(msg_dir, DEFAULT_OUTPUT_DIR, _seen_ids_by_dir)
262
+ # Write under the lock — concurrent appends through separate
263
+ # file objects can interleave mid-line at buffer boundaries.
264
+ logged = write_log(lines, out_dir)
265
+ self._send_json({'ok': True, 'logged': logged})
266
+ except ValueError as e:
267
+ self._send_json({'ok': False, 'error': str(e)}, 400)
268
+ except Exception as e:
269
+ log_info(f'ERROR /log: {e}')
270
+ log_debug(f' Traceback: {_format_exc()}')
271
+ self._send_json({'ok': False, 'error': str(e)}, 500)
272
+
273
+ def _handle_dump(self, body):
274
+ try:
275
+ msg_dir = body.get('outputDir', '').strip()
276
+ with _state_lock:
277
+ out_dir, _ = resolve_output_dir(msg_dir, DEFAULT_OUTPUT_DIR, _seen_ids_by_dir)
278
+ filename = body.get('filename', 'dump.json')
279
+ content = body.get('content', '')
280
+ path = write_dump(filename, content, out_dir)
281
+ self._send_json({'ok': True, 'path': path})
282
+ except ValueError as e:
283
+ self._send_json({'ok': False, 'error': str(e)}, 400)
284
+ except Exception as e:
285
+ log_info(f'ERROR /dump: {e}')
286
+ log_debug(f' Traceback: {_format_exc()}')
287
+ self._send_json({'ok': False, 'error': str(e)}, 500)
288
+
289
+ def _handle_test_path(self, body):
290
+ try:
291
+ msg_dir = body.get('outputDir', '').strip()
292
+ if not msg_dir:
293
+ self._send_json({'ok': False, 'error': 'outputDir is required'}, 400)
294
+ return
295
+ out_dir = validate_output_dir(os.path.expanduser(msg_dir))
296
+ test_path(out_dir)
297
+ self._send_json({'ok': True, 'type': 'TEST_PATH'})
298
+ except ValueError as e:
299
+ self._send_json({'ok': False, 'error': str(e)}, 400)
300
+ except Exception as e:
301
+ log_info(f'ERROR /test-path: {e}')
302
+ self._send_json({'ok': False, 'error': str(e)}, 500)
303
+
304
+ def _handle_check_ytdlp(self, body):
305
+ available = check_ytdlp()
306
+ log_debug(f' yt-dlp available: {available}')
307
+ self._send_json({'ok': True, 'available': available})
308
+
309
+ def _handle_download_video(self, body):
310
+ try:
311
+ tweet_url = body.get('tweetUrl', '')
312
+ direct_url = body.get('directUrl', '')
313
+ post_date = body.get('postDate', '')
314
+ msg_dir = body.get('outputDir', '').strip()
315
+ validate_tweet_url(tweet_url)
316
+ with _state_lock:
317
+ out_dir, _ = resolve_output_dir(msg_dir, DEFAULT_OUTPUT_DIR, _seen_ids_by_dir)
318
+ download_id = str(uuid.uuid4())
319
+ start_download(download_id, tweet_url, direct_url, out_dir, post_date)
320
+ log_debug(f' Download started: {download_id} -> {tweet_url}')
321
+ self._send_json({'ok': True, 'downloadId': download_id})
322
+ except ValueError as e:
323
+ self._send_json({'ok': False, 'error': str(e)}, 400)
324
+ except Exception as e:
325
+ log_info(f'ERROR /download-video: {e}')
326
+ self._send_json({'ok': False, 'error': str(e)}, 500)
327
+
328
+ def _handle_download_status(self, body):
329
+ download_id = body.get('downloadId', '')
330
+ status = get_download_status(download_id)
331
+ self._send_json({'ok': True, **status})
332
+
333
+
334
+ def _format_exc():
335
+ import traceback
336
+ return traceback.format_exc().replace('\n', ' | ')
337
+
338
+
339
+ def _setup_stdio():
340
+ """Redirect stdio to log files when running under pythonw (no console)."""
341
+ if sys.stderr is not None and sys.stdout is not None:
342
+ return
343
+ os.makedirs(XTAP_DIR, exist_ok=True)
344
+ if sys.stdout is None:
345
+ sys.stdout = open(os.path.join(XTAP_DIR, 'daemon-stdout.log'), 'a', encoding='utf-8')
346
+ if sys.stderr is None:
347
+ sys.stderr = open(os.path.join(XTAP_DIR, 'daemon-stderr.log'), 'a', encoding='utf-8')
348
+
349
+
350
+ def _log_startup_diagnostics():
351
+ """Log system and configuration info on startup."""
352
+ log_info(f'xTap daemon v{VERSION}')
353
+ log_info(f' Python: {sys.version.split(chr(10))[0]}')
354
+ log_info(f' Executable: {sys.executable}')
355
+ log_info(f' Script: {os.path.abspath(__file__)}')
356
+ log_info(f' Platform: {platform.system()} {platform.release()}')
357
+ log_info(f' Output dir: {DEFAULT_OUTPUT_DIR}')
358
+ log_info(f' Token: loaded ({len(_token)} chars)')
359
+ log_info(f' Log level: {LOG_LEVEL}')
360
+
361
+ # Check output dir writability
362
+ try:
363
+ test_path(DEFAULT_OUTPUT_DIR)
364
+ log_info(f' Output dir: writable')
365
+ except Exception as e:
366
+ log_info(f' Output dir: NOT writable ({e})')
367
+
368
+ # Check yt-dlp
369
+ ytdlp = check_ytdlp()
370
+ log_info(f' yt-dlp: {"available" if ytdlp else "not found"}')
371
+
372
+
373
+ def main():
374
+ global _token
375
+
376
+ _setup_stdio()
377
+
378
+ _token = load_token()
379
+
380
+ # Initialize output directory and seen IDs
381
+ os.makedirs(DEFAULT_OUTPUT_DIR, exist_ok=True)
382
+ _seen_ids_by_dir[DEFAULT_OUTPUT_DIR] = load_seen_ids(DEFAULT_OUTPUT_DIR)
383
+
384
+ _log_startup_diagnostics()
385
+ log_info(f' Seen IDs: {len(_seen_ids_by_dir[DEFAULT_OUTPUT_DIR])} loaded')
386
+
387
+ try:
388
+ server = XtapHTTPServer((BIND_HOST, BIND_PORT), DaemonHandler)
389
+ except OSError as e:
390
+ log_info(f'FATAL: Cannot bind to {BIND_HOST}:{BIND_PORT} — {e}')
391
+ log_info(f' Is another instance already running?')
392
+ sys.exit(1)
393
+
394
+ def shutdown(signum, frame):
395
+ log_info(f'Received signal {signum}, shutting down...')
396
+ # shutdown() must run on a different thread than serve_forever(): the
397
+ # signal handler interrupts the serve_forever thread, and shutdown()
398
+ # blocks until serve_forever exits — calling it inline deadlocks until
399
+ # launchd/systemd escalates to SIGKILL (killing in-flight writes).
400
+ threading.Thread(target=server.shutdown, daemon=True).start()
401
+
402
+ signal.signal(signal.SIGINT, shutdown)
403
+ if platform.system() == 'Windows':
404
+ signal.signal(signal.SIGBREAK, shutdown)
405
+ else:
406
+ signal.signal(signal.SIGTERM, shutdown)
407
+
408
+ log_info(f'Listening on {BIND_HOST}:{BIND_PORT}')
409
+ server.serve_forever()
410
+ # Join in-flight non-daemon handler threads so any active JSONL write can
411
+ # finish cleanly — but bound the join: a peer that trickles bytes defeats
412
+ # the per-read socket timeout and would pin server_close() forever,
413
+ # holding the SIGTERM window open until launchd/systemd SIGKILLs us
414
+ # mid-write anyway. Force-exit after the grace period instead: real
415
+ # writes have long finished; only stuck peers are cut off.
416
+ closer = threading.Thread(target=server.server_close, daemon=True)
417
+ closer.start()
418
+ closer.join(SHUTDOWN_GRACE_S)
419
+ if closer.is_alive():
420
+ log_info(f'Shutdown grace ({SHUTDOWN_GRACE_S}s) expired with handler '
421
+ f'threads still running — forcing exit')
422
+ sys.stderr.flush()
423
+ try:
424
+ sys.stdout.flush()
425
+ except (OSError, ValueError):
426
+ pass
427
+ os._exit(0)
428
+ log_info('Shutdown complete')
429
+
430
+
431
+ if __name__ == '__main__':
432
+ main()
extension/native-host/xtap_host.bat ADDED
@@ -0,0 +1 @@
 
 
1
+ @python "%~dp0xtap_host.py" %*
extension/native-host/xtap_host.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """xTap Native Messaging Host — token bootstrap for the Chrome/Firefox extension.
3
+
4
+ This minimal host exists solely to pass the HTTP daemon's auth token to the
5
+ browser extension via native messaging. All tweet/log/dump I/O goes through
6
+ the HTTP daemon (xtap_daemon.py).
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import struct
12
+ import sys
13
+ import traceback
14
+ from datetime import datetime
15
+
16
+ XTAP_PORT = int(os.environ.get('XTAP_DAEMON_PORT', 17381))
17
+ XTAP_DIR = os.path.expanduser('~/.xtap')
18
+ XTAP_SECRET = os.path.join(XTAP_DIR, 'secret')
19
+ XTAP_ERROR_LOG = os.path.join(XTAP_DIR, 'host-error.log')
20
+
21
+ MAX_MESSAGE_BYTES = 1 * 1024 * 1024 # 1 MiB guard
22
+
23
+
24
+ def read_exact(stream, size):
25
+ """Read exactly *size* bytes from *stream*, handling pipe fragmentation."""
26
+ buf = b''
27
+ while len(buf) < size:
28
+ chunk = stream.read(size - len(buf))
29
+ if not chunk:
30
+ raise EOFError('unexpected end of stream')
31
+ buf += chunk
32
+ return buf
33
+
34
+
35
+ def drain_exact(stream, size):
36
+ """Discard exactly *size* bytes so the next frame starts at a boundary."""
37
+ remaining = size
38
+ while remaining > 0:
39
+ chunk = stream.read(min(65536, remaining))
40
+ if not chunk:
41
+ raise EOFError('unexpected end of stream')
42
+ remaining -= len(chunk)
43
+
44
+
45
+ def read_message():
46
+ try:
47
+ header = read_exact(sys.stdin.buffer, 4)
48
+ except EOFError:
49
+ raise EOFError('stdin closed')
50
+ length = struct.unpack('<I', header)[0]
51
+ if length > MAX_MESSAGE_BYTES:
52
+ # Drain the payload before raising — leaving it in the pipe would make
53
+ # the next read interpret payload bytes as a length header, and the
54
+ # stream would never realign.
55
+ drain_exact(sys.stdin.buffer, length)
56
+ raise ValueError(f'message too large: {length} bytes')
57
+ data = read_exact(sys.stdin.buffer, length)
58
+ return json.loads(data)
59
+
60
+
61
+ def send_message(msg):
62
+ encoded = json.dumps(msg).encode('utf-8')
63
+ sys.stdout.buffer.write(struct.pack('<I', len(encoded)))
64
+ sys.stdout.buffer.write(encoded)
65
+ sys.stdout.buffer.flush()
66
+
67
+
68
+ def _main():
69
+ while True:
70
+ try:
71
+ msg = read_message()
72
+ except EOFError:
73
+ break
74
+ except Exception as e:
75
+ send_message({'ok': False, 'error': str(e)})
76
+ continue
77
+
78
+ if msg.get('type') == 'GET_TOKEN':
79
+ try:
80
+ with open(XTAP_SECRET, 'r', encoding='utf-8') as f:
81
+ token = f.read().strip()
82
+ send_message({'ok': True, 'token': token, 'port': XTAP_PORT})
83
+ except FileNotFoundError:
84
+ send_message({'ok': False, 'error': 'Daemon not installed (~/.xtap/secret not found)'})
85
+ except Exception as e:
86
+ send_message({'ok': False, 'error': str(e)})
87
+ else:
88
+ send_message({
89
+ 'ok': False,
90
+ 'error': f'Unsupported message type: {msg.get("type")}. '
91
+ 'All data is handled by the HTTP daemon.'
92
+ })
93
+
94
+
95
+ def main():
96
+ try:
97
+ _main()
98
+ except Exception:
99
+ os.makedirs(XTAP_DIR, exist_ok=True)
100
+ with open(XTAP_ERROR_LOG, 'a', encoding='utf-8') as f:
101
+ f.write(f'\n--- {datetime.now().isoformat()} ---\n')
102
+ f.write(f'Python: {sys.version}\n')
103
+ f.write(f'Script: {os.path.abspath(__file__)}\n')
104
+ f.write(f'sys.path: {sys.path}\n')
105
+ traceback.print_exc(file=f)
106
+ raise
107
+
108
+
109
+ if __name__ == '__main__':
110
+ main()
extension/options.html ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <link rel="stylesheet" href="popup.css">
6
+ <style>
7
+ body { min-width: 26rem; }
8
+ .options-note { font-size: 0.85em; opacity: 0.75; margin: 0.3rem 0 0.8rem; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <div class="container">
13
+ <h1>xtap-pool options</h1>
14
+ <div id="pool-status" class="status"></div>
15
+
16
+ <div class="settings">
17
+ <label class="settings-label" for="pool-url">Pool Space URL</label>
18
+ <p class="options-note">
19
+ The Space this extension syncs to. Changing it disconnects the current
20
+ pool token; use Connect in the popup (or paste a token below) after
21
+ saving.
22
+ </p>
23
+ <div class="settings-row">
24
+ <input type="text" id="pool-url" placeholder="https://your-space.hf.space" spellcheck="false">
25
+ </div>
26
+ </div>
27
+
28
+ <div class="settings">
29
+ <label class="settings-label" for="pool-token">Pool token (manual fallback)</label>
30
+ <p class="options-note">
31
+ Only needed if the automatic handoff on the Space's /connect page
32
+ fails. Copy the token shown there and paste it here.
33
+ </p>
34
+ <div class="settings-row">
35
+ <input type="text" id="pool-token" placeholder="xp1.…" spellcheck="false">
36
+ </div>
37
+ </div>
38
+
39
+ <div class="settings-row">
40
+ <button id="save" class="save-btn">Save</button>
41
+ </div>
42
+ </div>
43
+ <script src="options.js"></script>
44
+ </body>
45
+ </html>
extension/options.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const statusEl = document.getElementById('pool-status');
2
+ const urlInput = document.getElementById('pool-url');
3
+ const tokenInput = document.getElementById('pool-token');
4
+ const saveBtn = document.getElementById('save');
5
+
6
+ function render(state) {
7
+ if (!state) return;
8
+ urlInput.value = state.url || '';
9
+ if (state.connected) {
10
+ const who = state.username ? ` as @${state.username}` : '';
11
+ statusEl.textContent = `Connected${who} — ${state.queued} queued, ${state.synced} synced`;
12
+ statusEl.className = 'status connected';
13
+ } else {
14
+ statusEl.textContent = 'Not connected to a pool';
15
+ statusEl.className = 'status disconnected';
16
+ }
17
+ }
18
+
19
+ chrome.runtime.sendMessage({ type: 'POOL_STATUS' }, render);
20
+
21
+ saveBtn.addEventListener('click', () => {
22
+ const msg = { type: 'POOL_SET_CONFIG' };
23
+ const url = urlInput.value.trim();
24
+ const token = tokenInput.value.trim();
25
+ if (url) msg.url = url;
26
+ if (token) msg.token = token;
27
+ saveBtn.disabled = true;
28
+ chrome.runtime.sendMessage(msg, (state) => {
29
+ saveBtn.disabled = false;
30
+ tokenInput.value = '';
31
+ render(state);
32
+ });
33
+ });