Wave 2: data layer (C) + metadata/scanning (D)

Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor, SyncEngine
(push-then-pull, LWW, tombstones, client-generated ids), SettingsStore, AppContainer.
Open Library + Google Books merge, ISBN validation, CameraX + ML Kit scanner plumbing.

Verified by orchestrator: assembleDebug exit 0; testDebugUnitTest exit 0, 68 tests,
0 failures, 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
This commit is contained in:
2026-09-06 03:24:04 +00:00
parent 6c17e42037
commit d1a73a1193
61 changed files with 3104 additions and 26 deletions
+53
View File
@@ -0,0 +1,53 @@
You are Worker C on the Bookshelf project (~/bookshelf). Implement the DATA LAYER.
FIRST, READ THESE — they are the contract, follow them exactly, do not invent
alternative names or restate them back to me:
~/bookshelf/docs/SPEC.md (authoritative product/technical spec)
~/bookshelf/docs/HANDOFF.md (operational state and gotchas already paid for)
## Your scope — these packages ONLY, under app/app/src/main/java/org/modg/bookshelf/
data.local Room: BookEntity/BookcaseEntity/ShelfEntity, SyncState, Converters,
BookDao/BookcaseDao/ShelfDao, BookshelfDatabase
data.remote PocketBaseApi (Retrofit), request/response DTOs, PbAuthInterceptor
data.repo BookRepository, LocationRepository, AuthRepository, SyncEngine
data.prefs SettingsStore (DataStore: server URL, auth token, per-collection
sync cursors, last-sync time)
Plus `AppContainer` (manual DI, per SPEC "NO Hilt/kapt") and its wiring into
the existing BookshelfApplication.kt.
## HARD BOUNDARIES — you share this repo with Worker D, running right now
- DO NOT create or edit anything under `data.metadata`, `ui.scan`, or any `ui.*`
package. Those are Worker D's / wave 3's. Touching them WILL cause a conflict.
- DO NOT edit `app/build.gradle.kts` or `gradle/libs.versions.toml`. Every
dependency you need (room+ksp, retrofit, kotlinx-serialization, okhttp,
datastore, work-runtime, robolectric, coroutines-test) is ALREADY declared and
wired. If you genuinely believe something is missing, DO NOT add it — say so in
your final report and work around it.
- Worker D will need metadata lookup reachable from AppContainer. Do NOT try to
wire it. Just leave AppContainer easy to extend; D exposes a plain class that
gets wired later.
## Build/verify — CRITICAL
Never run `./gradlew` directly; a second worker builds concurrently and you will
corrupt each other's build. ALWAYS build with the serialized wrapper:
~/bookshelf/tasks/gw assembleDebug
~/bookshelf/tasks/gw testDebugUnitTest
It takes the lock and may block until the other worker's build finishes. That is
expected — wait for it, do not bypass it.
## Definition of done — all must actually pass, verified by you, not assumed
1. `~/bookshelf/tasks/gw assembleDebug` exits 0.
2. `~/bookshelf/tasks/gw testDebugUnitTest` exits 0.
3. Real unit tests with real assertions (SPEC: "Do not write assertion-free tests"):
- SyncEngine conflict resolution / last-write-wins on `updated`
- push ordering + syncState transitions, incl. 404-on-update -> drop local,
409/duplicate-id -> switch to PATCH
- client-side 15-char lowercase-alnum id generation
- DAO queries via Robolectric, proving `deleted = 0` filtering works
4. Soft delete everywhere. All reads come from Room. Nothing blocks on network.
## Report back (keep it short — it is read by a token-constrained orchestrator)
- exact pass/fail of the two gradle commands above
- files created, one line each
- anything in SPEC.md you could NOT satisfy, and why. Do not paper over gaps:
a truthfully reported gap is worth more than a false green.
+32
View File
@@ -0,0 +1,32 @@
Your wave-2 work is mostly good and `assembleDebug` passes. But you ended your turn
reporting that `testDebugUnitTest` was "running in background, will report once it
completes" — you never confirmed it. The orchestrator ran it. IT FAILS.
68 tests ran, 2 failed, both yours, both in
`app/app/src/test/java/org/modg/bookshelf/ui/scan/ScannerControllerTest.kt`:
ScannerControllerTest > "valid barcode emits on scanResults"
ScannerControllerTest > "invalid barcode does not reach scanResults"
both: kotlinx.coroutines.test.UncompletedCoroutinesError:
After waiting for 1m, the test body did not run to completion
Diagnosis (confirm it yourself before acting): `ScannerController._scanResults` is a
`MutableSharedFlow<String>(extraBufferCapacity = 1)` with NO `replay`. Both tests call
`onBarcodeScanned(...)` BEFORE anything subscribes, so with replay=0 the emission goes
nowhere, and the later `scanResults.first()` suspends forever until runTest's timeout.
Fix the TEST, not the production semantics, unless you have a concrete reason to do
otherwise. replay=0 is correct for a barcode scanner — a newly-attached collector must
not receive a stale scan from earlier. So make the test subscribe BEFORE emitting, e.g.
start the collector with `async`/`backgroundScope`, use `runCurrent()` to let it
subscribe, then call `onBarcodeScanned(...)`. Keep both tests' original intent intact:
the second one must still prove the invalid barcode is filtered out and only the valid
ISBN arrives. Do not weaken a test into an assertion-free or trivially-true test, and
do not delete a test to make the suite green.
Then VERIFY, and this time actually wait for the result before you answer:
~/bookshelf/tasks/gw testDebugUnitTest
(use that wrapper, never ./gradlew directly). It must exit 0 with 0 failures.
Reply with: the command's real exit code, the failure count, and one line on what you
changed. Nothing else.
+67
View File
@@ -0,0 +1,67 @@
You are Worker D on the Bookshelf project (~/bookshelf). Implement BOOK METADATA
LOOKUP + BARCODE SCANNING.
FIRST, READ THESE — they are the contract, follow them exactly, do not invent
alternative names or restate them back to me:
~/bookshelf/docs/SPEC.md (authoritative product/technical spec — see the
"Book metadata lookup" and "Barcode scanning"
sections especially)
~/bookshelf/docs/HANDOFF.md (operational state and gotchas already paid for)
## Your scope — these packages ONLY, under app/app/src/main/java/org/modg/bookshelf/
data.metadata
- IsbnUtils: ISBN-13 checksum validation, ISBN-10 -> 13 conversion,
normalization (strip hyphens/spaces, handle trailing X)
- OpenLibraryClient and GoogleBooksClient (Retrofit or OkHttp + kotlinx-
serialization; endpoints are in SPEC)
- BookMetadata (source-agnostic result model) and MetadataMerger implementing
SPEC's merge rule: prefer whichever has a title, fill blanks from the other,
return null if both miss
- MetadataRepository: the single entry point, `suspend fun lookup(isbn): BookMetadata?`
ui.scan — SCANNER PLUMBING ONLY, no finished screen (wave 3 builds the screen):
- a CameraX ImageAnalysis analyzer wrapping ML Kit BarcodeScanning
(EAN_13, EAN_8, UPC_A), validating the ISBN-13 checksum before emitting,
debouncing repeat reads of the same code
- a small stateful holder exposing scan results as a Flow, plus torch toggle
and camera-permission-denied states
## HARD BOUNDARIES — you share this repo with Worker C, running right now
- DO NOT create or edit anything under `data.local`, `data.remote`, `data.repo`,
`data.prefs`, `AppContainer`, or `BookshelfApplication.kt`. Those are Worker C's.
Touching them WILL cause a conflict.
- DO NOT edit any `ui.theme` or `ui.components` file — wave 1B finished those and
they are verified green. Reuse them; do not modify them.
- DO NOT edit `app/build.gradle.kts` or `gradle/libs.versions.toml`. Everything
you need (camerax core/camera2/lifecycle/view, mlkit barcode-scanning, retrofit,
kotlinx-serialization, okhttp, coroutines-test, robolectric) is ALREADY declared
and wired. If you think something is missing, DO NOT add it — report it instead.
- Your MetadataRepository must be a plain class with an explicit constructor
(e.g. taking OkHttpClient/Json). Do NOT wire it into AppContainer — Worker C owns
that file. In your final report, give the exact one-line wiring snippet needed.
## Build/verify — CRITICAL
Never run `./gradlew` directly; a second worker builds concurrently and you will
corrupt each other's build. ALWAYS build with the serialized wrapper:
~/bookshelf/tasks/gw assembleDebug
~/bookshelf/tasks/gw testDebugUnitTest
It takes the lock and may block until the other worker's build finishes. That is
expected — wait for it, do not bypass it.
## Definition of done — all must actually pass, verified by you, not assumed
1. `~/bookshelf/tasks/gw assembleDebug` exits 0.
2. `~/bookshelf/tasks/gw testDebugUnitTest` exits 0.
3. Real unit tests with real assertions (SPEC: "Do not write assertion-free tests"):
- ISBN-13 checksum: known-valid and known-invalid ISBNs, ISBN-10 conversion,
hyphen/space handling
- MetadataMerger: OL-only, GB-only, both, neither(-> null), and blank-filling
- client JSON parsing against CHECKED-IN SAMPLE JSON FIXTURES, not live network.
Tests must pass offline with no network access.
4. Network code must never be called on the main thread and must fail soft
(return null / empty) rather than throw on timeout or malformed JSON.
## Report back (keep it short — it is read by a token-constrained orchestrator)
- exact pass/fail of the two gradle commands above
- files created, one line each
- the one-line AppContainer wiring snippet for MetadataRepository
- anything in SPEC.md you could NOT satisfy, and why. Do not paper over gaps:
a truthfully reported gap is worth more than a false green.
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Serialized gradle wrapper. Two workers share one Gradle project dir; concurrent
# builds clobber each other's outputs. Always build via this, never ./gradlew.
export JAVA_HOME="$HOME/toolchain/jdk21"
export ANDROID_HOME="$HOME/toolchain/android-sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$PATH"
cd "$HOME/bookshelf/app" || exit 1
exec flock "$HOME/bookshelf/.gradle-build.lock" ./gradlew "$@"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# run-resume.sh <task-name> <prompt-file>
# Like run-task.sh, but resumes an EXISTING session with a SPECIFIC prompt
# (run-task.sh sends only a generic "continue" message on resume).
set -u
NAME="$1"; PROMPT_FILE="$2"
L="$HOME/bookshelf/logs"; LOG="$L/${NAME}.json"; ERR="$L/${NAME}.err"
SIDF="$L/${NAME}.sid"; ST="$L/${NAME}.state"
MAX_WALL="${MAX_WALL:-86400}"; POLL="${POLL:-600}"; MAX_HARD_FAILS="${MAX_HARD_FAILS:-3}"
export JAVA_HOME="$HOME/toolchain/jdk21"
export ANDROID_HOME="$HOME/toolchain/android-sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"
cd "$HOME/bookshelf" || exit 1
SID="$(cat "$SIDF")"
say(){ echo "[$(date -Is)] $NAME: $*" >> "$ST"; }
say "RESUME-with-prompt sid=$SID file=$PROMPT_FILE"
deadline=$(( $(date +%s) + MAX_WALL )); attempt=0; hard=0; qw=0
while [ "$(date +%s)" -lt "$deadline" ]; do
attempt=$((attempt+1)); say "attempt $attempt: resume $SID"
claude -p --model sonnet --permission-mode bypassPermissions \
--output-format json --add-dir "$HOME/bookshelf" --resume "$SID" \
< "$PROMPT_FILE" > "$LOG" 2>"$ERR"
rc=$?
blob="$(cat "$LOG" "$ERR" 2>/dev/null | head -c 20000)"
if printf '%s' "$blob" | grep -qiE 'usage limit|limit will reset|limit resets|rate_limit_error|rate limit exceeded|429|too many requests|overloaded_error'; then
qw=$((qw+1)); say "QUOTA hit (wait #$qw), sleeping ${POLL}s"; sleep "$POLL"; continue; fi
isErr="$(jq -r '.is_error // false' "$LOG" 2>/dev/null)"
if [ "$rc" -eq 0 ] && [ "$isErr" != "true" ]; then say "SUCCESS after $attempt attempt(s)"; break; fi
hard=$((hard+1)); say "hard failure #$hard (rc=$rc)"
[ "$hard" -ge "$MAX_HARD_FAILS" ] && { say "GIVING UP"; break; }
sleep 60
done
{ echo "=== $NAME attempts=$attempt quota_waits=$qw hard_fails=$hard ==="
jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring)' "$LOG" 2>/dev/null
echo "--- result ---"; jq -r '.result // "no result"' "$LOG" 2>/dev/null | tail -c 1500
} > "$L/${NAME}.summary"