@rubytech/create-realagent-code 0.1.589 → 0.1.591
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/roster.d.ts +12 -2
- package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.js +6 -2
- package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
- package/payload/platform/lib/routine-templates/src/roster.ts +14 -3
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +14 -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 +12 -2
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +386 -1
- 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 +569 -16
- 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 +61 -0
- 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 +163 -0
- 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 +4 -1
- 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 +252 -16
- 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 +411 -22
- 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 +16 -16
- 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 +62 -0
- 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, watermarkPath, observationPath, 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;
|
|
@@ -29,6 +29,11 @@ function quietSources(over = {}) {
|
|
|
29
29
|
whatsappStatus: async () => ({ connected: true }),
|
|
30
30
|
whatsappInbound: async (_a, since) => ({ latest: since, newer: false }),
|
|
31
31
|
calendarDigest: async () => "DIGEST-A",
|
|
32
|
+
// Task 2653 — "quiet" for Telegram is an account that holds no inbound row at
|
|
33
|
+
// all, which is what a fresh install answers.
|
|
34
|
+
telegramInbound: async () => ({ newestInboundAt: null }),
|
|
35
|
+
// Task 2657 — "quiet" for the Outlook arm is a folder holding no message.
|
|
36
|
+
mailOutlook: async () => ({ newestReceivedAt: null }),
|
|
32
37
|
...over,
|
|
33
38
|
};
|
|
34
39
|
}
|
|
@@ -57,12 +62,13 @@ const run = (checks, sources, commit = false) => runSchedulingGate({ eventId: EV
|
|
|
57
62
|
describe("d1 check", () => {
|
|
58
63
|
const CHECK = { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" };
|
|
59
64
|
it("is false on a zero count", async () => {
|
|
60
|
-
expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [] });
|
|
65
|
+
expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
61
66
|
});
|
|
62
67
|
it("is true on one row", async () => {
|
|
63
68
|
expect(await run([CHECK], quietSources({ d1: async () => 1 }))).toEqual({
|
|
64
69
|
hasWork: true,
|
|
65
70
|
reasons: ["d1"],
|
|
71
|
+
errored: [],
|
|
66
72
|
});
|
|
67
73
|
});
|
|
68
74
|
it("reports work rather than an error when the source is unreachable", async () => {
|
|
@@ -71,21 +77,25 @@ describe("d1 check", () => {
|
|
|
71
77
|
throw new Error("no cloudflare credentials");
|
|
72
78
|
},
|
|
73
79
|
});
|
|
74
|
-
expect(await run([CHECK], sources)).toEqual({
|
|
80
|
+
expect(await run([CHECK], sources)).toEqual({
|
|
81
|
+
hasWork: true,
|
|
82
|
+
reasons: ["d1"],
|
|
83
|
+
errored: [{ kind: "d1", detail: "no cloudflare credentials" }],
|
|
84
|
+
});
|
|
75
85
|
});
|
|
76
86
|
});
|
|
77
87
|
describe("mail check", () => {
|
|
78
88
|
const CHECK = { type: "mail", mailbox: "ops@example.com" };
|
|
79
89
|
it("is false when nothing sits above the watermark", async () => {
|
|
80
90
|
seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
|
|
81
|
-
expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [] });
|
|
91
|
+
expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
82
92
|
});
|
|
83
93
|
it("is true when a UID above the watermark matches", async () => {
|
|
84
94
|
seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
|
|
85
95
|
const sources = quietSources({
|
|
86
96
|
mail: async () => ({ matched: true, highestUid: 104, uidValidity: 1 }),
|
|
87
97
|
});
|
|
88
|
-
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"] });
|
|
98
|
+
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
89
99
|
});
|
|
90
100
|
it("probes from UID 0 on a first run, so nothing already there is missed", async () => {
|
|
91
101
|
const seen = [];
|
|
@@ -103,7 +113,7 @@ describe("mail check", () => {
|
|
|
103
113
|
const sources = quietSources({
|
|
104
114
|
mail: async () => ({ matched: false, highestUid: 100, uidValidity: 7 }),
|
|
105
115
|
});
|
|
106
|
-
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"] });
|
|
116
|
+
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
107
117
|
});
|
|
108
118
|
it("keys the watermark by folder, so two folders of one mailbox never collide", async () => {
|
|
109
119
|
seedWatermarks({ mail: { "ops@example.com|Archive": { uid: 100, uidValidity: 1 } } });
|
|
@@ -121,12 +131,12 @@ describe("mail check", () => {
|
|
|
121
131
|
describe("whatsapp check", () => {
|
|
122
132
|
it("status is false while connected", async () => {
|
|
123
133
|
const check = { type: "whatsapp", scope: "status" };
|
|
124
|
-
expect(await run([check], quietSources())).toEqual({ hasWork: false, reasons: [] });
|
|
134
|
+
expect(await run([check], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
125
135
|
});
|
|
126
136
|
it("status is true while disconnected", async () => {
|
|
127
137
|
const check = { type: "whatsapp", scope: "status" };
|
|
128
138
|
const sources = quietSources({ whatsappStatus: async () => ({ connected: false }) });
|
|
129
|
-
expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"] });
|
|
139
|
+
expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
130
140
|
});
|
|
131
141
|
it("inbound is true only on a message received since the watermark", async () => {
|
|
132
142
|
const check = { type: "whatsapp", scope: "inbound" };
|
|
@@ -134,7 +144,7 @@ describe("whatsapp check", () => {
|
|
|
134
144
|
const sources = quietSources({
|
|
135
145
|
whatsappInbound: async () => ({ latest: 1_754_000_900, newer: true }),
|
|
136
146
|
});
|
|
137
|
-
expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"] });
|
|
147
|
+
expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
138
148
|
});
|
|
139
149
|
it("inbound passes the stored watermark to the source", async () => {
|
|
140
150
|
const check = { type: "whatsapp", scope: "inbound" };
|
|
@@ -154,15 +164,15 @@ describe("calendar check", () => {
|
|
|
154
164
|
const CHECK = { type: "calendar", connector: "google" };
|
|
155
165
|
it("is false on an unchanged digest", async () => {
|
|
156
166
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
157
|
-
expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [] });
|
|
167
|
+
expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
158
168
|
});
|
|
159
169
|
it("is true on a changed digest", async () => {
|
|
160
170
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
161
171
|
const sources = quietSources({ calendarDigest: async () => "DIGEST-B" });
|
|
162
|
-
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["calendar"] });
|
|
172
|
+
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
163
173
|
});
|
|
164
174
|
it("is true on a first run, when nothing has been committed yet", async () => {
|
|
165
|
-
expect(await run([CHECK], quietSources())).toEqual({ hasWork: true, reasons: ["calendar"] });
|
|
175
|
+
expect(await run([CHECK], quietSources())).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
166
176
|
});
|
|
167
177
|
});
|
|
168
178
|
describe("digestBusy", () => {
|
|
@@ -219,7 +229,7 @@ describe("all four together", () => {
|
|
|
219
229
|
mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
|
|
220
230
|
calendar: { digest: "DIGEST-A" },
|
|
221
231
|
});
|
|
222
|
-
expect(await run(ALL, quietSources())).toEqual({ hasWork: false, reasons: [] });
|
|
232
|
+
expect(await run(ALL, quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
223
233
|
});
|
|
224
234
|
it("one noisy source is enough, and names only itself", async () => {
|
|
225
235
|
seedWatermarks({
|
|
@@ -229,6 +239,7 @@ describe("all four together", () => {
|
|
|
229
239
|
expect(await run(ALL, quietSources({ d1: async () => 2 }))).toEqual({
|
|
230
240
|
hasWork: true,
|
|
231
241
|
reasons: ["d1"],
|
|
242
|
+
errored: [],
|
|
232
243
|
});
|
|
233
244
|
});
|
|
234
245
|
});
|
|
@@ -256,6 +267,9 @@ describe("commit", () => {
|
|
|
256
267
|
whatsappInbound: async () => ({ latest: 1_754_009_999, newer: true }),
|
|
257
268
|
calendarDigest: async () => "DIGEST-Z",
|
|
258
269
|
});
|
|
270
|
+
// Task 2654 — the commit promotes what the gate read, so the gate call is
|
|
271
|
+
// part of the sequence. A bare commit advances nothing, by design.
|
|
272
|
+
await run(ALL, sources);
|
|
259
273
|
const result = await run(ALL, sources, true);
|
|
260
274
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
261
275
|
expect(marks.mail["ops@example.com|inbox"]).toEqual({ uid: 250, uidValidity: 9 });
|
|
@@ -276,9 +290,9 @@ describe("commit", () => {
|
|
|
276
290
|
it("a committed watermark makes the next plain call quiet", async () => {
|
|
277
291
|
const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
|
|
278
292
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
279
|
-
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"] });
|
|
293
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
280
294
|
await run(checks, sources, true);
|
|
281
|
-
expect(await run(checks, sources)).toEqual({ hasWork: false, reasons: [] });
|
|
295
|
+
expect(await run(checks, sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
282
296
|
});
|
|
283
297
|
it("a check that errors is not committed, so its work is not marked consumed", async () => {
|
|
284
298
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
@@ -300,6 +314,8 @@ describe("commit", () => {
|
|
|
300
314
|
{ type: "mail", mailbox: "ops@example.com" },
|
|
301
315
|
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
302
316
|
];
|
|
317
|
+
// Task 2654 — the gate call records the position the commit promotes.
|
|
318
|
+
await run(checks, quietSources());
|
|
303
319
|
const result = await run(checks, quietSources(), true);
|
|
304
320
|
// Task 2635 folds the folder segment to lower case on write.
|
|
305
321
|
expect(result.advanced).toEqual(["ops@example.com|inbox"]);
|
|
@@ -318,7 +334,7 @@ describe("commit", () => {
|
|
|
318
334
|
const checks = [
|
|
319
335
|
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
320
336
|
];
|
|
321
|
-
expect(await run(checks, quietSources())).toEqual({ hasWork: false, reasons: [] });
|
|
337
|
+
expect(await run(checks, quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
322
338
|
});
|
|
323
339
|
it("a check that errored is neither advanced nor skipped, because its state is unknown", async () => {
|
|
324
340
|
const checks = [
|
|
@@ -583,8 +599,12 @@ describe("watermark folder-case fold", () => {
|
|
|
583
599
|
const sources = quietSources({
|
|
584
600
|
mail: async (_c, since) => ({ matched: false, highestUid: since + 1, uidValidity: 1 }),
|
|
585
601
|
});
|
|
602
|
+
// Task 2654 — each commit promotes what its own gate call read, so both
|
|
603
|
+
// fires run the real two-step sequence.
|
|
604
|
+
await run([lower], sources);
|
|
586
605
|
const first = await run([lower], sources, true);
|
|
587
606
|
expect(first.advanced).toEqual(["ops@example.com|inbox"]);
|
|
607
|
+
await run([upper], sources);
|
|
588
608
|
const second = await run([upper], sources, true);
|
|
589
609
|
expect(second.advanced).toEqual(["ops@example.com|inbox"]);
|
|
590
610
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
@@ -622,4 +642,537 @@ describe("watermark folder-case fold", () => {
|
|
|
622
642
|
expect(readFileSync(path).equals(before)).toBe(true);
|
|
623
643
|
});
|
|
624
644
|
});
|
|
645
|
+
// Task 2653 — the Telegram check.
|
|
646
|
+
//
|
|
647
|
+
// Before it, a Telegram-driven routine could not be gated at all: the only way
|
|
648
|
+
// to ask "did anything arrive?" was to spend a full model turn. The watermark is
|
|
649
|
+
// an ISO string rather than a number because the store's own `createdAt` is one.
|
|
650
|
+
describe("telegram check", () => {
|
|
651
|
+
const CHECK = { type: "telegram", scope: "inbound" };
|
|
652
|
+
const at = (iso) => ({ telegramInbound: async () => ({ newestInboundAt: iso }) });
|
|
653
|
+
/** Captures stderr so a test can prove work was MEASURED, not merely inferred
|
|
654
|
+
* from a failure. Without this, every `hasWork: true` assertion below also
|
|
655
|
+
* passes when the check type is unrecognised — the error arm reports work and
|
|
656
|
+
* names the same type, so the two outcomes are indistinguishable from the
|
|
657
|
+
* return value alone. */
|
|
658
|
+
const linesFrom = () => {
|
|
659
|
+
const lines = [];
|
|
660
|
+
vi.spyOn(console, "error").mockImplementation((...a) => {
|
|
661
|
+
lines.push(a.join(" "));
|
|
662
|
+
});
|
|
663
|
+
return lines;
|
|
664
|
+
};
|
|
665
|
+
const measured = (lines) => lines.every((l) => !l.includes("op=check-error"));
|
|
666
|
+
it("is false when the account holds no inbound message at all", async () => {
|
|
667
|
+
expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
668
|
+
});
|
|
669
|
+
it("is false when nothing arrived after the watermark", async () => {
|
|
670
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
671
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
|
|
672
|
+
hasWork: false,
|
|
673
|
+
reasons: [],
|
|
674
|
+
errored: [],
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
it("is true when a message arrived after the watermark", async () => {
|
|
678
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
679
|
+
const lines = linesFrom();
|
|
680
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toEqual({
|
|
681
|
+
hasWork: true,
|
|
682
|
+
reasons: ["telegram"],
|
|
683
|
+
errored: [],
|
|
684
|
+
});
|
|
685
|
+
expect(measured(lines)).toBe(true);
|
|
686
|
+
});
|
|
687
|
+
it("is false on the exact watermark — that message is the one already handled", async () => {
|
|
688
|
+
// The boundary is `>`, not `>=`. A `>=` would re-report the last handled
|
|
689
|
+
// message on every fire and the gate could never go quiet.
|
|
690
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
691
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toEqual({
|
|
692
|
+
hasWork: false,
|
|
693
|
+
reasons: [],
|
|
694
|
+
errored: [],
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
it("is true on a first run, so nothing already waiting is missed", async () => {
|
|
698
|
+
const lines = linesFrom();
|
|
699
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
|
|
700
|
+
hasWork: true,
|
|
701
|
+
reasons: ["telegram"],
|
|
702
|
+
errored: [],
|
|
703
|
+
});
|
|
704
|
+
expect(measured(lines)).toBe(true);
|
|
705
|
+
});
|
|
706
|
+
it("reports work rather than an error when the probe is unreachable", async () => {
|
|
707
|
+
const lines = linesFrom();
|
|
708
|
+
const sources = quietSources({
|
|
709
|
+
telegramInbound: async () => {
|
|
710
|
+
throw new Error("HTTP 500 on GET /api/telegram-reader/gate-probe");
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
expect(await run([CHECK], sources)).toEqual({
|
|
714
|
+
hasWork: true,
|
|
715
|
+
reasons: ["telegram"],
|
|
716
|
+
errored: [{ kind: "telegram", detail: "HTTP 500 on GET /api/telegram-reader/gate-probe" }],
|
|
717
|
+
});
|
|
718
|
+
// The opposite of the two assertions above: this one MUST name the failure,
|
|
719
|
+
// or a permanently broken probe reads as a permanently busy account. The
|
|
720
|
+
// DETAIL is asserted, not just the kind — an unrecognised check type also
|
|
721
|
+
// logs `kind=telegram`, so the kind alone cannot tell "the probe failed"
|
|
722
|
+
// from "this build has no telegram check".
|
|
723
|
+
const line = lines.find((l) => l.includes("op=check-error"));
|
|
724
|
+
expect(line).toContain("kind=telegram");
|
|
725
|
+
expect(line).toContain("/api/telegram-reader/gate-probe");
|
|
726
|
+
});
|
|
727
|
+
it("a commit advances the watermark to what the gate's probe read", async () => {
|
|
728
|
+
// Task 2654 — the gate's probe, not the commit's. The commit runs at the end
|
|
729
|
+
// of the turn, so its own reading already includes anything that arrived
|
|
730
|
+
// while the turn ran.
|
|
731
|
+
await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")));
|
|
732
|
+
const result = await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")), true);
|
|
733
|
+
expect(result.advanced).toEqual(["telegram"]);
|
|
734
|
+
const marks = readWatermarks(EVENT, platformRoot);
|
|
735
|
+
expect(marks.telegram).toEqual({ inbound: "2026-08-05T11:00:00.000Z" });
|
|
736
|
+
});
|
|
737
|
+
// Task 2662 — the bot scope. Before it the check named no bot and the probe
|
|
738
|
+
// read every conversation on the account, so a routine watching a booking bot
|
|
739
|
+
// woke for every message to a relay bot sharing that account. A `botId` on the
|
|
740
|
+
// check was accepted by the schema and reached nothing that read it.
|
|
741
|
+
it("forwards botId to the source, so the probe can scope to one bot", async () => {
|
|
742
|
+
const seen = [];
|
|
743
|
+
const sources = quietSources({
|
|
744
|
+
telegramInbound: async (_a, botId) => {
|
|
745
|
+
seen.push(botId);
|
|
746
|
+
return { newestInboundAt: null };
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
await run([{ type: "telegram", scope: "inbound", botId: "111" }], sources);
|
|
750
|
+
expect(seen).toEqual(["111"]);
|
|
751
|
+
});
|
|
752
|
+
it("calls the source with no botId when the check names none", async () => {
|
|
753
|
+
// The account-wide shape 2653 shipped. A gate bound before 2660 carries no
|
|
754
|
+
// botId and must keep answering for the whole account.
|
|
755
|
+
const seen = [];
|
|
756
|
+
const sources = quietSources({
|
|
757
|
+
telegramInbound: async (_a, botId) => {
|
|
758
|
+
seen.push(botId);
|
|
759
|
+
return { newestInboundAt: null };
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
await run([CHECK], sources);
|
|
763
|
+
expect(seen).toEqual([undefined]);
|
|
764
|
+
});
|
|
765
|
+
it("keeps each bot scope on its own watermark, so narrowing a gate cannot skip work", async () => {
|
|
766
|
+
// The mail check keys `<mailbox>|<folder>` for this reason (Task 2635): two
|
|
767
|
+
// scopes sharing one position means the narrower one inherits a mark taken
|
|
768
|
+
// for a wider set. Here the account-wide mark is LATER than bot 111's own
|
|
769
|
+
// newest, so a shared key would answer "no work" while 111 holds a message
|
|
770
|
+
// nobody has read. That is the mute direction, and it loses a booking.
|
|
771
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
|
|
772
|
+
const check = { type: "telegram", scope: "inbound", botId: "111" };
|
|
773
|
+
expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).toEqual({
|
|
774
|
+
hasWork: true,
|
|
775
|
+
reasons: ["telegram"],
|
|
776
|
+
errored: [],
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
it("a scoped commit writes under the bot, leaving the account-wide mark alone", async () => {
|
|
780
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
|
|
781
|
+
const check = { type: "telegram", scope: "inbound", botId: "111" };
|
|
782
|
+
const sources = quietSources(at("2026-08-05T12:00:00.000Z"));
|
|
783
|
+
// Task 2654 — the commit promotes what the GATE read, so the gate call is
|
|
784
|
+
// part of the sequence. A bare commit advances nothing, by design.
|
|
785
|
+
await run([check], sources);
|
|
786
|
+
const result = await run([check], sources, true);
|
|
787
|
+
expect(result.advanced).toEqual(["telegram:111"]);
|
|
788
|
+
const marks = readWatermarks(EVENT, platformRoot);
|
|
789
|
+
expect(marks.telegram?.inbound).toBe("2026-08-05T18:00:00.000Z");
|
|
790
|
+
expect(marks.telegram?.byBot).toEqual({ "111": "2026-08-05T12:00:00.000Z" });
|
|
791
|
+
});
|
|
792
|
+
it("two bots on one event hold two positions", async () => {
|
|
793
|
+
seedWatermarks({
|
|
794
|
+
telegram: { byBot: { "111": "2026-08-05T12:00:00.000Z", "222": "2026-08-05T20:00:00.000Z" } },
|
|
795
|
+
});
|
|
796
|
+
const a = { type: "telegram", scope: "inbound", botId: "111" };
|
|
797
|
+
const b = { type: "telegram", scope: "inbound", botId: "222" };
|
|
798
|
+
expect(await run([a], quietSources(at("2026-08-05T13:00:00.000Z")))).toMatchObject({
|
|
799
|
+
hasWork: true,
|
|
800
|
+
});
|
|
801
|
+
expect(await run([b], quietSources(at("2026-08-05T13:00:00.000Z")))).toMatchObject({
|
|
802
|
+
hasWork: false,
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
it("reports work and names the bot when the probe refuses an unknown one", async () => {
|
|
806
|
+
// The operator's own reproduction: id `0000000000`. The probe answers 404,
|
|
807
|
+
// which reaches here as an unreadable check. It must report WORK, never
|
|
808
|
+
// quiet, or the routine is muted for ever with nothing in the log.
|
|
809
|
+
const lines = linesFrom();
|
|
810
|
+
const sources = quietSources({
|
|
811
|
+
telegramInbound: async (_a, botId) => {
|
|
812
|
+
throw new Error(`HTTP 404 on GET /api/telegram-reader/gate-probe?botId=${botId}`);
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
const check = { type: "telegram", scope: "inbound", botId: "0000000000" };
|
|
816
|
+
const result = await run([check], sources);
|
|
817
|
+
expect(result).toMatchObject({ hasWork: true, reasons: ["telegram"] });
|
|
818
|
+
// Task 2658's structured arm carries the reason, so a caller can tell a bot
|
|
819
|
+
// with messages from a bot id that resolves to nothing. `reasons` alone
|
|
820
|
+
// cannot: it holds "telegram" either way.
|
|
821
|
+
expect(result.errored).toEqual([
|
|
822
|
+
{
|
|
823
|
+
kind: "telegram",
|
|
824
|
+
detail: "HTTP 404 on GET /api/telegram-reader/gate-probe?botId=0000000000",
|
|
825
|
+
},
|
|
826
|
+
]);
|
|
827
|
+
const line = lines.find((l) => l.includes("op=check-error"));
|
|
828
|
+
expect(line).toContain("kind=telegram");
|
|
829
|
+
expect(line).toContain("0000000000");
|
|
830
|
+
});
|
|
831
|
+
it("a commit on a quiet account is skipped, not advanced onto nothing", async () => {
|
|
832
|
+
// A null probe is "nothing to measure from", not "position zero". There is
|
|
833
|
+
// no position to record, so the check reports as `skipped` exactly as `d1`
|
|
834
|
+
// does, and the stored mark is left alone. Writing null would drop the mark
|
|
835
|
+
// and re-report every stored message on the next fire.
|
|
836
|
+
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
837
|
+
const result = await run([CHECK], quietSources(), true);
|
|
838
|
+
expect(result).toMatchObject({ hasWork: false, advanced: [], skipped: ["telegram"] });
|
|
839
|
+
expect(readWatermarks(EVENT, platformRoot).telegram).toEqual({
|
|
840
|
+
inbound: "2026-08-05T10:00:00.000Z",
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
// Task 2653 — an unrecognised check type. `probe()`'s switch covered exactly the
|
|
845
|
+
// declared union, so a fifth type returned `undefined` and `outcome.hasWork` was
|
|
846
|
+
// read OUTSIDE the caller's try: a TypeError escaped `runSchedulingGate`, the
|
|
847
|
+
// dispatcher failed open, and NEITHER `op=check-error` nor `op=checked` reached
|
|
848
|
+
// any log. Measured on maxy 2026-08-10 from a `{type:'telegram'}` probe.
|
|
849
|
+
describe("unrecognised check type", () => {
|
|
850
|
+
const CHECK = { type: "nonsense" };
|
|
851
|
+
it("resolves rather than throwing, and counts the unknown type as work", async () => {
|
|
852
|
+
expect(await run([CHECK], quietSources())).toEqual({
|
|
853
|
+
hasWork: true,
|
|
854
|
+
reasons: ["nonsense"],
|
|
855
|
+
errored: [
|
|
856
|
+
{
|
|
857
|
+
kind: "nonsense",
|
|
858
|
+
detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram',
|
|
859
|
+
},
|
|
860
|
+
],
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
it("names the type and the accepted set in op=check-error", async () => {
|
|
864
|
+
const lines = [];
|
|
865
|
+
vi.spyOn(console, "error").mockImplementation((...a) => {
|
|
866
|
+
lines.push(a.join(" "));
|
|
867
|
+
});
|
|
868
|
+
await run([CHECK], quietSources());
|
|
869
|
+
const line = lines.find((l) => l.includes("op=check-error"));
|
|
870
|
+
expect(line).toContain("kind=nonsense");
|
|
871
|
+
expect(line).toContain("treatedAs=has-work");
|
|
872
|
+
expect(line).toMatch(/mail.*d1.*whatsapp.*calendar/);
|
|
873
|
+
});
|
|
874
|
+
it("still runs the sibling checks — one bad type does not abort the gate", async () => {
|
|
875
|
+
const checks = [CHECK, { type: "d1", database: "b", sql: "SELECT 1" }];
|
|
876
|
+
expect(await run(checks, quietSources())).toEqual({
|
|
877
|
+
hasWork: true,
|
|
878
|
+
reasons: ["nonsense"],
|
|
879
|
+
errored: [
|
|
880
|
+
{
|
|
881
|
+
kind: "nonsense",
|
|
882
|
+
detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram',
|
|
883
|
+
},
|
|
884
|
+
],
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
// Task 2658 — `reasons` carries the check type on the real-work arm and the
|
|
890
|
+
// error arm alike, so `{"hasWork":true,"reasons":["d1"]}` is byte-identical for
|
|
891
|
+
// a database holding rows and one that 403s. `errored` is the only place that
|
|
892
|
+
// difference survives the call, and no bind-time guard is writable without it.
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
describe("errored", () => {
|
|
895
|
+
const D1 = { type: "d1", database: "db", sql: "SELECT 1" };
|
|
896
|
+
it("is empty when every check answers", async () => {
|
|
897
|
+
const res = await run([D1], quietSources());
|
|
898
|
+
expect(res.errored).toEqual([]);
|
|
899
|
+
});
|
|
900
|
+
it("names the kind and the source's own message when a check throws", async () => {
|
|
901
|
+
const sources = quietSources({
|
|
902
|
+
d1: async () => {
|
|
903
|
+
throw new Error("HTTP 403 on POST /api/storage/d1/query");
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
const res = await run([D1], sources);
|
|
907
|
+
expect(res.errored).toEqual([{ kind: "d1", detail: "HTTP 403 on POST /api/storage/d1/query" }]);
|
|
908
|
+
});
|
|
909
|
+
it("is not distinguishable from a real hit by hasWork or reasons alone", async () => {
|
|
910
|
+
const thrown = await run([D1], quietSources({
|
|
911
|
+
d1: async () => {
|
|
912
|
+
throw new Error("HTTP 403 on POST /api/storage/d1/query");
|
|
913
|
+
},
|
|
914
|
+
}));
|
|
915
|
+
const real = await run([D1], quietSources({ d1: async () => 7 }));
|
|
916
|
+
expect(thrown.hasWork).toBe(real.hasWork);
|
|
917
|
+
expect(thrown.reasons).toEqual(real.reasons);
|
|
918
|
+
expect(thrown.errored).toHaveLength(1);
|
|
919
|
+
expect(real.errored).toEqual([]);
|
|
920
|
+
});
|
|
921
|
+
it("carries one entry per erroring check, in checks order", async () => {
|
|
922
|
+
const sources = quietSources({
|
|
923
|
+
d1: async () => {
|
|
924
|
+
throw new Error("d1 down");
|
|
925
|
+
},
|
|
926
|
+
calendarDigest: async () => {
|
|
927
|
+
throw new Error("calendar down");
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
const checks = [
|
|
931
|
+
D1,
|
|
932
|
+
{ type: "whatsapp", scope: "status" },
|
|
933
|
+
{ type: "calendar", connector: "google" },
|
|
934
|
+
];
|
|
935
|
+
const res = await run(checks, sources);
|
|
936
|
+
expect(res.errored).toEqual([
|
|
937
|
+
{ kind: "d1", detail: "d1 down" },
|
|
938
|
+
{ kind: "calendar", detail: "calendar down" },
|
|
939
|
+
]);
|
|
940
|
+
});
|
|
941
|
+
it("is present on a commit too", async () => {
|
|
942
|
+
const res = await run([D1], quietSources(), true);
|
|
943
|
+
expect(res.errored).toEqual([]);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
// Task 2657 — the same check, on a mailbox the outlook plugin holds.
|
|
947
|
+
//
|
|
948
|
+
// The first test is the one that matters. Graph's
|
|
949
|
+
// `$top=1&$orderby=receivedDateTime desc` returns the newest message whether or
|
|
950
|
+
// not anything arrived, exactly as `UID SEARCH n+1:*` returns the last message
|
|
951
|
+
// above the highest UID — so an implementation that reports work on any
|
|
952
|
+
// non-empty response passes every other test here, which is how Task 2471
|
|
953
|
+
// shipped.
|
|
954
|
+
describe("mail check — outlook provider", () => {
|
|
955
|
+
const CHECK = { type: "mail", mailbox: "admin@muvin.co.uk", provider: "outlook" };
|
|
956
|
+
const KEY = "admin@muvin.co.uk|inbox";
|
|
957
|
+
it("is false when the newest message equals the watermark", async () => {
|
|
958
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
959
|
+
const sources = quietSources({
|
|
960
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
961
|
+
});
|
|
962
|
+
expect(await run([CHECK], sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
963
|
+
});
|
|
964
|
+
it("is true on a message above the watermark, and advances that key on commit", async () => {
|
|
965
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
966
|
+
const sources = quietSources({
|
|
967
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:05:00Z" }),
|
|
968
|
+
});
|
|
969
|
+
// Task 2654 — the commit promotes what the gate read, so the gate call is
|
|
970
|
+
// part of the sequence. A bare commit advances nothing, by design.
|
|
971
|
+
await run([CHECK], sources);
|
|
972
|
+
expect(await run([CHECK], sources, true)).toEqual({
|
|
973
|
+
hasWork: true,
|
|
974
|
+
reasons: ["mail"],
|
|
975
|
+
errored: [],
|
|
976
|
+
advanced: [KEY],
|
|
977
|
+
skipped: [],
|
|
978
|
+
});
|
|
979
|
+
const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
980
|
+
expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:05:00Z" });
|
|
981
|
+
});
|
|
982
|
+
it("is true on a first run, when nothing is stored", async () => {
|
|
983
|
+
const sources = quietSources({
|
|
984
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
985
|
+
});
|
|
986
|
+
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
987
|
+
});
|
|
988
|
+
it("orders by instant, not lexically, so a fractional second is not read as older", async () => {
|
|
989
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
990
|
+
const sources = quietSources({
|
|
991
|
+
// Lexically "…00.500Z" sorts BELOW "…00Z" ('.' is 0x2E, 'Z' is 0x5A).
|
|
992
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00.500Z" }),
|
|
993
|
+
});
|
|
994
|
+
expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
995
|
+
});
|
|
996
|
+
it("reports an empty folder as skipped on a commit rather than advancing onto nothing", async () => {
|
|
997
|
+
const sources = quietSources({ mailOutlook: async () => ({ newestReceivedAt: null }) });
|
|
998
|
+
expect(await run([CHECK], sources, true)).toEqual({
|
|
999
|
+
hasWork: false,
|
|
1000
|
+
reasons: [],
|
|
1001
|
+
errored: [],
|
|
1002
|
+
advanced: [],
|
|
1003
|
+
skipped: ["mail"],
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
it("keys the folder segment lower-cased, so two spellings address one entry", async () => {
|
|
1007
|
+
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
1008
|
+
const check = { ...CHECK, folder: "Inbox" };
|
|
1009
|
+
const sources = quietSources({
|
|
1010
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
1011
|
+
});
|
|
1012
|
+
expect(await run([check], sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
|
|
1013
|
+
});
|
|
1014
|
+
it("counts a probe that throws as work, and never as quiet", async () => {
|
|
1015
|
+
const sources = quietSources({
|
|
1016
|
+
mailOutlook: async () => {
|
|
1017
|
+
throw new Error("No Outlook mailbox admin@muvin.co.uk is connected");
|
|
1018
|
+
},
|
|
1019
|
+
});
|
|
1020
|
+
expect(await run([CHECK], sources)).toEqual({
|
|
1021
|
+
hasWork: true,
|
|
1022
|
+
reasons: ["mail"],
|
|
1023
|
+
errored: [{ kind: "mail", detail: "No Outlook mailbox admin@muvin.co.uk is connected" }],
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
it("throws on a provider outside the accepted list", async () => {
|
|
1027
|
+
const check = { type: "mail", mailbox: "a@b.com", provider: "pop3" };
|
|
1028
|
+
expect(await run([check], quietSources())).toEqual({
|
|
1029
|
+
hasWork: true,
|
|
1030
|
+
reasons: ["mail"],
|
|
1031
|
+
errored: [{ kind: "mail", detail: 'unknown mail provider "pop3" — accepted: imap, outlook' }],
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
it("leaves the IMAP watermark map untouched", async () => {
|
|
1035
|
+
seedWatermarks({ mail: { [KEY]: { uid: 42, uidValidity: 7 } } });
|
|
1036
|
+
const sources = quietSources({
|
|
1037
|
+
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
1038
|
+
});
|
|
1039
|
+
// Task 2654 — the gate call records the position the commit promotes.
|
|
1040
|
+
await run([CHECK], sources);
|
|
1041
|
+
await run([CHECK], sources, true);
|
|
1042
|
+
const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1043
|
+
expect(written.mail[KEY]).toEqual({ uid: 42, uidValidity: 7 });
|
|
1044
|
+
expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:00:00Z" });
|
|
1045
|
+
});
|
|
1046
|
+
it("takes the IMAP arm when provider is absent", async () => {
|
|
1047
|
+
let outlookCalled = false;
|
|
1048
|
+
const sources = quietSources({
|
|
1049
|
+
mailOutlook: async () => {
|
|
1050
|
+
outlookCalled = true;
|
|
1051
|
+
return { newestReceivedAt: "2026-08-10T09:00:00Z" };
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
await run([{ type: "mail", mailbox: "admin@muvin.co.uk" }], sources);
|
|
1055
|
+
expect(outlookCalled).toBe(false);
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// Task 2654 — a source that moves between the gate call and the commit.
|
|
1060
|
+
//
|
|
1061
|
+
// The commit runs at the END of the agent turn, so its own probe already
|
|
1062
|
+
// includes whatever arrived while the turn ran. Writing that position puts the
|
|
1063
|
+
// arrival BELOW the watermark and no later fire reports it. The commit writes
|
|
1064
|
+
// what the GATE read instead, so the mover stays above.
|
|
1065
|
+
//
|
|
1066
|
+
// Each check is driven alone, and each test runs the real sequence: gate,
|
|
1067
|
+
// source moves, commit, gate again.
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
1069
|
+
describe("a source that moves between the gate call and the commit", () => {
|
|
1070
|
+
it("mail: a UID that arrives mid-turn is still reported after the commit", async () => {
|
|
1071
|
+
let highest = 105;
|
|
1072
|
+
const sources = quietSources({
|
|
1073
|
+
mail: async (_c, sinceUid) => ({
|
|
1074
|
+
matched: highest > sinceUid,
|
|
1075
|
+
highestUid: Math.max(highest, sinceUid),
|
|
1076
|
+
uidValidity: 1,
|
|
1077
|
+
}),
|
|
1078
|
+
});
|
|
1079
|
+
const checks = [{ type: "mail", mailbox: "ops@example.com" }];
|
|
1080
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
1081
|
+
highest = 106; // arrives while the turn runs
|
|
1082
|
+
await run(checks, sources, true);
|
|
1083
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1084
|
+
expect(marks.mail["ops@example.com|inbox"].uid).toBe(105);
|
|
1085
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
1086
|
+
});
|
|
1087
|
+
it("whatsapp: an inbound message mid-turn is still reported after the commit", async () => {
|
|
1088
|
+
let latest = 1_754_000_100;
|
|
1089
|
+
const sources = quietSources({
|
|
1090
|
+
whatsappInbound: async (_a, since) => ({
|
|
1091
|
+
latest: Math.max(latest, since),
|
|
1092
|
+
newer: latest > since,
|
|
1093
|
+
}),
|
|
1094
|
+
});
|
|
1095
|
+
const checks = [{ type: "whatsapp", scope: "inbound" }];
|
|
1096
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
1097
|
+
latest = 1_754_000_200;
|
|
1098
|
+
await run(checks, sources, true);
|
|
1099
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1100
|
+
expect(marks.whatsapp.inbound).toBe(1_754_000_100);
|
|
1101
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
1102
|
+
});
|
|
1103
|
+
it("telegram: an inbound message mid-turn is still reported after the commit", async () => {
|
|
1104
|
+
let newest = "2026-08-10T10:00:00.000Z";
|
|
1105
|
+
const sources = quietSources({ telegramInbound: async () => ({ newestInboundAt: newest }) });
|
|
1106
|
+
const checks = [{ type: "telegram", scope: "inbound" }];
|
|
1107
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["telegram"], errored: [] });
|
|
1108
|
+
newest = "2026-08-10T10:30:00.000Z";
|
|
1109
|
+
await run(checks, sources, true);
|
|
1110
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1111
|
+
expect(marks.telegram.inbound).toBe("2026-08-10T10:00:00.000Z");
|
|
1112
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["telegram"], errored: [] });
|
|
1113
|
+
});
|
|
1114
|
+
it("calendar: a connector change mid-turn is still reported after the commit", async () => {
|
|
1115
|
+
let digest = "DIGEST-A";
|
|
1116
|
+
const sources = quietSources({ calendarDigest: async () => digest });
|
|
1117
|
+
const checks = [{ type: "calendar", connector: "google" }];
|
|
1118
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
1119
|
+
digest = "DIGEST-B";
|
|
1120
|
+
await run(checks, sources, true);
|
|
1121
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1122
|
+
expect(marks.calendar.digest).toBe("DIGEST-A");
|
|
1123
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
1124
|
+
});
|
|
1125
|
+
// Task 2654 — the no-event case. A message swallowed in production emitted
|
|
1126
|
+
// nothing at all, and re-running the gate reproduced nothing, so the pair below
|
|
1127
|
+
// is the only way to see a watermark that jumped past an unseen window.
|
|
1128
|
+
describe("gate-read and commit positions pair on one fire", () => {
|
|
1129
|
+
// `console.error` is already spied in `beforeEach`, so its recorded calls are
|
|
1130
|
+
// the log. Re-mocking it here would drop the lines written before this point.
|
|
1131
|
+
const errorLines = () => vi.mocked(console.error).mock.calls.map((c) => c.map(String).join(" "));
|
|
1132
|
+
it("names the position read at gate time and the position written at commit time", async () => {
|
|
1133
|
+
const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
|
|
1134
|
+
const checks = [{ type: "calendar", connector: "google" }];
|
|
1135
|
+
await run(checks, sources);
|
|
1136
|
+
await run(checks, sources, true);
|
|
1137
|
+
expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar position=abcdef01"));
|
|
1138
|
+
expect(errorLines()).toContainEqual(expect.stringContaining("positions=calendar:abcdef01"));
|
|
1139
|
+
});
|
|
1140
|
+
it("names an unreadable observation, which otherwise reads as a first commit", async () => {
|
|
1141
|
+
// A present-but-unusable file stops every watermark advancing while
|
|
1142
|
+
// `op=commit` still prints an empty `advanced=` — indistinguishable from a
|
|
1143
|
+
// healthy first commit without a line of its own.
|
|
1144
|
+
const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
|
|
1145
|
+
const checks = [{ type: "calendar", connector: "google" }];
|
|
1146
|
+
await run(checks, sources);
|
|
1147
|
+
writeFileSync(observationPath(EVENT, platformRoot), "{ not json");
|
|
1148
|
+
const result = await run(checks, sources, true);
|
|
1149
|
+
expect(result.advanced).toEqual([]);
|
|
1150
|
+
expect(errorLines()).toContainEqual(expect.stringContaining("op=observation-unreadable eventId=65ef758c reason="));
|
|
1151
|
+
});
|
|
1152
|
+
it("says nothing when no observation file exists, which is a first fire", async () => {
|
|
1153
|
+
const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
|
|
1154
|
+
await run([{ type: "calendar", connector: "google" }], sources, true);
|
|
1155
|
+
expect(errorLines().filter((l) => l.includes("op=observation-unreadable"))).toEqual([]);
|
|
1156
|
+
});
|
|
1157
|
+
it("emits no gate-read for a check that holds no position", async () => {
|
|
1158
|
+
// `op=check-error` and `skipped=` already cover a check with no position;
|
|
1159
|
+
// a `position=null` line would only add a value nobody can compare.
|
|
1160
|
+
await run([{ type: "d1", database: "b", sql: "SELECT count(*) FROM b" }], quietSources());
|
|
1161
|
+
expect(errorLines().filter((l) => l.includes("op=gate-read"))).toEqual([]);
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
it("a commit with no gate call before it advances nothing", async () => {
|
|
1165
|
+
// Route two (PLUGIN.md) lets a separate action event issue the commit. With
|
|
1166
|
+
// no observation on file there is no position anyone saw, so the watermark
|
|
1167
|
+
// stays put and the next fire reports the work again — the fail-open
|
|
1168
|
+
// direction the whole tool holds to.
|
|
1169
|
+
const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
|
|
1170
|
+
const checks = [{ type: "calendar", connector: "google" }];
|
|
1171
|
+
const result = await run(checks, sources, true);
|
|
1172
|
+
expect(result.advanced).toEqual([]);
|
|
1173
|
+
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1174
|
+
expect(marks.calendar).toBeUndefined();
|
|
1175
|
+
expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
625
1178
|
//# sourceMappingURL=scheduling-gate.test.js.map
|