Files
jarvis/docs/backend/CODEX_PREHANDOVER_CHECKLIST.md
Robin Kutesa 27d0e22767 Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.

Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:59 +03:00

7.7 KiB
Raw Blame History

Jarvis Backend — Pre-Handover Checklist (Codex → integration)

Run every item before declaring the backend done. Most have a command + expected result, so "done" is verified, not assumed. Set the host once:

HOST=http://<container-ip>:8080        # e.g. http://192.168.30.50:8080

The single biggest cause of a "connected but empty" app is date format and key casing (§A). If §A doesn't pass, nothing else matters — the client drops the objects silently.


A. Contract compatibility (the silent killers)

  • Dates are YYYY-MM-DDTHH:MM:SSZ — UTC, literal Z, NO fractional seconds.
    curl -s "$HOST/api/v1/stories" | python3 -c '
    import sys,json,re
    d=json.load(sys.stdin)["data"]
    bad=[s[k] for s in d for k in ("updatedAt","createdAt")
         if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", s[k])]
    print("DATES OK" if not bad else ("BAD DATES: "+str(bad[:3])))'
    
  • Keys are camelCasesignalScore, scoreBreakdown, sourceCount, pollIntervalSeconds, publishedAt, nextCursor, hasMore, isBreaking, sourceUrl, imageUrl, storyId, articleCountToday, lastFetchedAt. No snake_case anywhere in any response body.
  • scoreBreakdown ints sum exactly to signalScore.
    curl -s "$HOST/api/v1/stories" | python3 -c '
    import sys,json
    d=json.load(sys.stdin)["data"]
    bad=[s["id"] for s in d if sum(s["scoreBreakdown"].values())!=s["signalScore"]]
    print("SCORES OK" if not bad else "SUM MISMATCH: "+str(bad))'
    
  • Stories sorted by signalScore descending in GET /stories.
  • Topic is always one of finance | tech | politics | africa (the app's pills).
  • Unknown/extra fields are fine, but no required field is missing or null where the client expects a value (e.g. headline, topic, signalScore, sourceCount, scoreBreakdown, updatedAt, createdAt, sources).

B. Endpoints behave exactly as the client calls them

  • GET /api/v1/health{status,version,storiesCount,feedsCount,uptime} (200).
  • GET /api/v1/stories?limit=20 honors limit (≤100), returns {data,nextCursor,hasMore,total}.
  • Pagination round-trips: take nextCursor, pass it as after, get the next page with no overlap and no dupes.
    C=$(curl -s "$HOST/api/v1/stories?limit=2" | python3 -c 'import sys,json;print(json.load(sys.stdin)["nextCursor"])')
    curl -s "$HOST/api/v1/stories?limit=2&after=$C" | python3 -c 'import sys,json;print("page2 ids:",[s["id"] for s in json.load(sys.stdin)["data"]])'
    
  • GET /api/v1/stories?topic=finance filters; &min_signal=80 filters.
  • GET /api/v1/stories/{id}StoryDetail with timeline[] (chronological).
  • Bad id → 404 with {"error":{"code":"story_not_found","message":...,"status":404}}.
  • GET /api/v1/articles/{id} → full body present (not truncated to the RSS blurb if avoidable); storyId matches.
  • GET /api/v1/feeds{data:[Feed]} with health, failureCount, articleCountToday, lastFetchedAt.
  • POST /api/v1/feeds with {"url","name","pollIntervalSeconds"}201 + feed body.
    curl -s -X POST "$HOST/api/v1/feeds" -H 'Content-Type: application/json' \
      -d '{"url":"https://feeds.bbci.co.uk/news/world/rss.xml","name":"BBC World","pollIntervalSeconds":900}' -i | head -1
    
  • Invalid feed URL → 400 invalid_url.
  • DELETE /api/v1/feeds/{id}204 no body; deleting missing id → 404 feed_not_found.

C. Correlation & scoring are actually working

  • Articles from different sources about the same event land in one Story (spot-check: a Story with sourceCount >= 2 whose timeline shows ≥2 distinct sources).
  • Not the degenerate case of one Story per article (clustering threshold too high) or everything in one Story (threshold too low).
  • sources[] / timeline[] list the right outlets; exactly one entry flagged the earliest/isBreaking per the seed logic.
  • consensus text appears when sourceCount >= 2; conflict is null unless a real dispute marker is present.
  • freshness decays over time (an old Story scores lower than a fresh one with the same sources).

D. WebSocket /ws

  • ws://<host>/ws accepts a connection (test below).
    python3 - <<'PY'
    import asyncio, websockets, json
    async def main():
        async with websockets.connect("ws://<container-ip>:8080/ws") as ws:
            print("connected; waiting for an event/ping (15s)...")
            try: print(await asyncio.wait_for(ws.recv(), 15))
            except asyncio.TimeoutError: print("no message in 15s (ok if idle)")
    asyncio.run(main())
    PY
    
  • Server sends {"type":"ping"} ~every 30s and tolerates the client's {"type":"pong"} reply.
  • A score change broadcasts story.updated with signalScore AND sourceCount (the client requires both on that event).
  • A brand-new cluster broadcasts story.created; a stale one broadcasts story.stale.
  • A feed health transition broadcasts feed.health with feedId, health, failureCount, updatedAt.
  • Multiple simultaneous clients all receive broadcasts; a dropped client doesn't crash the loop.

E. Ingest robustness

  • A dead/unreachable feed URL drives active → failing → dead and increments failureCount without crashing the poller.
  • Duplicate articles (same guid/link re-published) are not inserted twice.
  • HTML is stripped from body; imageUrl is populated when the feed provides one.
  • Missing published_at falls back sanely (now) rather than erroring.
  • Poller respects each feed's pollIntervalSeconds (doesn't hammer sources).

F. Deployment on Proxmox

  • Runs under systemd (systemctl status jarvis = active/running), not a stray shell.
  • Binds 0.0.0.0:8080 (reachable from the LAN, not just localhost): curl http://<container-ip>:8080/api/v1/health works from your Mac, not only inside the container.
  • Survives reboot: reboot the LXC, then curl .../health succeeds with the service back up and data intact.
  • SQLite DB persists at a fixed path (e.g. /opt/jarvis/jarvis.db) and isn't wiped on restart.
  • Container has a static IP / DHCP reservation so the app's saved host stays valid.
  • Firewall (if any) allows inbound TCP 8080.
  • Logs are reachable: journalctl -u jarvis -n 50 shows clean startup + poll cycles.

G. End-to-end with the real app (the real acceptance)

  • Onboarding: enter <container-ip>:8080Connect succeeds.
  • Home feed populates, ranked by signal, fade rendering looks right.
  • Header shows LIVE (green) — WebSocket connected.
  • Tap a story → detail with timeline → tap an article → reader shows full body.
  • Pull-to-refresh works; opening an article caches it (green dot / offline badge appears).
  • Feed manager (radio icon, top-right) lists feeds with correct health dots; add + swipe-delete work.

H. Handover packet (give these back for integration)

  • The container IP/host and confirmation the service is enabled on boot.
  • One sample GET /stories response and one GET /stories/{id} (so I can diff against the client decoder).
  • The seed feed list used and the localRelevance region/lexicon chosen.
  • Any deviations from BACKEND_HANDOFF.md (endpoints, fields, thresholds) called out.
  • requirements.txt frozen (pip freeze) and the repo/commit deployed.

Definition of done: §A§F all green, §G walks through on a real device, and §H is handed back. At that point ping me and I'll run the client-side integration pass.