Skip to content

cli: hold stdout for the --json envelope, and make card batches survivable - #231

Merged
jvsena42 merged 9 commits into
mainfrom
feat/cli-json-stdout-and-batch-edits
Sep 4, 2026
Merged

cli: hold stdout for the --json envelope, and make card batches survivable#231
jvsena42 merged 9 commits into
mainfrom
feat/cli-json-stdout-and-batch-edits

Conversation

@jvsena42

@jvsena42 jvsena42 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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 no journeys/ script applies and RESULTS.md is unchanged.

1. --json was unparseable, and 2>/dev/null could not fix it

libpubkycore installs a tracing subscriber 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, and System.out is re-pointed at a dup of 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 leaves RUST_LOG deciding how much the SDK says rather than where.

Proven by A/B against staging, since a green build says nothing about this:

$ RUST_LOG=debug loopky login --env staging --json     # without the guard
stdout lines: 20      # every one of them a tracing line
$ RUST_LOG=debug loopky login --env staging --json     # with it
stdout lines: 1       # the envelope; all 20 on stderr

2. A batch that could not finish said nothing about what it did

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 a naive retry was the only move, and it rewrote every row.

  • Everything is validated before the first write. A bad row 400 fails 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 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 does not end the batch — when a batch fails and the same rows apply singly, the row is not the problem. A failure that will refuse everything identically (expiry, full disk, unsupported host) does stop it, as does a run of five, and the result says how many were never reached. The code reported is the failure that ended the run: a full disk after an unrelated 500 is a full disk.
  • A failed batch still reports what it wrote. The same result shape 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 retried twice with backoff first. withWriteRetry already recovers an expiry, a 429 and an unreachable session round trip; a 500 was the gap.
  • Exit 12 server_error, new. It used to be 1 internal, 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 --json emits, 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 (sha256, no url) 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-images

Three 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 .stl or .webm, a renamed or deleted file, and a host refusing an unfamiliar client. One HEAD per distinct URL, opt-in on deck create, card add, card edit and import (--dry-run included, where it is worth the most). Warns, never refuses. Sends a real user agent — 403 Please set a user-agent is Wikimedia's answer to a generic client and is the very failure it catches — and falls back to a one-byte ranged GET where HEAD is 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 is image/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 with UIImage.

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, .stl and 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 list can page

--limit / --cursor walk 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; --json carries next_cursor. --missing-image / --has-image narrow 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 --limit saves 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

import never ran the static image checks at all — only card add/card edit did — so neither the /thumb/ warning nor the new format advice reached the path most decks arrive by. And an http:// address in an image column reached remoteImage, whose requireNotNull threw an IllegalArgumentException no 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 as image/svg+xml with 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 detektAll green. 40 new tests across six files, including the descriptor swap driven against a fake libc (a real dup2(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):

  • Item 1 — the A/B above. Without the guard, RUST_LOG=debug loopky login --env staging --json puts 20 tracing lines on stdout; with it, stdout is one line and all 20 are on stderr.
  • Item 3card list --json | jq -c '{id, front: {text, image: {url}}}' > edits.jsonl fed straight into card edit --from-file: written 5, skipped 0, failed 0. card list --has-image afterwards reports 5 of 5 and echoes back the stored ref, so the round trip is checkable from the envelope alone.
  • Item 2 — the same file again: written 0, skipped 5. That is the resume. And a file with one unknown id in row 2 exits 6 with the homeserver untouched — the good row 1 was still unapplied when re-read.
  • Item 6card list --missing-image --limit 3count 3, card_count 5, next_cursor "0:3", plus the hint on stderr; --cursor 0:3 --limit 10 → the remaining 2 and next_cursor: null. --missing-image goes 5 → 0 after the pictures land.
  • Items 4 + 5import --dry-run --check-images over real upload.wikimedia.org URLs flagged the SVG original (200 image/svg+xml, with the .../500px-Name.svg.png rewrite offered), an 800px thumbnail (400) and a deleted file (404), and left the real JPEG alone. On a live card 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."
  • The 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: 1

data on the failure envelope; failed / failures / not_attempted / image_checks on CardWriteResult; card_count / next_cursor on CardListResult; image_checks on the import result and preview; exit code 12. deck create returns a DeckCreateResult rather than a DeckShowResult — same deck field, plus image_checks, so a caller reading the deck out of the envelope is unaffected.

🤖 Generated with Claude Code

https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye

jvsena42 and others added 9 commits September 4, 2026 10:45
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
@jvsena42 jvsena42 changed the title cli: hold stdout for the --json envelope, and make card batches survivable (#229) cli: hold stdout for the --json envelope, and make card batches survivable Sep 4, 2026
@jvsena42
jvsena42 merged commit 7ba3875 into main Sep 4, 2026
4 checks passed
@jvsena42
jvsena42 deleted the feat/cli-json-stdout-and-batch-edits branch September 4, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

loopky-cli: --json is unparseable, large card edit --from-file 500s mid-write, and 5 more papercuts from illustrating an 855-card deck

1 participant