@rubytech/create-maxy-code 0.1.590 → 0.1.592
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/routine-templates/dist/index.d.ts +2 -2
- package/payload/platform/lib/routine-templates/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/routine-templates/dist/index.js +2 -9
- package/payload/platform/lib/routine-templates/dist/index.js.map +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.d.ts +1 -11
- package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.js +11 -15
- package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
- package/payload/platform/lib/routine-templates/src/__tests__/roster.test.ts +0 -0
- package/payload/platform/lib/routine-templates/src/__tests__/seed.test.ts +8 -3
- package/payload/platform/lib/routine-templates/src/index.ts +1 -8
- package/payload/platform/lib/routine-templates/src/roster.ts +10 -14
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +13 -0
- package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/outlook/PLUGIN.md +6 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.js +75 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/index.js +14 -0
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.d.ts +33 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.js +32 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.js.map +1 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +11 -5
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +4 -12
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js +85 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +55 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +10 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +42 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +22 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +73 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js +79 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +2 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +40 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +19 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +339 -275
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +510 -96
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +62 -31
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +153 -93
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +8 -36
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +234 -30
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +349 -38
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
- package/payload/platform/scripts/check-specialist-tool-surface.mjs +15 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/server/public/activity.html +6 -6
- package/payload/server/public/agents.html +5 -5
- package/payload/server/public/assets/{AdminLoginScreens-CemYu7ho.js → AdminLoginScreens-dBZJlMbp.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-dBZJlMbp.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-dBZJlMbp.js.gz +0 -0
- package/payload/server/public/assets/{AdminShell-B5vEeSLw.js → AdminShell-D94sgtsb.js} +1 -1
- package/payload/server/public/assets/AdminShell-D94sgtsb.js.br +0 -0
- package/payload/server/public/assets/AdminShell-D94sgtsb.js.gz +0 -0
- package/payload/server/public/assets/{activity-BHSuZrqG.js → activity-D6NEUvT7.js} +1 -1
- package/payload/server/public/assets/activity-D6NEUvT7.js.br +0 -0
- package/payload/server/public/assets/activity-D6NEUvT7.js.gz +0 -0
- package/payload/server/public/assets/{admin-DnvauP59.js → admin-BluqXSKn.js} +1 -1
- package/payload/server/public/assets/admin-BluqXSKn.js.br +0 -0
- package/payload/server/public/assets/admin-BluqXSKn.js.gz +0 -0
- package/payload/server/public/assets/{agents-CwTJjfyV.js → agents-D_6V66dr.js} +1 -1
- package/payload/server/public/assets/agents-D_6V66dr.js.br +0 -0
- package/payload/server/public/assets/agents-D_6V66dr.js.gz +0 -0
- package/payload/server/public/assets/{browser-D4yjnKEH.js → browser-Bz9s6ztS.js} +1 -1
- package/payload/server/public/assets/browser-Bz9s6ztS.js.br +0 -0
- package/payload/server/public/assets/browser-Bz9s6ztS.js.gz +0 -0
- package/payload/server/public/assets/{calendar-DUi1XFyY.js → calendar-D5OCrmwA.js} +1 -1
- package/payload/server/public/assets/calendar-D5OCrmwA.js.br +0 -0
- package/payload/server/public/assets/calendar-D5OCrmwA.js.gz +0 -0
- package/payload/server/public/assets/chat-CjlbEzZ-.js +1 -0
- package/payload/server/public/assets/chat-CjlbEzZ-.js.br +1 -0
- package/payload/server/public/assets/chat-CjlbEzZ-.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-b_r2jNvg.js +1 -0
- package/payload/server/public/assets/chevron-left-b_r2jNvg.js.br +0 -0
- package/payload/server/public/assets/clock-D8tMbEZD.js +1 -0
- package/payload/server/public/assets/clock-D8tMbEZD.js.br +0 -0
- package/payload/server/public/assets/clock-D8tMbEZD.js.gz +0 -0
- package/payload/server/public/assets/{copy-Bw2h4ofb.js → copy-CXBu2lHL.js} +1 -1
- package/payload/server/public/assets/copy-CXBu2lHL.js.br +0 -0
- package/payload/server/public/assets/copy-CXBu2lHL.js.gz +0 -0
- package/payload/server/public/assets/data-ChHg_Lq9.js +1 -0
- package/payload/server/public/assets/data-ChHg_Lq9.js.br +0 -0
- package/payload/server/public/assets/data-ChHg_Lq9.js.gz +0 -0
- package/payload/server/public/assets/{field-BS-mu5Jx.js → field-D4fXu_SP.js} +1 -1
- package/payload/server/public/assets/field-D4fXu_SP.js.br +0 -0
- package/payload/server/public/assets/field-D4fXu_SP.js.gz +0 -0
- package/payload/server/public/assets/{file-text-xUlDn7ui.js → file-text-BxZ1UMcQ.js} +1 -1
- package/payload/server/public/assets/file-text-BxZ1UMcQ.js.br +0 -0
- package/payload/server/public/assets/file-text-BxZ1UMcQ.js.gz +0 -0
- package/payload/server/public/assets/{graph-DEEXiQLx.js → graph-DMleOCBZ.js} +1 -1
- package/payload/server/public/assets/graph-DMleOCBZ.js.br +0 -0
- package/payload/server/public/assets/graph-DMleOCBZ.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-F6hQJKCn.js → graph-labels-BHapdrTm.js} +1 -1
- package/payload/server/public/assets/graph-labels-BHapdrTm.js.br +0 -0
- package/payload/server/public/assets/graph-labels-BHapdrTm.js.gz +0 -0
- package/payload/server/public/assets/{jsx-runtime-DDrveXI8.css → jsx-runtime-gqFnq1MC.css} +1 -1
- package/payload/server/public/assets/jsx-runtime-gqFnq1MC.css.br +0 -0
- package/payload/server/public/assets/{jsx-runtime-DDrveXI8.css.gz → jsx-runtime-gqFnq1MC.css.gz} +0 -0
- package/payload/server/public/assets/{operator-CkWK9uRB.js → operator-BpZTRiga.js} +1 -1
- package/payload/server/public/assets/operator-BpZTRiga.js.br +0 -0
- package/payload/server/public/assets/operator-BpZTRiga.js.gz +0 -0
- package/payload/server/public/assets/{page-Dh6fCuUi.js → page-CsHWZZ0U.js} +1 -1
- package/payload/server/public/assets/page-CsHWZZ0U.js.br +0 -0
- package/payload/server/public/assets/page-CsHWZZ0U.js.gz +0 -0
- package/payload/server/public/assets/{page-DbWIOK60.js → page-Dczx6CPP.js} +1 -1
- package/payload/server/public/assets/page-Dczx6CPP.js.br +0 -0
- package/payload/server/public/assets/page-Dczx6CPP.js.gz +0 -0
- package/payload/server/public/assets/play-D_xdb6gy.js +1 -0
- package/payload/server/public/assets/play-D_xdb6gy.js.br +0 -0
- package/payload/server/public/assets/play-D_xdb6gy.js.gz +0 -0
- package/payload/server/public/assets/{public-DmD6vvFM.js → public-BzpELWaZ.js} +1 -1
- package/payload/server/public/assets/public-BzpELWaZ.js.br +0 -0
- package/payload/server/public/assets/public-BzpELWaZ.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-BDdOWTDl.js → rotate-ccw-Bkpst1na.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-Bkpst1na.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-Bkpst1na.js.gz +0 -0
- package/payload/server/public/assets/{routines-CZoKUK0p.js → routines-Cmuf8pPP.js} +2 -2
- package/payload/server/public/assets/routines-Cmuf8pPP.js.br +0 -0
- package/payload/server/public/assets/routines-Cmuf8pPP.js.gz +0 -0
- package/payload/server/public/assets/{skills-D0yS0qLX.js → skills-Bfq3Pbw-.js} +1 -1
- package/payload/server/public/assets/skills-Bfq3Pbw-.js.br +0 -0
- package/payload/server/public/assets/skills-Bfq3Pbw-.js.gz +0 -0
- package/payload/server/public/assets/{tasks-Dtn1uDRY.js → tasks-nyWLrolq.js} +1 -1
- package/payload/server/public/assets/tasks-nyWLrolq.js.br +0 -0
- package/payload/server/public/assets/tasks-nyWLrolq.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-DN-Eswuf.js → triangle-alert-DkENIQJ6.js} +1 -1
- package/payload/server/public/assets/triangle-alert-DkENIQJ6.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-DkENIQJ6.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-BmOZr_Mw.js → useCopyFeedback-DmNjbXe9.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-DmNjbXe9.js.br +1 -0
- package/payload/server/public/assets/useCopyFeedback-DmNjbXe9.js.gz +0 -0
- package/payload/server/public/assets/{useMediaQuery-fFM7rxUD.js → useMediaQuery-DL-C1y_F.js} +1 -1
- package/payload/server/public/assets/useMediaQuery-DL-C1y_F.js.br +0 -0
- package/payload/server/public/assets/useMediaQuery-DL-C1y_F.js.gz +0 -0
- package/payload/server/public/assets/{useVoiceRecorder-D0MWFIde.js → useVoiceRecorder-DseLVULD.js} +1 -1
- package/payload/server/public/assets/useVoiceRecorder-DseLVULD.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-DseLVULD.js.gz +0 -0
- package/payload/server/public/assets/{wrench-C-yn7q1Q.js → wrench-C7tdp6z9.js} +1 -1
- package/payload/server/public/assets/wrench-C7tdp6z9.js.br +0 -0
- package/payload/server/public/assets/wrench-C7tdp6z9.js.gz +0 -0
- package/payload/server/public/browser.html +5 -5
- package/payload/server/public/calendar.html +8 -8
- package/payload/server/public/chat.html +14 -14
- package/payload/server/public/data.html +12 -12
- package/payload/server/public/field.html +3 -3
- package/payload/server/public/graph.html +10 -10
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +15 -15
- package/payload/server/public/public.html +14 -14
- package/payload/server/public/routines.html +8 -8
- package/payload/server/public/skills.html +6 -6
- package/payload/server/public/tasks.html +7 -7
- package/payload/server/server.js +41 -2
- package/payload/server/public/assets/AdminLoginScreens-CemYu7ho.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-CemYu7ho.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-B5vEeSLw.js.br +0 -0
- package/payload/server/public/assets/AdminShell-B5vEeSLw.js.gz +0 -0
- package/payload/server/public/assets/activity-BHSuZrqG.js.br +0 -0
- package/payload/server/public/assets/activity-BHSuZrqG.js.gz +0 -0
- package/payload/server/public/assets/admin-DnvauP59.js.br +0 -0
- package/payload/server/public/assets/admin-DnvauP59.js.gz +0 -0
- package/payload/server/public/assets/agents-CwTJjfyV.js.br +0 -0
- package/payload/server/public/assets/agents-CwTJjfyV.js.gz +0 -0
- package/payload/server/public/assets/browser-D4yjnKEH.js.br +0 -0
- package/payload/server/public/assets/browser-D4yjnKEH.js.gz +0 -0
- package/payload/server/public/assets/calendar-DUi1XFyY.js.br +0 -0
- package/payload/server/public/assets/calendar-DUi1XFyY.js.gz +0 -0
- package/payload/server/public/assets/chat-BJ1p4kuh.js +0 -1
- package/payload/server/public/assets/chat-BJ1p4kuh.js.br +0 -1
- package/payload/server/public/assets/chat-BJ1p4kuh.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-Ctw-3JsV.js +0 -1
- package/payload/server/public/assets/chevron-left-Ctw-3JsV.js.br +0 -0
- package/payload/server/public/assets/clock-BLOp42yY.js +0 -1
- package/payload/server/public/assets/clock-BLOp42yY.js.br +0 -0
- package/payload/server/public/assets/clock-BLOp42yY.js.gz +0 -0
- package/payload/server/public/assets/copy-Bw2h4ofb.js.br +0 -0
- package/payload/server/public/assets/copy-Bw2h4ofb.js.gz +0 -0
- package/payload/server/public/assets/data-uR7BZKVU.js +0 -1
- package/payload/server/public/assets/data-uR7BZKVU.js.br +0 -0
- package/payload/server/public/assets/data-uR7BZKVU.js.gz +0 -0
- package/payload/server/public/assets/field-BS-mu5Jx.js.br +0 -0
- package/payload/server/public/assets/field-BS-mu5Jx.js.gz +0 -0
- package/payload/server/public/assets/file-text-xUlDn7ui.js.br +0 -0
- package/payload/server/public/assets/file-text-xUlDn7ui.js.gz +0 -0
- package/payload/server/public/assets/graph-DEEXiQLx.js.br +0 -0
- package/payload/server/public/assets/graph-DEEXiQLx.js.gz +0 -0
- package/payload/server/public/assets/graph-labels-F6hQJKCn.js.br +0 -0
- package/payload/server/public/assets/graph-labels-F6hQJKCn.js.gz +0 -0
- package/payload/server/public/assets/jsx-runtime-DDrveXI8.css.br +0 -0
- package/payload/server/public/assets/operator-CkWK9uRB.js.br +0 -0
- package/payload/server/public/assets/operator-CkWK9uRB.js.gz +0 -0
- package/payload/server/public/assets/page-DbWIOK60.js.br +0 -0
- package/payload/server/public/assets/page-DbWIOK60.js.gz +0 -0
- package/payload/server/public/assets/page-Dh6fCuUi.js.br +0 -0
- package/payload/server/public/assets/page-Dh6fCuUi.js.gz +0 -0
- package/payload/server/public/assets/play-fy3tdi7M.js +0 -1
- package/payload/server/public/assets/play-fy3tdi7M.js.br +0 -0
- package/payload/server/public/assets/play-fy3tdi7M.js.gz +0 -0
- package/payload/server/public/assets/public-DmD6vvFM.js.br +0 -0
- package/payload/server/public/assets/public-DmD6vvFM.js.gz +0 -0
- package/payload/server/public/assets/rotate-ccw-BDdOWTDl.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-BDdOWTDl.js.gz +0 -0
- package/payload/server/public/assets/routines-CZoKUK0p.js.br +0 -0
- package/payload/server/public/assets/routines-CZoKUK0p.js.gz +0 -0
- package/payload/server/public/assets/skills-D0yS0qLX.js.br +0 -0
- package/payload/server/public/assets/skills-D0yS0qLX.js.gz +0 -0
- package/payload/server/public/assets/tasks-Dtn1uDRY.js.br +0 -0
- package/payload/server/public/assets/tasks-Dtn1uDRY.js.gz +0 -0
- package/payload/server/public/assets/triangle-alert-DN-Eswuf.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-DN-Eswuf.js.gz +0 -0
- package/payload/server/public/assets/useCopyFeedback-BmOZr_Mw.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-BmOZr_Mw.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-fFM7rxUD.js.br +0 -0
- package/payload/server/public/assets/useMediaQuery-fFM7rxUD.js.gz +0 -0
- package/payload/server/public/assets/useVoiceRecorder-D0MWFIde.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-D0MWFIde.js.gz +0 -0
- package/payload/server/public/assets/wrench-C-yn7q1Q.js.br +0 -0
- package/payload/server/public/assets/wrench-C-yn7q1Q.js.gz +0 -0
- /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js → jsx-runtime-BcrVQeV-.js} +0 -0
- /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js.br → jsx-runtime-BcrVQeV-.js.br} +0 -0
- /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js.gz → jsx-runtime-BcrVQeV-.js.gz} +0 -0
package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock("../../lib/neo4j.js", () => ({
|
|
|
17
17
|
close: vi.fn(async () => { }),
|
|
18
18
|
}),
|
|
19
19
|
}));
|
|
20
|
-
import { runSchedulingGate, watermarkPath, readWatermarks, foldMailWatermarks, parseD1Count, digestBusy, } from "../scheduling-gate.js";
|
|
20
|
+
import { runSchedulingGate, advanceGateMark, watermarkPath, readWatermarks, foldMailWatermarks, parseD1Count, digestBusy, } from "../scheduling-gate.js";
|
|
21
21
|
const ACCOUNT = "20028fb5-0000-4000-8000-000000000000";
|
|
22
22
|
const EVENT = "65ef758c-0000-4000-8000-000000000000";
|
|
23
23
|
let platformRoot;
|
|
@@ -32,6 +32,8 @@ function quietSources(over = {}) {
|
|
|
32
32
|
// Task 2653 — "quiet" for Telegram is an account that holds no inbound row at
|
|
33
33
|
// all, which is what a fresh install answers.
|
|
34
34
|
telegramInbound: async () => ({ newestInboundAt: null }),
|
|
35
|
+
// Task 2657 — "quiet" for the Outlook arm is a folder holding no message.
|
|
36
|
+
mailOutlook: async () => ({ newestReceivedAt: null }),
|
|
35
37
|
...over,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
@@ -56,16 +58,17 @@ afterEach(() => {
|
|
|
56
58
|
vi.doUnmock("imapflow");
|
|
57
59
|
delete process.env.PLATFORM_PORT;
|
|
58
60
|
});
|
|
59
|
-
const run = (checks, sources
|
|
61
|
+
const run = (checks, sources) => runSchedulingGate({ eventId: EVENT, checks }, ACCOUNT, platformRoot, sources);
|
|
60
62
|
describe("d1 check", () => {
|
|
61
63
|
const CHECK = { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" };
|
|
62
64
|
it("is false on a zero count", async () => {
|
|
63
|
-
expect(await run([CHECK], quietSources())).
|
|
65
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
64
66
|
});
|
|
65
67
|
it("is true on one row", async () => {
|
|
66
|
-
expect(await run([CHECK], quietSources({ d1: async () => 1 }))).
|
|
68
|
+
expect(await run([CHECK], quietSources({ d1: async () => 1 }))).toMatchObject({
|
|
67
69
|
hasWork: true,
|
|
68
70
|
reasons: ["d1"],
|
|
71
|
+
errored: [],
|
|
69
72
|
});
|
|
70
73
|
});
|
|
71
74
|
it("reports work rather than an error when the source is unreachable", async () => {
|
|
@@ -74,21 +77,25 @@ describe("d1 check", () => {
|
|
|
74
77
|
throw new Error("no cloudflare credentials");
|
|
75
78
|
},
|
|
76
79
|
});
|
|
77
|
-
expect(await run([CHECK], sources)).
|
|
80
|
+
expect(await run([CHECK], sources)).toMatchObject({
|
|
81
|
+
hasWork: true,
|
|
82
|
+
reasons: ["d1"],
|
|
83
|
+
errored: [{ kind: "d1", detail: "no cloudflare credentials" }],
|
|
84
|
+
});
|
|
78
85
|
});
|
|
79
86
|
});
|
|
80
87
|
describe("mail check", () => {
|
|
81
88
|
const CHECK = { type: "mail", mailbox: "ops@example.com" };
|
|
82
89
|
it("is false when nothing sits above the watermark", async () => {
|
|
83
90
|
seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
|
|
84
|
-
expect(await run([CHECK], quietSources())).
|
|
91
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
85
92
|
});
|
|
86
93
|
it("is true when a UID above the watermark matches", async () => {
|
|
87
94
|
seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
|
|
88
95
|
const sources = quietSources({
|
|
89
96
|
mail: async () => ({ matched: true, highestUid: 104, uidValidity: 1 }),
|
|
90
97
|
});
|
|
91
|
-
expect(await run([CHECK], sources)).
|
|
98
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
92
99
|
});
|
|
93
100
|
it("probes from UID 0 on a first run, so nothing already there is missed", async () => {
|
|
94
101
|
const seen = [];
|
|
@@ -106,7 +113,7 @@ describe("mail check", () => {
|
|
|
106
113
|
const sources = quietSources({
|
|
107
114
|
mail: async () => ({ matched: false, highestUid: 100, uidValidity: 7 }),
|
|
108
115
|
});
|
|
109
|
-
expect(await run([CHECK], sources)).
|
|
116
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
110
117
|
});
|
|
111
118
|
it("keys the watermark by folder, so two folders of one mailbox never collide", async () => {
|
|
112
119
|
seedWatermarks({ mail: { "ops@example.com|Archive": { uid: 100, uidValidity: 1 } } });
|
|
@@ -124,12 +131,12 @@ describe("mail check", () => {
|
|
|
124
131
|
describe("whatsapp check", () => {
|
|
125
132
|
it("status is false while connected", async () => {
|
|
126
133
|
const check = { type: "whatsapp", scope: "status" };
|
|
127
|
-
expect(await run([check], quietSources())).
|
|
134
|
+
expect(await run([check], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
128
135
|
});
|
|
129
136
|
it("status is true while disconnected", async () => {
|
|
130
137
|
const check = { type: "whatsapp", scope: "status" };
|
|
131
138
|
const sources = quietSources({ whatsappStatus: async () => ({ connected: false }) });
|
|
132
|
-
expect(await run([check], sources)).
|
|
139
|
+
expect(await run([check], sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
133
140
|
});
|
|
134
141
|
it("inbound is true only on a message received since the watermark", async () => {
|
|
135
142
|
const check = { type: "whatsapp", scope: "inbound" };
|
|
@@ -137,7 +144,7 @@ describe("whatsapp check", () => {
|
|
|
137
144
|
const sources = quietSources({
|
|
138
145
|
whatsappInbound: async () => ({ latest: 1_754_000_900, newer: true }),
|
|
139
146
|
});
|
|
140
|
-
expect(await run([check], sources)).
|
|
147
|
+
expect(await run([check], sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
141
148
|
});
|
|
142
149
|
it("inbound passes the stored watermark to the source", async () => {
|
|
143
150
|
const check = { type: "whatsapp", scope: "inbound" };
|
|
@@ -157,15 +164,15 @@ describe("calendar check", () => {
|
|
|
157
164
|
const CHECK = { type: "calendar", connector: "google" };
|
|
158
165
|
it("is false on an unchanged digest", async () => {
|
|
159
166
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
160
|
-
expect(await run([CHECK], quietSources())).
|
|
167
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
161
168
|
});
|
|
162
169
|
it("is true on a changed digest", async () => {
|
|
163
170
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
164
171
|
const sources = quietSources({ calendarDigest: async () => "DIGEST-B" });
|
|
165
|
-
expect(await run([CHECK], sources)).
|
|
172
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
166
173
|
});
|
|
167
174
|
it("is true on a first run, when nothing has been committed yet", async () => {
|
|
168
|
-
expect(await run([CHECK], quietSources())).
|
|
175
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
169
176
|
});
|
|
170
177
|
});
|
|
171
178
|
describe("digestBusy", () => {
|
|
@@ -222,44 +229,49 @@ describe("all four together", () => {
|
|
|
222
229
|
mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
|
|
223
230
|
calendar: { digest: "DIGEST-A" },
|
|
224
231
|
});
|
|
225
|
-
expect(await run(ALL, quietSources())).
|
|
232
|
+
expect(await run(ALL, quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
226
233
|
});
|
|
227
234
|
it("one noisy source is enough, and names only itself", async () => {
|
|
228
235
|
seedWatermarks({
|
|
229
236
|
mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
|
|
230
237
|
calendar: { digest: "DIGEST-A" },
|
|
231
238
|
});
|
|
232
|
-
expect(await run(ALL, quietSources({ d1: async () => 2 }))).
|
|
239
|
+
expect(await run(ALL, quietSources({ d1: async () => 2 }))).toMatchObject({
|
|
233
240
|
hasWork: true,
|
|
234
241
|
reasons: ["d1"],
|
|
242
|
+
errored: [],
|
|
235
243
|
});
|
|
236
244
|
});
|
|
237
245
|
});
|
|
238
|
-
|
|
246
|
+
// Task 2663 — the advance is the dispatcher's, so it is tested through
|
|
247
|
+
// `advanceGateMark` against the readings a real gate call returned. The old
|
|
248
|
+
// `commit` describe drove the same ground through a parameter the agent set.
|
|
249
|
+
describe("advanceGateMark", () => {
|
|
239
250
|
const ALL = [
|
|
240
251
|
{ type: "mail", mailbox: "ops@example.com" },
|
|
241
252
|
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
242
253
|
{ type: "whatsapp", scope: "inbound" },
|
|
243
254
|
{ type: "calendar", connector: "google" },
|
|
244
255
|
];
|
|
245
|
-
it("
|
|
256
|
+
it("the gate itself leaves the file bytes unchanged", async () => {
|
|
246
257
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
247
258
|
const path = watermarkPath(EVENT, platformRoot);
|
|
248
259
|
const before = readFileSync(path);
|
|
249
260
|
await run(ALL, quietSources({ calendarDigest: async () => "DIGEST-B" }));
|
|
250
261
|
expect(readFileSync(path).equals(before)).toBe(true);
|
|
251
262
|
});
|
|
252
|
-
it("
|
|
263
|
+
it("the gate on a first run writes no file at all", async () => {
|
|
253
264
|
await run(ALL, quietSources());
|
|
254
265
|
expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
|
|
255
266
|
});
|
|
256
|
-
it("advances every watermark
|
|
267
|
+
it("advances every watermark the gate read", async () => {
|
|
257
268
|
const sources = quietSources({
|
|
258
269
|
mail: async () => ({ matched: true, highestUid: 250, uidValidity: 9 }),
|
|
259
270
|
whatsappInbound: async () => ({ latest: 1_754_009_999, newer: true }),
|
|
260
271
|
calendarDigest: async () => "DIGEST-Z",
|
|
261
272
|
});
|
|
262
|
-
const
|
|
273
|
+
const gate = await run(ALL, sources);
|
|
274
|
+
const { advanced } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
263
275
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
264
276
|
expect(marks.mail["ops@example.com|inbox"]).toEqual({ uid: 250, uidValidity: 9 });
|
|
265
277
|
expect(marks.whatsapp.inbound).toBe(1_754_009_999);
|
|
@@ -267,75 +279,60 @@ describe("commit", () => {
|
|
|
267
279
|
expect(marks.lastCommitAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
268
280
|
// d1 has no watermark: its answer is a property of the present, not a
|
|
269
281
|
// position in a stream.
|
|
270
|
-
expect(
|
|
282
|
+
expect(advanced).toEqual(["ops@example.com|inbox", "whatsapp", "calendar"]);
|
|
283
|
+
});
|
|
284
|
+
it("names the position it wrote per advanced key", async () => {
|
|
285
|
+
const gate = await run([{ type: "calendar", connector: "google" }], quietSources({ calendarDigest: async () => "DIGEST-Z" }));
|
|
286
|
+
const { positions } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
287
|
+
expect(positions).toEqual(["calendar:DIGEST-Z"]);
|
|
288
|
+
});
|
|
289
|
+
it("stamps lastCommitAt only when something moved", async () => {
|
|
290
|
+
// Stamping it on every accepted wake would make `auditGateMarkStalled`,
|
|
291
|
+
// which counts accepted wakes SINCE this instant, read 0 or 1 for ever — the
|
|
292
|
+
// check built to catch a mark that stopped moving could then never fire, and
|
|
293
|
+
// `auditGateWatermarkAge` would go permanently green for the same reason.
|
|
294
|
+
const { advanced } = advanceGateMark(EVENT, platformRoot, {});
|
|
295
|
+
expect(advanced).toEqual([]);
|
|
296
|
+
expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
|
|
271
297
|
});
|
|
272
|
-
it("
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
expect(marks.lastCommitAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
298
|
+
it("leaves an existing file untouched when nothing moved", async () => {
|
|
299
|
+
seedWatermarks({ calendar: { digest: "DIGEST-A" }, lastCommitAt: "2026-08-10T10:00:00.000Z" });
|
|
300
|
+
const before = readFileSync(watermarkPath(EVENT, platformRoot));
|
|
301
|
+
advanceGateMark(EVENT, platformRoot, {});
|
|
302
|
+
expect(readFileSync(watermarkPath(EVENT, platformRoot)).equals(before)).toBe(true);
|
|
278
303
|
});
|
|
279
|
-
it("
|
|
304
|
+
it("an advanced watermark makes the next gate call quiet", async () => {
|
|
280
305
|
const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
|
|
281
306
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
307
|
+
const first = await run(checks, sources);
|
|
308
|
+
expect(first).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
309
|
+
advanceGateMark(EVENT, platformRoot, first.observed);
|
|
310
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
285
311
|
});
|
|
286
|
-
it("a check that
|
|
312
|
+
it("a check that errored is not advanced, so its work is not marked consumed", async () => {
|
|
287
313
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
288
314
|
const sources = quietSources({
|
|
289
315
|
calendarDigest: async () => {
|
|
290
316
|
throw new Error("neo4j down");
|
|
291
317
|
},
|
|
292
318
|
});
|
|
293
|
-
const
|
|
294
|
-
expect(
|
|
295
|
-
const
|
|
296
|
-
expect(
|
|
297
|
-
|
|
298
|
-
// Task 2633 — the agent that raised it read `advanced: []` from a commit over
|
|
299
|
-
// four d1 checks, inferred a broken watermark, reported an unfixable platform
|
|
300
|
-
// fault, and deleted all four checks.
|
|
301
|
-
it("names the checks that hold no watermark, instead of omitting them in silence", async () => {
|
|
302
|
-
const checks = [
|
|
303
|
-
{ type: "mail", mailbox: "ops@example.com" },
|
|
304
|
-
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
305
|
-
];
|
|
306
|
-
const result = await run(checks, quietSources(), true);
|
|
307
|
-
// Task 2635 folds the folder segment to lower case on write.
|
|
308
|
-
expect(result.advanced).toEqual(["ops@example.com|inbox"]);
|
|
309
|
-
expect(result.skipped).toEqual(["d1"]);
|
|
319
|
+
const gate = await run(checks, sources);
|
|
320
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"], observed: {} });
|
|
321
|
+
const { advanced } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
322
|
+
expect(advanced).toEqual([]);
|
|
323
|
+
expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
|
|
310
324
|
});
|
|
311
|
-
it("
|
|
312
|
-
|
|
325
|
+
it("a check holding no position contributes no key, so nothing advances for it", async () => {
|
|
326
|
+
// Task 2633 — an agent read an empty `advanced` over four d1 checks,
|
|
327
|
+
// inferred a broken watermark and deleted all four. `d1` and `whatsapp` in
|
|
328
|
+
// `status` scope read a current value; there is no position to record.
|
|
329
|
+
const gate = await run([
|
|
313
330
|
{ type: "d1", database: "bookings", sql: "SELECT max(id) FROM b" },
|
|
314
331
|
{ type: "d1", database: "leads", sql: "SELECT max(id) FROM l" },
|
|
315
332
|
{ type: "whatsapp", scope: "status" },
|
|
316
|
-
];
|
|
317
|
-
|
|
318
|
-
expect(
|
|
319
|
-
});
|
|
320
|
-
it("a plain call reports no skipped array at all", async () => {
|
|
321
|
-
const checks = [
|
|
322
|
-
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
323
|
-
];
|
|
324
|
-
expect(await run(checks, quietSources())).toEqual({ hasWork: false, reasons: [] });
|
|
325
|
-
});
|
|
326
|
-
it("a check that errored is neither advanced nor skipped, because its state is unknown", async () => {
|
|
327
|
-
const checks = [
|
|
328
|
-
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
329
|
-
{ type: "calendar", connector: "google" },
|
|
330
|
-
];
|
|
331
|
-
const sources = quietSources({
|
|
332
|
-
calendarDigest: async () => {
|
|
333
|
-
throw new Error("neo4j down");
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
const result = await run(checks, sources, true);
|
|
337
|
-
expect(result.advanced).toEqual([]);
|
|
338
|
-
expect(result.skipped).toEqual(["d1"]);
|
|
333
|
+
], quietSources());
|
|
334
|
+
expect(gate.observed).toEqual({});
|
|
335
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
|
|
339
336
|
});
|
|
340
337
|
});
|
|
341
338
|
describe("watermark location", () => {
|
|
@@ -586,10 +583,16 @@ describe("watermark folder-case fold", () => {
|
|
|
586
583
|
const sources = quietSources({
|
|
587
584
|
mail: async (_c, since) => ({ matched: false, highestUid: since + 1, uidValidity: 1 }),
|
|
588
585
|
});
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
expect(
|
|
586
|
+
// Task 2663 — each advance promotes what its own gate call read, so both
|
|
587
|
+
// fires run the real two-step sequence.
|
|
588
|
+
const firstGate = await run([lower], sources);
|
|
589
|
+
expect(advanceGateMark(EVENT, platformRoot, firstGate.observed).advanced).toEqual([
|
|
590
|
+
"ops@example.com|inbox",
|
|
591
|
+
]);
|
|
592
|
+
const secondGate = await run([upper], sources);
|
|
593
|
+
expect(advanceGateMark(EVENT, platformRoot, secondGate.observed).advanced).toEqual([
|
|
594
|
+
"ops@example.com|inbox",
|
|
595
|
+
]);
|
|
593
596
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
594
597
|
expect(Object.keys(marks.mail)).toEqual(["ops@example.com|inbox"]);
|
|
595
598
|
// The second call read the first call's position rather than restarting at 0.
|
|
@@ -647,21 +650,23 @@ describe("telegram check", () => {
|
|
|
647
650
|
};
|
|
648
651
|
const measured = (lines) => lines.every((l) => !l.includes("op=check-error"));
|
|
649
652
|
it("is false when the account holds no inbound message at all", async () => {
|
|
650
|
-
expect(await run([CHECK], quietSources())).
|
|
653
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
651
654
|
});
|
|
652
655
|
it("is false when nothing arrived after the watermark", async () => {
|
|
653
656
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
654
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).
|
|
657
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
|
|
655
658
|
hasWork: false,
|
|
656
659
|
reasons: [],
|
|
660
|
+
errored: [],
|
|
657
661
|
});
|
|
658
662
|
});
|
|
659
663
|
it("is true when a message arrived after the watermark", async () => {
|
|
660
664
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
661
665
|
const lines = linesFrom();
|
|
662
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).
|
|
666
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toMatchObject({
|
|
663
667
|
hasWork: true,
|
|
664
668
|
reasons: ["telegram"],
|
|
669
|
+
errored: [],
|
|
665
670
|
});
|
|
666
671
|
expect(measured(lines)).toBe(true);
|
|
667
672
|
});
|
|
@@ -669,16 +674,18 @@ describe("telegram check", () => {
|
|
|
669
674
|
// The boundary is `>`, not `>=`. A `>=` would re-report the last handled
|
|
670
675
|
// message on every fire and the gate could never go quiet.
|
|
671
676
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
672
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).
|
|
677
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toMatchObject({
|
|
673
678
|
hasWork: false,
|
|
674
679
|
reasons: [],
|
|
680
|
+
errored: [],
|
|
675
681
|
});
|
|
676
682
|
});
|
|
677
683
|
it("is true on a first run, so nothing already waiting is missed", async () => {
|
|
678
684
|
const lines = linesFrom();
|
|
679
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).
|
|
685
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
|
|
680
686
|
hasWork: true,
|
|
681
687
|
reasons: ["telegram"],
|
|
688
|
+
errored: [],
|
|
682
689
|
});
|
|
683
690
|
expect(measured(lines)).toBe(true);
|
|
684
691
|
});
|
|
@@ -689,7 +696,11 @@ describe("telegram check", () => {
|
|
|
689
696
|
throw new Error("HTTP 500 on GET /api/telegram-reader/gate-probe");
|
|
690
697
|
},
|
|
691
698
|
});
|
|
692
|
-
expect(await run([CHECK], sources)).
|
|
699
|
+
expect(await run([CHECK], sources)).toMatchObject({
|
|
700
|
+
hasWork: true,
|
|
701
|
+
reasons: ["telegram"],
|
|
702
|
+
errored: [{ kind: "telegram", detail: "HTTP 500 on GET /api/telegram-reader/gate-probe" }],
|
|
703
|
+
});
|
|
693
704
|
// The opposite of the two assertions above: this one MUST name the failure,
|
|
694
705
|
// or a permanently broken probe reads as a permanently busy account. The
|
|
695
706
|
// DETAIL is asserted, not just the kind — an unrecognised check type also
|
|
@@ -699,20 +710,112 @@ describe("telegram check", () => {
|
|
|
699
710
|
expect(line).toContain("kind=telegram");
|
|
700
711
|
expect(line).toContain("/api/telegram-reader/gate-probe");
|
|
701
712
|
});
|
|
702
|
-
it("
|
|
703
|
-
const
|
|
704
|
-
expect(
|
|
713
|
+
it("the advance writes the watermark to what the gate's probe read", async () => {
|
|
714
|
+
const gate = await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")));
|
|
715
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram"]);
|
|
705
716
|
const marks = readWatermarks(EVENT, platformRoot);
|
|
706
717
|
expect(marks.telegram).toEqual({ inbound: "2026-08-05T11:00:00.000Z" });
|
|
707
718
|
});
|
|
708
|
-
|
|
719
|
+
// Task 2662 — the bot scope. Before it the check named no bot and the probe
|
|
720
|
+
// read every conversation on the account, so a routine watching a booking bot
|
|
721
|
+
// woke for every message to a relay bot sharing that account. A `botId` on the
|
|
722
|
+
// check was accepted by the schema and reached nothing that read it.
|
|
723
|
+
it("forwards botId to the source, so the probe can scope to one bot", async () => {
|
|
724
|
+
const seen = [];
|
|
725
|
+
const sources = quietSources({
|
|
726
|
+
telegramInbound: async (_a, botId) => {
|
|
727
|
+
seen.push(botId);
|
|
728
|
+
return { newestInboundAt: null };
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
await run([{ type: "telegram", scope: "inbound", botId: "111" }], sources);
|
|
732
|
+
expect(seen).toEqual(["111"]);
|
|
733
|
+
});
|
|
734
|
+
it("calls the source with no botId when the check names none", async () => {
|
|
735
|
+
// The account-wide shape 2653 shipped. A gate bound before 2660 carries no
|
|
736
|
+
// botId and must keep answering for the whole account.
|
|
737
|
+
const seen = [];
|
|
738
|
+
const sources = quietSources({
|
|
739
|
+
telegramInbound: async (_a, botId) => {
|
|
740
|
+
seen.push(botId);
|
|
741
|
+
return { newestInboundAt: null };
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
await run([CHECK], sources);
|
|
745
|
+
expect(seen).toEqual([undefined]);
|
|
746
|
+
});
|
|
747
|
+
it("keeps each bot scope on its own watermark, so narrowing a gate cannot skip work", async () => {
|
|
748
|
+
// The mail check keys `<mailbox>|<folder>` for this reason (Task 2635): two
|
|
749
|
+
// scopes sharing one position means the narrower one inherits a mark taken
|
|
750
|
+
// for a wider set. Here the account-wide mark is LATER than bot 111's own
|
|
751
|
+
// newest, so a shared key would answer "no work" while 111 holds a message
|
|
752
|
+
// nobody has read. That is the mute direction, and it loses a booking.
|
|
753
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
|
|
754
|
+
const check = { type: "telegram", scope: "inbound", botId: "111" };
|
|
755
|
+
expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).toMatchObject({
|
|
756
|
+
hasWork: true,
|
|
757
|
+
reasons: ["telegram"],
|
|
758
|
+
errored: [],
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
it("a scoped advance writes under the bot, leaving the account-wide mark alone", async () => {
|
|
762
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
|
|
763
|
+
const check = { type: "telegram", scope: "inbound", botId: "111" };
|
|
764
|
+
const sources = quietSources(at("2026-08-05T12:00:00.000Z"));
|
|
765
|
+
const gate = await run([check], sources);
|
|
766
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram:111"]);
|
|
767
|
+
const marks = readWatermarks(EVENT, platformRoot);
|
|
768
|
+
expect(marks.telegram?.inbound).toBe("2026-08-05T18:00:00.000Z");
|
|
769
|
+
expect(marks.telegram?.byBot).toEqual({ "111": "2026-08-05T12:00:00.000Z" });
|
|
770
|
+
});
|
|
771
|
+
it("two bots on one event hold two positions", async () => {
|
|
772
|
+
seedWatermarks({
|
|
773
|
+
telegram: { byBot: { "111": "2026-08-05T12:00:00.000Z", "222": "2026-08-05T20:00:00.000Z" } },
|
|
774
|
+
});
|
|
775
|
+
const a = { type: "telegram", scope: "inbound", botId: "111" };
|
|
776
|
+
const b = { type: "telegram", scope: "inbound", botId: "222" };
|
|
777
|
+
expect(await run([a], quietSources(at("2026-08-05T13:00:00.000Z")))).toMatchObject({
|
|
778
|
+
hasWork: true,
|
|
779
|
+
});
|
|
780
|
+
expect(await run([b], quietSources(at("2026-08-05T13:00:00.000Z")))).toMatchObject({
|
|
781
|
+
hasWork: false,
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
it("reports work and names the bot when the probe refuses an unknown one", async () => {
|
|
785
|
+
// The operator's own reproduction: id `0000000000`. The probe answers 404,
|
|
786
|
+
// which reaches here as an unreadable check. It must report WORK, never
|
|
787
|
+
// quiet, or the routine is muted for ever with nothing in the log.
|
|
788
|
+
const lines = linesFrom();
|
|
789
|
+
const sources = quietSources({
|
|
790
|
+
telegramInbound: async (_a, botId) => {
|
|
791
|
+
throw new Error(`HTTP 404 on GET /api/telegram-reader/gate-probe?botId=${botId}`);
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
const check = { type: "telegram", scope: "inbound", botId: "0000000000" };
|
|
795
|
+
const result = await run([check], sources);
|
|
796
|
+
expect(result).toMatchObject({ hasWork: true, reasons: ["telegram"] });
|
|
797
|
+
// Task 2658's structured arm carries the reason, so a caller can tell a bot
|
|
798
|
+
// with messages from a bot id that resolves to nothing. `reasons` alone
|
|
799
|
+
// cannot: it holds "telegram" either way.
|
|
800
|
+
expect(result.errored).toEqual([
|
|
801
|
+
{
|
|
802
|
+
kind: "telegram",
|
|
803
|
+
detail: "HTTP 404 on GET /api/telegram-reader/gate-probe?botId=0000000000",
|
|
804
|
+
},
|
|
805
|
+
]);
|
|
806
|
+
const line = lines.find((l) => l.includes("op=check-error"));
|
|
807
|
+
expect(line).toContain("kind=telegram");
|
|
808
|
+
expect(line).toContain("0000000000");
|
|
809
|
+
});
|
|
810
|
+
it("a quiet account records no position, so nothing advances onto nothing", async () => {
|
|
709
811
|
// A null probe is "nothing to measure from", not "position zero". There is
|
|
710
|
-
// no position to record, so the check
|
|
711
|
-
//
|
|
712
|
-
//
|
|
812
|
+
// no position to record, so the check contributes no key and the stored mark
|
|
813
|
+
// is left alone. Writing null would drop the mark and re-report every stored
|
|
814
|
+
// message on the next fire.
|
|
713
815
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
714
|
-
const
|
|
715
|
-
expect(
|
|
816
|
+
const gate = await run([CHECK], quietSources());
|
|
817
|
+
expect(gate).toMatchObject({ hasWork: false, observed: {} });
|
|
818
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
|
|
716
819
|
expect(readWatermarks(EVENT, platformRoot).telegram).toEqual({
|
|
717
820
|
inbound: "2026-08-05T10:00:00.000Z",
|
|
718
821
|
});
|
|
@@ -726,7 +829,16 @@ describe("telegram check", () => {
|
|
|
726
829
|
describe("unrecognised check type", () => {
|
|
727
830
|
const CHECK = { type: "nonsense" };
|
|
728
831
|
it("resolves rather than throwing, and counts the unknown type as work", async () => {
|
|
729
|
-
expect(await run([CHECK], quietSources())).
|
|
832
|
+
expect(await run([CHECK], quietSources())).toMatchObject({
|
|
833
|
+
hasWork: true,
|
|
834
|
+
reasons: ["nonsense"],
|
|
835
|
+
errored: [
|
|
836
|
+
{
|
|
837
|
+
kind: "nonsense",
|
|
838
|
+
detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram',
|
|
839
|
+
},
|
|
840
|
+
],
|
|
841
|
+
});
|
|
730
842
|
});
|
|
731
843
|
it("names the type and the accepted set in op=check-error", async () => {
|
|
732
844
|
const lines = [];
|
|
@@ -741,7 +853,309 @@ describe("unrecognised check type", () => {
|
|
|
741
853
|
});
|
|
742
854
|
it("still runs the sibling checks — one bad type does not abort the gate", async () => {
|
|
743
855
|
const checks = [CHECK, { type: "d1", database: "b", sql: "SELECT 1" }];
|
|
744
|
-
expect(await run(checks, quietSources())).
|
|
856
|
+
expect(await run(checks, quietSources())).toMatchObject({
|
|
857
|
+
hasWork: true,
|
|
858
|
+
reasons: ["nonsense"],
|
|
859
|
+
errored: [
|
|
860
|
+
{
|
|
861
|
+
kind: "nonsense",
|
|
862
|
+
detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram',
|
|
863
|
+
},
|
|
864
|
+
],
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
// Task 2658 — `reasons` carries the check type on the real-work arm and the
|
|
870
|
+
// error arm alike, so `{"hasWork":true,"reasons":["d1"]}` is byte-identical for
|
|
871
|
+
// a database holding rows and one that 403s. `errored` is the only place that
|
|
872
|
+
// difference survives the call, and no bind-time guard is writable without it.
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
describe("errored", () => {
|
|
875
|
+
const D1 = { type: "d1", database: "db", sql: "SELECT 1" };
|
|
876
|
+
it("is empty when every check answers", async () => {
|
|
877
|
+
const res = await run([D1], quietSources());
|
|
878
|
+
expect(res.errored).toEqual([]);
|
|
879
|
+
});
|
|
880
|
+
it("names the kind and the source's own message when a check throws", async () => {
|
|
881
|
+
const sources = quietSources({
|
|
882
|
+
d1: async () => {
|
|
883
|
+
throw new Error("HTTP 403 on POST /api/storage/d1/query");
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
const res = await run([D1], sources);
|
|
887
|
+
expect(res.errored).toEqual([{ kind: "d1", detail: "HTTP 403 on POST /api/storage/d1/query" }]);
|
|
888
|
+
});
|
|
889
|
+
it("is not distinguishable from a real hit by hasWork or reasons alone", async () => {
|
|
890
|
+
const thrown = await run([D1], quietSources({
|
|
891
|
+
d1: async () => {
|
|
892
|
+
throw new Error("HTTP 403 on POST /api/storage/d1/query");
|
|
893
|
+
},
|
|
894
|
+
}));
|
|
895
|
+
const real = await run([D1], quietSources({ d1: async () => 7 }));
|
|
896
|
+
expect(thrown.hasWork).toBe(real.hasWork);
|
|
897
|
+
expect(thrown.reasons).toEqual(real.reasons);
|
|
898
|
+
expect(thrown.errored).toHaveLength(1);
|
|
899
|
+
expect(real.errored).toEqual([]);
|
|
900
|
+
});
|
|
901
|
+
it("carries one entry per erroring check, in checks order", async () => {
|
|
902
|
+
const sources = quietSources({
|
|
903
|
+
d1: async () => {
|
|
904
|
+
throw new Error("d1 down");
|
|
905
|
+
},
|
|
906
|
+
calendarDigest: async () => {
|
|
907
|
+
throw new Error("calendar down");
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
const checks = [
|
|
911
|
+
D1,
|
|
912
|
+
{ type: "whatsapp", scope: "status" },
|
|
913
|
+
{ type: "calendar", connector: "google" },
|
|
914
|
+
];
|
|
915
|
+
const res = await run(checks, sources);
|
|
916
|
+
expect(res.errored).toEqual([
|
|
917
|
+
{ kind: "d1", detail: "d1 down" },
|
|
918
|
+
{ kind: "calendar", detail: "calendar down" },
|
|
919
|
+
]);
|
|
920
|
+
});
|
|
921
|
+
it("is present and empty when every check answered", async () => {
|
|
922
|
+
const res = await run([D1], quietSources());
|
|
923
|
+
expect(res.errored).toEqual([]);
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
// Task 2657 — the same check, on a mailbox the outlook plugin holds.
|
|
927
|
+
//
|
|
928
|
+
// The first test is the one that matters. Graph's
|
|
929
|
+
// `$top=1&$orderby=receivedDateTime desc` returns the newest message whether or
|
|
930
|
+
// not anything arrived, exactly as `UID SEARCH n+1:*` returns the last message
|
|
931
|
+
// above the highest UID — so an implementation that reports work on any
|
|
932
|
+
// non-empty response passes every other test here, which is how Task 2471
|
|
933
|
+
// shipped.
|
|
934
|
+
describe("mail check — outlook provider", () => {
|
|
935
|
+
const CHECK = { type: "mail", mailbox: "admin@muvin.co.uk", provider: "outlook" };
|
|
936
|
+
const KEY = "admin@muvin.co.uk|inbox";
|
|
937
|
+
it("is false when the newest message equals the watermark", async () => {
|
|
938
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
939
|
+
const sources = quietSources({
|
|
940
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
941
|
+
});
|
|
942
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
943
|
+
});
|
|
944
|
+
it("is true on a message above the watermark, and the advance writes that key", async () => {
|
|
945
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
946
|
+
const sources = quietSources({
|
|
947
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:05:00Z" }),
|
|
948
|
+
});
|
|
949
|
+
const gate = await run([CHECK], sources);
|
|
950
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
951
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([KEY]);
|
|
952
|
+
const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
953
|
+
expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:05:00Z" });
|
|
954
|
+
});
|
|
955
|
+
it("is true on a first run, when nothing is stored", async () => {
|
|
956
|
+
const sources = quietSources({
|
|
957
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
958
|
+
});
|
|
959
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
960
|
+
});
|
|
961
|
+
it("orders by instant, not lexically, so a fractional second is not read as older", async () => {
|
|
962
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
963
|
+
const sources = quietSources({
|
|
964
|
+
// Lexically "…00.500Z" sorts BELOW "…00Z" ('.' is 0x2E, 'Z' is 0x5A).
|
|
965
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00.500Z" }),
|
|
966
|
+
});
|
|
967
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
968
|
+
});
|
|
969
|
+
it("records no position for an empty folder rather than advancing onto nothing", async () => {
|
|
970
|
+
const sources = quietSources({ mailOutlook: async () => ({ newestReceivedAt: null }) });
|
|
971
|
+
const gate = await run([CHECK], sources);
|
|
972
|
+
expect(gate).toEqual({ hasWork: false, reasons: [], errored: [], observed: {} });
|
|
973
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
|
|
974
|
+
});
|
|
975
|
+
it("keys the folder segment lower-cased, so two spellings address one entry", async () => {
|
|
976
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
977
|
+
const check = { ...CHECK, folder: "Inbox" };
|
|
978
|
+
const sources = quietSources({
|
|
979
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
980
|
+
});
|
|
981
|
+
expect(await run([check], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
982
|
+
});
|
|
983
|
+
it("counts a probe that throws as work, and never as quiet", async () => {
|
|
984
|
+
const sources = quietSources({
|
|
985
|
+
mailOutlook: async () => {
|
|
986
|
+
throw new Error("No Outlook mailbox admin@muvin.co.uk is connected");
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
expect(await run([CHECK], sources)).toMatchObject({
|
|
990
|
+
hasWork: true,
|
|
991
|
+
reasons: ["mail"],
|
|
992
|
+
errored: [{ kind: "mail", detail: "No Outlook mailbox admin@muvin.co.uk is connected" }],
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
it("throws on a provider outside the accepted list", async () => {
|
|
996
|
+
const check = { type: "mail", mailbox: "a@b.com", provider: "pop3" };
|
|
997
|
+
expect(await run([check], quietSources())).toMatchObject({
|
|
998
|
+
hasWork: true,
|
|
999
|
+
reasons: ["mail"],
|
|
1000
|
+
errored: [{ kind: "mail", detail: 'unknown mail provider "pop3" — accepted: imap, outlook' }],
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
it("leaves the IMAP watermark map untouched", async () => {
|
|
1004
|
+
seedWatermarks({ mail: { [KEY]: { uid: 42, uidValidity: 7 } } });
|
|
1005
|
+
const sources = quietSources({
|
|
1006
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
1007
|
+
});
|
|
1008
|
+
// Task 2663 — the gate call records the position the advance promotes.
|
|
1009
|
+
const gate = await run([CHECK], sources);
|
|
1010
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1011
|
+
const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1012
|
+
expect(written.mail[KEY]).toEqual({ uid: 42, uidValidity: 7 });
|
|
1013
|
+
expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:00:00Z" });
|
|
1014
|
+
});
|
|
1015
|
+
it("takes the IMAP arm when provider is absent", async () => {
|
|
1016
|
+
let outlookCalled = false;
|
|
1017
|
+
const sources = quietSources({
|
|
1018
|
+
mailOutlook: async () => {
|
|
1019
|
+
outlookCalled = true;
|
|
1020
|
+
return { newestReceivedAt: "2026-08-10T09:00:00Z" };
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
await run([{ type: "mail", mailbox: "admin@muvin.co.uk" }], sources);
|
|
1024
|
+
expect(outlookCalled).toBe(false);
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
// Task 2654, kept under Task 2663 — a source that moves between the gate call
|
|
1029
|
+
// and the advance.
|
|
1030
|
+
//
|
|
1031
|
+
// The advance happens after the wake was accepted, by which time the source may
|
|
1032
|
+
// have moved again. Writing what the source holds THEN would put the arrival
|
|
1033
|
+
// below the mark and no later fire would report it. The dispatcher writes what
|
|
1034
|
+
// the GATE returned instead, so the mover stays above. Under 2654 that guarantee
|
|
1035
|
+
// depended on a sidecar file being fresh; it is now structural, because the
|
|
1036
|
+
// positions travel with the result.
|
|
1037
|
+
//
|
|
1038
|
+
// Each check is driven alone, and each test runs the real sequence: gate, source
|
|
1039
|
+
// moves, advance, gate again.
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
describe("a source that moves between the gate call and the advance", () => {
|
|
1042
|
+
it("mail: a UID that arrives mid-turn is still reported after the advance", async () => {
|
|
1043
|
+
let highest = 105;
|
|
1044
|
+
const sources = quietSources({
|
|
1045
|
+
mail: async (_c, sinceUid) => ({
|
|
1046
|
+
matched: highest > sinceUid,
|
|
1047
|
+
highestUid: Math.max(highest, sinceUid),
|
|
1048
|
+
uidValidity: 1,
|
|
1049
|
+
}),
|
|
1050
|
+
});
|
|
1051
|
+
const checks = [{ type: "mail", mailbox: "ops@example.com" }];
|
|
1052
|
+
const gate = await run(checks, sources);
|
|
1053
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
1054
|
+
highest = 106; // arrives while the turn runs
|
|
1055
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1056
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1057
|
+
expect(marks.mail["ops@example.com|inbox"].uid).toBe(105);
|
|
1058
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["mail"] });
|
|
1059
|
+
});
|
|
1060
|
+
it("whatsapp: an inbound message mid-turn is still reported after the advance", async () => {
|
|
1061
|
+
let latest = 1_754_000_100;
|
|
1062
|
+
const sources = quietSources({
|
|
1063
|
+
whatsappInbound: async (_a, since) => ({
|
|
1064
|
+
latest: Math.max(latest, since),
|
|
1065
|
+
newer: latest > since,
|
|
1066
|
+
}),
|
|
1067
|
+
});
|
|
1068
|
+
const checks = [{ type: "whatsapp", scope: "inbound" }];
|
|
1069
|
+
const gate = await run(checks, sources);
|
|
1070
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
1071
|
+
latest = 1_754_000_200;
|
|
1072
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1073
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1074
|
+
expect(marks.whatsapp.inbound).toBe(1_754_000_100);
|
|
1075
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"] });
|
|
1076
|
+
});
|
|
1077
|
+
it("telegram: an inbound message mid-turn is still reported after the advance", async () => {
|
|
1078
|
+
let newest = "2026-08-10T10:00:00.000Z";
|
|
1079
|
+
const sources = quietSources({ telegramInbound: async () => ({ newestInboundAt: newest }) });
|
|
1080
|
+
const checks = [{ type: "telegram", scope: "inbound" }];
|
|
1081
|
+
const gate = await run(checks, sources);
|
|
1082
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["telegram"], errored: [] });
|
|
1083
|
+
newest = "2026-08-10T10:30:00.000Z";
|
|
1084
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1085
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1086
|
+
expect(marks.telegram.inbound).toBe("2026-08-10T10:00:00.000Z");
|
|
1087
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["telegram"] });
|
|
1088
|
+
});
|
|
1089
|
+
it("calendar: a connector change mid-turn is still reported after the advance", async () => {
|
|
1090
|
+
let digest = "DIGEST-A";
|
|
1091
|
+
const sources = quietSources({ calendarDigest: async () => digest });
|
|
1092
|
+
const checks = [{ type: "calendar", connector: "google" }];
|
|
1093
|
+
const gate = await run(checks, sources);
|
|
1094
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
1095
|
+
digest = "DIGEST-B";
|
|
1096
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1097
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1098
|
+
expect(marks.calendar.digest).toBe("DIGEST-A");
|
|
1099
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["calendar"] });
|
|
1100
|
+
});
|
|
1101
|
+
// Task 2663 — the no-event case. The gate that could never go quiet emitted a
|
|
1102
|
+
// line on every fire that looked exactly like a healthy one, because it named
|
|
1103
|
+
// only what the probe had just read and never the value it was compared
|
|
1104
|
+
// against. Both sides now print, so a mark stuck on an empty slot is legible
|
|
1105
|
+
// from one line without reproducing anything.
|
|
1106
|
+
describe("gate-read names both sides of the comparison", () => {
|
|
1107
|
+
// `console.error` is already spied in `beforeEach`, so its recorded calls are
|
|
1108
|
+
// the log. Re-mocking it here would drop the lines written before this point.
|
|
1109
|
+
const errorLines = () => vi.mocked(console.error).mock.calls.map((c) => c.map(String).join(" "));
|
|
1110
|
+
it("names null stored on a first fire, and the observed position beside it", async () => {
|
|
1111
|
+
const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
|
|
1112
|
+
await run([{ type: "calendar", connector: "google" }], sources);
|
|
1113
|
+
expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=null observed=abcdef01"));
|
|
1114
|
+
});
|
|
1115
|
+
it("names the stored position once the mark has advanced", async () => {
|
|
1116
|
+
const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
|
|
1117
|
+
const checks = [{ type: "calendar", connector: "google" }];
|
|
1118
|
+
const gate = await run(checks, sources);
|
|
1119
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1120
|
+
await run(checks, sources);
|
|
1121
|
+
expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=abcdef01 observed=abcdef01"));
|
|
1122
|
+
});
|
|
1123
|
+
it("emits no gate-read for a check that holds no position", async () => {
|
|
1124
|
+
// `op=check-error` already covers a check with no position; a line of two
|
|
1125
|
+
// nulls would only add a value nobody can compare.
|
|
1126
|
+
await run([{ type: "d1", database: "b", sql: "SELECT count(*) FROM b" }], quietSources());
|
|
1127
|
+
expect(errorLines().filter((l) => l.includes("op=gate-read"))).toEqual([]);
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
// Task 2663 — the gate hands its readings back to the dispatcher and touches no
|
|
1132
|
+
// sidecar. The commit and the `<eventId>.observed.json` file it promoted from
|
|
1133
|
+
// are both gone: a sidecar survives a gate call that errored, so the stale
|
|
1134
|
+
// positions from an earlier fire would be promoted on the next accepted wake and
|
|
1135
|
+
// mark as seen messages nobody read.
|
|
1136
|
+
describe("observed positions (Task 2663)", () => {
|
|
1137
|
+
it("returns the positions it read, and writes no sidecar", async () => {
|
|
1138
|
+
const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({ telegramInbound: async () => ({ newestInboundAt: "2026-08-10T18:00:00.000Z" }) }));
|
|
1139
|
+
expect(result.observed).toEqual({ telegram: { byBot: { "555": "2026-08-10T18:00:00.000Z" } } });
|
|
1140
|
+
expect(existsSync(join(platformRoot, "..", "state", "scheduling-gate", `${EVENT}.observed.json`))).toBe(false);
|
|
1141
|
+
});
|
|
1142
|
+
it("reports no position for a check that holds none", async () => {
|
|
1143
|
+
// `d1` reads a current value rather than a position in a stream, so there is
|
|
1144
|
+
// nothing for the dispatcher to promote and `observed` is empty rather than
|
|
1145
|
+
// carrying a fabricated key.
|
|
1146
|
+
const result = await run([{ type: "d1", database: "b", sql: "SELECT 1" }], quietSources({ d1: async () => 1 }));
|
|
1147
|
+
expect(result.observed).toEqual({});
|
|
1148
|
+
});
|
|
1149
|
+
it("reports no position for a check that threw", async () => {
|
|
1150
|
+
// A check that errored knows nothing about its source. Recording a position
|
|
1151
|
+
// for it would let the dispatcher advance past work the gate never saw.
|
|
1152
|
+
const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({
|
|
1153
|
+
telegramInbound: async () => {
|
|
1154
|
+
throw new Error("fetch failed");
|
|
1155
|
+
},
|
|
1156
|
+
}));
|
|
1157
|
+
expect(result.hasWork).toBe(true);
|
|
1158
|
+
expect(result.observed).toEqual({});
|
|
745
1159
|
});
|
|
746
1160
|
});
|
|
747
1161
|
//# sourceMappingURL=scheduling-gate.test.js.map
|