@rubytech/create-realagent-code 0.1.591 → 0.1.593
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/routine-templates/dist/index.d.ts +2 -2
- package/payload/platform/lib/routine-templates/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/routine-templates/dist/index.js +2 -9
- package/payload/platform/lib/routine-templates/dist/index.js.map +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.d.ts +1 -11
- package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.js +11 -15
- package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
- package/payload/platform/lib/routine-templates/src/__tests__/roster.test.ts +0 -0
- package/payload/platform/lib/routine-templates/src/__tests__/seed.test.ts +8 -3
- package/payload/platform/lib/routine-templates/src/index.ts +1 -8
- package/payload/platform/lib/routine-templates/src/roster.ts +10 -14
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +7 -3
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +10 -0
- package/payload/platform/plugins/docs/references/internals.md +4 -2
- package/payload/platform/plugins/docs/references/telegram-guide.md +2 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +6 -6
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +4 -12
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js +85 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-audits.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +55 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +10 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +42 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts +22 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js +73 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/schedule-audit.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js +93 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/agent-turn-dispatch.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +2 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +45 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +19 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +27 -280
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +315 -208
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +1 -31
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +15 -95
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +5 -36
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +83 -51
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +152 -105
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +37 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +178 -13
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.d.ts +13 -5
- package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.js +23 -9
- package/payload/platform/services/claude-session-manager/dist/rc-public-lockdown.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.d.ts +7 -4
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.js +5 -3
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-mcp.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.d.ts +15 -1
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.js +16 -7
- package/payload/platform/services/claude-session-manager/dist/telegram-channel-store.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.d.ts +28 -0
- package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.js +53 -0
- package/payload/platform/services/claude-session-manager/dist/telegram-plugin-mcp.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +9 -5
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +12 -6
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts +7 -3
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js +5 -1
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/instructions.d.ts +14 -2
- package/payload/platform/services/telegram-channel/dist/instructions.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/instructions.js +34 -3
- package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
- package/payload/server/public/operator.html +1 -1
- package/payload/server/server.js +74 -9
package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock("../../lib/neo4j.js", () => ({
|
|
|
17
17
|
close: vi.fn(async () => { }),
|
|
18
18
|
}),
|
|
19
19
|
}));
|
|
20
|
-
import { runSchedulingGate,
|
|
20
|
+
import { runSchedulingGate, advanceGateMark, watermarkPath, readWatermarks, readWatermarksChecked, 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;
|
|
@@ -58,14 +58,14 @@ afterEach(() => {
|
|
|
58
58
|
vi.doUnmock("imapflow");
|
|
59
59
|
delete process.env.PLATFORM_PORT;
|
|
60
60
|
});
|
|
61
|
-
const run = (checks, sources
|
|
61
|
+
const run = (checks, sources) => runSchedulingGate({ eventId: EVENT, checks }, ACCOUNT, platformRoot, sources);
|
|
62
62
|
describe("d1 check", () => {
|
|
63
63
|
const CHECK = { type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" };
|
|
64
64
|
it("is false on a zero count", async () => {
|
|
65
|
-
expect(await run([CHECK], quietSources())).
|
|
65
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
66
66
|
});
|
|
67
67
|
it("is true on one row", async () => {
|
|
68
|
-
expect(await run([CHECK], quietSources({ d1: async () => 1 }))).
|
|
68
|
+
expect(await run([CHECK], quietSources({ d1: async () => 1 }))).toMatchObject({
|
|
69
69
|
hasWork: true,
|
|
70
70
|
reasons: ["d1"],
|
|
71
71
|
errored: [],
|
|
@@ -77,7 +77,7 @@ describe("d1 check", () => {
|
|
|
77
77
|
throw new Error("no cloudflare credentials");
|
|
78
78
|
},
|
|
79
79
|
});
|
|
80
|
-
expect(await run([CHECK], sources)).
|
|
80
|
+
expect(await run([CHECK], sources)).toMatchObject({
|
|
81
81
|
hasWork: true,
|
|
82
82
|
reasons: ["d1"],
|
|
83
83
|
errored: [{ kind: "d1", detail: "no cloudflare credentials" }],
|
|
@@ -88,14 +88,14 @@ describe("mail check", () => {
|
|
|
88
88
|
const CHECK = { type: "mail", mailbox: "ops@example.com" };
|
|
89
89
|
it("is false when nothing sits above the watermark", async () => {
|
|
90
90
|
seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
|
|
91
|
-
expect(await run([CHECK], quietSources())).
|
|
91
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
92
92
|
});
|
|
93
93
|
it("is true when a UID above the watermark matches", async () => {
|
|
94
94
|
seedWatermarks({ mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } } });
|
|
95
95
|
const sources = quietSources({
|
|
96
96
|
mail: async () => ({ matched: true, highestUid: 104, uidValidity: 1 }),
|
|
97
97
|
});
|
|
98
|
-
expect(await run([CHECK], sources)).
|
|
98
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
99
99
|
});
|
|
100
100
|
it("probes from UID 0 on a first run, so nothing already there is missed", async () => {
|
|
101
101
|
const seen = [];
|
|
@@ -113,7 +113,7 @@ describe("mail check", () => {
|
|
|
113
113
|
const sources = quietSources({
|
|
114
114
|
mail: async () => ({ matched: false, highestUid: 100, uidValidity: 7 }),
|
|
115
115
|
});
|
|
116
|
-
expect(await run([CHECK], sources)).
|
|
116
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
117
117
|
});
|
|
118
118
|
it("keys the watermark by folder, so two folders of one mailbox never collide", async () => {
|
|
119
119
|
seedWatermarks({ mail: { "ops@example.com|Archive": { uid: 100, uidValidity: 1 } } });
|
|
@@ -131,12 +131,12 @@ describe("mail check", () => {
|
|
|
131
131
|
describe("whatsapp check", () => {
|
|
132
132
|
it("status is false while connected", async () => {
|
|
133
133
|
const check = { type: "whatsapp", scope: "status" };
|
|
134
|
-
expect(await run([check], quietSources())).
|
|
134
|
+
expect(await run([check], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
135
135
|
});
|
|
136
136
|
it("status is true while disconnected", async () => {
|
|
137
137
|
const check = { type: "whatsapp", scope: "status" };
|
|
138
138
|
const sources = quietSources({ whatsappStatus: async () => ({ connected: false }) });
|
|
139
|
-
expect(await run([check], sources)).
|
|
139
|
+
expect(await run([check], sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
140
140
|
});
|
|
141
141
|
it("inbound is true only on a message received since the watermark", async () => {
|
|
142
142
|
const check = { type: "whatsapp", scope: "inbound" };
|
|
@@ -144,7 +144,7 @@ describe("whatsapp check", () => {
|
|
|
144
144
|
const sources = quietSources({
|
|
145
145
|
whatsappInbound: async () => ({ latest: 1_754_000_900, newer: true }),
|
|
146
146
|
});
|
|
147
|
-
expect(await run([check], sources)).
|
|
147
|
+
expect(await run([check], sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
148
148
|
});
|
|
149
149
|
it("inbound passes the stored watermark to the source", async () => {
|
|
150
150
|
const check = { type: "whatsapp", scope: "inbound" };
|
|
@@ -164,15 +164,15 @@ describe("calendar check", () => {
|
|
|
164
164
|
const CHECK = { type: "calendar", connector: "google" };
|
|
165
165
|
it("is false on an unchanged digest", async () => {
|
|
166
166
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
167
|
-
expect(await run([CHECK], quietSources())).
|
|
167
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
168
168
|
});
|
|
169
169
|
it("is true on a changed digest", async () => {
|
|
170
170
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
171
171
|
const sources = quietSources({ calendarDigest: async () => "DIGEST-B" });
|
|
172
|
-
expect(await run([CHECK], sources)).
|
|
172
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
173
173
|
});
|
|
174
174
|
it("is true on a first run, when nothing has been committed yet", async () => {
|
|
175
|
-
expect(await run([CHECK], quietSources())).
|
|
175
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
178
|
describe("digestBusy", () => {
|
|
@@ -229,48 +229,49 @@ describe("all four together", () => {
|
|
|
229
229
|
mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
|
|
230
230
|
calendar: { digest: "DIGEST-A" },
|
|
231
231
|
});
|
|
232
|
-
expect(await run(ALL, quietSources())).
|
|
232
|
+
expect(await run(ALL, quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
233
233
|
});
|
|
234
234
|
it("one noisy source is enough, and names only itself", async () => {
|
|
235
235
|
seedWatermarks({
|
|
236
236
|
mail: { "ops@example.com|INBOX": { uid: 100, uidValidity: 1 } },
|
|
237
237
|
calendar: { digest: "DIGEST-A" },
|
|
238
238
|
});
|
|
239
|
-
expect(await run(ALL, quietSources({ d1: async () => 2 }))).
|
|
239
|
+
expect(await run(ALL, quietSources({ d1: async () => 2 }))).toMatchObject({
|
|
240
240
|
hasWork: true,
|
|
241
241
|
reasons: ["d1"],
|
|
242
242
|
errored: [],
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
245
|
});
|
|
246
|
-
|
|
246
|
+
// Task 2663 — the advance is the dispatcher's, so it is tested through
|
|
247
|
+
// `advanceGateMark` against the readings a real gate call returned. The old
|
|
248
|
+
// `commit` describe drove the same ground through a parameter the agent set.
|
|
249
|
+
describe("advanceGateMark", () => {
|
|
247
250
|
const ALL = [
|
|
248
251
|
{ type: "mail", mailbox: "ops@example.com" },
|
|
249
252
|
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
250
253
|
{ type: "whatsapp", scope: "inbound" },
|
|
251
254
|
{ type: "calendar", connector: "google" },
|
|
252
255
|
];
|
|
253
|
-
it("
|
|
256
|
+
it("the gate itself leaves the file bytes unchanged", async () => {
|
|
254
257
|
seedWatermarks({ calendar: { digest: "DIGEST-A" } });
|
|
255
258
|
const path = watermarkPath(EVENT, platformRoot);
|
|
256
259
|
const before = readFileSync(path);
|
|
257
260
|
await run(ALL, quietSources({ calendarDigest: async () => "DIGEST-B" }));
|
|
258
261
|
expect(readFileSync(path).equals(before)).toBe(true);
|
|
259
262
|
});
|
|
260
|
-
it("
|
|
263
|
+
it("the gate on a first run writes no file at all", async () => {
|
|
261
264
|
await run(ALL, quietSources());
|
|
262
265
|
expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
|
|
263
266
|
});
|
|
264
|
-
it("advances every watermark
|
|
267
|
+
it("advances every watermark the gate read", async () => {
|
|
265
268
|
const sources = quietSources({
|
|
266
269
|
mail: async () => ({ matched: true, highestUid: 250, uidValidity: 9 }),
|
|
267
270
|
whatsappInbound: async () => ({ latest: 1_754_009_999, newer: true }),
|
|
268
271
|
calendarDigest: async () => "DIGEST-Z",
|
|
269
272
|
});
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await run(ALL, sources);
|
|
273
|
-
const result = await run(ALL, sources, true);
|
|
273
|
+
const gate = await run(ALL, sources);
|
|
274
|
+
const { advanced } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
274
275
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
275
276
|
expect(marks.mail["ops@example.com|inbox"]).toEqual({ uid: 250, uidValidity: 9 });
|
|
276
277
|
expect(marks.whatsapp.inbound).toBe(1_754_009_999);
|
|
@@ -278,77 +279,79 @@ describe("commit", () => {
|
|
|
278
279
|
expect(marks.lastCommitAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
279
280
|
// d1 has no watermark: its answer is a property of the present, not a
|
|
280
281
|
// position in a stream.
|
|
281
|
-
expect(
|
|
282
|
+
expect(advanced).toEqual(["ops@example.com|inbox", "whatsapp", "calendar"]);
|
|
283
|
+
});
|
|
284
|
+
it("names the position it wrote per advanced key", async () => {
|
|
285
|
+
const gate = await run([{ type: "calendar", connector: "google" }], quietSources({ calendarDigest: async () => "DIGEST-Z" }));
|
|
286
|
+
const { positions } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
287
|
+
expect(positions).toEqual(["calendar:DIGEST-Z"]);
|
|
288
|
+
});
|
|
289
|
+
it("stamps lastCommitAt only when something moved", async () => {
|
|
290
|
+
// Stamping it on every accepted wake would make `auditGateMarkStalled`,
|
|
291
|
+
// which counts accepted wakes SINCE this instant, read 0 or 1 for ever — the
|
|
292
|
+
// check built to catch a mark that stopped moving could then never fire, and
|
|
293
|
+
// `auditGateWatermarkAge` would go permanently green for the same reason.
|
|
294
|
+
const { advanced } = advanceGateMark(EVENT, platformRoot, {});
|
|
295
|
+
expect(advanced).toEqual([]);
|
|
296
|
+
expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
|
|
282
297
|
});
|
|
283
|
-
it("
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
expect(marks.lastCommitAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
298
|
+
it("leaves an existing file untouched when nothing moved", async () => {
|
|
299
|
+
seedWatermarks({ calendar: { digest: "DIGEST-A" }, lastCommitAt: "2026-08-10T10:00:00.000Z" });
|
|
300
|
+
const before = readFileSync(watermarkPath(EVENT, platformRoot));
|
|
301
|
+
advanceGateMark(EVENT, platformRoot, {});
|
|
302
|
+
expect(readFileSync(watermarkPath(EVENT, platformRoot)).equals(before)).toBe(true);
|
|
289
303
|
});
|
|
290
|
-
it("
|
|
304
|
+
it("an advanced watermark makes the next gate call quiet", async () => {
|
|
291
305
|
const sources = quietSources({ calendarDigest: async () => "DIGEST-Z" });
|
|
292
306
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
307
|
+
const first = await run(checks, sources);
|
|
308
|
+
expect(first).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
309
|
+
advanceGateMark(EVENT, platformRoot, first.observed);
|
|
310
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
296
311
|
});
|
|
297
|
-
it("a check that
|
|
312
|
+
it("a check that errored is not advanced, so its work is not marked consumed", async () => {
|
|
298
313
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
299
314
|
const sources = quietSources({
|
|
300
315
|
calendarDigest: async () => {
|
|
301
316
|
throw new Error("neo4j down");
|
|
302
317
|
},
|
|
303
318
|
});
|
|
304
|
-
const
|
|
305
|
-
expect(
|
|
306
|
-
const
|
|
307
|
-
expect(
|
|
319
|
+
const gate = await run(checks, sources);
|
|
320
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"], observed: {} });
|
|
321
|
+
const { advanced } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
322
|
+
expect(advanced).toEqual([]);
|
|
323
|
+
expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(false);
|
|
308
324
|
});
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
{ type: "mail", mailbox: "ops@example.com" },
|
|
315
|
-
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
316
|
-
];
|
|
317
|
-
// Task 2654 — the gate call records the position the commit promotes.
|
|
318
|
-
await run(checks, quietSources());
|
|
319
|
-
const result = await run(checks, quietSources(), true);
|
|
320
|
-
// Task 2635 folds the folder segment to lower case on write.
|
|
321
|
-
expect(result.advanced).toEqual(["ops@example.com|inbox"]);
|
|
322
|
-
expect(result.skipped).toEqual(["d1"]);
|
|
323
|
-
});
|
|
324
|
-
it("counts every skipped check, not every skipped kind", async () => {
|
|
325
|
-
const checks = [
|
|
325
|
+
it("a check holding no position contributes no key, so nothing advances for it", async () => {
|
|
326
|
+
// Task 2633 — an agent read an empty `advanced` over four d1 checks,
|
|
327
|
+
// inferred a broken watermark and deleted all four. `d1` and `whatsapp` in
|
|
328
|
+
// `status` scope read a current value; there is no position to record.
|
|
329
|
+
const gate = await run([
|
|
326
330
|
{ type: "d1", database: "bookings", sql: "SELECT max(id) FROM b" },
|
|
327
331
|
{ type: "d1", database: "leads", sql: "SELECT max(id) FROM l" },
|
|
328
332
|
{ type: "whatsapp", scope: "status" },
|
|
329
|
-
];
|
|
330
|
-
|
|
331
|
-
expect(
|
|
333
|
+
], quietSources());
|
|
334
|
+
expect(gate.observed).toEqual({});
|
|
335
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
// Task 2664 — the destructive half. `readWatermarks` returned `{}` for a file
|
|
338
|
+
// it could not parse, this function promoted into that empty object and wrote
|
|
339
|
+
// it, and the file's real positions were destroyed rather than repaired.
|
|
340
|
+
it("does not overwrite a file it could not parse", async () => {
|
|
341
|
+
const path = watermarkPath(EVENT, platformRoot);
|
|
342
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
343
|
+
writeFileSync(path, "{ not json");
|
|
344
|
+
const before = readFileSync(path);
|
|
345
|
+
const gate = await run([{ type: "calendar", connector: "google" }], quietSources({ calendarDigest: async () => "DIGEST-Z" }));
|
|
346
|
+
const { advanced, unreadable } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
347
|
+
expect({ advanced, unreadable }).toEqual({ advanced: [], unreadable: true });
|
|
348
|
+
expect(readFileSync(path).equals(before)).toBe(true);
|
|
332
349
|
});
|
|
333
|
-
it("a
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
];
|
|
337
|
-
expect(
|
|
338
|
-
});
|
|
339
|
-
it("a check that errored is neither advanced nor skipped, because its state is unknown", async () => {
|
|
340
|
-
const checks = [
|
|
341
|
-
{ type: "d1", database: "bookings", sql: "SELECT count(*) FROM b" },
|
|
342
|
-
{ type: "calendar", connector: "google" },
|
|
343
|
-
];
|
|
344
|
-
const sources = quietSources({
|
|
345
|
-
calendarDigest: async () => {
|
|
346
|
-
throw new Error("neo4j down");
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
const result = await run(checks, sources, true);
|
|
350
|
-
expect(result.advanced).toEqual([]);
|
|
351
|
-
expect(result.skipped).toEqual(["d1"]);
|
|
350
|
+
it("still writes a first fire, which has no file at all", async () => {
|
|
351
|
+
const gate = await run([{ type: "calendar", connector: "google" }], quietSources({ calendarDigest: async () => "DIGEST-Z" }));
|
|
352
|
+
const { advanced, unreadable } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
353
|
+
expect({ advanced, unreadable }).toEqual({ advanced: ["calendar"], unreadable: false });
|
|
354
|
+
expect(existsSync(watermarkPath(EVENT, platformRoot))).toBe(true);
|
|
352
355
|
});
|
|
353
356
|
});
|
|
354
357
|
describe("watermark location", () => {
|
|
@@ -599,14 +602,16 @@ describe("watermark folder-case fold", () => {
|
|
|
599
602
|
const sources = quietSources({
|
|
600
603
|
mail: async (_c, since) => ({ matched: false, highestUid: since + 1, uidValidity: 1 }),
|
|
601
604
|
});
|
|
602
|
-
// Task
|
|
605
|
+
// Task 2663 — each advance promotes what its own gate call read, so both
|
|
603
606
|
// fires run the real two-step sequence.
|
|
604
|
-
await run([lower], sources);
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
expect(
|
|
607
|
+
const firstGate = await run([lower], sources);
|
|
608
|
+
expect(advanceGateMark(EVENT, platformRoot, firstGate.observed).advanced).toEqual([
|
|
609
|
+
"ops@example.com|inbox",
|
|
610
|
+
]);
|
|
611
|
+
const secondGate = await run([upper], sources);
|
|
612
|
+
expect(advanceGateMark(EVENT, platformRoot, secondGate.observed).advanced).toEqual([
|
|
613
|
+
"ops@example.com|inbox",
|
|
614
|
+
]);
|
|
610
615
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
611
616
|
expect(Object.keys(marks.mail)).toEqual(["ops@example.com|inbox"]);
|
|
612
617
|
// The second call read the first call's position rather than restarting at 0.
|
|
@@ -628,6 +633,111 @@ describe("watermark folder-case fold", () => {
|
|
|
628
633
|
await run([check], sources);
|
|
629
634
|
expect(seen).toEqual([106]);
|
|
630
635
|
});
|
|
636
|
+
// Task 2664 — a file that is present and unusable is a third state. Today it
|
|
637
|
+
// reads identically to a healthy first fire: `{}` and no line.
|
|
638
|
+
describe("readWatermarksChecked", () => {
|
|
639
|
+
/** The lines console.error saw, with the suite's default silencer replaced. */
|
|
640
|
+
function captureErrors() {
|
|
641
|
+
const lines = [];
|
|
642
|
+
vi.spyOn(console, "error").mockImplementation((...a) => void lines.push(a.join(" ")));
|
|
643
|
+
return lines;
|
|
644
|
+
}
|
|
645
|
+
function writeRaw(body) {
|
|
646
|
+
const path = watermarkPath(EVENT, platformRoot);
|
|
647
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
648
|
+
writeFileSync(path, body);
|
|
649
|
+
}
|
|
650
|
+
it("is silent on a missing file and does not call it unreadable", () => {
|
|
651
|
+
const lines = captureErrors();
|
|
652
|
+
expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: false });
|
|
653
|
+
expect(lines.filter((l) => l.includes("op=watermark-unreadable"))).toEqual([]);
|
|
654
|
+
});
|
|
655
|
+
it("names a file that does not parse, by its id8", () => {
|
|
656
|
+
writeRaw("{ not json");
|
|
657
|
+
const lines = captureErrors();
|
|
658
|
+
const { marks, unreadable } = readWatermarksChecked(EVENT, platformRoot);
|
|
659
|
+
expect({ marks, unreadable }).toEqual({ marks: {}, unreadable: true });
|
|
660
|
+
expect(lines.join("\n")).toContain(`[scheduling-gate] op=watermark-unreadable eventId=${EVENT.slice(0, 8)} reason=`);
|
|
661
|
+
});
|
|
662
|
+
it("names a file that parses to a non-object", () => {
|
|
663
|
+
writeRaw('"a string"');
|
|
664
|
+
const lines = captureErrors();
|
|
665
|
+
expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: true });
|
|
666
|
+
expect(lines.join("\n")).toContain("op=watermark-unreadable");
|
|
667
|
+
expect(lines.join("\n")).toContain("reason=not an object");
|
|
668
|
+
});
|
|
669
|
+
// Task 2667 — an array is an object to `typeof`, so it passed the guard
|
|
670
|
+
// above. `foldMailWatermarks` returned it unchanged (`marks.mail` is
|
|
671
|
+
// undefined), `advanceGateMark` promoted into it, and `JSON.stringify`
|
|
672
|
+
// dropped every non-index property on the way to disk, so the file was
|
|
673
|
+
// written back as `[]` while `op=mark-advanced` named the positions it had
|
|
674
|
+
// just lost.
|
|
675
|
+
it("names a file that parses to an array, by its shape", () => {
|
|
676
|
+
writeRaw("[]");
|
|
677
|
+
const lines = captureErrors();
|
|
678
|
+
expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: true });
|
|
679
|
+
expect(lines.join("\n")).toContain(`[scheduling-gate] op=watermark-unreadable eventId=${EVENT.slice(0, 8)} ` +
|
|
680
|
+
`reason=an array, not a watermark object`);
|
|
681
|
+
});
|
|
682
|
+
it("leaves an array file byte-identical across an advance that observed a position", async () => {
|
|
683
|
+
// An array holding an element, written compactly. A bare `[]` cannot tell
|
|
684
|
+
// the two behaviours apart: the promoted properties are dropped by
|
|
685
|
+
// `JSON.stringify`, so the destructive write reproduces `[]` byte for byte
|
|
686
|
+
// and the assertion passes while the file is being rewritten on every fire.
|
|
687
|
+
writeRaw('["a stray element"]');
|
|
688
|
+
captureErrors();
|
|
689
|
+
const path = watermarkPath(EVENT, platformRoot);
|
|
690
|
+
const before = readFileSync(path);
|
|
691
|
+
const gate = await run([{ type: "calendar", connector: "google" }], quietSources());
|
|
692
|
+
// The advance has something to promote, which is what made this destructive.
|
|
693
|
+
expect(gate.observed.calendar?.digest).toBeDefined();
|
|
694
|
+
const { advanced, unreadable } = advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
695
|
+
// The bytes first: the defect is the WRITE, and asserting the return value
|
|
696
|
+
// first would leave the emptied file unobserved on a red run.
|
|
697
|
+
expect(readFileSync(path).equals(before)).toBe(true);
|
|
698
|
+
expect({ advanced, unreadable }).toEqual({ advanced: [], unreadable: true });
|
|
699
|
+
});
|
|
700
|
+
it("a gate over an array file still reports work", async () => {
|
|
701
|
+
writeRaw("[]");
|
|
702
|
+
captureErrors();
|
|
703
|
+
const gate = await run([{ type: "calendar", connector: "google" }], quietSources());
|
|
704
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"] });
|
|
705
|
+
});
|
|
706
|
+
it("names a read error that is not a missing file", () => {
|
|
707
|
+
// A directory where the file should be: present, and unreadable for a
|
|
708
|
+
// reason that is not ENOENT.
|
|
709
|
+
mkdirSync(watermarkPath(EVENT, platformRoot), { recursive: true });
|
|
710
|
+
const lines = captureErrors();
|
|
711
|
+
expect(readWatermarksChecked(EVENT, platformRoot)).toEqual({ marks: {}, unreadable: true });
|
|
712
|
+
expect(lines.join("\n")).toContain("op=watermark-unreadable");
|
|
713
|
+
});
|
|
714
|
+
it("emits on EVERY read, so it repeats until the file is fixed", () => {
|
|
715
|
+
writeRaw("{ not json");
|
|
716
|
+
const lines = captureErrors();
|
|
717
|
+
readWatermarksChecked(EVENT, platformRoot);
|
|
718
|
+
readWatermarksChecked(EVENT, platformRoot);
|
|
719
|
+
expect(lines.filter((l) => l.includes("op=watermark-unreadable"))).toHaveLength(2);
|
|
720
|
+
});
|
|
721
|
+
it("reports a readable file as readable and folds it", () => {
|
|
722
|
+
seedWatermarks({ mail: { "a@b.com|INBOX": { uid: 106, uidValidity: 11 } } });
|
|
723
|
+
const lines = captureErrors();
|
|
724
|
+
const { marks, unreadable } = readWatermarksChecked(EVENT, platformRoot);
|
|
725
|
+
expect(unreadable).toBe(false);
|
|
726
|
+
expect(marks.mail).toEqual({ "a@b.com|inbox": { uid: 106, uidValidity: 11 } });
|
|
727
|
+
expect(lines.filter((l) => l.includes("op=watermark-unreadable"))).toEqual([]);
|
|
728
|
+
});
|
|
729
|
+
it("readWatermarks still returns just the marks, so its callers are untouched", () => {
|
|
730
|
+
writeRaw("{ not json");
|
|
731
|
+
captureErrors();
|
|
732
|
+
expect(readWatermarks(EVENT, platformRoot)).toEqual({});
|
|
733
|
+
});
|
|
734
|
+
it("a gate over an unreadable file still reports work", async () => {
|
|
735
|
+
writeRaw("{ not json");
|
|
736
|
+
captureErrors();
|
|
737
|
+
const gate = await run([{ type: "calendar", connector: "google" }], quietSources());
|
|
738
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"] });
|
|
739
|
+
});
|
|
740
|
+
});
|
|
631
741
|
it("readWatermarks writes nothing, so the audit path stays read-only", () => {
|
|
632
742
|
// schedule-audit.ts:218 reads every gated event's file and never writes.
|
|
633
743
|
seedWatermarks({
|
|
@@ -664,11 +774,11 @@ describe("telegram check", () => {
|
|
|
664
774
|
};
|
|
665
775
|
const measured = (lines) => lines.every((l) => !l.includes("op=check-error"));
|
|
666
776
|
it("is false when the account holds no inbound message at all", async () => {
|
|
667
|
-
expect(await run([CHECK], quietSources())).
|
|
777
|
+
expect(await run([CHECK], quietSources())).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
668
778
|
});
|
|
669
779
|
it("is false when nothing arrived after the watermark", async () => {
|
|
670
780
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
671
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).
|
|
781
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
|
|
672
782
|
hasWork: false,
|
|
673
783
|
reasons: [],
|
|
674
784
|
errored: [],
|
|
@@ -677,7 +787,7 @@ describe("telegram check", () => {
|
|
|
677
787
|
it("is true when a message arrived after the watermark", async () => {
|
|
678
788
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
679
789
|
const lines = linesFrom();
|
|
680
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).
|
|
790
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")))).toMatchObject({
|
|
681
791
|
hasWork: true,
|
|
682
792
|
reasons: ["telegram"],
|
|
683
793
|
errored: [],
|
|
@@ -688,7 +798,7 @@ describe("telegram check", () => {
|
|
|
688
798
|
// The boundary is `>`, not `>=`. A `>=` would re-report the last handled
|
|
689
799
|
// message on every fire and the gate could never go quiet.
|
|
690
800
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
691
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).
|
|
801
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T10:00:00.000Z")))).toMatchObject({
|
|
692
802
|
hasWork: false,
|
|
693
803
|
reasons: [],
|
|
694
804
|
errored: [],
|
|
@@ -696,7 +806,7 @@ describe("telegram check", () => {
|
|
|
696
806
|
});
|
|
697
807
|
it("is true on a first run, so nothing already waiting is missed", async () => {
|
|
698
808
|
const lines = linesFrom();
|
|
699
|
-
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).
|
|
809
|
+
expect(await run([CHECK], quietSources(at("2026-08-05T09:00:00.000Z")))).toMatchObject({
|
|
700
810
|
hasWork: true,
|
|
701
811
|
reasons: ["telegram"],
|
|
702
812
|
errored: [],
|
|
@@ -710,7 +820,7 @@ describe("telegram check", () => {
|
|
|
710
820
|
throw new Error("HTTP 500 on GET /api/telegram-reader/gate-probe");
|
|
711
821
|
},
|
|
712
822
|
});
|
|
713
|
-
expect(await run([CHECK], sources)).
|
|
823
|
+
expect(await run([CHECK], sources)).toMatchObject({
|
|
714
824
|
hasWork: true,
|
|
715
825
|
reasons: ["telegram"],
|
|
716
826
|
errored: [{ kind: "telegram", detail: "HTTP 500 on GET /api/telegram-reader/gate-probe" }],
|
|
@@ -724,13 +834,9 @@ describe("telegram check", () => {
|
|
|
724
834
|
expect(line).toContain("kind=telegram");
|
|
725
835
|
expect(line).toContain("/api/telegram-reader/gate-probe");
|
|
726
836
|
});
|
|
727
|
-
it("
|
|
728
|
-
|
|
729
|
-
|
|
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"]);
|
|
837
|
+
it("the advance writes the watermark to what the gate's probe read", async () => {
|
|
838
|
+
const gate = await run([CHECK], quietSources(at("2026-08-05T11:00:00.000Z")));
|
|
839
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram"]);
|
|
734
840
|
const marks = readWatermarks(EVENT, platformRoot);
|
|
735
841
|
expect(marks.telegram).toEqual({ inbound: "2026-08-05T11:00:00.000Z" });
|
|
736
842
|
});
|
|
@@ -770,21 +876,18 @@ describe("telegram check", () => {
|
|
|
770
876
|
// nobody has read. That is the mute direction, and it loses a booking.
|
|
771
877
|
seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
|
|
772
878
|
const check = { type: "telegram", scope: "inbound", botId: "111" };
|
|
773
|
-
expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).
|
|
879
|
+
expect(await run([check], quietSources(at("2026-08-05T12:00:00.000Z")))).toMatchObject({
|
|
774
880
|
hasWork: true,
|
|
775
881
|
reasons: ["telegram"],
|
|
776
882
|
errored: [],
|
|
777
883
|
});
|
|
778
884
|
});
|
|
779
|
-
it("a scoped
|
|
885
|
+
it("a scoped advance writes under the bot, leaving the account-wide mark alone", async () => {
|
|
780
886
|
seedWatermarks({ telegram: { inbound: "2026-08-05T18:00:00.000Z" } });
|
|
781
887
|
const check = { type: "telegram", scope: "inbound", botId: "111" };
|
|
782
888
|
const sources = quietSources(at("2026-08-05T12:00:00.000Z"));
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
await run([check], sources);
|
|
786
|
-
const result = await run([check], sources, true);
|
|
787
|
-
expect(result.advanced).toEqual(["telegram:111"]);
|
|
889
|
+
const gate = await run([check], sources);
|
|
890
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual(["telegram:111"]);
|
|
788
891
|
const marks = readWatermarks(EVENT, platformRoot);
|
|
789
892
|
expect(marks.telegram?.inbound).toBe("2026-08-05T18:00:00.000Z");
|
|
790
893
|
expect(marks.telegram?.byBot).toEqual({ "111": "2026-08-05T12:00:00.000Z" });
|
|
@@ -828,14 +931,15 @@ describe("telegram check", () => {
|
|
|
828
931
|
expect(line).toContain("kind=telegram");
|
|
829
932
|
expect(line).toContain("0000000000");
|
|
830
933
|
});
|
|
831
|
-
it("a
|
|
934
|
+
it("a quiet account records no position, so nothing advances onto nothing", async () => {
|
|
832
935
|
// A null probe is "nothing to measure from", not "position zero". There is
|
|
833
|
-
// no position to record, so the check
|
|
834
|
-
//
|
|
835
|
-
//
|
|
936
|
+
// no position to record, so the check contributes no key and the stored mark
|
|
937
|
+
// is left alone. Writing null would drop the mark and re-report every stored
|
|
938
|
+
// message on the next fire.
|
|
836
939
|
seedWatermarks({ telegram: { inbound: "2026-08-05T10:00:00.000Z" } });
|
|
837
|
-
const
|
|
838
|
-
expect(
|
|
940
|
+
const gate = await run([CHECK], quietSources());
|
|
941
|
+
expect(gate).toMatchObject({ hasWork: false, observed: {} });
|
|
942
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
|
|
839
943
|
expect(readWatermarks(EVENT, platformRoot).telegram).toEqual({
|
|
840
944
|
inbound: "2026-08-05T10:00:00.000Z",
|
|
841
945
|
});
|
|
@@ -849,7 +953,7 @@ describe("telegram check", () => {
|
|
|
849
953
|
describe("unrecognised check type", () => {
|
|
850
954
|
const CHECK = { type: "nonsense" };
|
|
851
955
|
it("resolves rather than throwing, and counts the unknown type as work", async () => {
|
|
852
|
-
expect(await run([CHECK], quietSources())).
|
|
956
|
+
expect(await run([CHECK], quietSources())).toMatchObject({
|
|
853
957
|
hasWork: true,
|
|
854
958
|
reasons: ["nonsense"],
|
|
855
959
|
errored: [
|
|
@@ -873,7 +977,7 @@ describe("unrecognised check type", () => {
|
|
|
873
977
|
});
|
|
874
978
|
it("still runs the sibling checks — one bad type does not abort the gate", async () => {
|
|
875
979
|
const checks = [CHECK, { type: "d1", database: "b", sql: "SELECT 1" }];
|
|
876
|
-
expect(await run(checks, quietSources())).
|
|
980
|
+
expect(await run(checks, quietSources())).toMatchObject({
|
|
877
981
|
hasWork: true,
|
|
878
982
|
reasons: ["nonsense"],
|
|
879
983
|
errored: [
|
|
@@ -938,8 +1042,8 @@ describe("errored", () => {
|
|
|
938
1042
|
{ kind: "calendar", detail: "calendar down" },
|
|
939
1043
|
]);
|
|
940
1044
|
});
|
|
941
|
-
it("is present
|
|
942
|
-
const res = await run([D1], quietSources()
|
|
1045
|
+
it("is present and empty when every check answered", async () => {
|
|
1046
|
+
const res = await run([D1], quietSources());
|
|
943
1047
|
expect(res.errored).toEqual([]);
|
|
944
1048
|
});
|
|
945
1049
|
});
|
|
@@ -959,23 +1063,16 @@ describe("mail check — outlook provider", () => {
|
|
|
959
1063
|
const sources = quietSources({
|
|
960
1064
|
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
961
1065
|
});
|
|
962
|
-
expect(await run([CHECK], sources)).
|
|
1066
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
963
1067
|
});
|
|
964
|
-
it("is true on a message above the watermark, and
|
|
1068
|
+
it("is true on a message above the watermark, and the advance writes that key", async () => {
|
|
965
1069
|
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
966
1070
|
const sources = quietSources({
|
|
967
1071
|
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:05:00Z" }),
|
|
968
1072
|
});
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
expect(await run([CHECK], sources, true)).toEqual({
|
|
973
|
-
hasWork: true,
|
|
974
|
-
reasons: ["mail"],
|
|
975
|
-
errored: [],
|
|
976
|
-
advanced: [KEY],
|
|
977
|
-
skipped: [],
|
|
978
|
-
});
|
|
1073
|
+
const gate = await run([CHECK], sources);
|
|
1074
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
1075
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([KEY]);
|
|
979
1076
|
const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
980
1077
|
expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:05:00Z" });
|
|
981
1078
|
});
|
|
@@ -983,7 +1080,7 @@ describe("mail check — outlook provider", () => {
|
|
|
983
1080
|
const sources = quietSources({
|
|
984
1081
|
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
985
1082
|
});
|
|
986
|
-
expect(await run([CHECK], sources)).
|
|
1083
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
987
1084
|
});
|
|
988
1085
|
it("orders by instant, not lexically, so a fractional second is not read as older", async () => {
|
|
989
1086
|
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
@@ -991,17 +1088,13 @@ describe("mail check — outlook provider", () => {
|
|
|
991
1088
|
// Lexically "…00.500Z" sorts BELOW "…00Z" ('.' is 0x2E, 'Z' is 0x5A).
|
|
992
1089
|
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00.500Z" }),
|
|
993
1090
|
});
|
|
994
|
-
expect(await run([CHECK], sources)).
|
|
1091
|
+
expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
995
1092
|
});
|
|
996
|
-
it("
|
|
1093
|
+
it("records no position for an empty folder rather than advancing onto nothing", async () => {
|
|
997
1094
|
const sources = quietSources({ mailOutlook: async () => ({ newestReceivedAt: null }) });
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
errored: [],
|
|
1002
|
-
advanced: [],
|
|
1003
|
-
skipped: ["mail"],
|
|
1004
|
-
});
|
|
1095
|
+
const gate = await run([CHECK], sources);
|
|
1096
|
+
expect(gate).toEqual({ hasWork: false, reasons: [], errored: [], observed: {} });
|
|
1097
|
+
expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
|
|
1005
1098
|
});
|
|
1006
1099
|
it("keys the folder segment lower-cased, so two spellings address one entry", async () => {
|
|
1007
1100
|
seedWatermarks({ mailOutlook: { [KEY]: { receivedAt: "2026-08-10T09:00:00Z" } } });
|
|
@@ -1009,7 +1102,7 @@ describe("mail check — outlook provider", () => {
|
|
|
1009
1102
|
const sources = quietSources({
|
|
1010
1103
|
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
1011
1104
|
});
|
|
1012
|
-
expect(await run([check], sources)).
|
|
1105
|
+
expect(await run([check], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
|
|
1013
1106
|
});
|
|
1014
1107
|
it("counts a probe that throws as work, and never as quiet", async () => {
|
|
1015
1108
|
const sources = quietSources({
|
|
@@ -1017,7 +1110,7 @@ describe("mail check — outlook provider", () => {
|
|
|
1017
1110
|
throw new Error("No Outlook mailbox admin@muvin.co.uk is connected");
|
|
1018
1111
|
},
|
|
1019
1112
|
});
|
|
1020
|
-
expect(await run([CHECK], sources)).
|
|
1113
|
+
expect(await run([CHECK], sources)).toMatchObject({
|
|
1021
1114
|
hasWork: true,
|
|
1022
1115
|
reasons: ["mail"],
|
|
1023
1116
|
errored: [{ kind: "mail", detail: "No Outlook mailbox admin@muvin.co.uk is connected" }],
|
|
@@ -1025,7 +1118,7 @@ describe("mail check — outlook provider", () => {
|
|
|
1025
1118
|
});
|
|
1026
1119
|
it("throws on a provider outside the accepted list", async () => {
|
|
1027
1120
|
const check = { type: "mail", mailbox: "a@b.com", provider: "pop3" };
|
|
1028
|
-
expect(await run([check], quietSources())).
|
|
1121
|
+
expect(await run([check], quietSources())).toMatchObject({
|
|
1029
1122
|
hasWork: true,
|
|
1030
1123
|
reasons: ["mail"],
|
|
1031
1124
|
errored: [{ kind: "mail", detail: 'unknown mail provider "pop3" — accepted: imap, outlook' }],
|
|
@@ -1036,9 +1129,9 @@ describe("mail check — outlook provider", () => {
|
|
|
1036
1129
|
const sources = quietSources({
|
|
1037
1130
|
mailOutlook: async () => ({ newestReceivedAt: "2026-08-10T09:00:00Z" }),
|
|
1038
1131
|
});
|
|
1039
|
-
// Task
|
|
1040
|
-
await run([CHECK], sources);
|
|
1041
|
-
|
|
1132
|
+
// Task 2663 — the gate call records the position the advance promotes.
|
|
1133
|
+
const gate = await run([CHECK], sources);
|
|
1134
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1042
1135
|
const written = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1043
1136
|
expect(written.mail[KEY]).toEqual({ uid: 42, uidValidity: 7 });
|
|
1044
1137
|
expect(written.mailOutlook[KEY]).toEqual({ receivedAt: "2026-08-10T09:00:00Z" });
|
|
@@ -1056,18 +1149,21 @@ describe("mail check — outlook provider", () => {
|
|
|
1056
1149
|
});
|
|
1057
1150
|
});
|
|
1058
1151
|
// ---------------------------------------------------------------------------
|
|
1059
|
-
// Task 2654 — a source that moves between the gate call
|
|
1152
|
+
// Task 2654, kept under Task 2663 — a source that moves between the gate call
|
|
1153
|
+
// and the advance.
|
|
1060
1154
|
//
|
|
1061
|
-
// The
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1155
|
+
// The advance happens after the wake was accepted, by which time the source may
|
|
1156
|
+
// have moved again. Writing what the source holds THEN would put the arrival
|
|
1157
|
+
// below the mark and no later fire would report it. The dispatcher writes what
|
|
1158
|
+
// the GATE returned instead, so the mover stays above. Under 2654 that guarantee
|
|
1159
|
+
// depended on a sidecar file being fresh; it is now structural, because the
|
|
1160
|
+
// positions travel with the result.
|
|
1065
1161
|
//
|
|
1066
|
-
// Each check is driven alone, and each test runs the real sequence: gate,
|
|
1067
|
-
//
|
|
1162
|
+
// Each check is driven alone, and each test runs the real sequence: gate, source
|
|
1163
|
+
// moves, advance, gate again.
|
|
1068
1164
|
// ---------------------------------------------------------------------------
|
|
1069
|
-
describe("a source that moves between the gate call and the
|
|
1070
|
-
it("mail: a UID that arrives mid-turn is still reported after the
|
|
1165
|
+
describe("a source that moves between the gate call and the advance", () => {
|
|
1166
|
+
it("mail: a UID that arrives mid-turn is still reported after the advance", async () => {
|
|
1071
1167
|
let highest = 105;
|
|
1072
1168
|
const sources = quietSources({
|
|
1073
1169
|
mail: async (_c, sinceUid) => ({
|
|
@@ -1077,14 +1173,15 @@ describe("a source that moves between the gate call and the commit", () => {
|
|
|
1077
1173
|
}),
|
|
1078
1174
|
});
|
|
1079
1175
|
const checks = [{ type: "mail", mailbox: "ops@example.com" }];
|
|
1080
|
-
|
|
1176
|
+
const gate = await run(checks, sources);
|
|
1177
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["mail"], errored: [] });
|
|
1081
1178
|
highest = 106; // arrives while the turn runs
|
|
1082
|
-
|
|
1179
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1083
1180
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1084
1181
|
expect(marks.mail["ops@example.com|inbox"].uid).toBe(105);
|
|
1085
|
-
expect(await run(checks, sources)).
|
|
1182
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["mail"] });
|
|
1086
1183
|
});
|
|
1087
|
-
it("whatsapp: an inbound message mid-turn is still reported after the
|
|
1184
|
+
it("whatsapp: an inbound message mid-turn is still reported after the advance", async () => {
|
|
1088
1185
|
let latest = 1_754_000_100;
|
|
1089
1186
|
const sources = quietSources({
|
|
1090
1187
|
whatsappInbound: async (_a, since) => ({
|
|
@@ -1093,86 +1190,96 @@ describe("a source that moves between the gate call and the commit", () => {
|
|
|
1093
1190
|
}),
|
|
1094
1191
|
});
|
|
1095
1192
|
const checks = [{ type: "whatsapp", scope: "inbound" }];
|
|
1096
|
-
|
|
1193
|
+
const gate = await run(checks, sources);
|
|
1194
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["whatsapp"], errored: [] });
|
|
1097
1195
|
latest = 1_754_000_200;
|
|
1098
|
-
|
|
1196
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1099
1197
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1100
1198
|
expect(marks.whatsapp.inbound).toBe(1_754_000_100);
|
|
1101
|
-
expect(await run(checks, sources)).
|
|
1199
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["whatsapp"] });
|
|
1102
1200
|
});
|
|
1103
|
-
it("telegram: an inbound message mid-turn is still reported after the
|
|
1201
|
+
it("telegram: an inbound message mid-turn is still reported after the advance", async () => {
|
|
1104
1202
|
let newest = "2026-08-10T10:00:00.000Z";
|
|
1105
1203
|
const sources = quietSources({ telegramInbound: async () => ({ newestInboundAt: newest }) });
|
|
1106
1204
|
const checks = [{ type: "telegram", scope: "inbound" }];
|
|
1107
|
-
|
|
1205
|
+
const gate = await run(checks, sources);
|
|
1206
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["telegram"], errored: [] });
|
|
1108
1207
|
newest = "2026-08-10T10:30:00.000Z";
|
|
1109
|
-
|
|
1208
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1110
1209
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1111
1210
|
expect(marks.telegram.inbound).toBe("2026-08-10T10:00:00.000Z");
|
|
1112
|
-
expect(await run(checks, sources)).
|
|
1211
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["telegram"] });
|
|
1113
1212
|
});
|
|
1114
|
-
it("calendar: a connector change mid-turn is still reported after the
|
|
1213
|
+
it("calendar: a connector change mid-turn is still reported after the advance", async () => {
|
|
1115
1214
|
let digest = "DIGEST-A";
|
|
1116
1215
|
const sources = quietSources({ calendarDigest: async () => digest });
|
|
1117
1216
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
1118
|
-
|
|
1217
|
+
const gate = await run(checks, sources);
|
|
1218
|
+
expect(gate).toMatchObject({ hasWork: true, reasons: ["calendar"], errored: [] });
|
|
1119
1219
|
digest = "DIGEST-B";
|
|
1120
|
-
|
|
1220
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1121
1221
|
const marks = JSON.parse(readFileSync(watermarkPath(EVENT, platformRoot), "utf-8"));
|
|
1122
1222
|
expect(marks.calendar.digest).toBe("DIGEST-A");
|
|
1123
|
-
expect(await run(checks, sources)).
|
|
1124
|
-
});
|
|
1125
|
-
// Task
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
|
|
1223
|
+
expect(await run(checks, sources)).toMatchObject({ hasWork: true, reasons: ["calendar"] });
|
|
1224
|
+
});
|
|
1225
|
+
// Task 2663 — the no-event case. The gate that could never go quiet emitted a
|
|
1226
|
+
// line on every fire that looked exactly like a healthy one, because it named
|
|
1227
|
+
// only what the probe had just read and never the value it was compared
|
|
1228
|
+
// against. Both sides now print, so a mark stuck on an empty slot is legible
|
|
1229
|
+
// from one line without reproducing anything.
|
|
1230
|
+
describe("gate-read names both sides of the comparison", () => {
|
|
1129
1231
|
// `console.error` is already spied in `beforeEach`, so its recorded calls are
|
|
1130
1232
|
// the log. Re-mocking it here would drop the lines written before this point.
|
|
1131
1233
|
const errorLines = () => vi.mocked(console.error).mock.calls.map((c) => c.map(String).join(" "));
|
|
1132
|
-
it("names
|
|
1234
|
+
it("names null stored on a first fire, and the observed position beside it", async () => {
|
|
1235
|
+
const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
|
|
1236
|
+
await run([{ type: "calendar", connector: "google" }], sources);
|
|
1237
|
+
expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=null observed=abcdef01"));
|
|
1238
|
+
});
|
|
1239
|
+
it("names the stored position once the mark has advanced", async () => {
|
|
1133
1240
|
const sources = quietSources({ calendarDigest: async () => "abcdef0123456789" });
|
|
1134
1241
|
const checks = [{ type: "calendar", connector: "google" }];
|
|
1242
|
+
const gate = await run(checks, sources);
|
|
1243
|
+
advanceGateMark(EVENT, platformRoot, gate.observed);
|
|
1135
1244
|
await run(checks, sources);
|
|
1136
|
-
|
|
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([]);
|
|
1245
|
+
expect(errorLines()).toContainEqual(expect.stringContaining("op=gate-read eventId=65ef758c check=calendar stored=abcdef01 observed=abcdef01"));
|
|
1156
1246
|
});
|
|
1157
1247
|
it("emits no gate-read for a check that holds no position", async () => {
|
|
1158
|
-
// `op=check-error`
|
|
1159
|
-
//
|
|
1248
|
+
// `op=check-error` already covers a check with no position; a line of two
|
|
1249
|
+
// nulls would only add a value nobody can compare.
|
|
1160
1250
|
await run([{ type: "d1", database: "b", sql: "SELECT count(*) FROM b" }], quietSources());
|
|
1161
1251
|
expect(errorLines().filter((l) => l.includes("op=gate-read"))).toEqual([]);
|
|
1162
1252
|
});
|
|
1163
1253
|
});
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
expect(
|
|
1175
|
-
|
|
1254
|
+
});
|
|
1255
|
+
// Task 2663 — the gate hands its readings back to the dispatcher and touches no
|
|
1256
|
+
// sidecar. The commit and the `<eventId>.observed.json` file it promoted from
|
|
1257
|
+
// are both gone: a sidecar survives a gate call that errored, so the stale
|
|
1258
|
+
// positions from an earlier fire would be promoted on the next accepted wake and
|
|
1259
|
+
// mark as seen messages nobody read.
|
|
1260
|
+
describe("observed positions (Task 2663)", () => {
|
|
1261
|
+
it("returns the positions it read, and writes no sidecar", async () => {
|
|
1262
|
+
const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({ telegramInbound: async () => ({ newestInboundAt: "2026-08-10T18:00:00.000Z" }) }));
|
|
1263
|
+
expect(result.observed).toEqual({ telegram: { byBot: { "555": "2026-08-10T18:00:00.000Z" } } });
|
|
1264
|
+
expect(existsSync(join(platformRoot, "..", "state", "scheduling-gate", `${EVENT}.observed.json`))).toBe(false);
|
|
1265
|
+
});
|
|
1266
|
+
it("reports no position for a check that holds none", async () => {
|
|
1267
|
+
// `d1` reads a current value rather than a position in a stream, so there is
|
|
1268
|
+
// nothing for the dispatcher to promote and `observed` is empty rather than
|
|
1269
|
+
// carrying a fabricated key.
|
|
1270
|
+
const result = await run([{ type: "d1", database: "b", sql: "SELECT 1" }], quietSources({ d1: async () => 1 }));
|
|
1271
|
+
expect(result.observed).toEqual({});
|
|
1272
|
+
});
|
|
1273
|
+
it("reports no position for a check that threw", async () => {
|
|
1274
|
+
// A check that errored knows nothing about its source. Recording a position
|
|
1275
|
+
// for it would let the dispatcher advance past work the gate never saw.
|
|
1276
|
+
const result = await run([{ type: "telegram", scope: "inbound", botId: "555" }], quietSources({
|
|
1277
|
+
telegramInbound: async () => {
|
|
1278
|
+
throw new Error("fetch failed");
|
|
1279
|
+
},
|
|
1280
|
+
}));
|
|
1281
|
+
expect(result.hasWork).toBe(true);
|
|
1282
|
+
expect(result.observed).toEqual({});
|
|
1176
1283
|
});
|
|
1177
1284
|
});
|
|
1178
1285
|
//# sourceMappingURL=scheduling-gate.test.js.map
|