@rubytech/create-maxy-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.
Files changed (216) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/routine-templates/dist/roster.d.ts +12 -2
  3. package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
  4. package/payload/platform/lib/routine-templates/dist/roster.js +6 -2
  5. package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
  6. package/payload/platform/lib/routine-templates/src/roster.ts +14 -3
  7. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +14 -0
  8. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  9. package/payload/platform/plugins/outlook/PLUGIN.md +6 -1
  10. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.d.ts +2 -0
  11. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.d.ts.map +1 -0
  12. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.js +75 -0
  13. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.js.map +1 -0
  14. package/payload/platform/plugins/outlook/mcp/dist/index.js +14 -0
  15. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.d.ts +33 -0
  17. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.d.ts.map +1 -0
  18. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.js +32 -0
  19. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.js.map +1 -0
  20. package/payload/platform/plugins/scheduling/PLUGIN.md +12 -2
  21. package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
  22. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  23. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +386 -1
  24. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  25. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +569 -16
  26. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  27. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +61 -0
  28. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  29. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +163 -0
  30. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  31. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  32. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +4 -1
  33. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  34. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +252 -16
  35. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  36. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +411 -22
  37. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  38. package/payload/platform/scripts/check-specialist-tool-surface.mjs +15 -1
  39. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  41. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  42. package/payload/server/public/activity.html +6 -6
  43. package/payload/server/public/agents.html +5 -5
  44. package/payload/server/public/assets/{AdminLoginScreens-CemYu7ho.js → AdminLoginScreens-dBZJlMbp.js} +1 -1
  45. package/payload/server/public/assets/AdminLoginScreens-dBZJlMbp.js.br +0 -0
  46. package/payload/server/public/assets/AdminLoginScreens-dBZJlMbp.js.gz +0 -0
  47. package/payload/server/public/assets/{AdminShell-B5vEeSLw.js → AdminShell-D94sgtsb.js} +1 -1
  48. package/payload/server/public/assets/AdminShell-D94sgtsb.js.br +0 -0
  49. package/payload/server/public/assets/AdminShell-D94sgtsb.js.gz +0 -0
  50. package/payload/server/public/assets/{activity-BHSuZrqG.js → activity-D6NEUvT7.js} +1 -1
  51. package/payload/server/public/assets/activity-D6NEUvT7.js.br +0 -0
  52. package/payload/server/public/assets/activity-D6NEUvT7.js.gz +0 -0
  53. package/payload/server/public/assets/{admin-DnvauP59.js → admin-BluqXSKn.js} +1 -1
  54. package/payload/server/public/assets/admin-BluqXSKn.js.br +0 -0
  55. package/payload/server/public/assets/admin-BluqXSKn.js.gz +0 -0
  56. package/payload/server/public/assets/{agents-CwTJjfyV.js → agents-D_6V66dr.js} +1 -1
  57. package/payload/server/public/assets/agents-D_6V66dr.js.br +0 -0
  58. package/payload/server/public/assets/agents-D_6V66dr.js.gz +0 -0
  59. package/payload/server/public/assets/{browser-D4yjnKEH.js → browser-Bz9s6ztS.js} +1 -1
  60. package/payload/server/public/assets/browser-Bz9s6ztS.js.br +0 -0
  61. package/payload/server/public/assets/browser-Bz9s6ztS.js.gz +0 -0
  62. package/payload/server/public/assets/{calendar-DUi1XFyY.js → calendar-D5OCrmwA.js} +1 -1
  63. package/payload/server/public/assets/calendar-D5OCrmwA.js.br +0 -0
  64. package/payload/server/public/assets/calendar-D5OCrmwA.js.gz +0 -0
  65. package/payload/server/public/assets/chat-CjlbEzZ-.js +1 -0
  66. package/payload/server/public/assets/chat-CjlbEzZ-.js.br +1 -0
  67. package/payload/server/public/assets/chat-CjlbEzZ-.js.gz +0 -0
  68. package/payload/server/public/assets/chevron-left-b_r2jNvg.js +1 -0
  69. package/payload/server/public/assets/chevron-left-b_r2jNvg.js.br +0 -0
  70. package/payload/server/public/assets/clock-D8tMbEZD.js +1 -0
  71. package/payload/server/public/assets/clock-D8tMbEZD.js.br +0 -0
  72. package/payload/server/public/assets/clock-D8tMbEZD.js.gz +0 -0
  73. package/payload/server/public/assets/{copy-Bw2h4ofb.js → copy-CXBu2lHL.js} +1 -1
  74. package/payload/server/public/assets/copy-CXBu2lHL.js.br +0 -0
  75. package/payload/server/public/assets/copy-CXBu2lHL.js.gz +0 -0
  76. package/payload/server/public/assets/data-ChHg_Lq9.js +1 -0
  77. package/payload/server/public/assets/data-ChHg_Lq9.js.br +0 -0
  78. package/payload/server/public/assets/data-ChHg_Lq9.js.gz +0 -0
  79. package/payload/server/public/assets/{field-BS-mu5Jx.js → field-D4fXu_SP.js} +1 -1
  80. package/payload/server/public/assets/field-D4fXu_SP.js.br +0 -0
  81. package/payload/server/public/assets/field-D4fXu_SP.js.gz +0 -0
  82. package/payload/server/public/assets/{file-text-xUlDn7ui.js → file-text-BxZ1UMcQ.js} +1 -1
  83. package/payload/server/public/assets/file-text-BxZ1UMcQ.js.br +0 -0
  84. package/payload/server/public/assets/file-text-BxZ1UMcQ.js.gz +0 -0
  85. package/payload/server/public/assets/{graph-DEEXiQLx.js → graph-DMleOCBZ.js} +1 -1
  86. package/payload/server/public/assets/graph-DMleOCBZ.js.br +0 -0
  87. package/payload/server/public/assets/graph-DMleOCBZ.js.gz +0 -0
  88. package/payload/server/public/assets/{graph-labels-F6hQJKCn.js → graph-labels-BHapdrTm.js} +1 -1
  89. package/payload/server/public/assets/graph-labels-BHapdrTm.js.br +0 -0
  90. package/payload/server/public/assets/graph-labels-BHapdrTm.js.gz +0 -0
  91. package/payload/server/public/assets/{jsx-runtime-DDrveXI8.css → jsx-runtime-gqFnq1MC.css} +1 -1
  92. package/payload/server/public/assets/jsx-runtime-gqFnq1MC.css.br +0 -0
  93. package/payload/server/public/assets/{jsx-runtime-DDrveXI8.css.gz → jsx-runtime-gqFnq1MC.css.gz} +0 -0
  94. package/payload/server/public/assets/{operator-CkWK9uRB.js → operator-BpZTRiga.js} +1 -1
  95. package/payload/server/public/assets/operator-BpZTRiga.js.br +0 -0
  96. package/payload/server/public/assets/operator-BpZTRiga.js.gz +0 -0
  97. package/payload/server/public/assets/{page-Dh6fCuUi.js → page-CsHWZZ0U.js} +1 -1
  98. package/payload/server/public/assets/page-CsHWZZ0U.js.br +0 -0
  99. package/payload/server/public/assets/page-CsHWZZ0U.js.gz +0 -0
  100. package/payload/server/public/assets/{page-DbWIOK60.js → page-Dczx6CPP.js} +1 -1
  101. package/payload/server/public/assets/page-Dczx6CPP.js.br +0 -0
  102. package/payload/server/public/assets/page-Dczx6CPP.js.gz +0 -0
  103. package/payload/server/public/assets/play-D_xdb6gy.js +1 -0
  104. package/payload/server/public/assets/play-D_xdb6gy.js.br +0 -0
  105. package/payload/server/public/assets/play-D_xdb6gy.js.gz +0 -0
  106. package/payload/server/public/assets/{public-DmD6vvFM.js → public-BzpELWaZ.js} +1 -1
  107. package/payload/server/public/assets/public-BzpELWaZ.js.br +0 -0
  108. package/payload/server/public/assets/public-BzpELWaZ.js.gz +0 -0
  109. package/payload/server/public/assets/{rotate-ccw-BDdOWTDl.js → rotate-ccw-Bkpst1na.js} +1 -1
  110. package/payload/server/public/assets/rotate-ccw-Bkpst1na.js.br +0 -0
  111. package/payload/server/public/assets/rotate-ccw-Bkpst1na.js.gz +0 -0
  112. package/payload/server/public/assets/{routines-CZoKUK0p.js → routines-Cmuf8pPP.js} +2 -2
  113. package/payload/server/public/assets/routines-Cmuf8pPP.js.br +0 -0
  114. package/payload/server/public/assets/routines-Cmuf8pPP.js.gz +0 -0
  115. package/payload/server/public/assets/{skills-D0yS0qLX.js → skills-Bfq3Pbw-.js} +1 -1
  116. package/payload/server/public/assets/skills-Bfq3Pbw-.js.br +0 -0
  117. package/payload/server/public/assets/skills-Bfq3Pbw-.js.gz +0 -0
  118. package/payload/server/public/assets/{tasks-Dtn1uDRY.js → tasks-nyWLrolq.js} +1 -1
  119. package/payload/server/public/assets/tasks-nyWLrolq.js.br +0 -0
  120. package/payload/server/public/assets/tasks-nyWLrolq.js.gz +0 -0
  121. package/payload/server/public/assets/{triangle-alert-DN-Eswuf.js → triangle-alert-DkENIQJ6.js} +1 -1
  122. package/payload/server/public/assets/triangle-alert-DkENIQJ6.js.br +0 -0
  123. package/payload/server/public/assets/triangle-alert-DkENIQJ6.js.gz +0 -0
  124. package/payload/server/public/assets/{useCopyFeedback-BmOZr_Mw.js → useCopyFeedback-DmNjbXe9.js} +1 -1
  125. package/payload/server/public/assets/useCopyFeedback-DmNjbXe9.js.br +1 -0
  126. package/payload/server/public/assets/useCopyFeedback-DmNjbXe9.js.gz +0 -0
  127. package/payload/server/public/assets/{useMediaQuery-fFM7rxUD.js → useMediaQuery-DL-C1y_F.js} +1 -1
  128. package/payload/server/public/assets/useMediaQuery-DL-C1y_F.js.br +0 -0
  129. package/payload/server/public/assets/useMediaQuery-DL-C1y_F.js.gz +0 -0
  130. package/payload/server/public/assets/{useVoiceRecorder-D0MWFIde.js → useVoiceRecorder-DseLVULD.js} +1 -1
  131. package/payload/server/public/assets/useVoiceRecorder-DseLVULD.js.br +0 -0
  132. package/payload/server/public/assets/useVoiceRecorder-DseLVULD.js.gz +0 -0
  133. package/payload/server/public/assets/{wrench-C-yn7q1Q.js → wrench-C7tdp6z9.js} +1 -1
  134. package/payload/server/public/assets/wrench-C7tdp6z9.js.br +0 -0
  135. package/payload/server/public/assets/wrench-C7tdp6z9.js.gz +0 -0
  136. package/payload/server/public/browser.html +5 -5
  137. package/payload/server/public/calendar.html +8 -8
  138. package/payload/server/public/chat.html +14 -14
  139. package/payload/server/public/data.html +12 -12
  140. package/payload/server/public/field.html +3 -3
  141. package/payload/server/public/graph.html +10 -10
  142. package/payload/server/public/index.html +14 -14
  143. package/payload/server/public/operator.html +15 -15
  144. package/payload/server/public/public.html +14 -14
  145. package/payload/server/public/routines.html +8 -8
  146. package/payload/server/public/skills.html +6 -6
  147. package/payload/server/public/tasks.html +7 -7
  148. package/payload/server/server.js +62 -0
  149. package/payload/server/public/assets/AdminLoginScreens-CemYu7ho.js.br +0 -0
  150. package/payload/server/public/assets/AdminLoginScreens-CemYu7ho.js.gz +0 -0
  151. package/payload/server/public/assets/AdminShell-B5vEeSLw.js.br +0 -0
  152. package/payload/server/public/assets/AdminShell-B5vEeSLw.js.gz +0 -0
  153. package/payload/server/public/assets/activity-BHSuZrqG.js.br +0 -0
  154. package/payload/server/public/assets/activity-BHSuZrqG.js.gz +0 -0
  155. package/payload/server/public/assets/admin-DnvauP59.js.br +0 -0
  156. package/payload/server/public/assets/admin-DnvauP59.js.gz +0 -0
  157. package/payload/server/public/assets/agents-CwTJjfyV.js.br +0 -0
  158. package/payload/server/public/assets/agents-CwTJjfyV.js.gz +0 -0
  159. package/payload/server/public/assets/browser-D4yjnKEH.js.br +0 -0
  160. package/payload/server/public/assets/browser-D4yjnKEH.js.gz +0 -0
  161. package/payload/server/public/assets/calendar-DUi1XFyY.js.br +0 -0
  162. package/payload/server/public/assets/calendar-DUi1XFyY.js.gz +0 -0
  163. package/payload/server/public/assets/chat-BJ1p4kuh.js +0 -1
  164. package/payload/server/public/assets/chat-BJ1p4kuh.js.br +0 -1
  165. package/payload/server/public/assets/chat-BJ1p4kuh.js.gz +0 -0
  166. package/payload/server/public/assets/chevron-left-Ctw-3JsV.js +0 -1
  167. package/payload/server/public/assets/chevron-left-Ctw-3JsV.js.br +0 -0
  168. package/payload/server/public/assets/clock-BLOp42yY.js +0 -1
  169. package/payload/server/public/assets/clock-BLOp42yY.js.br +0 -0
  170. package/payload/server/public/assets/clock-BLOp42yY.js.gz +0 -0
  171. package/payload/server/public/assets/copy-Bw2h4ofb.js.br +0 -0
  172. package/payload/server/public/assets/copy-Bw2h4ofb.js.gz +0 -0
  173. package/payload/server/public/assets/data-uR7BZKVU.js +0 -1
  174. package/payload/server/public/assets/data-uR7BZKVU.js.br +0 -0
  175. package/payload/server/public/assets/data-uR7BZKVU.js.gz +0 -0
  176. package/payload/server/public/assets/field-BS-mu5Jx.js.br +0 -0
  177. package/payload/server/public/assets/field-BS-mu5Jx.js.gz +0 -0
  178. package/payload/server/public/assets/file-text-xUlDn7ui.js.br +0 -0
  179. package/payload/server/public/assets/file-text-xUlDn7ui.js.gz +0 -0
  180. package/payload/server/public/assets/graph-DEEXiQLx.js.br +0 -0
  181. package/payload/server/public/assets/graph-DEEXiQLx.js.gz +0 -0
  182. package/payload/server/public/assets/graph-labels-F6hQJKCn.js.br +0 -0
  183. package/payload/server/public/assets/graph-labels-F6hQJKCn.js.gz +0 -0
  184. package/payload/server/public/assets/jsx-runtime-DDrveXI8.css.br +0 -0
  185. package/payload/server/public/assets/operator-CkWK9uRB.js.br +0 -0
  186. package/payload/server/public/assets/operator-CkWK9uRB.js.gz +0 -0
  187. package/payload/server/public/assets/page-DbWIOK60.js.br +0 -0
  188. package/payload/server/public/assets/page-DbWIOK60.js.gz +0 -0
  189. package/payload/server/public/assets/page-Dh6fCuUi.js.br +0 -0
  190. package/payload/server/public/assets/page-Dh6fCuUi.js.gz +0 -0
  191. package/payload/server/public/assets/play-fy3tdi7M.js +0 -1
  192. package/payload/server/public/assets/play-fy3tdi7M.js.br +0 -0
  193. package/payload/server/public/assets/play-fy3tdi7M.js.gz +0 -0
  194. package/payload/server/public/assets/public-DmD6vvFM.js.br +0 -0
  195. package/payload/server/public/assets/public-DmD6vvFM.js.gz +0 -0
  196. package/payload/server/public/assets/rotate-ccw-BDdOWTDl.js.br +0 -0
  197. package/payload/server/public/assets/rotate-ccw-BDdOWTDl.js.gz +0 -0
  198. package/payload/server/public/assets/routines-CZoKUK0p.js.br +0 -0
  199. package/payload/server/public/assets/routines-CZoKUK0p.js.gz +0 -0
  200. package/payload/server/public/assets/skills-D0yS0qLX.js.br +0 -0
  201. package/payload/server/public/assets/skills-D0yS0qLX.js.gz +0 -0
  202. package/payload/server/public/assets/tasks-Dtn1uDRY.js.br +0 -0
  203. package/payload/server/public/assets/tasks-Dtn1uDRY.js.gz +0 -0
  204. package/payload/server/public/assets/triangle-alert-DN-Eswuf.js.br +0 -0
  205. package/payload/server/public/assets/triangle-alert-DN-Eswuf.js.gz +0 -0
  206. package/payload/server/public/assets/useCopyFeedback-BmOZr_Mw.js.br +0 -0
  207. package/payload/server/public/assets/useCopyFeedback-BmOZr_Mw.js.gz +0 -0
  208. package/payload/server/public/assets/useMediaQuery-fFM7rxUD.js.br +0 -0
  209. package/payload/server/public/assets/useMediaQuery-fFM7rxUD.js.gz +0 -0
  210. package/payload/server/public/assets/useVoiceRecorder-D0MWFIde.js.br +0 -0
  211. package/payload/server/public/assets/useVoiceRecorder-D0MWFIde.js.gz +0 -0
  212. package/payload/server/public/assets/wrench-C-yn7q1Q.js.br +0 -0
  213. package/payload/server/public/assets/wrench-C-yn7q1Q.js.gz +0 -0
  214. /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js → jsx-runtime-BcrVQeV-.js} +0 -0
  215. /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js.br → jsx-runtime-BcrVQeV-.js.br} +0 -0
  216. /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js.gz → jsx-runtime-BcrVQeV-.js.gz} +0 -0
@@ -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({ hasWork: true, reasons: ["d1"] });
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