feat(06-06): envelope flows + composed ladder — minimize POST, resume GET, trip-end DELETE (GREEN)
- 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
This commit is contained in:
+170
-27
@@ -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<void> {
|
||||
// 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<typeof createRouteController> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
},
|
||||
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<void> {
|
||||
// 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<typeof routeController>): Promise<void> {
|
||||
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<void> {
|
||||
renderState({ kind: "validating" });
|
||||
const session = await createSession(wa?.initData ?? "");
|
||||
@@ -447,19 +583,26 @@ async function boot(): Promise<void> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user