@rubytech/create-maxy-code 0.1.590 → 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 (211) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +9 -0
  3. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  4. package/payload/platform/plugins/outlook/PLUGIN.md +6 -1
  5. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.d.ts +2 -0
  6. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.d.ts.map +1 -0
  7. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.js +75 -0
  8. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-gate-probe.test.js.map +1 -0
  9. package/payload/platform/plugins/outlook/mcp/dist/index.js +14 -0
  10. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  11. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.d.ts +33 -0
  12. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.d.ts.map +1 -0
  13. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.js +32 -0
  14. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-gate-probe.js.map +1 -0
  15. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -2
  16. package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
  17. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  18. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +318 -1
  19. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  20. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +452 -21
  21. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  22. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +61 -0
  23. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  24. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +141 -1
  25. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  26. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  27. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +4 -1
  28. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  29. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +215 -15
  30. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  31. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +353 -29
  32. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  33. package/payload/platform/scripts/check-specialist-tool-surface.mjs +15 -1
  34. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  36. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  37. package/payload/server/public/activity.html +6 -6
  38. package/payload/server/public/agents.html +5 -5
  39. package/payload/server/public/assets/{AdminLoginScreens-CemYu7ho.js → AdminLoginScreens-dBZJlMbp.js} +1 -1
  40. package/payload/server/public/assets/AdminLoginScreens-dBZJlMbp.js.br +0 -0
  41. package/payload/server/public/assets/AdminLoginScreens-dBZJlMbp.js.gz +0 -0
  42. package/payload/server/public/assets/{AdminShell-B5vEeSLw.js → AdminShell-D94sgtsb.js} +1 -1
  43. package/payload/server/public/assets/AdminShell-D94sgtsb.js.br +0 -0
  44. package/payload/server/public/assets/AdminShell-D94sgtsb.js.gz +0 -0
  45. package/payload/server/public/assets/{activity-BHSuZrqG.js → activity-D6NEUvT7.js} +1 -1
  46. package/payload/server/public/assets/activity-D6NEUvT7.js.br +0 -0
  47. package/payload/server/public/assets/activity-D6NEUvT7.js.gz +0 -0
  48. package/payload/server/public/assets/{admin-DnvauP59.js → admin-BluqXSKn.js} +1 -1
  49. package/payload/server/public/assets/admin-BluqXSKn.js.br +0 -0
  50. package/payload/server/public/assets/admin-BluqXSKn.js.gz +0 -0
  51. package/payload/server/public/assets/{agents-CwTJjfyV.js → agents-D_6V66dr.js} +1 -1
  52. package/payload/server/public/assets/agents-D_6V66dr.js.br +0 -0
  53. package/payload/server/public/assets/agents-D_6V66dr.js.gz +0 -0
  54. package/payload/server/public/assets/{browser-D4yjnKEH.js → browser-Bz9s6ztS.js} +1 -1
  55. package/payload/server/public/assets/browser-Bz9s6ztS.js.br +0 -0
  56. package/payload/server/public/assets/browser-Bz9s6ztS.js.gz +0 -0
  57. package/payload/server/public/assets/{calendar-DUi1XFyY.js → calendar-D5OCrmwA.js} +1 -1
  58. package/payload/server/public/assets/calendar-D5OCrmwA.js.br +0 -0
  59. package/payload/server/public/assets/calendar-D5OCrmwA.js.gz +0 -0
  60. package/payload/server/public/assets/chat-CjlbEzZ-.js +1 -0
  61. package/payload/server/public/assets/chat-CjlbEzZ-.js.br +1 -0
  62. package/payload/server/public/assets/chat-CjlbEzZ-.js.gz +0 -0
  63. package/payload/server/public/assets/chevron-left-b_r2jNvg.js +1 -0
  64. package/payload/server/public/assets/chevron-left-b_r2jNvg.js.br +0 -0
  65. package/payload/server/public/assets/clock-D8tMbEZD.js +1 -0
  66. package/payload/server/public/assets/clock-D8tMbEZD.js.br +0 -0
  67. package/payload/server/public/assets/clock-D8tMbEZD.js.gz +0 -0
  68. package/payload/server/public/assets/{copy-Bw2h4ofb.js → copy-CXBu2lHL.js} +1 -1
  69. package/payload/server/public/assets/copy-CXBu2lHL.js.br +0 -0
  70. package/payload/server/public/assets/copy-CXBu2lHL.js.gz +0 -0
  71. package/payload/server/public/assets/data-ChHg_Lq9.js +1 -0
  72. package/payload/server/public/assets/data-ChHg_Lq9.js.br +0 -0
  73. package/payload/server/public/assets/data-ChHg_Lq9.js.gz +0 -0
  74. package/payload/server/public/assets/{field-BS-mu5Jx.js → field-D4fXu_SP.js} +1 -1
  75. package/payload/server/public/assets/field-D4fXu_SP.js.br +0 -0
  76. package/payload/server/public/assets/field-D4fXu_SP.js.gz +0 -0
  77. package/payload/server/public/assets/{file-text-xUlDn7ui.js → file-text-BxZ1UMcQ.js} +1 -1
  78. package/payload/server/public/assets/file-text-BxZ1UMcQ.js.br +0 -0
  79. package/payload/server/public/assets/file-text-BxZ1UMcQ.js.gz +0 -0
  80. package/payload/server/public/assets/{graph-DEEXiQLx.js → graph-DMleOCBZ.js} +1 -1
  81. package/payload/server/public/assets/graph-DMleOCBZ.js.br +0 -0
  82. package/payload/server/public/assets/graph-DMleOCBZ.js.gz +0 -0
  83. package/payload/server/public/assets/{graph-labels-F6hQJKCn.js → graph-labels-BHapdrTm.js} +1 -1
  84. package/payload/server/public/assets/graph-labels-BHapdrTm.js.br +0 -0
  85. package/payload/server/public/assets/graph-labels-BHapdrTm.js.gz +0 -0
  86. package/payload/server/public/assets/{jsx-runtime-DDrveXI8.css → jsx-runtime-gqFnq1MC.css} +1 -1
  87. package/payload/server/public/assets/jsx-runtime-gqFnq1MC.css.br +0 -0
  88. package/payload/server/public/assets/{jsx-runtime-DDrveXI8.css.gz → jsx-runtime-gqFnq1MC.css.gz} +0 -0
  89. package/payload/server/public/assets/{operator-CkWK9uRB.js → operator-BpZTRiga.js} +1 -1
  90. package/payload/server/public/assets/operator-BpZTRiga.js.br +0 -0
  91. package/payload/server/public/assets/operator-BpZTRiga.js.gz +0 -0
  92. package/payload/server/public/assets/{page-Dh6fCuUi.js → page-CsHWZZ0U.js} +1 -1
  93. package/payload/server/public/assets/page-CsHWZZ0U.js.br +0 -0
  94. package/payload/server/public/assets/page-CsHWZZ0U.js.gz +0 -0
  95. package/payload/server/public/assets/{page-DbWIOK60.js → page-Dczx6CPP.js} +1 -1
  96. package/payload/server/public/assets/page-Dczx6CPP.js.br +0 -0
  97. package/payload/server/public/assets/page-Dczx6CPP.js.gz +0 -0
  98. package/payload/server/public/assets/play-D_xdb6gy.js +1 -0
  99. package/payload/server/public/assets/play-D_xdb6gy.js.br +0 -0
  100. package/payload/server/public/assets/play-D_xdb6gy.js.gz +0 -0
  101. package/payload/server/public/assets/{public-DmD6vvFM.js → public-BzpELWaZ.js} +1 -1
  102. package/payload/server/public/assets/public-BzpELWaZ.js.br +0 -0
  103. package/payload/server/public/assets/public-BzpELWaZ.js.gz +0 -0
  104. package/payload/server/public/assets/{rotate-ccw-BDdOWTDl.js → rotate-ccw-Bkpst1na.js} +1 -1
  105. package/payload/server/public/assets/rotate-ccw-Bkpst1na.js.br +0 -0
  106. package/payload/server/public/assets/rotate-ccw-Bkpst1na.js.gz +0 -0
  107. package/payload/server/public/assets/{routines-CZoKUK0p.js → routines-Cmuf8pPP.js} +2 -2
  108. package/payload/server/public/assets/routines-Cmuf8pPP.js.br +0 -0
  109. package/payload/server/public/assets/routines-Cmuf8pPP.js.gz +0 -0
  110. package/payload/server/public/assets/{skills-D0yS0qLX.js → skills-Bfq3Pbw-.js} +1 -1
  111. package/payload/server/public/assets/skills-Bfq3Pbw-.js.br +0 -0
  112. package/payload/server/public/assets/skills-Bfq3Pbw-.js.gz +0 -0
  113. package/payload/server/public/assets/{tasks-Dtn1uDRY.js → tasks-nyWLrolq.js} +1 -1
  114. package/payload/server/public/assets/tasks-nyWLrolq.js.br +0 -0
  115. package/payload/server/public/assets/tasks-nyWLrolq.js.gz +0 -0
  116. package/payload/server/public/assets/{triangle-alert-DN-Eswuf.js → triangle-alert-DkENIQJ6.js} +1 -1
  117. package/payload/server/public/assets/triangle-alert-DkENIQJ6.js.br +0 -0
  118. package/payload/server/public/assets/triangle-alert-DkENIQJ6.js.gz +0 -0
  119. package/payload/server/public/assets/{useCopyFeedback-BmOZr_Mw.js → useCopyFeedback-DmNjbXe9.js} +1 -1
  120. package/payload/server/public/assets/useCopyFeedback-DmNjbXe9.js.br +1 -0
  121. package/payload/server/public/assets/useCopyFeedback-DmNjbXe9.js.gz +0 -0
  122. package/payload/server/public/assets/{useMediaQuery-fFM7rxUD.js → useMediaQuery-DL-C1y_F.js} +1 -1
  123. package/payload/server/public/assets/useMediaQuery-DL-C1y_F.js.br +0 -0
  124. package/payload/server/public/assets/useMediaQuery-DL-C1y_F.js.gz +0 -0
  125. package/payload/server/public/assets/{useVoiceRecorder-D0MWFIde.js → useVoiceRecorder-DseLVULD.js} +1 -1
  126. package/payload/server/public/assets/useVoiceRecorder-DseLVULD.js.br +0 -0
  127. package/payload/server/public/assets/useVoiceRecorder-DseLVULD.js.gz +0 -0
  128. package/payload/server/public/assets/{wrench-C-yn7q1Q.js → wrench-C7tdp6z9.js} +1 -1
  129. package/payload/server/public/assets/wrench-C7tdp6z9.js.br +0 -0
  130. package/payload/server/public/assets/wrench-C7tdp6z9.js.gz +0 -0
  131. package/payload/server/public/browser.html +5 -5
  132. package/payload/server/public/calendar.html +8 -8
  133. package/payload/server/public/chat.html +14 -14
  134. package/payload/server/public/data.html +12 -12
  135. package/payload/server/public/field.html +3 -3
  136. package/payload/server/public/graph.html +10 -10
  137. package/payload/server/public/index.html +14 -14
  138. package/payload/server/public/operator.html +15 -15
  139. package/payload/server/public/public.html +14 -14
  140. package/payload/server/public/routines.html +8 -8
  141. package/payload/server/public/skills.html +6 -6
  142. package/payload/server/public/tasks.html +7 -7
  143. package/payload/server/server.js +41 -2
  144. package/payload/server/public/assets/AdminLoginScreens-CemYu7ho.js.br +0 -0
  145. package/payload/server/public/assets/AdminLoginScreens-CemYu7ho.js.gz +0 -0
  146. package/payload/server/public/assets/AdminShell-B5vEeSLw.js.br +0 -0
  147. package/payload/server/public/assets/AdminShell-B5vEeSLw.js.gz +0 -0
  148. package/payload/server/public/assets/activity-BHSuZrqG.js.br +0 -0
  149. package/payload/server/public/assets/activity-BHSuZrqG.js.gz +0 -0
  150. package/payload/server/public/assets/admin-DnvauP59.js.br +0 -0
  151. package/payload/server/public/assets/admin-DnvauP59.js.gz +0 -0
  152. package/payload/server/public/assets/agents-CwTJjfyV.js.br +0 -0
  153. package/payload/server/public/assets/agents-CwTJjfyV.js.gz +0 -0
  154. package/payload/server/public/assets/browser-D4yjnKEH.js.br +0 -0
  155. package/payload/server/public/assets/browser-D4yjnKEH.js.gz +0 -0
  156. package/payload/server/public/assets/calendar-DUi1XFyY.js.br +0 -0
  157. package/payload/server/public/assets/calendar-DUi1XFyY.js.gz +0 -0
  158. package/payload/server/public/assets/chat-BJ1p4kuh.js +0 -1
  159. package/payload/server/public/assets/chat-BJ1p4kuh.js.br +0 -1
  160. package/payload/server/public/assets/chat-BJ1p4kuh.js.gz +0 -0
  161. package/payload/server/public/assets/chevron-left-Ctw-3JsV.js +0 -1
  162. package/payload/server/public/assets/chevron-left-Ctw-3JsV.js.br +0 -0
  163. package/payload/server/public/assets/clock-BLOp42yY.js +0 -1
  164. package/payload/server/public/assets/clock-BLOp42yY.js.br +0 -0
  165. package/payload/server/public/assets/clock-BLOp42yY.js.gz +0 -0
  166. package/payload/server/public/assets/copy-Bw2h4ofb.js.br +0 -0
  167. package/payload/server/public/assets/copy-Bw2h4ofb.js.gz +0 -0
  168. package/payload/server/public/assets/data-uR7BZKVU.js +0 -1
  169. package/payload/server/public/assets/data-uR7BZKVU.js.br +0 -0
  170. package/payload/server/public/assets/data-uR7BZKVU.js.gz +0 -0
  171. package/payload/server/public/assets/field-BS-mu5Jx.js.br +0 -0
  172. package/payload/server/public/assets/field-BS-mu5Jx.js.gz +0 -0
  173. package/payload/server/public/assets/file-text-xUlDn7ui.js.br +0 -0
  174. package/payload/server/public/assets/file-text-xUlDn7ui.js.gz +0 -0
  175. package/payload/server/public/assets/graph-DEEXiQLx.js.br +0 -0
  176. package/payload/server/public/assets/graph-DEEXiQLx.js.gz +0 -0
  177. package/payload/server/public/assets/graph-labels-F6hQJKCn.js.br +0 -0
  178. package/payload/server/public/assets/graph-labels-F6hQJKCn.js.gz +0 -0
  179. package/payload/server/public/assets/jsx-runtime-DDrveXI8.css.br +0 -0
  180. package/payload/server/public/assets/operator-CkWK9uRB.js.br +0 -0
  181. package/payload/server/public/assets/operator-CkWK9uRB.js.gz +0 -0
  182. package/payload/server/public/assets/page-DbWIOK60.js.br +0 -0
  183. package/payload/server/public/assets/page-DbWIOK60.js.gz +0 -0
  184. package/payload/server/public/assets/page-Dh6fCuUi.js.br +0 -0
  185. package/payload/server/public/assets/page-Dh6fCuUi.js.gz +0 -0
  186. package/payload/server/public/assets/play-fy3tdi7M.js +0 -1
  187. package/payload/server/public/assets/play-fy3tdi7M.js.br +0 -0
  188. package/payload/server/public/assets/play-fy3tdi7M.js.gz +0 -0
  189. package/payload/server/public/assets/public-DmD6vvFM.js.br +0 -0
  190. package/payload/server/public/assets/public-DmD6vvFM.js.gz +0 -0
  191. package/payload/server/public/assets/rotate-ccw-BDdOWTDl.js.br +0 -0
  192. package/payload/server/public/assets/rotate-ccw-BDdOWTDl.js.gz +0 -0
  193. package/payload/server/public/assets/routines-CZoKUK0p.js.br +0 -0
  194. package/payload/server/public/assets/routines-CZoKUK0p.js.gz +0 -0
  195. package/payload/server/public/assets/skills-D0yS0qLX.js.br +0 -0
  196. package/payload/server/public/assets/skills-D0yS0qLX.js.gz +0 -0
  197. package/payload/server/public/assets/tasks-Dtn1uDRY.js.br +0 -0
  198. package/payload/server/public/assets/tasks-Dtn1uDRY.js.gz +0 -0
  199. package/payload/server/public/assets/triangle-alert-DN-Eswuf.js.br +0 -0
  200. package/payload/server/public/assets/triangle-alert-DN-Eswuf.js.gz +0 -0
  201. package/payload/server/public/assets/useCopyFeedback-BmOZr_Mw.js.br +0 -0
  202. package/payload/server/public/assets/useCopyFeedback-BmOZr_Mw.js.gz +0 -0
  203. package/payload/server/public/assets/useMediaQuery-fFM7rxUD.js.br +0 -0
  204. package/payload/server/public/assets/useMediaQuery-fFM7rxUD.js.gz +0 -0
  205. package/payload/server/public/assets/useVoiceRecorder-D0MWFIde.js.br +0 -0
  206. package/payload/server/public/assets/useVoiceRecorder-D0MWFIde.js.gz +0 -0
  207. package/payload/server/public/assets/wrench-C-yn7q1Q.js.br +0 -0
  208. package/payload/server/public/assets/wrench-C-yn7q1Q.js.gz +0 -0
  209. /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js → jsx-runtime-BcrVQeV-.js} +0 -0
  210. /package/payload/server/public/assets/{jsx-runtime-DXWyHqht.js.br → jsx-runtime-BcrVQeV-.js.br} +0 -0
  211. /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;
@@ -32,6 +32,8 @@ function quietSources(over = {}) {
32
32
  // Task 2653 — "quiet" for Telegram is an account that holds no inbound row at
33
33
  // all, which is what a fresh install answers.
34
34
  telegramInbound: async () => ({ newestInboundAt: null }),
35
+ // Task 2657 — "quiet" for the Outlook arm is a folder holding no message.
36
+ mailOutlook: async () => ({ newestReceivedAt: null }),
35
37
  ...over,
36
38
  };
37
39
  }
@@ -60,12 +62,13 @@ const run = (checks, sources, commit = false) => runSchedulingGate({ eventId: EV
60
62
  describe("d1 check", () => {
61
63
  const CHECK = { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" };
62
64
  it("is false on a zero count", async () => {
63
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [] });
65
+ expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
64
66
  });
65
67
  it("is true on one row", async () => {
66
68
  expect(await run([CHECK], quietSources({ d1: async () => 1 }))).toEqual({
67
69
  hasWork: true,
68
70
  reasons: ["d1"],
71
+ errored: [],
69
72
  });
70
73
  });
71
74
  it("reports work rather than an error when the source is unreachable", async () => {
@@ -74,21 +77,25 @@ describe("d1 check", () => {
74
77
  throw new Error("no cloudflare credentials");
75
78
  },
76
79
  });
77
- expect(await run([CHECK], sources)).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
+ });
78
85
  });
79
86
  });
80
87
  describe("mail check", () => {
81
88
  const CHECK = { type: "mail", mailbox: "ops@example.com" };
82
89
  it("is false when nothing sits above the watermark", async () => {
83
90
  seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
84
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [] });
91
+ expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
85
92
  });
86
93
  it("is true when a UID above the watermark matches", async () => {
87
94
  seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
88
95
  const sources = quietSources({
89
96
  mail: async () => ({ matched: true, highestUid: 104, uidValidity: 1 }),
90
97
  });
91
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"] });
98
+ expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
92
99
  });
93
100
  it("probes from UID 0 on a first run, so nothing already there is missed", async () => {
94
101
  const seen = [];
@@ -106,7 +113,7 @@ describe("mail check", () => {
106
113
  const sources = quietSources({
107
114
  mail: async () => ({ matched: false, highestUid: 100, uidValidity: 7 }),
108
115
  });
109
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"] });
116
+ expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["mail"], errored: [] });
110
117
  });
111
118
  it("keys the watermark by folder, so two folders of one mailbox never collide", async () => {
112
119
  seedWatermarks({ mail: { "ops@example.com|Archive": { uid: 100, uidValidity: 1 } } });
@@ -124,12 +131,12 @@ describe("mail check", () => {
124
131
  describe("whatsapp check", () => {
125
132
  it("status is false while connected", async () => {
126
133
  const check = { type: "whatsapp", scope: "status" };
127
- expect(await run([check], quietSources())).toEqual({ hasWork: false, reasons: [] });
134
+ expect(await run([check], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
128
135
  });
129
136
  it("status is true while disconnected", async () => {
130
137
  const check = { type: "whatsapp", scope: "status" };
131
138
  const sources = quietSources({ whatsappStatus: async () => ({ connected: false }) });
132
- expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"] });
139
+ expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
133
140
  });
134
141
  it("inbound is true only on a message received since the watermark", async () => {
135
142
  const check = { type: "whatsapp", scope: "inbound" };
@@ -137,7 +144,7 @@ describe("whatsapp check", () => {
137
144
  const sources = quietSources({
138
145
  whatsappInbound: async () => ({ latest: 1_754_000_900, newer: true }),
139
146
  });
140
- expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"] });
147
+ expect(await run([check], sources)).toEqual({ hasWork: true, reasons: ["whatsapp"], errored: [] });
141
148
  });
142
149
  it("inbound passes the stored watermark to the source", async () => {
143
150
  const check = { type: "whatsapp", scope: "inbound" };
@@ -157,15 +164,15 @@ describe("calendar check", () => {
157
164
  const CHECK = { type: "calendar", connector: "google" };
158
165
  it("is false on an unchanged digest", async () => {
159
166
  seedWatermarks({ calendar: { digest: "DIGEST-A" } });
160
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [] });
167
+ expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
161
168
  });
162
169
  it("is true on a changed digest", async () => {
163
170
  seedWatermarks({ calendar: { digest: "DIGEST-A" } });
164
171
  const sources = quietSources({ calendarDigest: async () => "DIGEST-B" });
165
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["calendar"] });
172
+ expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
166
173
  });
167
174
  it("is true on a first run, when nothing has been committed yet", async () => {
168
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: true, reasons: ["calendar"] });
175
+ expect(await run([CHECK], quietSources())).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
169
176
  });
170
177
  });
171
178
  describe("digestBusy", () => {
@@ -222,7 +229,7 @@ describe("all four together", () => {
222
229
  mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
223
230
  calendar: { digest: "DIGEST-A" },
224
231
  });
225
- expect(await run(ALL, quietSources())).toEqual({ hasWork: false, reasons: [] });
232
+ expect(await run(ALL, quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
226
233
  });
227
234
  it("one noisy source is enough, and names only itself", async () => {
228
235
  seedWatermarks({
@@ -232,6 +239,7 @@ describe("all four together", () => {
232
239
  expect(await run(ALL, quietSources({ d1: async () => 2 }))).toEqual({
233
240
  hasWork: true,
234
241
  reasons: ["d1"],
242
+ errored: [],
235
243
  });
236
244
  });
237
245
  });
@@ -259,6 +267,9 @@ describe("commit", () => {
259
267
  whatsappInbound: async () => ({ latest: 1_754_009_999, newer: true }),
260
268
  calendarDigest: async () => "DIGEST-Z",
261
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);
262
273
  const result = await run(ALL, sources, true);
263
274
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
264
275
  expect(marks.mail["ops@example.com|inbox"]).toEqual({ uid: 250, uidValidity: 9 });
@@ -279,9 +290,9 @@ describe("commit", () => {
279
290
  it("a committed watermark makes the next plain call quiet", async () => {
280
291
  const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
281
292
  const checks = [{ type: "calendar", connector: "google" }];
282
- expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"] });
293
+ expect(await run(checks, sources)).toEqual({ hasWork: true, reasons: ["calendar"], errored: [] });
283
294
  await run(checks, sources, true);
284
- expect(await run(checks, sources)).toEqual({ hasWork: false, reasons: [] });
295
+ expect(await run(checks, sources)).toEqual({ hasWork: false, reasons: [], errored: [] });
285
296
  });
286
297
  it("a check that errors is not committed, so its work is not marked consumed", async () => {
287
298
  const checks = [{ type: "calendar", connector: "google" }];
@@ -303,6 +314,8 @@ describe("commit", () => {
303
314
  { type: "mail", mailbox: "ops@example.com" },
304
315
  { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
305
316
  ];
317
+ // Task 2654 — the gate call records the position the commit promotes.
318
+ await run(checks, quietSources());
306
319
  const result = await run(checks, quietSources(), true);
307
320
  // Task 2635 folds the folder segment to lower case on write.
308
321
  expect(result.advanced).toEqual(["ops@example.com|inbox"]);
@@ -321,7 +334,7 @@ describe("commit", () => {
321
334
  const checks = [
322
335
  { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
323
336
  ];
324
- expect(await run(checks, quietSources())).toEqual({ hasWork: false, reasons: [] });
337
+ expect(await run(checks, quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
325
338
  });
326
339
  it("a check that errored is neither advanced nor skipped, because its state is unknown", async () => {
327
340
  const checks = [
@@ -586,8 +599,12 @@ describe("watermark folder-case fold", () => {
586
599
  const sources = quietSources({
587
600
  mail: async (_c, since) => ({ matched: false, highestUid: since + 1, uidValidity: 1 }),
588
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);
589
605
  const first = await run([lower], sources, true);
590
606
  expect(first.advanced).toEqual(["ops@example.com|inbox"]);
607
+ await run([upper], sources);
591
608
  const second = await run([upper], sources, true);
592
609
  expect(second.advanced).toEqual(["ops@example.com|inbox"]);
593
610
  const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
@@ -647,13 +664,14 @@ describe("telegram check", () => {
647
664
  };
648
665
  const measured = (lines) => lines.every((l) => !l.includes("op=check-error"));
649
666
  it("is false when the account holds no inbound message at all", async () => {
650
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [] });
667
+ expect(await run([CHECK], quietSources())).toEqual({ hasWork: false, reasons: [], errored: [] });
651
668
  });
652
669
  it("is false when nothing arrived after the watermark", async () => {
653
670
  seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
654
671
  expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
655
672
  hasWork: false,
656
673
  reasons: [],
674
+ errored: [],
657
675
  });
658
676
  });
659
677
  it("is true when a message arrived after the watermark", async () => {
@@ -662,6 +680,7 @@ describe("telegram check", () => {
662
680
  expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toEqual({
663
681
  hasWork: true,
664
682
  reasons: ["telegram"],
683
+ errored: [],
665
684
  });
666
685
  expect(measured(lines)).toBe(true);
667
686
  });
@@ -672,6 +691,7 @@ describe("telegram check", () => {
672
691
  expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toEqual({
673
692
  hasWork: false,
674
693
  reasons: [],
694
+ errored: [],
675
695
  });
676
696
  });
677
697
  it("is true on a first run, so nothing already waiting is missed", async () => {
@@ -679,6 +699,7 @@ describe("telegram check", () => {
679
699
  expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toEqual({
680
700
  hasWork: true,
681
701
  reasons: ["telegram"],
702
+ errored: [],
682
703
  });
683
704
  expect(measured(lines)).toBe(true);
684
705
  });
@@ -689,7 +710,11 @@ describe("telegram check", () => {
689
710
  throw new Error("HTTP 500 on GET /api/telegram-reader/gate-probe");
690
711
  },
691
712
  });
692
- expect(await run([CHECK], sources)).toEqual({ hasWork: true, reasons: ["telegram"] });
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
+ });
693
718
  // The opposite of the two assertions above: this one MUST name the failure,
694
719
  // or a permanently broken probe reads as a permanently busy account. The
695
720
  // DETAIL is asserted, not just the kind — an unrecognised check type also
@@ -699,12 +724,110 @@ describe("telegram check", () => {
699
724
  expect(line).toContain("kind=telegram");
700
725
  expect(line).toContain("/api/telegram-reader/gate-probe");
701
726
  });
702
- it("a commit advances the watermark to what the probe just read", async () => {
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")));
703
732
  const result = await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")), true);
704
733
  expect(result.advanced).toEqual(["telegram"]);
705
734
  const marks = readWatermarks(EVENT, platformRoot);
706
735
  expect(marks.telegram).toEqual({ inbound: "2026-08-05T11:00:00.000Z" });
707
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
+ });
708
831
  it("a commit on a quiet account is skipped, not advanced onto nothing", async () => {
709
832
  // A null probe is "nothing to measure from", not "position zero". There is
710
833
  // no position to record, so the check reports as `skipped` exactly as `d1`
@@ -726,7 +849,16 @@ describe("telegram check", () => {
726
849
  describe("unrecognised check type", () => {
727
850
  const CHECK = { type: "nonsense" };
728
851
  it("resolves rather than throwing, and counts the unknown type as work", async () => {
729
- expect(await run([CHECK], quietSources())).toEqual({ hasWork: true, reasons: ["nonsense"] });
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
+ });
730
862
  });
731
863
  it("names the type and the accepted set in op=check-error", async () => {
732
864
  const lines = [];
@@ -741,7 +873,306 @@ describe("unrecognised check type", () => {
741
873
  });
742
874
  it("still runs the sibling checks — one bad type does not abort the gate", async () => {
743
875
  const checks = [CHECK, { type: "d1", database: "b", sql: "SELECT 1" }];
744
- expect(await run(checks, quietSources())).toEqual({ hasWork: true, reasons: ["nonsense"] });
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: [] });
745
1176
  });
746
1177
  });
747
1178
  //# sourceMappingURL=scheduling-gate.test.js.map