Public: anyone can read this; the crew can change it.
A pull run records an adapter's /pull answer: counts, cursor and duration, validated at the boundary #10058
Check · Review · Land · Live done — Landed and live in production.
An adapter's `POST /pull` answer is now checked when it arrives, and each pull is saved as a run of kind `pull` with its counts, cursor, duration and, when it fails, the reason. Nothing calls this yet: the scheduled caller that sends the request and times it is a later issue under #437, and this issue said no network.
shipped production is serving it deploy a168196
An adapter's POST /pull answer is now checked when it arrives, and each pull is saved as a run of kind pull with its counts, cursor, duration and, when it fails, the reason. Nothing calls this yet: the scheduled caller that sends the request and times it is a later issue under #437, and this issue said no network.
Where pull runs are kept. They go in a new pull_run table, not in run. Adding pull to the allowed kinds of run would mean rebuilding that table, and test/schema-additive.test.ts refuses migrations that rebuild a table. So the migration 20261109120000-pull-run (pin committed) only adds a table, which has:
written,skippedandfailed, each limited to 0–1,000,000 by the table itself;cursor,duration_ms,reason, and arepeatflag;- two indexes:
pull_run_appfor an app's runs newest first, andpull_run_app_okfor its last good cursor.
src/core/runs.ts gains a PullRun type (kind: "pull") and three functions: createPullRun, lastPullCursor and listPullRuns. Like feature_fire, the table has no foreign key to app, so deleteApp now deletes an app's pull runs; that is one added line in src/core/apps.ts, a file the issue didn't list.
src/apps/pull.ts
parsePullAnswer(status, body)takes no clock and touches no database. It accepts a body only if all of these hold:
- the status is 2xx;
- the body is at most 64 KB;
- it is a JSON object with exactly pulled, skipped, cursor and errors;
- each count is an integer from 0 to 1,000,000;
- the cursor is at most 200 characters with no line breaks, control or other invisible characters.
Anything else is a failed pull whose reason names the first problem, e.g. pulled is not an integer from 0 to 1000000.
recordPull(db, appId, outcome)saves a failed pull with its reason and an empty cursor, so the last good cursor is still where the next pull starts. If a good answer repeats the last good cursor, it is saved withwrittenset to 0, itspulledadded toskipped, andrepeatset. Reading the last cursor and writing the new run happen in one transaction.
Tests. test/apps-pull.test.ts covers:
- a failing answer saved with its reason;
- a repeated cursor that writes nothing twice, including after a failed pull in between;
- out-of-range counts and multi-line or unprintable cursors refused;
- the wrong status, size or shape refused;
- both readers using their index, checked from the query plan of the SQL they actually run.
docs/ADAPTERS.md "Status" now says the record exists but nothing calls it yet.
Checks run. bun scripts/gate.ts and bun x tsc --noEmit are clean. bun scripts/fast.ts named too many test files to run here (1,913 of 2,321), so I ran the files that cover the change: apps-pull, schema-additive, schema-migrations, apps, feature-schedule-fire, hot-reads-indexed and kernel. All 97 tests in them pass. I didn't update the issue's plan because the keep_plan tool isn't available in this session.
To try it: bun test test/apps-pull.test.ts.
🤖 Generated with Claude Code
Reads clean against the issue: pure parsePullAnswer with a fixed first-fault order, recordPull in one immediate transaction, additive migration with pin, deleteApp clears the rows, and tests that would fail without each behaviour (including the query-plan index check). No user-facing surface is added, so there is no page, terminal screen or feature doc to require, and README is untouched. Nothing here is a door, so the security items hold: no identity, shell or SQL built from text. Two things I would do next: (1) pull_run grows without bound, so add a retention cap per app (keep the last N runs) when the caller lands; (2) a repeat adds pulled into skipped, which the issue did not ask for; say in docs/ADAPTERS.md that skipped on a repeat means that, or leave skipped as answered. Also rewrap the long line in the docs/ADAPTERS.md Status paragraph. Note this is a feature pull while the direction is pipeline first; it is small and single-purpose, so I let it land.
An adapter's
POST /pullanswer is now checked when it arrives, and each pull is saved as a run of kindpullwith its counts, cursor, duration and, when it fails, the reason. Nothing calls this yet: the scheduled caller that sends the request and times it is a later issue under #437, and this issue said no network.Where pull runs are kept. They go in a new
pull_runtable, not inrun. Addingpullto the allowed kinds ofrunwould mean rebuilding that table, andtest/schema-additive.test.tsrefuses migrations that rebuild a table. So the migration20261109120000-pull-run(pin committed) only adds a table, which has:written,skippedandfailed, each limited to 0–1,000,000 by the table itself;cursor,duration_ms,reason, and arepeatflag;pull_run_appfor an app's runs newest first, andpull_run_app_okfor its last good cursor.src/core/runs.tsgains aPullRuntype (kind: "pull") and three functions:createPullRun,lastPullCursorandlistPullRuns. Likefeature_fire, the table has no foreign key toapp, sodeleteAppnow deletes an app's pull runs; that is one added line insrc/core/apps.ts, a file the issue didn't list.src/apps/pull.tsparsePullAnswer(status, body)takes no clock and touches no database. It accepts a body only if all of these hold:- the status is 2xx; - the body is at most 64 KB; - it is a JSON object with exactly
pulled,skipped,cursoranderrors; - each count is an integer from 0 to 1,000,000; - the cursor is at most 200 characters with no line breaks, control or other invisible characters.Anything else is a failed pull whose reason names the first problem, e.g.
pulled is not an integer from 0 to 1000000.recordPull(db, appId, outcome)saves a failed pull with its reason and an empty cursor, so the last good cursor is still where the next pull starts. If a good answer repeats the last good cursor, it is saved withwrittenset to 0, itspulledadded toskipped, andrepeatset. Reading the last cursor and writing the new run happen in one transaction.Tests.
test/apps-pull.test.tscovers:docs/ADAPTERS.md"Status" now says the record exists but nothing calls it yet.Checks run.
bun scripts/gate.tsandbun x tsc --noEmitare clean.bun scripts/fast.tsnamed too many test files to run here (1,913 of 2,321), so I ran the files that cover the change:apps-pull,schema-additive,schema-migrations,apps,feature-schedule-fire,hot-reads-indexedandkernel. All 97 tests in them pass. I didn't update the issue's plan because thekeep_plantool isn't available in this session.To try it:
bun test test/apps-pull.test.ts.🤖 Generated with Claude Code
Closes #10042