perf(consumer): materialize observation boundaries
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +248 -44
- docs/2026-07-06-implementation-plan.md +292 -0
- docs/2026-07-27-unit-consumer-api.md +104 -0
- docs/2026-07-28-durable-enrichment-implementation-plan.md +263 -0
- docs/2026-07-28-labels-and-free-labels-implementation-plan.md +177 -0
- docs/2026-07-29-parallel-enrichment-plan.md +193 -0
- docs/2026-08-04-durable-enrichment-index-plan.md +219 -0
- docs/2026-08-06-scrape-job-receipts-plan.md +99 -0
- docs/2026-08-12-bucket-object-log-plan.md +361 -0
- docs/2026-08-16-resumable-registry-review-plan.md +96 -0
- docs/2026-08-19-small-worker-checkpoints-plan.md +1122 -0
- docs/2026-09-07-consumer-http-handoff.md +252 -0
- docs/2026-09-07-incremental-consumer-and-engagement-history-plan.md +558 -0
- docs/2026-09-08-consumer-deployment.md +144 -0
- extension/.coveragerc +5 -0
- extension/.github/workflows/release.yml +58 -0
- extension/.github/workflows/test.yml +35 -0
- extension/.gitignore +18 -0
- extension/LICENSE +21 -0
- extension/README.md +404 -0
- extension/VENDORED.md +81 -0
- extension/background.js +1111 -0
- extension/cutover.html +12 -0
- extension/cutover.js +10 -0
- extension/debug.css +232 -0
- extension/debug.html +76 -0
- extension/debug.js +168 -0
- extension/icons/icon128.png +0 -0
- extension/icons/icon16.png +0 -0
- extension/icons/icon48.png +0 -0
- extension/lib/dedup.js +60 -0
- extension/lib/graphql-capture.js +214 -0
- extension/lib/observations.js +59 -0
- extension/lib/pool-sync.js +286 -0
- extension/lib/scrape-bridge.js +235 -0
- extension/lib/scrape-receipts.js +537 -0
- extension/lib/tweet-parser.js +525 -0
- extension/manifest.json +48 -0
- extension/native-host/com.xtap.daemon.plist +32 -0
- extension/native-host/com.xtap.daemon.service +15 -0
- extension/native-host/com.xtap.host.json +7 -0
- extension/native-host/install.ps1 +139 -0
- extension/native-host/install.sh +280 -0
- extension/native-host/xtap_core.py +836 -0
- extension/native-host/xtap_daemon.bat +1 -0
- extension/native-host/xtap_daemon.py +432 -0
- extension/native-host/xtap_host.bat +1 -0
- extension/native-host/xtap_host.py +110 -0
- extension/options.html +45 -0
- 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 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
- `
|
| 20 |
-
|
| 21 |
-
- `/` —
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
and
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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> ·
|
| 13 |
+
<a href="#how-it-works">How It Works</a> ·
|
| 14 |
+
<a href="#is-this-safe-to-use">Stealth</a> ·
|
| 15 |
+
<a href="#output-format">Output Format</a> ·
|
| 16 |
+
<a href="#configuration">Configuration</a> ·
|
| 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  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 (``) 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(``);
|
| 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/&/\&/g' -e 's/</\</g' -e 's/>/\>/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 (``).
|
| 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 |
+
});
|