cli: hold stdout for the --json envelope, and make card batches survivable - #231
Merged
Conversation
libpubkycore installs a tracing subscriber whose default writer is
stdout, so a DHT bootstrap error — routine on a host that reaches the
homeserver fine over HTTPS — landed ahead of the envelope and made it
undecodable. `2>/dev/null` could not remove it, because it was never on
stderr, so every JSON call in a caller's script needed `| grep '^{'` in
front of it (#229, item 1).
Fixed at the descriptor rather than at the log level: `dup2(2, 1)` points
fd 1 itself at stderr before the FFI is loaded, and System.out is
re-pointed at a dup of the real stdout taken first. That catches
everything writing to the raw descriptor rather than only the layer we
know about today, and leaves RUST_LOG deciding how much the SDK says
rather than where.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
…s neither app decodes "Drop the /thumb/ segment for the original, which is always served" is right for a raster file and exactly backwards for an SVG: the original is image/svg+xml and the thumbnail is the only rendered raster there is. A deck illustrated on that advice had every flag and colour swatch land as a blank card (#229, item 5). So the two rules become one function. A Wikimedia SVG original is now rewritten in the warning as .../Name.svg/500px-Name.svg.png; a bad width over an SVG source keeps the width advice and withholds the drop-the- thumb line. The same pass names .tif, .webm, .ogv, .stl and friends, which is what Dente, Piede, Tostapane, Rotonda and Tastiera resolve to on Wikipedia — valid upload.wikimedia.org addresses that answer 200 and are blank on both clients, since Coil ships no SVG decoder here and UIImage decodes none of them. Only the SVG rewrite is spelled out as a URL. Wikimedia does render .tif and .webm under /thumb/, with prefixes of their own, and an address invented here that 404s would be worse than the sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
The two shapes disagreed: reads emit sides as objects with the picture as
`{"url": …}`, writes take flat `front` and `front_image_url`. Answering
"has this row already been applied?" across that gap needed a shape check
and a percent-decode, and getting it wrong rewrote all 665 rows on every
pass of a resume loop (#229, item 3).
A JSONL row may now be either shape, per side, so a deck can be read,
edited with jq and fed straight back. The tri-state survives the
translation — an absent key leaves the field alone, an explicit null
clears it — and since `card list --json` writes explicit nulls, feeding
its output back sets every field to exactly what it read.
A blob picture is the one thing a card file cannot name, so an image with
a sha256 and no url is left unchanged rather than cleared, and counted in
one note on stderr. Clearing it would strip every picture off an
.apkg-imported deck the moment someone round-tripped it.
Also stops refusing a blank image URL in a JSONL row: blank is the
documented way to clear a picture, as at `--back-image=`, and it was
being checked as an address that could never render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
A 665-row `card edit --from-file` 500'd after 35 writes, reported nothing about the 35, and had no way to pick up where it stopped — so the only move was a naive retry, which rewrote every row (#229, item 2). Four changes, one shared batch path for `card add` and `card edit`: - Everything is resolved and validated before the first write, so a bad row 400 fails the command with the homeserver untouched rather than 399 rows in. - A row already holding what it asks for is skipped. That makes re-running the same file the resume, which is better than the `--resume` flag the report asked for: no cursor to keep, nothing to pass, and no updated_at churn on rows that did not change. - One refused row no longer ends the batch — the rows that applied singly were the same rows. A failure that will refuse everything (expiry, full disk, unsupported host) still stops it, as does a run of five, and the result says how many were never reached. - The result travels on the failure envelope as `data`: written / skipped / failed / not_attempted, and per failed row its file line, card id, exit code and message. A homeserver 500 is now retried twice with backoff before it counts as a failure — the shared layer recovers an expiry, a 429 and an unreachable session round trip, and a 500 was the gap. One that never clears exits 12 `server_error` rather than 1 `internal`, which this CLI documents as "worth reporting as a bug": a 500 is not the client's bug, not the caller's input, and may well work next time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
The static checks answer what a string can be known to be wrong about, and they caught real mistakes. Three things they cannot see produce exactly the same silent blank card (#229, item 4): a Wikipedia lead image that resolves to .stl or .webm behind an ordinary-looking address, a file that has been renamed or deleted, and a host that refuses an unfamiliar client. The reporter ended up writing that check by hand anyway. Opt-in on deck create, card add, card edit and import — including --dry-run, where it is worth the most, since that is the last moment 900 addresses can be fixed before a card carries one. Findings go to stderr and to --json as `image_checks`; a URL that answers 2xx with an image type produces no row at all, because a finding buried in 900 lines of "this one is fine" is no better than no check. It warns and never refuses: a host having a bad minute must not be able to fail somebody's import. One request per distinct URL at eight at a time, with a real user agent — 403 Please set a user-agent is Wikimedia's answer to a generic client, which is the very failure this catches — and a host that refuses HEAD is asked again with a one-byte ranged GET. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
…needs Deciding which of 4,000 cards still wanted a picture meant dumping the whole deck — ~700 KB — on every round of a retry loop (#229, item 6). There is no server-side filter to ask for instead: the homeserver stores opaque records and Nexus indexes tags, not cards. What there is is the manifest's chunk table, so `--limit` and `--cursor` walk it and fetch only the records the page needs; `--json` carries next_cursor while there is more. `--missing-image` / `--has-image` narrow what comes back and compose with both, and without `--limit` they save the output and the caller's work rather than the fetch — which the README says plainly. Plain `card list` still reads the deck in one call, asserted with a fake that refuses readChunk. The cursor is a place in the deck rather than a snapshot of it, so one naming a chunk compaction has folded away resumes at the next one that exists rather than failing at something the caller cannot fix. The "more cards" hint goes to stderr: one line per card is what this command prints, and a trailer would make the line count disagree with the card count for anything counting them. The listing moves to its own file; Cards.kt had grown past the functions-per-file threshold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
The stdout descriptor guarantee and `data` on the failure envelope in §13.3, exit code 12 and why it is not 1 or 5 in §13.4, batch idempotence and partial-batch accounting in §13.7, and the card-file round trip, --check-images and card list paging in §13.8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
…icture Two gaps found by running --check-images against real Wikimedia URLs. `import` never ran the static checks at all — only `card add`/`card edit` did — so the /thumb/ width warning and the new undecodable-format advice never reached the path most decks arrive by. Worse, an http:// address in an image column reached `remoteImage`, whose requireNotNull threw an IllegalArgumentException no classifier recognises: the user got exit 1 "internal" plus a Kotlin assertion for an unrenderable URL in their own file. It is now exit 9 with the scheme named, before anything is built. And an `image/` prefix is not a decodable picture. Wikimedia serves an SVG original as image/svg+xml with an ordinary 200, so the probe called every flag and colour swatch fine — exactly the deck the static rule was written for. image/svg+xml and image/tiff are findings. Verified against upload.wikimedia.org: the SVG original, an 800px thumbnail (400) and a deleted file (404) are all reported, the JPEG is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
…d the batch The skipped wording was chosen by comparing the verb string, and the exit code came from the first failure — so a full disk after an unrelated 500 reported the 500, sending an agent to retry against a wall. The stopping failure is the state the run is now in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #229, except item 7 (image search) — the Unsplash half was ruled out, and the Wikipedia half with it.
Everything here is
cli/and docs. No shared, Android or iOS code is touched, so nojourneys/script applies andRESULTS.mdis unchanged.1.
--jsonwas unparseable, and2>/dev/nullcould not fix itlibpubkycoreinstalls atracingsubscriber whose default writer is stdout, so a DHT bootstrap error — routine on a host that reaches the homeserver perfectly well over HTTPS — landed ahead of the envelope. Every JSON call in the reporter's scripts needed| grep -a '^{'in front of it.Fixed at the descriptor rather than at the log level:
dup2(2, 1)points fd 1 itself at stderr before the FFI loads, andSystem.outis re-pointed at adupof the real stdout taken first (StdoutGuard.kt). That catches anything writing to the raw descriptor rather than only the layer we know about today, and leavesRUST_LOGdeciding how much the SDK says rather than where.Proven by A/B against staging, since a green build says nothing about this:
2. A batch that could not finish said nothing about what it did
A 665-row
card edit --from-file500'd after 35 writes, reported nothing about the 35, and had no way to pick up where it stopped — so a naive retry was the only move, and it rewrote every row.--resumethe report asked for: no cursor to keep, nothing to pass, and noupdated_atchurn on rows that did not change.data:written/skipped/failed/not_attempted, and per failed row its file line, card id, exit code and message.withWriteRetryalready recovers an expiry, a 429 and an unreachable session round trip; a 500 was the gap.server_error, new. It used to be 1internal, which the CLI documents as "worth reporting as a bug" — a 500 is not the client's bug, not the caller's input, and may well work next time. Deliberately not 5, which promises the request never arrived.3. The round trip
A JSONL row may now also be the shape
card list --jsonemits, so a deck can be read, edited withjqand fed straight back. The tri-state survives the translation — an absent key leaves the field alone, an explicit null clears it — and sincecard list --jsonwrites explicit nulls, feeding its output back sets every field to exactly what it read. A blob picture (sha256, nourl) is the one thing a card file cannot name, so those are left unchanged rather than cleared, and counted in one note: clearing would strip every picture off an.apkg-imported deck the moment someone round-tripped it.4.
--check-imagesThree failures produce exactly the same silent blank card and no rule over the URL can see any of them: a Wikipedia lead image that resolves to
.stlor.webm, a renamed or deleted file, and a host refusing an unfamiliar client. OneHEADper distinct URL, opt-in ondeck create,card add,card editandimport(--dry-runincluded, where it is worth the most). Warns, never refuses. Sends a real user agent —403 Please set a user-agentis Wikimedia's answer to a generic client and is the very failure it catches — and falls back to a one-byte rangedGETwhereHEADis refused. A URL that is fine produces no row at all.5. The
/thumb/advice was inverted for a vector"Drop the
/thumb/segment for the original, which is always served" is right for a raster and exactly backwards for an SVG: the original isimage/svg+xml, and the thumbnail is the only rendered raster there is. To answer the question in the issue directly — no, neither client decodes SVG: Android loads with Coil and ships no SVG decoder here, iOS withUIImage.So a Wikimedia SVG original is now rewritten in the warning as
.../Name.svg/500px-Name.svg.png, a bad width over an SVG source keeps the width advice and withholds the drop-the-thumb line, and.tif,.webm,.ogv,.stland friends are named. Only the SVG rewrite is spelled out as a URL — Wikimedia renders the others under/thumb/with prefixes of their own, and an address invented here that 404s would be worse than the sentence.6.
card listcan page--limit/--cursorwalk the manifest's chunk table and fetch only the records a page needs, so deciding which of 4,000 cards still want a picture no longer costs ~700 KB per pass;--jsoncarriesnext_cursor.--missing-image/--has-imagenarrow what comes back.Honest about the limit: there is no server-side filter to offer instead — the homeserver stores opaque records and Nexus indexes tags, not cards — so a filter without
--limitsaves the output and the caller's work, not the fetch. Both the README and Architecture.md say so. A cursor is a place in the deck rather than a snapshot: one naming a chunk compaction has since folded away resumes at the next one that exists.Two more found while testing this on staging
importnever ran the static image checks at all — onlycard add/card editdid — so neither the/thumb/warning nor the new format advice reached the path most decks arrive by. And anhttp://address in an image column reachedremoteImage, whoserequireNotNullthrew anIllegalArgumentExceptionno classifier recognises: exit 1 "internal" plus a Kotlin assertion, for an unrenderable URL in someone's own file. Now exit 9 with the scheme named, before anything is built.Also: an
image/prefix is not a decodable picture. Wikimedia serves an SVG original asimage/svg+xmlwith an ordinary 200, so the probe called every flag and colour swatch fine — the exact deck the static rule was written for.Verification
./gradlew :cli:test detektAllgreen. 40 new tests across six files, including the descriptor swap driven against a fake libc (a realdup2(2, 1)in the test JVM would send Gradle's own stdout to stderr).Against a live staging homeserver, end to end, on a throwaway 5-card deck (published, exercised, then
deck deleted):RUST_LOG=debug loopky login --env staging --jsonputs 20 tracing lines on stdout; with it, stdout is one line and all 20 are on stderr.card list --json | jq -c '{id, front: {text, image: {url}}}' > edits.jsonlfed straight intocard edit --from-file: written 5, skipped 0, failed 0.card list --has-imageafterwards reports 5 of 5 and echoes back the stored ref, so the round trip is checkable from the envelope alone.card list --missing-image --limit 3→count 3,card_count 5,next_cursor "0:3", plus the hint on stderr;--cursor 0:3 --limit 10→ the remaining 2 andnext_cursor: null.--missing-imagegoes 5 → 0 after the pictures land.import --dry-run --check-imagesover realupload.wikimedia.orgURLs flagged the SVG original (200image/svg+xml, with the.../500px-Name.svg.pngrewrite offered), an 800px thumbnail (400) and a deleted file (404), and left the real JPEG alone. On a livecard edit --back-image <...>.svg --check-images, the static advice and the probe both fire; a real JPEG gives "every picture URL answered with an image."http://image column now exiting 9 with the scheme named, where it used to be exit 1 plus a Kotlin assertion.Envelope changes, all additive under
schema: 1dataon the failure envelope;failed/failures/not_attempted/image_checksonCardWriteResult;card_count/next_cursoronCardListResult;image_checkson the import result and preview; exit code 12.deck createreturns aDeckCreateResultrather than aDeckShowResult— samedeckfield, plusimage_checks, so a caller reading the deck out of the envelope is unaffected.🤖 Generated with Claude Code
https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye