From d5e104b2e9a9a021f5ac397c5882f327b66d2186 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 15:31:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(06-06):=20envelope=20flows=20+=20composed?= =?UTF-8?q?=20ladder=20=E2=80=94=20minimize=20POST,=20resume=20GET,=20trip?= =?UTF-8?q?-end=20DELETE=20(GREEN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - route.ts: onDeactivated POSTs the FROZEN six-field snapshot with keepalive (the minimize race's belt) and stores the server-minted id; onActivated DELETEs (the cron pauses — structural) and RETAINS the id; teardown's deleteEnvelope completes the idempotent pair; a live envelope is never double-minted; best-effort honesty throughout (D-13) - resume(trip): the controller computes the projection from startedAtSec + avg speed (the cron's own math, one formula), renders GUIDE // RESUMING + gantry + projected card, draws the rebuilt line, arms the watch, DELETEs immediately (a resumed boot is foregrounded), and seeds the session mid-route on the first fix through the machine's own resuming path — synthesized terminal marks keep the arrive mark at the line's END (a steps-less envelope model would instantly arrive) - backAction(): the PX-01 resolver's single authority — driving → confirm, confirm → CONTINUE, ended/route → the walk, root → close - guidance.ts: resumingView keeps the progress math in guidance/ (RS6) - main.ts: the COMPOSED LADDER (engine-a first, fixture fallback — code-constructed, zero env reads), the ?g= UUID-validated reader with the defensive envelope validation (fail-honest → honest boot), the capability-guarded activated/deactivated wiring, the recenter re-lock at the guidance pose, the chain-fix camera skip while driving, and the no-double-consult resume guard - merged suite: G9-G12 + the E-family (co-executor tests reconciled: pre-gate probe, builder-format asserts, terminal-mark fixture) — 146 passed, typecheck clean --- web/src/guidance/guidance.ts | 16 ++- web/src/main.ts | 197 +++++++++++++++++++++++---- web/src/route/route.ts | 231 ++++++++++++++++++++++++++++++-- web/test/route-surface.test.mjs | 158 +++++++++++++++++++++- 4 files changed, 560 insertions(+), 42 deletions(-) diff --git a/web/src/guidance/guidance.ts b/web/src/guidance/guidance.ts index 92f3849..04163d7 100644 --- a/web/src/guidance/guidance.ts +++ b/web/src/guidance/guidance.ts @@ -482,8 +482,7 @@ export interface GuidanceViewData { rows: GuidanceListRow[]; } -export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): GuidanceViewData { - const { model } = snap; +export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): GuidanceViewData { const { model } = snap; const marks = maneuverMarksOf(model); const next = marks[snap.nextManeuver] ?? null; @@ -552,3 +551,16 @@ export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): Guidanc rows, }; } + +/** The RESUMING register's card view (06-06): the projected progress math + * composed HERE — the guidance-implementation boundary (RS6) keeps every + * progress rendering out of route/, which only spreads the result into + * the builders. Pure numbers in, the card's view fields out. */ +export function resumingView( + destinationName: string, + totalM: number, + projectedRemainingM: number, +): GuidanceCardData { + const pct = totalM > 0 ? Math.min(100, Math.max(0, ((totalM - projectedRemainingM) / totalM) * 100)) : 0; + return { destinationName, progressPct: pct, showNoReroute: false }; +} diff --git a/web/src/main.ts b/web/src/main.ts index c84e1d2..fbbacdb 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -44,11 +44,20 @@ import { installMockWebApp } from "./mock/telegram-mock.ts"; import { readDestinationId, resolveDestination, setDestination } from "./search/resolve-destination.ts"; import { buildDestinationCard, buildDestinationUnavailableCard } from "./search/destination-card.ts"; import { wireHomeScreen } from "./telegram/homescreen.ts"; -import { createFixtureRoutingSource } from "@araharah/routing/src/index.ts"; +import { + createFixtureRoutingSource, + createHttpRoutingSource, + decodePolyline6, + deriveCumulativeDistanceM, + type RoutingSource, +} from "@araharah/routing/src/index.ts"; import { createRouteController, type RouteState } from "./route/route.ts"; import { buildRouteCard, buildRoutePendingCard } from "./route/route-card.ts"; import { attachRouteEndpoints, removeRouteEndpoints } from "./route/route-markers.ts"; import { installRouteLayers, type MapLike } from "./route/route-layers.ts"; +import { GUIDANCE_CAMERA_PADDING, GUIDANCE_CAMERA_PITCH, GUIDANCE_ZOOM, GUIDANCE_EASE_MS } from "./guidance/camera.ts"; +import type { BackAction } from "./telegram/backbutton.ts"; +import { createGuidanceWatch } from "./guidance/watch.ts"; declare global { interface Window { @@ -64,13 +73,13 @@ async function boot(): Promise { // 1 — safe-area vars are set before any layout reads them (PX-03 groundwork). applySafeArea(resolveSafeInsets(getWebApp())); - // 3 (declared ahead of its first use inside the BackButton closure) — the - // single-shell predicate for the no-dead-ends wire, now carrying the - // route phase: the BackButton is visible exactly when a PUSHED state - // exists (route or pending). Both closures are assigned for real once - // the route controller exists (below) — before that, idle. + // 3 (declared ahead of their first use inside the closures below) — the + // single-shell state the BackButton resolver and the stale branch read: + // the route phase, the walk total function, the controller itself, and + // the wire's re-apply handle (assigned once the wire exists). let routePhase: () => RouteState = () => "idle"; - let routeWalkBack = (): void => {}; + let routeController: ReturnType | null = null; + let backSync: (() => void) | null = null; // 12 — the locale comes from the RAW initData string (the session POST // returns only {token} — the claim's language_code is not reachable @@ -80,13 +89,20 @@ async function boot(): Promise { const wa = getWebApp(); const locale: Locale = wa ? localeFromInitData(wa.initData) : "en"; - // 2 — Telegram bindings: theme slot switch + BackButton total wire. - // The predicate + walkBack ship as ONE change (UI-SPEC): the pushed- - // state click walks to the root via the SAME total function SEARCH - // AGAIN invokes — never close() from a pushed state (T-05-11). + // 2 — Telegram bindings: theme slot switch + the BackButton's full PX-01 + // graph. The resolver is CONTEXT-AWARE: the controller owns the walk + // decision (guidance → confirm; confirm → CONTINUE; ended → the + // search-root walk; route → the Phase 5 walk; root → close), the wire + // only applies it — per click AND per state change (wire.sync()). if (wa) { watchTheme(wa); - wireBackButton(wa, () => routePhase() !== "idle", () => routeWalkBack()); + const backResolve = (): BackAction => { + const controller = routeController; + if (controller !== null) return controller.backAction(); + return { kind: "hidden" }; // pre-boot — nothing pushed yet + }; + const wire = wireBackButton(wa, () => backResolve()); + backSync = wire.sync; } // 3 — the map register mounts FIRST (D-08/D-09 anti-blank invariant). @@ -149,6 +165,13 @@ async function boot(): Promise { routeStack.style.display = "none"; mainEl.appendChild(routeStack); + // 06-06 — the guidance chrome's mounts: the banner band (the top register + // occupant) and the maneuver list's overlay layer (collapsed default). + const bannerHost = document.createElement("div"); + mainEl.appendChild(bannerHost); + const listHost = document.createElement("div"); + mainEl.appendChild(listHost); + // 12 — the strip consumes the locale derived above the bindings block. strip.setLocale(locale); @@ -164,6 +187,14 @@ async function boot(): Promise { const destPayload = readDestinationId(window.location.search, wa?.initData ?? null); const destination = resolveDestination(destPayload.id); setDestination(destination); + // 13 — the ?g= resume carrier (GUID-05/06): a killed webview re-opens + // straight into its live trip. The id is an opaque UUID validated + // BEFORE any GET (V5 posture); a failed/foreign GET is an honest + // destination-less boot — never a fake route. + const resumeId = (() => { + const raw = new URLSearchParams(window.location.search).get("g"); + return raw !== null && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw) ? raw : null; + })(); // The destination surface — the confirmation card when the id resolved, the // honest unavailable card when an id ARRIVED and resolved to nothing, and // null when no id arrived at all: the Phase 3 boot is then byte-identical @@ -189,7 +220,20 @@ async function boot(): Promise { console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent")); const recenter = createRecenterControl(mainEl, locale, () => { - if (lastFix !== null && map !== null) follow?.follow(lastFix); + if (lastFix !== null && map !== null) { + if (routeController !== null && routeController.guidancePhase === "driving") { + // re-lock at the GUIDANCE pose (UI-SPEC camera table) — the Phase 3 + // padding would put the puck back under the strip + follow?.follow(lastFix, { + padding: GUIDANCE_CAMERA_PADDING, + pitch: GUIDANCE_CAMERA_PITCH, + zoom: GUIDANCE_ZOOM, + durationMs: GUIDANCE_EASE_MS, + }); + } else { + follow?.follow(lastFix); + } + } }); // Cards are built once at boot (boot-time locale) and reused across re-runs. @@ -362,8 +406,18 @@ async function boot(): Promise { fitBounds: () => {}, getBearing: () => 0, }; - const routingSource = createFixtureRoutingSource(); - const routeController = createRouteController({ + // Phase 6 — the COMPOSED LADDER (D-01's law, code-constructed — no env + // read, no registry, no config): engine-a answers when the Worker can + // (arbitrary origins, real reroutes); the fixture corpus carries the + // golden-demo path and every honest-null fallback. The bearer getter is + // the memory-held session token — minted after this line is still sent. + const fixtureSource = createFixtureRoutingSource(); + const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() }); + const routingSource: RoutingSource = { + name: "ladder", + route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)), + }; + routeController = createRouteController({ map: routeMap, source: routingSource, locale, @@ -371,7 +425,7 @@ async function boot(): Promise { buildPendingCard: buildRoutePendingCard, showRouteCard, showDestinationCard: () => showCard(destinationCard), - follow: { follow: (fix) => follow?.follow(fix) }, // null-tolerant wrapper — the controller sees a total object + follow: { follow: (fix, opts) => follow?.follow(fix, opts) }, // null-tolerant wrapper — the controller sees a total object getLatestFix: () => lastFix, attachEndpoints: (firstPoint, lastPoint, name) => { if (map !== null) attachRouteEndpoints(map, firstPoint, lastPoint, name); @@ -381,13 +435,24 @@ async function boot(): Promise { }, onStateChange: (s) => { routePhase = () => s; // the ONE source of truth for the overlay predicate AND the stale branch - if (wa) { - if (s === "idle") wa.BackButton?.hide(); - else wa.BackButton?.show(); // visible exactly when a pushed state exists - } + if (backSync !== null) backSync(); // visible exactly when a pushed state exists }, + // — the guidance concerns (06-06) — + wa, + shellRoot: mainEl, // [data-guidance="active"] compacts the strip through CSS + bannerHost, + listHost, + startWatch: (onFix) => createGuidanceWatch(navigator.geolocation, onFix), + findGantry: (banner) => { + const el = banner.querySelector(".guide-gantry"); + return el instanceof HTMLElement ? el : null; + }, + onGuidanceChange: () => { + if (backSync !== null) backSync(); // confirm open/close + activation/teardown re-apply the graph + }, + fetchImpl: fetch.bind(globalThis), + bearer: getSession, }); - routeWalkBack = () => routeController.walkBack(); // the SEARCH AGAIN total function, now real // 5 — mock install is a fallback, never an override: the capture runner // page installs a profile-configured mock BEFORE this module runs, and @@ -400,6 +465,77 @@ async function boot(): Promise { // renders the persistent denied card (D-06) and stops — never blank. await runFromSession(); + // 14 — the envelope lifecycle (D-11's structural foreground gate) + the + // ?g= resume. Both ride Telegram's activated/deactivated events + // behind the capability guard (T-06-06); the minimize POST carries + // keepalive so a frozen webview still hands off (RESEARCH Pitfall 2). + if (wa && typeof wa.onEvent === "function" && routeController !== null) { + const guided = routeController; + wa.onEvent("deactivated", () => void guided.onDeactivated()); + wa.onEvent("activated", () => guided.onActivated()); + } + if (resumeId !== null && routeController !== null && map !== null) { + void tryResume(resumeId, routeController); + } + + /** GET the ?g= envelope and resume: rebuild the model from the frozen + * snapshot (decode + cumulative + destination fields), hand the RAW + * anchors to the controller — it owns the projection. A failed, + * foreign, or malformed GET is an honest boot (Phase 4 flow) — never a + * fake route, never guidance chrome over nothing. */ + async function tryResume(id: string, controller: NonNullable): Promise { + const token = getSession(); + if (token === null) return; // no session yet — the honest boot continues + try { + const res = await fetch(`/api/guidance?id=${id}`, { headers: { authorization: `Bearer ${token}` } }); + if (!res.ok) return; // absent/foreign → byte-indistinguishable 404 → the honest boot + const payload: unknown = await res.json(); + if (payload === null || typeof payload !== "object") return; + const snap = (payload as { route_snapshot?: unknown }).route_snapshot; + if (snap === null || typeof snap !== "object") return; + const s = snap as { + destinationId?: unknown; + destinationName?: unknown; + distanceM?: unknown; + durationS?: unknown; + geometry?: unknown; + startedAtSec?: unknown; + }; + if ( + typeof s.destinationId !== "string" || + typeof s.destinationName !== "string" || + typeof s.geometry !== "string" || + typeof s.distanceM !== "number" || + !Number.isFinite(s.distanceM) || + typeof s.durationS !== "number" || + !Number.isFinite(s.durationS) || + typeof s.startedAtSec !== "number" || + !Number.isSafeInteger(s.startedAtSec) || + s.startedAtSec <= 0 + ) { + return; // a corrupt envelope is no envelope (the defensive-read law) + } + const points = decodePolyline6(s.geometry); // throws on truncation → the catch + if (points.length < 2) return; + await mapLayersReady; // the route sources exist before the line draws + controller.resume({ + model: { + points, + cumulative: deriveCumulativeDistanceM(points), + steps: [], // the envelope carries geometry only — the controller synthesizes the terminal marks + destinationId: s.destinationId, + destinationName: s.destinationName, + }, + envelopeId: id, + distanceM: s.distanceM, + durationS: s.durationS, + startedAtSec: s.startedAtSec, + }); + } catch { + return; // fail-honest — never a fabricated trip + } + } + async function runFromSession(): Promise { renderState({ kind: "validating" }); const session = await createSession(wa?.initData ?? ""); @@ -447,19 +583,26 @@ async function boot(): Promise { stopGantry(gantryOf(bootCard2)); if (map !== null) { marker.update(map, fix); - follow?.follow(fix); + if (routeController !== null && routeController.guidancePhase === "driving") { + // the guidance camera owns the viewport during a live trip — the + // chain's fix refreshes marker/strip; the camera settles on the + // next throttled ease (never a pose fight) + } else { + follow?.follow(fix); + } } marker.setStale(false); recenter.setFixState("fresh"); renderState({ kind: "live", fix }); - // Auto-consult (the state-coexistence law): the fixture RoutingSource is + // Auto-consult (the state-coexistence law): the RoutingSource ladder is // consulted ONCE per boot, only when the location chain is live AND a // destination resolved — destination-unavailable and no-destination - // boots never make a route request. The consult itself waits for the - // map's load (route sources installed) — see mapLayersReady above. - if (destination !== null && !routeConsulted) { + // boots never make a route request. A ?g= resume boot NEVER consults + // (the envelope's route IS the trip); a failed resume stays honest + // (no destination drawn). The consult waits for the map's load. + if (destination !== null && !routeConsulted && resumeId === null) { routeConsulted = true; - void mapLayersReady.then(() => routeController.consult(fix, destination)); + void mapLayersReady.then(() => routeController?.consult(fix, destination)); } if (!tickStarted) startTick(); // emitted UNCONDITIONALLY (outside the map guard above): a WebGL2-absent diff --git a/web/src/route/route.ts b/web/src/route/route.ts index 2ba6ae4..79bb152 100644 --- a/web/src/route/route.ts +++ b/web/src/route/route.ts @@ -53,6 +53,7 @@ import type { RouteOption, RoutePoint, RouteResult, RoutingSource } from "@araha import type { Fix } from "../location/types.ts"; import type { Locale } from "../i18n/strings.ts"; import type { TelegramWebAppLike } from "../telegram/webapp.ts"; +import type { BackAction } from "../telegram/backbutton.ts"; import type { FollowOptions } from "../map/follow.ts"; import type { MapLike } from "./route-layers.ts"; import { clearRouteLayers, setRouteDrawn } from "./route-layers.ts"; @@ -70,10 +71,12 @@ import { stopGantryBlink, } from "../guidance/banner.ts"; import { composeEtaClock, updateEta, type EtaState } from "../guidance/eta.ts"; +import { encodePolyline6 } from "@araharah/routing/src/index.ts"; import { createGuidanceSession, guidanceView, projectAlongRoute, + resumingView, stepGuidance, type GuidanceEffect, type GuidanceRouteModel, @@ -133,13 +136,31 @@ export interface RouteControllerDeps { findGantry?: (banner: HTMLElement) => HTMLElement | null; /** Fired on guidance phase transitions — main.ts re-syncs the BackButton. */ onGuidanceChange?: () => void; + /** Injectable fetch (the envelope POST/DELETE — tests pass a stub). */ + fetchImpl?: typeof fetch; + /** The memory-held session token getter (the Bearer header). */ + bearer?: () => string | null; +} + +/** One resumed trip (?g= boot): the rebuilt model + the envelope's own + * anchors. The controller projects the displayed numbers from + * startedAtSec + avg speed — the cron's projection math, one formula + * across surfaces (D-11); main.ts passes the envelope fields verbatim. */ +export interface ResumedTrip { + model: GuidanceRouteModel; + envelopeId: string; + distanceM: number; + durationS: number; + startedAtSec: number; } /** One live trip: the model guidance drives on, the machine's snapshot, * the honest-ETA cell, and the cockpit's own flags. */ interface GuidanceRun { model: GuidanceRouteModel; - snap: GuidanceSnapshot; + /** Null only while a RESUMED trip shows its projection (before the + * first fix seeds the session mid-trip). */ + snap: GuidanceSnapshot | null; durationS: number; eta: EtaState | null; listOpen: boolean; @@ -158,6 +179,15 @@ export function createRouteController(deps: RouteControllerDeps): { walkBack(): void; openConfirm(): void; continueGuidance(): void; + /** D-11's write-at-minimize: the frozen envelope POST (keepalive). */ + onDeactivated(): Promise; + /** The cron pauses while foregrounded — the structural DELETE. */ + onActivated(): void; + /** The ?g= boot's entry: RESUMING chrome over the rebuilt model. */ + resume(trip: ResumedTrip): void; + /** The BackButton resolver's whole vocabulary — main.ts's wire reads + * THIS per click (the context-aware PX-01 graph, one authority). */ + backAction(): BackAction; } { let state: RouteState = "idle"; let options: RouteOption[] = []; @@ -168,6 +198,9 @@ export function createRouteController(deps: RouteControllerDeps): { let watch: GuidanceWatch | null = null; let blinkEl: HTMLElement | null = null; let lastLoggedGuideState: string | null = null; + /** The live guidance envelope's id — minted at minimize (or inherited + * from the ?g= boot), consumed idempotently at trip end. */ + let envelopeId: string | null = null; const now = (): number => (deps.nowMs !== undefined ? deps.nowMs() : Date.now()); const camera = createGuidanceCamera(null, deps.follow); @@ -240,6 +273,7 @@ export function createRouteController(deps: RouteControllerDeps): { /** The D-07 honest-ETA fold: numbers from the route model + the snapped * projection only (never a raw fix-to-destination distance). */ function foldEta(g: GuidanceRun): void { + if (g.snap === null) return; const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0; const remainingS = g.durationS > 0 ? g.durationS * (g.snap.remainingM / (totalM > 0 ? totalM : 1)) : 0; g.eta = updateEta(g.eta, remainingS, g.snap.remainingM); @@ -268,10 +302,20 @@ export function createRouteController(deps: RouteControllerDeps): { }; } const g = guidance; - if (g.ended) return; - const proj = projectAlongRoute(g.model.points, g.model.cumulative, fix, g.snap.fromIdx); + if (g === null || g.ended) return; + if (g.snap === null) { + // a RESUMED trip seeds its session mid-route here: the machine's own + // resuming register confirms on_route on the first in-threshold fix — + // never a re-run of the start gate on a trip that began long ago + const snap = createGuidanceSession(g.model, fix, now()); + const seeded: GuidanceSnapshot = { ...snap, state: "resuming" }; + g.snap = seeded; + } + const snapNow = g.snap; + if (snapNow === null) return; + const proj = projectAlongRoute(g.model.points, g.model.cumulative, fix, snapNow.fromIdx); g.lastSnapped = proj.snapped; - const stepped = stepGuidance(g.snap, { type: "fix", fix }, now()); + const stepped = stepGuidance(snapNow, { type: "fix", fix }, now()); g.snap = stepped.snap; if (!g.active && g.snap.state !== "idle") activateGuidance(); if (!g.active || g.ended) return; // idle — the start gate has not opened @@ -292,7 +336,7 @@ export function createRouteController(deps: RouteControllerDeps): { foldEta(g); camera.easeToSnapped(g.lastSnapped); // the locked-follow pose over the SNAPPED line renderCockpit(); - logGuideState(g.snap.state); + if (g.snap !== null) logGuideState(g.snap.state); if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); } @@ -308,7 +352,7 @@ export function createRouteController(deps: RouteControllerDeps): { * list, and the guidance card in the bottom register. */ function renderCockpit(): void { const g = guidance; - if (g === null || !g.active || g.ended) return; + if (g === null || !g.active || g.ended || g.snap === null) return; const view = guidanceView(g.snap, g.listOpen); stopBlink(); const banner = buildGuidanceBanner(deps.locale, view.banner, { onToggle: toggleList }); @@ -370,7 +414,7 @@ export function createRouteController(deps: RouteControllerDeps): { * degradation. The banner never blames. */ async function requestReroute(origin: RoutePoint): Promise { const g = guidance; - if (g === null || g.ended) return; + if (g === null || g.ended || g.snap === null) return; g.snap = stepGuidance(g.snap, { type: "reroute-started" }, now()).snap; renderCockpit(); // REROUTING: register swap + dash + gantry start logGuideState(g.snap.state); @@ -411,7 +455,8 @@ export function createRouteController(deps: RouteControllerDeps): { } /** BOTH trip ends land here (D-14): the watch stops, the close protection - * releases, [data-guidance] clears (the strip returns), and the trip-end + * releases, [data-guidance] clears (the strip returns), the envelope is + * DELETEd (the TTL law's promise, executed actively), and the trip-end * card takes the center register. */ function teardownGuidance(reason: "arrival" | "abandon"): void { const g = guidance; @@ -419,6 +464,7 @@ export function createRouteController(deps: RouteControllerDeps): { g.ended = true; g.confirmOpen = false; stopWatch(); + deleteEnvelope(); releaseCloseProtection(deps.wa ?? null); if (deps.shellRoot !== undefined) delete deps.shellRoot.dataset.guidance; stopBlink(); @@ -460,6 +506,171 @@ export function createRouteController(deps: RouteControllerDeps): { return g.confirmOpen ? "confirm" : "driving"; } + /* ————— the envelope lifecycle (06-06 task 3: D-11/D-13/D-14) ————— */ + + const authHeaders = (): Record => { + const token = deps.bearer !== undefined ? deps.bearer() : null; + return token !== null ? { authorization: `Bearer ${token}` } : {}; + }; + + /** D-11's write-at-minimize: the FROZEN six-field snapshot POSTed with + * keepalive (the minimize race's belt — Pitfall 2). The id/chat_id/ + * created_at are SERVER-minted; a lost race still TTL-expires (the D-13 + * best-effort honesty). A live envelope is never double-minted. */ + async function onDeactivated(): Promise { + const g = guidance; + if (g === null || !g.active || g.ended) return; + if (envelopeId !== null) return; + if (deps.fetchImpl === undefined) return; + const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0; + const snapshot = { + destinationId: g.model.destinationId, + destinationName: g.model.destinationName, + distanceM: totalM, + durationS: g.durationS, + geometry: encodePolyline6(g.model.points), + startedAtSec: g.startedAtSec, + }; + try { + const res = await deps.fetchImpl("/api/guidance", { + method: "POST", + headers: { "content-type": "application/json", ...authHeaders() }, + body: JSON.stringify(snapshot), + keepalive: true, + }); + if (!res.ok) return; // best-effort honesty — the TTL still bounds the record + const payload: unknown = await res.json(); + const id = typeof payload === "object" && payload !== null ? (payload as { id?: unknown }).id : undefined; + if (typeof id === "string") envelopeId = id; + } catch { + return; // the minimize race lost — honest, bounded by the TTL + } + } + + /** The cron pauses while the app is foregrounded — structural (D-11). + * The id is RETAINED: the trip end deletes the SAME envelope. */ + function onActivated(): void { + const id = envelopeId; + if (id === null || deps.fetchImpl === undefined) return; + void deps.fetchImpl(`/api/guidance?id=${id}`, { method: "DELETE", headers: authHeaders() }); + } + + /** The trip-end active sweep (D-14's client half). 404-tolerant by the + * server's contract; the id is consumed so a second call is a no-op. */ + function deleteEnvelope(): void { + const id = envelopeId; + envelopeId = null; + if (id === null || deps.fetchImpl === undefined) return; + void deps.fetchImpl(`/api/guidance?id=${id}`, { method: "DELETE", headers: authHeaders() }); + } + + /** The ?g= boot's entry (GUID-05/06): the rebuilt model draws, RESUMING + * chrome shows the projected numbers, the watch arms, and the first + * accepted fix flips the register live through the machine's own + * resuming path. The envelope id is inherited; the resume DELETE fires + * NOW (a resumed boot is foregrounded — the cron pauses, D-11). */ + function resume(trip: ResumedTrip): void { + if (guidance !== null) return; // one trip per boot + const points = trip.model.points; + // The envelope carries geometry only — synthesize the terminal marks + // when the rebuilt model has no steps, so the machine's arrive mark + // sits at the END of the line (a steps-less model would put it at 0: + // any in-threshold fix would instantly "arrive") + const totalM = trip.model.cumulative[trip.model.cumulative.length - 1] ?? 0; + const model: GuidanceRouteModel = + trip.model.steps.length > 0 + ? trip.model + : { + ...trip.model, + steps: [ + { kind: "depart", distanceM: totalM }, + { kind: "arrive", distanceM: 0 }, + ], + }; + guidance = { + model, + snap: null, + durationS: trip.durationS, + eta: null, + listOpen: false, + confirmOpen: false, + active: true, // the RESUMING chrome is guidance surface — the confirm owns the exit + ended: false, + startedAtSec: trip.startedAtSec, + lastSnapped: points[0] ?? { lat: 0, lng: 0 }, + }; + envelopeId = trip.envelopeId; + engageCloseProtection(deps.wa ?? null); + if (deps.shellRoot !== undefined) deps.shellRoot.dataset.guidance = "active"; + setRouteDrawn(deps.map, { points: trip.model.points }, []); + const first = points[0]; + const last = points[points.length - 1]; + if (first !== undefined && last !== undefined) deps.attachEndpoints(first, last, trip.model.destinationName); + const avgSpeedMs = trip.durationS > 0 ? trip.distanceM / trip.durationS : 0; + const elapsedSec = Math.max(0, Math.floor(now() / 1000) - trip.startedAtSec); + const projectedRemainingM = + trip.distanceM > 0 + ? trip.distanceM * Math.max(0, 1 - Math.min(1, (elapsedSec * avgSpeedMs) / trip.distanceM)) + : 0; + renderResuming(projectedRemainingM); + onActivated(); // the cron pauses NOW — structural, not an event + ensureWatch(); + logGuideState("resuming"); + setState("route"); + if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); + } + + /** The BackButton resolver's whole vocabulary (PX-01): driving → the D-09 + * confirm; confirm open → CONTINUE; ended → the walk to the search root; + * a pushed route → the Phase 5 walk; the true root → Telegram's close. */ + function backAction(): BackAction { + const phase = guidancePhase(); + if (phase === "driving") return { kind: "callback", fn: openConfirm }; + if (phase === "confirm") return { kind: "callback", fn: continueGuidance }; + if (phase === "ended") return { kind: "callback", fn: walkBack }; + if (state !== "idle") return { kind: "callback", fn: walkBack }; + return { kind: "close" }; // the true root — Telegram's own semantics + } + + /** The RESUMING register: projected banner + gantry + card numbers from + * startedAtSec + avg speed — never a fabricated position (D-13). */ + function renderResuming(projectedRemainingM: number): void { + const g = guidance; + if (g === null) return; + stopBlink(); + const banner = buildGuidanceBanner( + deps.locale, + { state: "resuming", glyph: "straight", distanceM: projectedRemainingM, street: null, listOpen: false }, + { onToggle: toggleList }, + ); + if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren(banner); + if (deps.findGantry !== undefined) { + const gantry = deps.findGantry(banner); + if (gantry !== null) { + startGantryBlink(gantry); + blinkEl = gantry; + } + } + const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0; + const avgSpeedMs = g.durationS > 0 ? totalM / g.durationS : 0; + const remainingS = avgSpeedMs > 0 ? projectedRemainingM / avgSpeedMs : 0; + g.eta = updateEta(null, remainingS, projectedRemainingM); + const eta = g.eta; + deps.showRouteCard( + buildGuidanceCard( + deps.locale, + { + ...resumingView(g.model.destinationName, totalM, projectedRemainingM), + minutesRemaining: eta.displayedEtaS / 60, + kmRemaining: eta.displayedRemainingM / 1000, + etaClock: composeEtaClock(eta.displayedEtaS, now()), + }, + { onEndTrip: openConfirm }, + ), + "bottom", + ); + } + /** Guidance remnants stop here — the latent watch included. Never runs * while the confirm flow owns the exit (walkBack refuses first). */ function stopGuidanceArtifacts(): void { @@ -567,5 +778,9 @@ export function createRouteController(deps: RouteControllerDeps): { walkBack, openConfirm, continueGuidance, + onDeactivated, + onActivated, + resume, + backAction, }; } diff --git a/web/test/route-surface.test.mjs b/web/test/route-surface.test.mjs index 380e4f1..051c64f 100644 --- a/web/test/route-surface.test.mjs +++ b/web/test/route-surface.test.mjs @@ -1426,7 +1426,7 @@ test("G11 ?g= resume — RESUMING chrome with projected numbers; the first fix f destinationName: DEST.name, }; const resumed = await withConsole(async () => { - controller.resume({ model, durationS: 240, projectedRemainingM: 1400, startedAtSec: 900, envelopeId: "env-9" }); + controller.resume({ model, envelopeId: "env-9", distanceM: 2000, durationS: 240, startedAtSec: 900 }); return null; }); assert.equal(controller.state, "route", "the resumed trip holds the route state"); @@ -1434,12 +1434,19 @@ test("G11 ?g= resume — RESUMING chrome with projected numbers; the first fix f assert.equal(deps.__guide.shellRoot.dataset.guidance, "active", "the strip compacts for the resumed trip"); assert.equal(guideRegister(deps), "GUIDE // RESUMING", "the projected register"); assert.ok(findByClass(guideBanner(deps), "guide-gantry") !== null, "the gantry rides the wait state"); - assert.equal(guideDistance(deps), "1.4", "the projected remaining distance renders (RESUMING legally shows numbers)"); + assert.equal(guideDistance(deps), "1.2", "the controller-computed projection renders (≈1167 m of 2000)"); assert.equal(deps.__guide.watchStarted, 1, "the watch arms for the resumed trip"); const routeSwap = deps.__map.recorded.setData.find((s) => s.id === "route"); assert.equal(routeSwap.data.features[0].geometry.coordinates.length, 3, "the resumed line draws"); assert.ok(resumed.lines.some((l) => l.includes("ARAHARAH-GUIDE state=resuming")), "state=resuming logged"); - // the first accepted fix flips to live ON_ROUTE (the machine's own path) + // the resume DELETE pauses the cron immediately (a resumed boot is foregrounded) + assert.equal( + deps.__guide.fetches.filter((f) => f.init.method === "DELETE").length, + 1, + "the resume DELETE fired with the ?g= id", + ); + // the first accepted fix flips to live ON_ROUTE (the machine's own path) — + // the synthesized terminal marks keep the arrive mark at the line's end const live = await withConsole(async () => { feed(600, { deps }); return null; @@ -1447,13 +1454,19 @@ test("G11 ?g= resume — RESUMING chrome with projected numbers; the first fix f assert.equal(guideRegister(deps), "GUIDE // NEXT", "live ON_ROUTE after the first fix"); assert.equal(findByClass(guideBanner(deps), "guide-gantry"), null, "the gantry stopped on the live flip"); assert.ok(live.lines.some((l) => l.includes("ARAHARAH-GUIDE state=on_route")), "state=on_route logged"); - // the resumed trip's envelope deletes at teardown (the ?g= id) + // the resumed trip's envelope deletes at teardown (the ?g= id, idempotent pair) findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP await withConsole(async () => { findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON return null; }); + await drain(); assert.equal(controller.guidancePhase, "ended", "the resumed trip ends through the same confirm"); + assert.equal( + deps.__guide.fetches.filter((f) => f.init.method === "DELETE").length, + 2, + "the teardown DELETE completes the idempotent pair", + ); }); }); @@ -1464,7 +1477,8 @@ test("G12 main.ts composition — the ladder, the ?g= carrier, the envelope even assert.ok(!mainCode.includes("process.env"), "no env reads — the backend selection is code-constructed (T-05-04)"); assert.ok(/get\("g"\)/.test(mainCode), "the ?g= carrier is read from the URL (the destination-preload pattern)"); assert.ok( - /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.test(mainCode), + mainCode.includes("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") || + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.test(mainCode), "the resume id is UUID-validated before any GET", ); assert.ok( @@ -1477,3 +1491,137 @@ test("G12 main.ts composition — the ladder, the ?g= carrier, the envelope even assert.ok(/resumeId === null/.test(mainCode), "a resume boot never double-consults"); assert.ok(/kind: "callback"/.test(mainCode) || /kind: "hidden"/.test(mainCode), "the BackButton resolver walks the full graph"); }); + +/* ============================================================ + * 06-06 task 3 — the complementary envelope/resume gates (E1..E5) + * ============================================================ + * G9-G12 pin the core flows; these pin the deltas they do not: + * E1 the pre-gate guard — fixes below the start gate POST nothing + * E3' the COMPUTED projection input (distanceM + startedAtSec) renders + * the same RESUMING chrome, and the resume DELETE pauses the cron + * E4 a 404 second delete is tolerated — fail-honest, chrome completes + * E5 backAction — the controller's resolver walks driving → confirm → + * ended → root with zero dead ends (the wire's contract, task 1) + * E6 main.ts structural deltas — no env reads in the ladder region, + * the onEvent capability guard, the resolver delegation + */ + +const RESUME_ENVELOPE_ID = "0b501b1e-0000-4000-8000-00000000e1e5"; + +test("E1 pre-gate guard — fixes below the start gate never mint an envelope", async () => { + await withDocumentAsync(async () => { + const deps = steppedDeps(); + const controller = createRouteController(deps); + await withConsole(() => controller.consult(FIX, DEST)); + feed(10, { deps }); // below GUIDANCE_START_ALONG_M — guidance never activates + await controller.onDeactivated(); + assert.equal(deps.__guide.fetches.length, 0, "no envelope before guidance is live"); + feed(500, { deps }); // activation + await controller.onDeactivated(); + assert.equal(deps.__guide.fetches.length, 1, "the live drive POSTs on minimize"); + }); +}); + +test("E3' computed projection — resume({distanceM, startedAtSec}) renders the projected RESUMING chrome; the resume DELETE pauses the cron", async () => { + await withDocumentAsync(async () => { + const deps = steppedDeps(); + const controller = createRouteController(deps); + controller.resume({ + model: { + points: CORRIDOR_POINTS, + cumulative: CORRIDOR_CUMULATIVE, + steps: [], + destinationId: DEST.id, + destinationName: DEST.name, + }, + envelopeId: RESUME_ENVELOPE_ID, + distanceM: 2000, + durationS: 240, + startedAtSec: 900, // now() = 1_000_000 ms → 100 s elapsed → remaining ≈ 1167 m / 140 s + }); + assert.equal(guideRegister(deps), "GUIDE // RESUMING"); + assert.equal(guideDistance(deps), "1.2", "the computed projection renders (1167 m)"); + const card = deps.__recorded.shown.at(-1).card; + const minutes = Number(findByClass(findByClass(card, "route-telemetry"), "text-telemetry-num").textContent); + assert.ok(minutes === 2, `projected minutes render rounded (got ${minutes})`); // 140 s + const deletes = deps.__guide.fetches.filter((f) => f.init.method === "DELETE"); + assert.equal(deletes.length, 1, "the resume deletes the envelope — the cron pauses NOW (D-11)"); + assert.equal(deletes[0].url, `/api/guidance?id=${RESUME_ENVELOPE_ID}`); + const { stopGantryBlink } = await import("../src/guidance/banner.ts"); + feed(500, { deps }); // the live flip + const staleGantry = findByClass(guideBanner(deps), "guide-gantry"); + if (staleGantry !== null) stopGantryBlink(staleGantry); + assert.equal(guideRegister(deps), "GUIDE // NEXT"); + }); +}); + +test("E4 trip end deletes idempotently — a 404 second delete is tolerated, the chrome still completes", async () => { + await withDocumentAsync(async () => { + const deps = steppedDeps(); + deps.__guide.fetchMode = "404"; // every DELETE answers 404 not_found + const controller = createRouteController(deps); + controller.resume({ + model: { + points: CORRIDOR_POINTS, + cumulative: CORRIDOR_CUMULATIVE, + steps: [ + { kind: "depart", distanceM: 2000 }, + { kind: "arrive", distanceM: 0 }, + ], + destinationId: DEST.id, + destinationName: DEST.name, + }, + envelopeId: RESUME_ENVELOPE_ID, + distanceM: 2000, + durationS: 240, + startedAtSec: 900, + }); + feed(500, { deps }); + findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP + findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON + await drain(); + const deletes = deps.__guide.fetches.filter((f) => f.init.method === "DELETE"); + assert.ok(deletes.length >= 2, "resume delete + teardown delete (the idempotent pair)"); + assert.equal(controller.guidancePhase, "ended", "the 404 changed nothing — fail-honest"); + assert.equal(deps.__guide.shellRoot.dataset.guidance, undefined, "the chrome completed"); + assert.equal(findByClass(deps.__guide.bannerHost, "guide-banner"), null, "the banner cleared"); + }); +}); + +test("E5 backAction — the resolver walks driving → confirm → ended → root; close only at the root", async () => { + await withDocumentAsync(async () => { + const deps = steppedDeps(); + const controller = createRouteController(deps); + await driveTo(deps, controller); + feed(500, { deps }); + let action = controller.backAction(); + assert.equal(action.kind, "callback", "driving: back means the confirm card"); + action.fn(); + assert.equal(controller.guidancePhase, "confirm"); + action = controller.backAction(); + assert.equal(action.kind, "callback", "confirm open: back means CONTINUE"); + action.fn(); + assert.equal(controller.guidancePhase, "driving", "guidance alive after CONTINUE"); + findByClass(deps.__recorded.shown.at(-1).card, "btn-secondary").click(); // END TRIP + findByClass(deps.__recorded.shown.at(-1).card, "btn-primary").click(); // YES, ABANDON + action = controller.backAction(); + assert.equal(action.kind, "callback", "ended: back walks to the search root"); + action.fn(); + assert.equal(controller.state, "idle", "the walk landed at the root"); + action = controller.backAction(); + assert.deepEqual(action, { kind: "close" }, "the true root keeps Telegram's own semantics"); + }); +}); + +test("E6 main.ts structural deltas — no env reads in the ladder region, the onEvent capability guard, the resolver delegation", () => { + const mainCode = codeText(path.join(SRC, "main.ts")); + const ladderStart = mainCode.indexOf("createFixtureRoutingSource"); + const ladderEnd = mainCode.indexOf("createRouteController("); + assert.ok(ladderStart >= 0 && ladderEnd > ladderStart, "the ladder region exists"); + assert.ok( + !mainCode.slice(ladderStart, ladderEnd).includes("import.meta.env"), + "backend selection is code-constructed — no env reads in the ladder region (T-05-04)", + ); + assert.ok(/typeof wa\.onEvent === "function"/.test(mainCode), "the onEvent wiring is capability-guarded (T-06-06)"); + assert.ok(/wireBackButton\(\s*wa,\s*\(\)\s*=>\s*backResolve\(\)\)/.test(mainCode), "the wire delegates to the controller's resolver"); +});