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

270 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 06-turn-by-turn-guidance-live-driving
plan: 07
type: execute
wave: 3
depends_on: [06-03, 06-04, 06-05]
files_modified:
- 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
autonomous: true
requirements: [GUID-05]
must_haves:
truths:
- "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)"
- "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)"
artifacts:
- path: "bot/src/guidance/cron.ts"
provides: "PURE projectTurnCards + the scheduled() projection/push logic"
exports: ["projectTurnCards", "CARD_CADENCE_S"]
- path: "bot/src/webhook.ts"
provides: "scheduled() handler with KV list pagination"
- path: "bot/wrangler.jsonc"
provides: "triggers.crons */1 * * * * (60 s scan; the card decision is cadence-gated in code)"
- path: "web/public/glyphs/*.png"
provides: "8 committed 256×256 turn-card PNGs served by the Worker's static assets"
key_links:
- from: "bot/src/guidance/cron.ts"
to: "routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM)"
via: "projection over the snapshot geometry — one turn authority shared with engine A"
pattern: "classifyTurn"
- from: "bot/src/webhook.ts scheduled()"
to: "bot/src/guidance/cron.ts"
via: "KV list prefix guidance: → projectTurnCards → maybe sendPhoto"
pattern: "projectTurnCards"
- from: "routing/scripts/build-glyphs.mjs"
to: "web/src/guidance/glyphs/*.svg"
via: "the committed SVGs are the single source; PNGs are their rasterization"
pattern: "glyphs"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- FROZEN envelope (bot/src/kv/guidance-sessions.ts — read-only here; GE2 means NO steps field):
{ id, chat_id, route_snapshot: { destinationId, destinationName, distanceM, durationS,
geometry (polyline6), startedAtSec }, created_at } -->
<!-- From 06-01 (routing workspace — bot already depends on it): classifyTurn(prevDeg, nextDeg),
decodePolyline6(s), deriveCumulativeDistanceM(points), snapToGraph? (NOT needed — see the
recorded call below), haversineM. Subpath-with-extension imports only. -->
<!-- RECORDED CALL (UI-SPEC deviation, honesty-law rationale): the turn-card's maneuver line is
composed STREET-LESS — "{dist} — {action}" — because the FROZEN envelope carries geometry
only, no step names (GE2 field freeze stays; adding a 7th snapshot field is a user
decision, not a plan's). Snapping turn vertices to the graph to guess street names was
REJECTED: a wrong street name in a card is a lie; a street-less card is honest. The
UI-SPEC's "{action} onto {street}" form remains the app-side banner's job, where the
route model DOES carry steps. Record this call in the phase fidelity artifact. -->
<!-- Caption (UI-SPEC Turn-Card section, HTML, ≤1024 chars — ~120 used):
GUIDE // T-{nn} ← instrument constant, code-composed
<b>{dist} — {cardActionBare[action]}</b> ← uppercase, street-less per the recorded call
ETA {HH:MM} · {km} KM LEFT ← cardEtaTemplate
Best-effort projection — updates while the app is minimized. ← cardHonesty
Inline keyboard: [{ text: cardOpenButton, web_app: { url: "<MINI_APP_URL>/?g=<id>" } }]
(web_app buttons carry ONLY a url — the 04-03 mechanism-correction precedent — so the
guidance id travels as ?g= in the URL, read by plan 06's boot reader.) -->
<!-- Cadence (D-11 discretion, Open Q 3): cron scans every 60 s; a card is DUE on the first
tick of each 180 s window — `CARD_CADENCE_S = 180`, `due = nowSec % 180 < 60` (cron fires
at second ~0 of each minute). Stateless: zero cursor keys, zero KV writes. -->
<!-- Bot copy keys (bot/src/i18n/strings.ts, en + ms, UI-SPEC pinned):
cardActionDepart "DEPART"/"MULA" · cardActionStraight "CONTINUE ON"/"TERUS KE" ·
cardActionLeft "TURN LEFT ONTO"/"BELOK KIRI KE" · cardActionRight "TURN RIGHT ONTO"/"BELOK KANAN KE" ·
cardActionSlightLeft "KEEP LEFT ONTO"/"SEDAKIT KIRI KE" · cardActionSlightRight "KEEP RIGHT ONTO"/"SEDAKIT KANAN KE" ·
cardActionUturn "MAKE A U-TURN ONTO"/"BUAT PUSARAN KE" · cardActionArrive "ARRIVE AT"/"TIBA DI"
(kept verbatim for the copy gate + future street-bearing use)
cardActionBareDepart "DEPART"/"MULA" · cardActionBareStraight "CONTINUE"/"TERUS" ·
cardActionBareLeft "TURN LEFT"/"BELOK KIRI" · cardActionBareRight "TURN RIGHT"/"BELOK KANAN" ·
cardActionBareSlightLeft "KEEP LEFT"/"SEDAKIT KIRI" · cardActionBareSlightRight "KEEP RIGHT"/"SEDAKIT KANAN" ·
cardActionBareUturn "MAKE A U-TURN"/"BUAT PUSARAN" · cardActionBareArrive "ARRIVE"/"TIBA"
cardEtaTemplate "ETA {time} · {km} KM LEFT"/"ETA {masa} · {km} KM LAGI"
cardHonesty "Best-effort projection — updates while the app is minimized."/"Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan."
cardOpenButton "OPEN GUIDANCE"/"BUKA PANDUAN" -->
<!-- createBot(env, { baseFetch }) (bot/src/bot/bot.ts) — the cron-side send:
bot.api.sendPhoto(chat_id, "<MINI_APP_URL>/glyphs/<kind>.png", { caption, parse_mode: "HTML", reply_markup });
scheduled() copies webhook.ts's ExportedHandler extension (RESEARCH code example):
KV list({ prefix: "guidance:", cursor }) + list_complete pagination, category-only logs
([guidance-cron] …), fail-closed when BOT_TOKEN/binding missing. -->
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: projectTurnCards (pure) + bot copy keys</name>
<files>bot/src/guidance/cron.ts, bot/src/i18n/strings.ts, bot/test/guidance-cron.test.mjs, bot/test/commands.test.mjs</files>
<read_first>
- 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 (the projection's building blocks — import, never re-implement)
- 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)
</read_first>
<behavior>
- 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, contains NO street names (street-less recorded call), register zero-padded
- copy law: caption contains none of "live", "real-time", "guaranteed" (case-insensitive) — D-13's strongest-claim gate
</behavior>
<action>
1. Bot copy: append the 22 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.
3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards is PURE: injected nowSec, no KV, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number): { 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 (HTML escape NOT needed: all parts are code-composed from numbers + string-table constants — assert no envelope-derived string like destinationName enters the caption in v1). GlyphKindName is cron.ts's local union mirroring the 8 PNG names. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
</action>
<verify>
<automated>npm test -w bot</automated>
</verify>
<acceptance_criteria>
- 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, no-street-name assertion, D-13 banned-words scan
- bot/src/guidance/cron.ts imports classifyTurn from @araharah/routing (grep) — no local turn classifier
- bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green
- grep `console\.` → 0 matches in bot/src/guidance/cron.ts (pure module)
</acceptance_criteria>
<done>Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption are all Node-testable with zero infrastructure</done>
</task>
<task type="auto" tdd="true">
<name>task 2: scheduled() handler + crons config + offline send tests + KV-budget pin</name>
<files>bot/src/webhook.ts, bot/wrangler.jsonc, bot/test/guidance-cron.test.mjs</files>
<read_first>
- 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/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`)
</read_first>
<behavior>
- 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
- offline send capture: createBot(env, { baseFetch: recording fake }) — the recorded sendPhoto args show url `${MINI_APP_URL}/glyphs/<kind>.png`, caption matching the task-1 anatomy, parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=<envelope id>`
- 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)
</behavior>
<action>
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, baseFetch recording; assert the behavior list.
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; per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec)` → 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).
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- guidance-cron.test.mjs scheduled() block green: due-card send recorded with envelope chat_id + glyph URL + ?g= deep link; 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)
- 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
</acceptance_criteria>
<done>The cron is live in dev: scan → project → push, envelope-chat-only, zero KV writes, honest failure posture, and locally testable via the /cdn-cgi/local/scheduled endpoint</done>
</task>
<task type="auto">
<name>task 3: build-glyphs.mjs — the committed 8-glyph PNG set (sharp)</name>
<files>routing/scripts/build-glyphs.mjs, routing/package.json, web/public/glyphs/*.png</files>
<read_first>
- 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)
</read_first>
<behavior>
- `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)
</behavior>
<action>
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 `<rect width="32" height="32" fill="#1A1A1A"/>` as the first child of `<svg …>` 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).
</action>
<verify>
<automated>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')"</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>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</done>
</task>
</tasks>
<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/turn-kind to the USER'S OWN chat only; the D-13 copy gate bans "live/real-time/guaranteed". |
| — | DoS (KV budget) | scheduled() | mitigate | 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). |
| 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>
<verification>
- `npm test -w bot && npm run bot:typecheck` green (projection, scheduled wiring, copy gate, KV-budget pin)
- `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)
</verification>
<success_criteria>
Minimized sessions get honest turn-cards: the cron scans every minute, projects along the frozen snapshot, pushes token-colored glyph cards on the 3-minute cadence to the owner's chat with an OPEN deep-link that resumes a killed webview — zero KV writes, zero dishonest claims, zero renderer dependencies.
</success_criteria>
<output>
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-SUMMARY.md`
</output>