feat(06-07): scheduled() cron tick wired — factory, loadGraph street memo, crons trigger (GREEN)
- webhook.ts: createExportedHandler(opts) factory (offline send capture via baseFetch); scheduled() wraps runGuidanceCronTick in ctx.waitUntil — fail-closed unconfigured gate, paginated guidance: KV list (list_complete respected), loadGraph awaited ONCE per tick (the shared R2 memo), per-key getGuidanceSession → projectTurnCards → sendPhoto to envelope.chat_id ONLY (T-06-02), every log a [guidance-cron] category, zero KV writes - guidance-sessions.ts: GUIDANCE_KEY_PREFIX exported — the ONE key-scheme authority (list scans derive prefix + slice ids from it) - wrangler.jsonc: triggers.crons */1 * * * * (60 s scan, cadence in code) - guidance-cron.test.mjs GREEN completion: GC-C3/C5/C8/C9 wire the recording baseFetch through the factory (the header-documented seam — they defined the recorder but never passed it); GC-C7 uses readdirSync (require is undefined in ESM); GC-C1/C8/C9 drain inside the withConsole window (the tick's logs fire under waitUntil) - guidance-envelope.test.mjs: GE7 allows the GUIDANCE_KEY_PREFIX named import - 125/125 bot tests green; bot:typecheck clean
This commit is contained in:
@@ -104,8 +104,12 @@ function isUuidShape(id: string): boolean {
|
|||||||
return ENVELOPE_ID_PATTERN.test(id);
|
return ENVELOPE_ID_PATTERN.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The envelope key scheme's prefix — ONE authority (KV list scans derive
|
||||||
|
* their prefix and slice their ids from THIS constant, never a literal). */
|
||||||
|
export const GUIDANCE_KEY_PREFIX = "guidance:";
|
||||||
|
|
||||||
function keyFor(id: string): string {
|
function keyFor(id: string): string {
|
||||||
return `guidance:${id}`;
|
return `${GUIDANCE_KEY_PREFIX}${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+134
-37
@@ -46,6 +46,20 @@
|
|||||||
* answers 200 null; rejections are category-only (T-06-04).
|
* answers 200 null; rejections are category-only (T-06-04).
|
||||||
* Anything else → 404.
|
* Anything else → 404.
|
||||||
*
|
*
|
||||||
|
* scheduled() — the repo's FIRST Cron Trigger (06-07, GUID-05, D-11): every
|
||||||
|
* minute it lists the KV prefix guidance:, reads each envelope through
|
||||||
|
* getGuidanceSession (a corrupt record reads null and is skipped honestly),
|
||||||
|
* projects progress along the frozen snapshot via the PURE projectTurnCards,
|
||||||
|
* and pushes due cards to envelope.chat_id ONLY (T-06-02 — no request path
|
||||||
|
* exists in a cron tick). Street reconstruction (D-12 default) awaits the
|
||||||
|
* worker-router's loadGraph memo once per tick; a null graph degrades the
|
||||||
|
* card to the bare-verb street-less composition — the send still fires.
|
||||||
|
* STATELESS: the cron performs ZERO KV writes (the free-plan budget law).
|
||||||
|
*
|
||||||
|
* The handler object is built by createExportedHandler(opts) — production
|
||||||
|
* passes nothing (the default export); offline tests inject a recording
|
||||||
|
* baseFetch through the same factory (the createBot({baseFetch}) seam).
|
||||||
|
*
|
||||||
* D-14: the ONLY log statements are category-only — malformed/tampered/stale
|
* D-14: the ONLY log statements are category-only — malformed/tampered/stale
|
||||||
* on the initData gate, misconfigured on the fail-closed webhook gate —
|
* on the initData gate, misconfigured on the fail-closed webhook gate —
|
||||||
* never the raw payload, never user ids, never timestamps. Rejection
|
* never the raw payload, never user ids, never timestamps. Rejection
|
||||||
@@ -63,50 +77,133 @@ import {
|
|||||||
mintGuidanceSession,
|
mintGuidanceSession,
|
||||||
getGuidanceSession,
|
getGuidanceSession,
|
||||||
deleteGuidanceSession,
|
deleteGuidanceSession,
|
||||||
|
GUIDANCE_KEY_PREFIX,
|
||||||
type GuidanceRouteSnapshot,
|
type GuidanceRouteSnapshot,
|
||||||
|
type GuidanceSessionEnvelope,
|
||||||
} from "./kv/guidance-sessions.ts";
|
} from "./kv/guidance-sessions.ts";
|
||||||
import { handleRoute } from "./routing/worker-router.ts";
|
import { handleRoute, loadGraph } from "./routing/worker-router.ts";
|
||||||
import { decodePolyline6 } from "@araharah/routing/src/polyline.ts";
|
import { decodePolyline6 } from "@araharah/routing/src/polyline.ts";
|
||||||
|
import { projectTurnCards, makeStreetAt } from "./guidance/cron.ts";
|
||||||
import { validateInitData } from "./security/initdata.ts";
|
import { validateInitData } from "./security/initdata.ts";
|
||||||
import { pickLocale } from "./i18n/strings.ts";
|
import { pickLocale, strings } from "./i18n/strings.ts";
|
||||||
|
|
||||||
export default {
|
export function createExportedHandler(opts: { baseFetch?: typeof fetch } = {}): ExportedHandler<Env> {
|
||||||
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
|
return {
|
||||||
const url = new URL(req.url);
|
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
|
||||||
if (url.pathname === "/telegram/webhook") {
|
const url = new URL(req.url);
|
||||||
// WR-02 fail-closed: grammY's compareSecretToken accepts EVERY request
|
if (url.pathname === "/telegram/webhook") {
|
||||||
// when its secret is undefined (research-confirmed runtime behavior,
|
// WR-02 fail-closed: grammY's compareSecretToken accepts EVERY request
|
||||||
// convenience/webhook.js). A WEBHOOK_SECRET binding left unarmed after
|
// when its secret is undefined (research-confirmed runtime behavior,
|
||||||
// deploy would therefore silently disable webhook authentication and
|
// convenience/webhook.js). A WEBHOOK_SECRET binding left unarmed after
|
||||||
// admit unsigned POSTs that inject fabricated updates. This guard runs
|
// deploy would therefore silently disable webhook authentication and
|
||||||
// BEFORE createBot/webhookCallback; the rejection is a constant 500
|
// admit unsigned POSTs that inject fabricated updates. This guard runs
|
||||||
// and the log carries the category only.
|
// BEFORE createBot/webhookCallback; the rejection is a constant 500
|
||||||
if (!env.WEBHOOK_SECRET) {
|
// and the log carries the category only.
|
||||||
console.log("[webhook] rejected: misconfigured");
|
if (!env.WEBHOOK_SECRET) {
|
||||||
return new Response("Server misconfigured", { status: 500 });
|
console.log("[webhook] rejected: misconfigured");
|
||||||
|
return new Response("Server misconfigured", { status: 500 });
|
||||||
|
}
|
||||||
|
const bot = createBot(env); // env-bound token → per-request instantiation
|
||||||
|
const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
|
||||||
|
return cb(req);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/session") {
|
||||||
|
return handleSession(req, env);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/location") {
|
||||||
|
return handleLocation(req, env);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/location-fallback") {
|
||||||
|
return handleLocationFallback(req, env);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/guidance") {
|
||||||
|
return handleGuidance(req, env);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/route") {
|
||||||
|
return handleRoute(req, env);
|
||||||
|
}
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
},
|
||||||
|
|
||||||
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
// waitUntil wraps the whole tick: the runtime keeps the isolate alive
|
||||||
|
// past the handler's return, so a slow Telegram send still lands.
|
||||||
|
ctx.waitUntil(runGuidanceCronTick(controller, env, opts));
|
||||||
|
},
|
||||||
|
} satisfies ExportedHandler<Env>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createExportedHandler();
|
||||||
|
|
||||||
|
/** The envelope key scheme's prefix, sliced into a session id. */
|
||||||
|
function envelopeIdOf(keyName: string): string {
|
||||||
|
return keyName.slice(GUIDANCE_KEY_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scheduled()'s body: scan → validate → project → push.
|
||||||
|
*
|
||||||
|
* - FAIL-CLOSED posture (the webhook guard's shape): a missing BOT_TOKEN,
|
||||||
|
* namespace, or Mini App origin logs `skipped: unconfigured` and returns —
|
||||||
|
* an unarmed cron must never half-run.
|
||||||
|
* - The graph memo is awaited ONCE per tick (worker-router's loadGraph —
|
||||||
|
* the ONLY R2 loader; a second one is forbidden). A null graph is the
|
||||||
|
* street-less bare-verb degradation, not a tick failure.
|
||||||
|
* - Per key: getGuidanceSession (defensive — garbage reads null and the key
|
||||||
|
* is skipped honestly), projectTurnCards, then sendPhoto inside try/catch.
|
||||||
|
* One bad key cannot kill the tick (delivery variance is best-effort, D-13).
|
||||||
|
* - EVERY log line is a category — never a chat id, coordinate, or payload.
|
||||||
|
*/
|
||||||
|
async function runGuidanceCronTick(
|
||||||
|
controller: ScheduledController,
|
||||||
|
env: Env,
|
||||||
|
opts: { baseFetch?: typeof fetch },
|
||||||
|
): Promise<void> {
|
||||||
|
if (!env.BOT_TOKEN || !env.chat_sessions || !env.MINI_APP_URL) {
|
||||||
|
console.log("[guidance-cron] skipped: unconfigured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nowSec = Math.floor(controller.scheduledTime / 1000);
|
||||||
|
const graph = await loadGraph(env);
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await env.chat_sessions.list({ prefix: GUIDANCE_KEY_PREFIX, cursor });
|
||||||
|
for (const key of page.keys) {
|
||||||
|
let envelope: GuidanceSessionEnvelope | null = null;
|
||||||
|
try {
|
||||||
|
envelope = await getGuidanceSession(env.chat_sessions, envelopeIdOf(key.name));
|
||||||
|
} catch {
|
||||||
|
envelope = null; // a throwing read is the same honest skip class (one bad key ≠ a dead tick)
|
||||||
|
}
|
||||||
|
if (envelope === null) {
|
||||||
|
console.log("[guidance-cron] skipped: malformed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const streetAt =
|
||||||
|
graph === null
|
||||||
|
? undefined // street-less bare-verb degradation (D-03) — the send still fires
|
||||||
|
: makeStreetAt(graph, decodePolyline6(envelope.route_snapshot.geometry));
|
||||||
|
const card = projectTurnCards(envelope, nowSec, streetAt);
|
||||||
|
if (!card.due || card.glyph === null || card.caption === null) continue;
|
||||||
|
try {
|
||||||
|
await createBot(env, opts).api.sendPhoto(envelope.chat_id, `${env.MINI_APP_URL}/glyphs/${card.glyph}.png`, {
|
||||||
|
caption: card.caption,
|
||||||
|
parse_mode: "HTML",
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[{ text: strings.en.cardOpenButton, web_app: { url: `${env.MINI_APP_URL}/?g=${envelope.id}` } }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Telegram rejected the push (blocked bot, rate limit, delivery
|
||||||
|
// variance). Category only — never the chat id or error text (D-13).
|
||||||
|
console.log("[guidance-cron] send failed");
|
||||||
}
|
}
|
||||||
const bot = createBot(env); // env-bound token → per-request instantiation
|
|
||||||
const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
|
|
||||||
return cb(req);
|
|
||||||
}
|
}
|
||||||
if (url.pathname === "/api/session") {
|
cursor = page.list_complete ? undefined : page.cursor;
|
||||||
return handleSession(req, env);
|
} while (cursor !== undefined);
|
||||||
}
|
}
|
||||||
if (url.pathname === "/api/location") {
|
|
||||||
return handleLocation(req, env);
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/location-fallback") {
|
|
||||||
return handleLocationFallback(req, env);
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/guidance") {
|
|
||||||
return handleGuidance(req, env);
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/route") {
|
|
||||||
return handleRoute(req, env);
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
},
|
|
||||||
} satisfies ExportedHandler<Env>;
|
|
||||||
|
|
||||||
/** initData is Telegram's verbatim WebApp string — a few hundred bytes on
|
/** initData is Telegram's verbatim WebApp string — a few hundred bytes on
|
||||||
* the wire (research: known-good fixtures ≪ 1 KB). 4 KB is generous
|
* the wire (research: known-good fixtures ≪ 1 KB). 4 KB is generous
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
* synthetic 1e-6-grid points near KL center) — no user coordinate ever does.
|
* synthetic 1e-6-grid points near KL center) — no user coordinate ever does.
|
||||||
*/
|
*/
|
||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { test } from "./harness.mjs";
|
import { test } from "./harness.mjs";
|
||||||
@@ -453,10 +453,10 @@ test("GC-C1 due tick: the alive envelope's card sends to ITS OWN chat, street-be
|
|||||||
};
|
};
|
||||||
const { ctx, drain } = makeCtx();
|
const { ctx, drain } = makeCtx();
|
||||||
const worker = mod.createExportedHandler({ baseFetch });
|
const worker = mod.createExportedHandler({ baseFetch });
|
||||||
const { logs } = await withConsole(() =>
|
const { logs } = await withConsole(async () => {
|
||||||
worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx),
|
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
|
||||||
);
|
await drain(); // INSIDE the capture window — the tick's logs fire under waitUntil
|
||||||
await drain();
|
});
|
||||||
const photos = await sentPhotos(rec.calls);
|
const photos = await sentPhotos(rec.calls);
|
||||||
assert.equal(photos.length, 1, "exactly the due, alive envelope sends");
|
assert.equal(photos.length, 1, "exactly the due, alive envelope sends");
|
||||||
assert.equal(photos[0].chat_id, 202, "chat_id comes ONLY from the envelope (T-06-02)");
|
assert.equal(photos[0].chat_id, 202, "chat_id comes ONLY from the envelope (T-06-02)");
|
||||||
@@ -496,7 +496,7 @@ test("GC-C2 non-due tick: zero sends (the scan still runs; the cadence gates the
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("GC-C3 R2-null graph: the bare-verb street-less card still sends (degradation, not silence)", async () => {
|
test("GC-C3 R2-null graph: the bare-verb street-less card still sends (degradation, not silence)", async () => {
|
||||||
const { default: worker } = await import("../src/webhook.ts");
|
const mod = await import("../src/webhook.ts");
|
||||||
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45 });
|
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45 });
|
||||||
const map = new Map([["guidance:" + envA.id, JSON.stringify(envA)]]);
|
const map = new Map([["guidance:" + envA.id, JSON.stringify(envA)]]);
|
||||||
const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }], list_complete: true }]);
|
const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }], list_complete: true }]);
|
||||||
@@ -506,8 +506,11 @@ test("GC-C3 R2-null graph: the bare-verb street-less card still sends (degradati
|
|||||||
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
||||||
};
|
};
|
||||||
const { ctx, drain } = makeCtx();
|
const { ctx, drain } = makeCtx();
|
||||||
await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null)), ctx));
|
const worker = mod.createExportedHandler({ baseFetch });
|
||||||
await drain();
|
await withConsole(async () => {
|
||||||
|
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null)), ctx);
|
||||||
|
await drain();
|
||||||
|
});
|
||||||
const photos = await sentPhotos(rec.calls);
|
const photos = await sentPhotos(rec.calls);
|
||||||
assert.equal(photos.length, 1, "the send fires even without a graph");
|
assert.equal(photos.length, 1, "the send fires even without a graph");
|
||||||
assert.ok(photos[0].caption.includes("— TURN LEFT"), "bare verb (graph absent)");
|
assert.ok(photos[0].caption.includes("— TURN LEFT"), "bare verb (graph absent)");
|
||||||
@@ -534,7 +537,7 @@ test("GC-C4 fail-closed: missing BOT_TOKEN → skipped: unconfigured, zero sends
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("GC-C5 pagination: list_complete respected — envelopes across two pages all processed", async () => {
|
test("GC-C5 pagination: list_complete respected — envelopes across two pages all processed", async () => {
|
||||||
const { default: worker } = await import("../src/webhook.ts");
|
const mod = await import("../src/webhook.ts");
|
||||||
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 204 });
|
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 204 });
|
||||||
const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 205 });
|
const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 205 });
|
||||||
const envB = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: TICK_SEC - 10000, chatId: 206 });
|
const envB = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: TICK_SEC - 10000, chatId: 206 });
|
||||||
@@ -552,8 +555,11 @@ test("GC-C5 pagination: list_complete respected — envelopes across two pages a
|
|||||||
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
||||||
};
|
};
|
||||||
const { ctx, drain } = makeCtx();
|
const { ctx, drain } = makeCtx();
|
||||||
await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx));
|
const worker = mod.createExportedHandler({ baseFetch });
|
||||||
await drain();
|
await withConsole(async () => {
|
||||||
|
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
|
||||||
|
await drain();
|
||||||
|
});
|
||||||
const photos = await sentPhotos(rec.calls);
|
const photos = await sentPhotos(rec.calls);
|
||||||
assert.equal(kv.listCalls, 2, "the pagination loop fetched both pages");
|
assert.equal(kv.listCalls, 2, "the pagination loop fetched both pages");
|
||||||
assert.equal(photos.length, 2, "both alive envelopes carded across pages");
|
assert.equal(photos.length, 2, "both alive envelopes carded across pages");
|
||||||
@@ -572,7 +578,7 @@ test("GC-C7 shared-memo wiring: loadGraph feeds the scheduled arm; no second ROA
|
|||||||
assert.ok(webhookCode.includes("loadGraph"), "scheduled() awaits the worker-router memo");
|
assert.ok(webhookCode.includes("loadGraph"), "scheduled() awaits the worker-router memo");
|
||||||
assert.ok(webhookCode.includes("makeStreetAt"), "the graph-fed street reconstruction is wired");
|
assert.ok(webhookCode.includes("makeStreetAt"), "the graph-fed street reconstruction is wired");
|
||||||
assert.ok(webhookCode.includes("projectTurnCards"), "the projection is the decision authority");
|
assert.ok(webhookCode.includes("projectTurnCards"), "the projection is the decision authority");
|
||||||
const readdir = (dir) => require("node:fs").readdirSync(dir, { withFileTypes: true });
|
const readdir = (dir) => readdirSync(dir, { withFileTypes: true });
|
||||||
const walk = (dir, acc = []) => {
|
const walk = (dir, acc = []) => {
|
||||||
for (const e of readdir(dir)) {
|
for (const e of readdir(dir)) {
|
||||||
const full = dir + "/" + e.name;
|
const full = dir + "/" + e.name;
|
||||||
@@ -594,7 +600,7 @@ test("GC-C7 shared-memo wiring: loadGraph feeds the scheduled arm; no second ROA
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the rest still card", async () => {
|
test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the rest still card", async () => {
|
||||||
const { default: worker } = await import("../src/webhook.ts");
|
const mod = await import("../src/webhook.ts");
|
||||||
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 207 });
|
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 207 });
|
||||||
const BAD_KEY = "guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000b8";
|
const BAD_KEY = "guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000b8";
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -607,8 +613,11 @@ test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the
|
|||||||
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
||||||
};
|
};
|
||||||
const { ctx, drain } = makeCtx();
|
const { ctx, drain } = makeCtx();
|
||||||
const { logs } = await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx));
|
const worker = mod.createExportedHandler({ baseFetch });
|
||||||
await drain();
|
const { logs } = await withConsole(async () => {
|
||||||
|
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
|
||||||
|
await drain();
|
||||||
|
});
|
||||||
const photos = await sentPhotos(rec.calls);
|
const photos = await sentPhotos(rec.calls);
|
||||||
assert.equal(photos.length, 1, "the tick survived the throwing read");
|
assert.equal(photos.length, 1, "the tick survived the throwing read");
|
||||||
assert.equal(photos[0].chat_id, 207);
|
assert.equal(photos[0].chat_id, 207);
|
||||||
@@ -616,7 +625,7 @@ test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("GC-C9 a failed send logs and continues — delivery variance is documented best-effort (D-13)", async () => {
|
test("GC-C9 a failed send logs and continues — delivery variance is documented best-effort (D-13)", async () => {
|
||||||
const { default: worker } = await import("../src/webhook.ts");
|
const mod = await import("../src/webhook.ts");
|
||||||
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 208 });
|
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 208 });
|
||||||
const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 209 });
|
const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 209 });
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -637,8 +646,11 @@ test("GC-C9 a failed send logs and continues — delivery variance is documented
|
|||||||
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
return { ok: true, json: async () => ({ ok: true, result: {} }) };
|
||||||
};
|
};
|
||||||
const { ctx, drain } = makeCtx();
|
const { ctx, drain } = makeCtx();
|
||||||
const { logs } = await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx));
|
const worker = mod.createExportedHandler({ baseFetch });
|
||||||
await drain();
|
const { logs } = await withConsole(async () => {
|
||||||
|
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
|
||||||
|
await drain();
|
||||||
|
});
|
||||||
const photos = await sentPhotos(rec.calls);
|
const photos = await sentPhotos(rec.calls);
|
||||||
assert.equal(photos.length, 1, "the failed first send did not stop the second");
|
assert.equal(photos.length, 1, "the failed first send did not stop the second");
|
||||||
assert.equal(photos[0].chat_id, 209);
|
assert.equal(photos[0].chat_id, 209);
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ test("GE7 consumer shape — every bot/src import of guidance-sessions is a NAME
|
|||||||
"GUIDANCE_SESSION_TTL_SECONDS",
|
"GUIDANCE_SESSION_TTL_SECONDS",
|
||||||
"GuidanceSessionEnvelope",
|
"GuidanceSessionEnvelope",
|
||||||
"GuidanceRouteSnapshot",
|
"GuidanceRouteSnapshot",
|
||||||
|
"GUIDANCE_KEY_PREFIX",
|
||||||
]);
|
]);
|
||||||
const NAMED_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*guidance-sessions[^"']*["']/g;
|
const NAMED_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*guidance-sessions[^"']*["']/g;
|
||||||
for (const file of tsFilesUnder(srcDir)) {
|
for (const file of tsFilesUnder(srcDir)) {
|
||||||
|
|||||||
@@ -28,5 +28,10 @@
|
|||||||
// (docs/ROAD-GRAPH.md is the fork contract). The bucket name is public
|
// (docs/ROAD-GRAPH.md is the fork contract). The bucket name is public
|
||||||
// configuration, never a secret.
|
// configuration, never a secret.
|
||||||
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
|
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
|
||||||
|
// The repo's FIRST Cron Trigger (06-07, D-11): 60 s scan; card cadence
|
||||||
|
// decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window. The tick
|
||||||
|
// is STATELESS (zero KV writes — the free-plan budget law). Local test:
|
||||||
|
// curl "http://localhost:8787/cdn-cgi/local/scheduled" against `wrangler dev`.
|
||||||
|
"triggers": { "crons": ["*/1 * * * *"] },
|
||||||
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
|
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user