Files
arah2/.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-PLAN.md
T

28 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-turn-by-turn-guidance-live-driving 07 execute 3
06-03
06-04
06-05
bot/src/guidance/cron.ts
bot/src/webhook.ts
bot/wrangler.jsonc
bot/src/i18n/strings.ts
bot/test/guidance-cron.test.mjs
bot/test/commands.test.mjs
routing/scripts/build-glyphs.mjs
routing/package.json
web/public/glyphs/depart.png
web/public/glyphs/straight.png
web/public/glyphs/left.png
web/public/glyphs/right.png
web/public/glyphs/slight-left.png
web/public/glyphs/slight-right.png
web/public/glyphs/uturn.png
web/public/glyphs/arrive.png
true
GUID-05
truths artifacts key_links
A Worker Cron Trigger scans guidance:* envelopes every 60 s, projects progress along the snapshot, and pushes a glyph+text turn-card on its 3-minute cadence (D-11/D-12)
Card maneuver lines carry the STREET by DEFAULT — reconstructed by snapping the carded turn's vertex to the memoized R2 graph (snapToGraph + nameAt, per D-12's ETA/distance/street text); street-less bare-verb cards ONLY when the graph is absent in the isolate or the snap misses (D-03-style honest degradation)
Cards push ONLY to envelope.chat_id read from KV — no client-supplied chat id exists anywhere in the cron path (T-06-02)
The cron performs ZERO KV writes — the stateless cadence holds the 1,000-writes/day free budget (Pitfall 3)
Card copy never promises live tracking — 'best-effort projection' is the strongest claim (D-13)
The 8 committed glyph PNGs exist, token-colored, built from the SAME committed SVG sources the app renders (D-12 single source)
The card's OPEN button deep-links ${MINI_APP_URL}/?g=<id> so a killed webview resumes from the envelope (GUID-06 bridge)
path provides exports
bot/src/guidance/cron.ts PURE projectTurnCards + PURE makeStreetAt graph→street resolver + the scheduled() projection/push logic
projectTurnCards
makeStreetAt
CARD_CADENCE_S
path provides
bot/src/webhook.ts scheduled() handler with KV list pagination + loadGraph-fed street reconstruction
path provides
bot/wrangler.jsonc triggers.crons */1 * * * * (60 s scan; the card decision is cadence-gated in code)
path provides
web/public/glyphs/*.png 8 committed 256×256 turn-card PNGs served by the Worker's static assets
from to via pattern
bot/src/guidance/cron.ts routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM, snapToGraph, nameAt) projection + street reconstruction over shared primitives — one turn/street authority with engine A classifyTurn|snapToGraph
from to via pattern
bot/src/webhook.ts scheduled() bot/src/routing/worker-router.ts loadGraph(env) — the exported isolate-memoized R2 graph feeding makeStreetAt (no second R2 loader) loadGraph
from to via pattern
bot/src/webhook.ts scheduled() bot/src/guidance/cron.ts KV list prefix guidance: → projectTurnCards → maybe sendPhoto projectTurnCards
from to via pattern
routing/scripts/build-glyphs.mjs web/src/guidance/glyphs/*.svg the committed SVGs are the single source; PNGs are their rasterization glyphs
GUID-05's backgrounded half: the repo's first Cron Trigger scanning live guidance envelopes, projecting progress along the frozen snapshot geometry, and pushing honest glyph+text turn-cards to the user's own chat — plus the committed 8-glyph PNG set built from the app's SVG sources.

Purpose: navigation survives the app being minimized, best-effort and honestly framed (D-13). The envelope lifecycle is structural (plan 06): no key, no push. Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with KV-budget pins + 8 committed PNGs.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md task 1: projectTurnCards (pure) + bot copy keys bot/src/guidance/cron.ts, bot/src/i18n/strings.ts, bot/test/guidance-cron.test.mjs, bot/test/commands.test.mjs - bot/src/i18n/strings.ts (the BotStrings interface + copy-gate law — append keys, never inline) - bot/test/commands.test.mjs (the bot copy gate — extend for the new keys in the same change) - routing/src/astar.ts classifyTurn + routing/src/polyline.ts + routing/src/geo.ts + routing/src/snap.ts (the projection's + street reconstruction's building blocks — import, never re-implement) - routing/test/fixtures/build-mini-graph.mjs + the committed mini-graph fixture (the street-present test's graph: its named way supplies the expected street string) - bot/src/kv/guidance-sessions.ts (the frozen snapshot this projects — geometry + startedAtSec + distanceM/durationS) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Turn-card send" + UI-SPEC Turn-Card Chat Message section (caption anatomy + `{action} onto {street}` line) - progress projection: given envelope with known geometry (a synthetic 3-leg polyline with bearing changes at known points), distanceM 10000, durationS 900, startedAtSec = T: projectTurnCards(env, T+300) → progressM ≈ 10000×(300/900), remainingM ≈ 6667, etaClock = now + 600 s rounded to HH:MM - next-turn detection: the FIRST classified turn (classifyTurn over consecutive-leg bearings) whose along-distance ≥ progressM → kind + zero-padded index in the register (GUIDE // T-03) - remaining distance to that turn = its along-distance − progressM, formatted "{n} M" / "{n.n} KM" - cadence: nowSec % 180 < 60 → due true (e.g. nowSec = 3600 → true; 3660 → false); CARD_CADENCE_S = 180 exported - progress ≥ distanceM (trip would have ended) → due false, no card (the cron never cards a dead trip) - caption: exactly 4 lines per the anatomy, HTML parse mode, ≤ 1024 chars, contains the honesty line verbatim, register zero-padded - street-bearing DEFAULT (D-12): with a streetAt resolver returning a name, the maneuver line is "{dist} — {cardAction[action]} {STREET}" (the verb carries ONTO/KE; street uppercased); nameAt's unnamed id → cardStreetUnnamed ("UNNAMED ROAD"/"JALAN TANPA NAMA") - street-less DEGRADATION: streetAt omitted or null for the carded vertex → "{dist} — {cardActionBare[action]}" — bare verbs, honest absence, same 4-line anatomy - makeStreetAt(graph) is PURE (graph in → resolver out; snapToGraph + nameAt imported from @araharah/routing, zero I/O): against decodeGraph of the committed mini-graph fixture, a vertex on the fixture's named way resolves that way's name; a vertex off-graph → null - copy law: caption contains none of "live", "real-time", "guaranteed" (case-insensitive) — D-13's strongest-claim gate 1. Bot copy: append the 23 keys from the context box to `BotStrings` + both locale records (verbatim values); extend the copy gate in `bot/test/commands.test.mjs` to assert them in en + ms. 2. RED: `bot/test/guidance-cron.test.mjs` — build a synthetic envelope (geometry via encodePolyline6 of a known 3-leg path with 90° and 45° turns), assert every behavior above with fixed nowSec values. Street cases: (a) fake resolver `streetAt = () => "JALAN SULTAN ISMAIL"` → the ONTO line composes with that street; (b) `streetAt` omitted / returns null → the bare-verb line; (c) mini-graph integration — construct the synthetic envelope geometry ALONG the mini-graph fixture's named way so the carded turn's vertex snaps deterministically onto it, run the REAL `makeStreetAt(decodeGraph(miniGraphBuffer))`, and assert the caption contains the fixture way's name. 3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards and makeStreetAt are PURE: injected nowSec/graph, no KV, no R2, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number, streetAt?: (vertexIdx: number) => string | null): { due: boolean; glyph: GlyphKindName | null; caption: string | null; maneuverIdx: number | null }` — decode geometry → cumulative → progressM (clamped to distanceM) → walk bearings (bearing changes between segments; skip degenerate sub-m points) → classifyTurn → first turn ≥ progressM (none → "straight" glyph, index 00) → compose caption via strings + the exact anatomy: street string present → `{cardAction[action]} {street}`; null/absent resolver → `{cardActionBare[action]}`. Also export `makeStreetAt(graph: RoadGraph): (vertexIdx: number) => string | null` — snap the geometry vertex at vertexIdx (snapToGraph) → `graph.nameAt(node)`; snap miss → null; unnamed id → the cardStreetUnnamed constant. GlyphKindName is cron.ts's local union mirroring the 8 PNG names. HTML-escape surface stays ZERO: all caption parts are code-composed from numbers + string-table constants — assert no envelope-derived string (e.g. destinationName) enters the caption; street text arrives only via makeStreetAt's graph lookup. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2). npm test -w bot - guidance-cron.test.mjs green: projection math, next-turn kind + zero-padded index, cadence table, dead-trip no-card, 4-line caption ≤ 1024, honesty-line present, street-bearing line asserted against the mini-graph fixture (real makeStreetAt), street-less bare-verb case asserted in the graph-absent test, unnamed-id → cardStreetUnnamed, D-13 banned-words scan - bot/src/guidance/cron.ts imports classifyTurn + snapToGraph from @araharah/routing (grep) — no local turn classifier, no local snapper - bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardStreetUnnamed + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green - grep `console\.` → 0 matches in bot/src/guidance/cron.ts (pure module) Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption — street-bearing by default (D-12), street-less only as designed degradation — are all Node-testable with zero infrastructure task 2: scheduled() handler + crons config + offline send tests + KV-budget pin bot/src/webhook.ts, bot/wrangler.jsonc, bot/test/guidance-cron.test.mjs - bot/src/webhook.ts (the ExportedHandler fetch skeleton — scheduled() extends the SAME object; the fail-closed secret posture lines 58–71) - bot/src/bot/bot.ts createBot(env, { baseFetch }) (the injectable-fetch factory the offline tests capture sends through) - bot/src/routing/worker-router.ts (06-04's EXPORTED loadGraph(env) memo — the ONLY R2 graph loader the cron may use) - bot/src/config.ts (MINI_APP_URL var — the glyph URL + deep link base) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Cron config + scheduled handler" code example + Pitfall 3 (KV write budget) + local-test endpoint (`/cdn-cgi/local/scheduled`) - scheduled() lists KV prefix "guidance:" with cursor pagination (list_complete respected), gets each envelope via getGuidanceSession (malformed → skip + `[guidance-cron] skipped: malformed` category log), projects, and for due cards calls bot.api.sendPhoto with chat_id FROM THE ENVELOPE ONLY - street reconstruction wiring (D-12 default): scheduled() awaits loadGraph(env) ONCE per tick (the worker-router memo — no second R2 loader), and when the graph resolved passes streetAt = makeStreetAt(graph) into projectTurnCards so cards carry the street; graph null (absent/corrupt R2) → streetAt omitted → the street-less bare-verb degradation card — the send still fires (degradation, not silence; D-03 pattern) - snapping is BOUNDED: only the carded turn's vertex is snapped per carded envelope (makeStreetAt called for that index, not a walk of the whole geometry) — the cron CPU tradeoff this creates is a named review item in 06-08's blocking checkpoint - offline send capture: createBot(env, { baseFetch: recording fake }) — the recorded sendPhoto args show url `${MINI_APP_URL}/glyphs/.png`, caption matching the task-1 anatomy (street-bearing under the R2 mini-graph fake; bare-verb under the R2-null fake), parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=` - non-due tick → zero sends; absent/failed envelope → skip, never throw (one bad key cannot kill the tick) - KV-BUDGET PIN (Pitfall 3): structural scan — bot/src/guidance/cron.ts + the scheduled() arm contain ZERO `.put(` calls (stateless law; the gate lives in guidance-cron.test.mjs) - wrangler.jsonc gains `"triggers": { "crons": ["*/1 * * * *"] }`; kv_namespaces stays exactly ONE entry (GE5) - fail-closed: missing BOT_TOKEN → scheduled() logs `[guidance-cron] skipped: unconfigured` and returns (the webhook fail-closed posture) 1. `bot/wrangler.jsonc`: add the triggers block with a law comment ("60 s scan; card cadence decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window; local test: curl \"http://localhost:8787/cdn-cgi/local/scheduled\""). 2. RED: extend `bot/test/guidance-cron.test.mjs` with the scheduled() block — invoke the exported `scheduled(controller, env, ctx)` directly (controller = `{ cron: "*/1 * * * *" } as ScheduledController`), env = KV Map-fake seeded with 3 envelopes (one due, one not-due, one malformed string) + a valid BOT_TOKEN + an R2 fake (`{ get: async () => ({ arrayBuffer: async () => miniGraphBuffer }) }` — the 06-04 webhook-test shape), baseFetch recording; assert the behavior list INCLUDING both street states (mini-graph fake → caption carries the fixture street; a second run with the R2 fake returning null → bare-verb caption, send still recorded). 3. GREEN in `bot/src/webhook.ts`: add `async scheduled(controller, env, ctx)` to the ExportedHandler — fail-closed config check (BOT_TOKEN + chat_sessions present, else category log + return); paginated KV list; `const graph = await loadGraph(env).catch(() => null)` once per tick (import from worker-router — a corrupt/absent graph degrades to the street-less card, never fails the tick); per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec, graph ? makeStreetAt(graph) : undefined)` → due ? `createBot(env).api.sendPhoto(...)` inside try/catch (delivery variance is documented best-effort, D-13 — a failed send logs `[guidance-cron] send failed` and continues). ctx.waitUntil wraps the whole body. Category-only logs throughout. 4. Keep `npm run bot:typecheck` green (ScheduledController/ExportedHandler types from @cloudflare/workers-types). npm test -w bot && npm run bot:typecheck - guidance-cron.test.mjs scheduled() block green: due-card send recorded with envelope chat_id + glyph URL + ?g= deep link; R2 mini-graph fake → caption carries the fixture street; R2-null fake → bare-verb street-less caption with the send still recorded; non-due → 0 sends; malformed key skipped; one-bad-key tick survives - structural KV-budget pin: grep `\.put\(` → 0 matches in bot/src/guidance/cron.ts AND in the scheduled() arm of webhook.ts (assert in-test, PATTERNS PG style) - grep `loadGraph` matches the scheduled() arm (the shared memo — no second R2 loader: grep for a second `ROAD_GRAPH.get` outside worker-router.ts → 0 matches in bot/src) - bot/wrangler.jsonc has triggers.crons ["*/1 * * * *"]; kv_namespaces length still 1 (GE5 green) - D-13 copy law holds in the wired path (caption from projectTurnCards, unchanged) - bot:typecheck clean The cron is live in dev: scan → project → push with street-bearing cards from the shared graph memo (street-less only when the graph is absent), envelope-chat-only, zero KV writes, honest failure posture, and locally testable via the /cdn-cgi/local/scheduled endpoint task 3: build-glyphs.mjs — the committed 8-glyph PNG set (sharp) routing/scripts/build-glyphs.mjs, routing/package.json, web/public/glyphs/*.png - web/src/guidance/glyphs/*.svg (06-05's committed sources — THE single source these PNGs rasterize) - routing/scripts/fetch-route-fixtures.mjs (the maintainer-tool shell: header, Usage, Exit codes, collect-then-write) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md "The 8-glyph set" production paragraph (256×256, #EB8008 on #1A1A1A full-bleed, committed beside the static assets) - `npm run glyphs` (script added to routing/package.json: "glyphs": "node scripts/build-glyphs.mjs") produces web/public/glyphs/{depart,straight,left,right,slight-left,slight-right,uturn,arrive}.png - each PNG is 256×256, non-trivial size (> 500 bytes), visually the token-colored glyph on the matte-black plate - the script recolors currentColor → #EB8008 and injects a full-bleed #1A1A1A plate — the SOURCE SVGs stay untouched (currentColor law for the app's theme-following rendering) - any missing/invalid source SVG → exit 1, NO partial writes (collect-then-write) 1. `npm install -D -w routing sharp` (research Installation — devDependency only, zero runtime deps added; verify `npm ls sharp` shows it under routing). 2. Write `routing/scripts/build-glyphs.mjs` in the maintainer shell: header names the D-12 boundary + the single-source law; named constants `GLYPH_KINDS` (the 8), `STROKE = "#EB8008"`, `PLATE = "#1A1A1A"`, `SIZE = 256` (hex literals allowed HERE — build constants copied verbatim from tokens.css, the DSGN-01 precedent; the script is not runtime CSS); read each SVG, string-substitute `currentColor` → STROKE and inject `` as the first child of `` plus width/height 256 attributes; render via `sharp(Buffer.from(svg), { density: 72 * (256 / 32) }).png().toBuffer()` — collect all 8 buffers, then write all files; print the 8 output paths + sizes; exit codes per the shell. 3. Run it; commit the 8 PNGs (committed artifacts per D-12 — "committed once, reused everywhere"; web/public/* rides the Vite build into web/dist, served by the Worker's static handler, fetched by sendPhoto). 4. Sanity: `git status` shows the SVG sources UNCHANGED (the recolor is in-memory only). npm run glyphs && node -e "const fs=require('fs');const kinds=['depart','straight','left','right','slight-left','slight-right','uturn','arrive'];for(const k of kinds){const s=fs.statSync('web/public/glyphs/'+k+'.png');if(s.size<500)process.exit(1)};console.log('8 PNGs ok')" - all 8 web/public/glyphs/*.png exist, each > 500 bytes, committed - routing/package.json has sharp in devDependencies and the "glyphs" script - the SVG sources are unmodified after a run (git status clean for web/src/guidance/glyphs/) - the script carries the maintainer header + named color constants (#EB8008 / #1A1A1A verbatim from tokens.css) The chat card's visual language is committed: 8 crisp token-colored glyphs rasterized once from the same sources the banner renders — no renderer dependency, no runtime cost

<threat_model>

Trust Boundaries

Boundary Description
Cron tick → Telegram API The Worker pushes only to chat ids read from defensively-validated KV envelopes
KV envelopes → cards A malformed record must never produce a card (or a crash)

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-02 Spoofing / Information Disclosure (cross-chat push) scheduled() → sendPhoto mitigate chat_id comes ONLY from getGuidanceSession's validated envelope (06-03's defensive read); no request path can influence the recipient; the offline test asserts the recorded send's chat_id equals the seeded envelope's.
T-06-04 Information Disclosure (logs + card copy) cron logging + caption mitigate Category-only logs ([guidance-cron] skipped: malformed) — never chat ids, coordinates, or payloads; the card carries ETA/distance/street/turn-kind to the USER'S OWN chat only; street text comes from the repo's own R2 graph (OSM-derived public data), never from the envelope; the D-13 copy gate bans "live/real-time/guaranteed".
— DoS (KV budget + cron CPU) scheduled() mitigate / review STATELESS: zero KV writes per tick (structural pin); reads are 1 list + N gets per minute — comfortably inside 100 k reads/day at v1 scale (A8). The street reconstruction adds 1 graph memo (1 R2 GET/cold isolate) + ONE snapToGraph per carded tick — inside the free-plan CPU budget by design, and surfaced as a NAMED ratification item in 06-08's blocking checkpoint (street-less ratification path recorded there).
T-06-03 Tampering (glyph assets) web/public/glyphs/*.png accept Committed repo artifacts built by a maintainer script from committed sources; the Worker serves its own repo's assets — no untrusted upload path.
— Repudiation — accept Cards are transactional messages to the owning chat (the Out-of-Scope table bans engagement pushes); nothing persistent beyond the TTL'd envelope.
</threat_model>
- `npm test -w bot && npm run bot:typecheck` green (projection + street reconstruction, scheduled wiring, copy gate, KV-budget pin) - Street states proven: mini-graph-backed caption carries the fixture street; graph-absent case produces the bare-verb honest card (both asserted in guidance-cron.test.mjs) - `npm run glyphs` green; 8 committed PNGs; SVG sources untouched - wrangler.jsonc: crons configured, exactly one KV namespace (GE5) - Local cron smoke (dev): `npx wrangler dev` + `curl "http://localhost:8787/cdn-cgi/local/scheduled"` documented in the SUMMARY (requires a seeded local envelope — optional manual smoke)

<success_criteria> Minimized sessions get honest turn-cards: the cron scans every minute, projects along the frozen snapshot, and pushes token-colored glyph cards on the 3-minute cadence to the owner's chat — maneuver lines street-bearing by DEFAULT via the shared memoized graph (D-12), bare-verb street-less only as designed degradation — with an OPEN deep-link that resumes a killed webview, zero KV writes, zero dishonest claims, zero renderer dependencies. </success_criteria>

After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-SUMMARY.md`