@rubytech/create-realagent-code 0.1.603 → 0.1.605

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 (133) hide show
  1. package/dist/__tests__/email-sampler-unit.test.js +22 -0
  2. package/dist/index.js +3 -0
  3. package/dist/port-resolution.js +9 -1
  4. package/package.json +1 -1
  5. package/payload/platform/lib/routine-templates/dist/roster.d.ts +46 -2
  6. package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
  7. package/payload/platform/lib/routine-templates/dist/roster.js +84 -32
  8. package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
  9. package/payload/platform/lib/routine-templates/src/__tests__/roster.test.ts +0 -0
  10. package/payload/platform/lib/routine-templates/src/__tests__/seed.test.ts +48 -2
  11. package/payload/platform/lib/routine-templates/src/roster.ts +133 -35
  12. package/payload/platform/lib/telegram-store-read/dist/index.d.ts +59 -0
  13. package/payload/platform/lib/telegram-store-read/dist/index.d.ts.map +1 -0
  14. package/payload/platform/lib/telegram-store-read/dist/index.js +219 -0
  15. package/payload/platform/lib/telegram-store-read/dist/index.js.map +1 -0
  16. package/payload/platform/lib/telegram-store-read/src/__tests__/fold.test.ts +124 -0
  17. package/payload/platform/lib/telegram-store-read/src/index.ts +262 -0
  18. package/payload/platform/lib/telegram-store-read/tsconfig.json +9 -0
  19. package/payload/platform/lib/telegram-store-read/vitest.config.ts +9 -0
  20. package/payload/platform/package.json +2 -2
  21. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +39 -6
  22. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +11 -0
  23. package/payload/platform/plugins/connector/PLUGIN.md +9 -1
  24. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts +2 -0
  25. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js +67 -0
  27. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js.map +1 -0
  28. package/payload/platform/plugins/connector/mcp/dist/index.js +36 -5
  29. package/payload/platform/plugins/connector/mcp/dist/index.js.map +1 -1
  30. package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts +35 -0
  31. package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts.map +1 -1
  32. package/payload/platform/plugins/connector/mcp/dist/lib/store.js +33 -0
  33. package/payload/platform/plugins/connector/mcp/dist/lib/store.js.map +1 -1
  34. package/payload/platform/plugins/connector/mcp/package.json +4 -2
  35. package/payload/platform/plugins/connector/mcp/vitest.config.ts +8 -0
  36. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  37. package/payload/platform/plugins/docs/references/outlook-guide.md +21 -2
  38. package/payload/platform/plugins/docs/references/telegram-guide.md +15 -1
  39. package/payload/platform/plugins/email/PLUGIN.md +14 -1
  40. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts +2 -0
  41. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts.map +1 -0
  42. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js +233 -0
  43. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js.map +1 -0
  44. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/sample-mailbox.test.js +30 -0
  45. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/sample-mailbox.test.js.map +1 -1
  46. package/payload/platform/plugins/email/mcp/dist/sampler/main.js +68 -3
  47. package/payload/platform/plugins/email/mcp/dist/sampler/main.js.map +1 -1
  48. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts +3 -0
  49. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts.map +1 -0
  50. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js +146 -0
  51. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js.map +1 -0
  52. package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts +150 -0
  53. package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts.map +1 -0
  54. package/payload/platform/plugins/email/mcp/dist/sampler/retention.js +199 -0
  55. package/payload/platform/plugins/email/mcp/dist/sampler/retention.js.map +1 -0
  56. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.d.ts +5 -0
  57. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.d.ts.map +1 -1
  58. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js +10 -5
  59. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js.map +1 -1
  60. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js +11 -0
  61. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js.map +1 -1
  62. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  63. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +8 -0
  64. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  65. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts +2 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts.map +1 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js +324 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js.map +1 -0
  69. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts +2 -0
  70. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts.map +1 -0
  71. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js +102 -0
  72. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js.map +1 -0
  73. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts +2 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts.map +1 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js +125 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js.map +1 -0
  77. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts +2 -0
  78. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts.map +1 -0
  79. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js +109 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js.map +1 -0
  81. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts +25 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js +94 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts +41 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js +122 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts +73 -0
  90. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts.map +1 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.js +162 -0
  92. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.js.map +1 -0
  93. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -1
  94. package/payload/platform/plugins/scheduling/mcp/dist/index.js +1 -1
  95. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  96. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.d.ts +2 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.d.ts.map +1 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js +90 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js.map +1 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +26 -4
  101. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
  102. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts +78 -0
  103. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts.map +1 -0
  104. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js +76 -0
  105. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js.map +1 -0
  106. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +8 -0
  107. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
  108. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +9 -1
  109. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
  110. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js +15 -0
  111. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js.map +1 -1
  112. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +12 -1
  113. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  114. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +34 -1
  115. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  116. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +9 -1
  117. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  118. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +40 -0
  119. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  120. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +435 -5
  121. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  122. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  123. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +21 -1
  124. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  125. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +294 -6
  126. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  127. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +553 -11
  128. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  129. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -1
  130. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  131. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  132. package/payload/server/public/operator.html +1 -1
  133. package/payload/server/server.js +69 -5
@@ -5,7 +5,7 @@
5
5
  // that only ever ran all four together could not tell a working check from an
6
6
  // always-true one riding on a sibling.
7
7
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
8
- import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
8
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, symlinkSync, } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  /** Records the mocked neo4j session returns. Empty for every test but the mail
@@ -17,7 +17,7 @@ vi.mock("../../lib/neo4j.js", () => ({
17
17
  close: vi.fn(async () => { }),
18
18
  }),
19
19
  }));
20
- import { runSchedulingGate, advanceGateMark, watermarkPath, readWatermarks, readWatermarksChecked, foldMailWatermarks, parseD1Count, digestBusy, slotKey, slotPosition, slotsIn, promoteSlot, } from "../scheduling-gate.js";
20
+ import { runSchedulingGate, advanceGateMark, watermarkPath, readWatermarks, readWatermarksChecked, foldMailWatermarks, parseD1Count, digestBusy, digestSweepState, slotKey, slotPosition, slotsIn, promoteSlot, UNFLAGGED_ID_CAP, } 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;
@@ -37,6 +37,20 @@ function quietSources(over = {}) {
37
37
  // Task 2681 — "quiet" for the graph arm is an account whose graph holds no
38
38
  // appointment activity at all.
39
39
  graphActivity: async () => ({ newestActivityAt: null }),
40
+ // Task 2684 — "quiet" for the unflagged arm is a label holding nothing this
41
+ // routine has not already flagged.
42
+ graphUnflagged: async () => ({ ids: [] }),
43
+ // Task 2688 — "quiet" for the connector arm is an account that registered
44
+ // no queue of the requested kind, which is what every install answers until
45
+ // an operator describes one.
46
+ connectorQueues: async () => ({ pluginPresent: true, bindings: [] }),
47
+ connectorQueueRead: async () => ({ status: 200, items: 0, truncated: false }),
48
+ // Task 2689 — "quiet" for the sweep arm is a sweep that ran just now with
49
+ // both review queues empty.
50
+ sweepState: async () => ({ newestReportAt: new Date().toISOString(), orphans: 0, citations: 0 }),
51
+ // Task 2689 — "quiet" for the filesystem arm is an account directory that
52
+ // has not grown since the stored measurement.
53
+ filesystemBytes: async () => ({ bytes: 0, capped: false }),
40
54
  ...over,
41
55
  };
42
56
  }
@@ -1037,7 +1051,7 @@ describe("unrecognised check type", () => {
1037
1051
  errored: [
1038
1052
  {
1039
1053
  kind: "nonsense",
1040
- detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram, graph',
1054
+ detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram, graph, graph-unflagged, connector, sweep, filesystem',
1041
1055
  },
1042
1056
  ],
1043
1057
  });
@@ -1061,7 +1075,7 @@ describe("unrecognised check type", () => {
1061
1075
  errored: [
1062
1076
  {
1063
1077
  kind: "nonsense",
1064
- detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram, graph',
1078
+ detail: 'unknown check type "nonsense" — accepted: mail, d1, whatsapp, calendar, telegram, graph, graph-unflagged, connector, sweep, filesystem',
1065
1079
  },
1066
1080
  ],
1067
1081
  });
@@ -1173,7 +1187,14 @@ describe("mail check — outlook provider", () => {
1173
1187
  it("records no position for an empty folder rather than advancing onto nothing", async () => {
1174
1188
  const sources = quietSources({ mailOutlook: async () => ({ newestReceivedAt: null }) });
1175
1189
  const gate = await run([CHECK], sources);
1176
- expect(gate).toEqual({ hasWork: false, genuineWork: false, reasons: [], errored: [], observed: {} });
1190
+ expect(gate).toEqual({
1191
+ hasWork: false,
1192
+ genuineWork: false,
1193
+ reasons: [],
1194
+ errored: [],
1195
+ observed: {},
1196
+ unflagged: [],
1197
+ });
1177
1198
  expect(advanceGateMark(EVENT, platformRoot, gate.observed).advanced).toEqual([]);
1178
1199
  });
1179
1200
  it("keys the folder segment lower-cased, so two spellings address one entry", async () => {
@@ -1437,4 +1458,413 @@ describe("REAL_SOURCES.graphActivity", () => {
1437
1458
  await expect(S.graphActivity(ACCOUNT, "appointments")).rejects.toThrow(/expected an ISO string/);
1438
1459
  });
1439
1460
  });
1461
+ // Task 2684 — the unflagged-node check. Where every check above compares a
1462
+ // source against ONE stored position, this one asks which nodes are outstanding
1463
+ // for this routine, which is an exact set: a node that arrives between the probe
1464
+ // and the flag write is simply still unflagged on the next fire.
1465
+ describe("graph-unflagged check", () => {
1466
+ const SECTIONS = { type: "graph-unflagged", label: "Section" };
1467
+ const unflagged = (ids) => ({
1468
+ graphUnflagged: async () => ({ ids }),
1469
+ });
1470
+ it("is false when the label holds nothing outstanding for this routine", async () => {
1471
+ expect(await run([SECTIONS], quietSources(unflagged([])))).toMatchObject({
1472
+ hasWork: false,
1473
+ reasons: [],
1474
+ errored: [],
1475
+ unflagged: [],
1476
+ });
1477
+ });
1478
+ it("is true when one node of the label is unflagged, and hands its id over", async () => {
1479
+ expect(await run([SECTIONS], quietSources(unflagged(["4:abc:1"])))).toMatchObject({
1480
+ hasWork: true,
1481
+ reasons: ["graph-unflagged"],
1482
+ errored: [],
1483
+ unflagged: [{ label: "Section", ids: ["4:abc:1"] }],
1484
+ });
1485
+ });
1486
+ it("records no watermark position — its state is the flag, not a stored mark", async () => {
1487
+ const result = await run([SECTIONS], quietSources(unflagged(["4:abc:1"])));
1488
+ expect(result.observed).toEqual({});
1489
+ });
1490
+ it("probes under this routine's own event id, so another routine's flag still counts", async () => {
1491
+ const keys = [];
1492
+ await run([SECTIONS], quietSources({
1493
+ graphUnflagged: async (_a, _l, flagKey) => {
1494
+ keys.push(flagKey);
1495
+ return { ids: [] };
1496
+ },
1497
+ }));
1498
+ expect(keys).toEqual([EVENT]);
1499
+ });
1500
+ it("caps the handed set, so a backlog drains over fires rather than being skipped", async () => {
1501
+ const limits = [];
1502
+ await run([SECTIONS], quietSources({
1503
+ graphUnflagged: async (_a, _l, _k, limit) => {
1504
+ limits.push(limit);
1505
+ return { ids: [] };
1506
+ },
1507
+ }));
1508
+ expect(limits).toEqual([UNFLAGGED_ID_CAP]);
1509
+ });
1510
+ it("carries one entry per check, so two labels in one gate flag separately", async () => {
1511
+ const result = await run([SECTIONS, { type: "graph-unflagged", label: "ConversationArchive" }], quietSources({
1512
+ graphUnflagged: async (_a, label) => ({ ids: [`${label}-1`] }),
1513
+ }));
1514
+ expect(result.unflagged).toEqual([
1515
+ { label: "Section", ids: ["Section-1"] },
1516
+ { label: "ConversationArchive", ids: ["ConversationArchive-1"] },
1517
+ ]);
1518
+ });
1519
+ it("hands nothing over when the source throws, and counts the throw as work", async () => {
1520
+ const result = await run([SECTIONS], quietSources({
1521
+ graphUnflagged: async () => {
1522
+ throw new Error("neo4j down");
1523
+ },
1524
+ }));
1525
+ expect(result.hasWork).toBe(true);
1526
+ expect(result.errored.map((e) => e.kind)).toContain("graph-unflagged");
1527
+ // A check that threw knows nothing about its source, so flagging on it would
1528
+ // mark processed what no turn ever saw.
1529
+ expect(result.unflagged).toEqual([]);
1530
+ });
1531
+ it("refuses a label it cannot probe, and counts that as work rather than quiet", async () => {
1532
+ const result = await run([{ type: "graph-unflagged", label: "Person" }], quietSources(unflagged([])));
1533
+ expect(result.hasWork).toBe(true);
1534
+ expect(result.errored[0].detail).toMatch(/unknown graph-unflagged label/);
1535
+ expect(result.unflagged).toEqual([]);
1536
+ });
1537
+ it("names the label in its source, so two labels are distinguishable in the log", async () => {
1538
+ const result = await run([SECTIONS], quietSources({
1539
+ graphUnflagged: async () => {
1540
+ throw new Error("neo4j down");
1541
+ },
1542
+ }));
1543
+ expect(result.errored[0].source).toBe("graph:Section");
1544
+ });
1545
+ });
1546
+ describe("REAL_SOURCES.graphUnflagged", () => {
1547
+ it("returns the ids the label holds outstanding for this routine", async () => {
1548
+ neo4jRecords = [
1549
+ { get: (k) => (k === "id" ? "4:abc:1" : null) },
1550
+ { get: (k) => (k === "id" ? "4:abc:2" : null) },
1551
+ ];
1552
+ const { REAL_SOURCES: S } = await import("../scheduling-gate.js");
1553
+ expect(await S.graphUnflagged(ACCOUNT, "Section", EVENT, 500)).toEqual({
1554
+ ids: ["4:abc:1", "4:abc:2"],
1555
+ });
1556
+ });
1557
+ it("throws on a non-string id rather than dropping it from the handed set", async () => {
1558
+ neo4jRecords = [{ get: (k) => (k === "id" ? 17 : null) }];
1559
+ const { REAL_SOURCES: S } = await import("../scheduling-gate.js");
1560
+ await expect(S.graphUnflagged(ACCOUNT, "Section", EVENT, 500)).rejects.toThrow(/expected a string/);
1561
+ });
1562
+ });
1563
+ // Task 2688 — the connector queue arm.
1564
+ //
1565
+ // The three quiet cases are asserted apart from the error cases on purpose:
1566
+ // this is the only check whose source can fail and still answer "no work", so a
1567
+ // suite that ran them together could not tell an unauthorised connector from an
1568
+ // unreachable one, which is exactly the distinction the arm exists to make.
1569
+ describe("connector check", () => {
1570
+ const CHECK = { type: "connector", scope: "operational" };
1571
+ const QUEUE = {
1572
+ kind: "operational",
1573
+ path: "/v2/jobs/changed",
1574
+ changedSinceParam: "updatedSince",
1575
+ itemsField: "items",
1576
+ };
1577
+ const BINDING = { name: "jobs", queue: QUEUE };
1578
+ const AT = "2026-08-14T06:00:00.000Z";
1579
+ const YESTERDAY = "2026-08-13T06:00:00.000Z";
1580
+ const bound = (over = {}) => quietSources({
1581
+ connectorQueues: async () => ({ pluginPresent: true, bindings: [BINDING] }),
1582
+ ...over,
1583
+ });
1584
+ beforeEach(() => {
1585
+ vi.useFakeTimers();
1586
+ vi.setSystemTime(new Date(AT));
1587
+ });
1588
+ afterEach(() => {
1589
+ vi.useRealTimers();
1590
+ });
1591
+ it("reports work and records the probe instant on a first run, without calling the queue", async () => {
1592
+ let called = 0;
1593
+ const res = await run([CHECK], bound({
1594
+ connectorQueueRead: async () => {
1595
+ called++;
1596
+ return { status: 200, items: 0, truncated: false };
1597
+ },
1598
+ }));
1599
+ // An unfiltered read of a queue that has never been filtered is the body
1600
+ // connector-call caps, and a capped body cannot be counted.
1601
+ expect(called).toBe(0);
1602
+ expect(res).toMatchObject({ hasWork: true, genuineWork: true, reasons: ["connector"] });
1603
+ expect(res.observed.connector).toEqual({ jobs: { changedSince: AT } });
1604
+ });
1605
+ it("reports work when the queue holds records above the stored position", async () => {
1606
+ seedWatermarks({ connector: { jobs: { changedSince: YESTERDAY } } });
1607
+ const res = await run([CHECK], bound({ connectorQueueRead: async () => ({ status: 200, items: 3, truncated: false }) }));
1608
+ expect(res).toMatchObject({ hasWork: true, reasons: ["connector"], errored: [] });
1609
+ expect(res.observed.connector).toEqual({ jobs: { changedSince: AT } });
1610
+ });
1611
+ it("reports no work on an empty queue, and still records the position", async () => {
1612
+ seedWatermarks({ connector: { jobs: { changedSince: YESTERDAY } } });
1613
+ const res = await run([CHECK], bound());
1614
+ expect(res).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1615
+ expect(res.observed.connector).toEqual({ jobs: { changedSince: AT } });
1616
+ });
1617
+ it("passes the stored position as the changed-since argument", async () => {
1618
+ seedWatermarks({ connector: { jobs: { changedSince: YESTERDAY } } });
1619
+ const seen = [];
1620
+ await run([CHECK], bound({
1621
+ connectorQueueRead: async (_a, _b, changedSince) => {
1622
+ seen.push(changedSince);
1623
+ return { status: 200, items: 0, truncated: false };
1624
+ },
1625
+ }));
1626
+ expect(seen).toEqual([YESTERDAY]);
1627
+ });
1628
+ it("asks only for the queues whose kind matches the scope", async () => {
1629
+ const asked = [];
1630
+ await run([{ type: "connector", scope: "accounting" }], quietSources({
1631
+ connectorQueues: async (_a, kind) => {
1632
+ asked.push(kind);
1633
+ return { pluginPresent: true, bindings: [] };
1634
+ },
1635
+ }));
1636
+ expect(asked).toEqual(["accounting"]);
1637
+ });
1638
+ it("answers quiet when the connector plugin is not installed", async () => {
1639
+ const res = await run([CHECK], quietSources({ connectorQueues: async () => ({ pluginPresent: false, bindings: [] }) }));
1640
+ expect(res).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1641
+ expect(res.observed.connector).toBeUndefined();
1642
+ });
1643
+ it("answers quiet when no registered queue carries that kind", async () => {
1644
+ const res = await run([CHECK], quietSources());
1645
+ expect(res).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1646
+ expect(res.observed.connector).toBeUndefined();
1647
+ });
1648
+ it.each([401, 403])("answers quiet on %i and records no position", async (status) => {
1649
+ seedWatermarks({ connector: { jobs: { changedSince: YESTERDAY } } });
1650
+ const res = await run([CHECK], bound({ connectorQueueRead: async () => ({ status, items: 0, truncated: false }) }));
1651
+ expect(res).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1652
+ // No position: an unauthorised connector was never read, so advancing past
1653
+ // it would skip whatever arrived while the credential was dead.
1654
+ expect(res.observed.connector).toBeUndefined();
1655
+ });
1656
+ it("counts any other non-2xx as work, so an outage still fires", async () => {
1657
+ seedWatermarks({ connector: { jobs: { changedSince: YESTERDAY } } });
1658
+ const res = await run([CHECK], bound({ connectorQueueRead: async () => ({ status: 500, items: 0, truncated: false }) }));
1659
+ expect(res).toMatchObject({ hasWork: true, genuineWork: false, reasons: ["connector"] });
1660
+ expect(res.errored[0]).toMatchObject({ kind: "connector", source: "connector:operational" });
1661
+ expect(res.observed.connector).toBeUndefined();
1662
+ });
1663
+ it("treats a truncated body as work AND records the position, so the window narrows", async () => {
1664
+ // A body the connector capped cannot be counted, but it is proof the queue
1665
+ // moved. Recording nothing would leave the same window on the next fire,
1666
+ // which would truncate again: the routine would then wake the model on
1667
+ // every fire for ever, with no way back to quiet.
1668
+ seedWatermarks({ connector: { jobs: { changedSince: YESTERDAY } } });
1669
+ const res = await run([CHECK], bound({ connectorQueueRead: async () => ({ status: 200, items: 0, truncated: true }) }));
1670
+ expect(res).toMatchObject({ hasWork: true, genuineWork: true, reasons: ["connector"], errored: [] });
1671
+ expect(res.observed.connector).toEqual({ jobs: { changedSince: AT } });
1672
+ });
1673
+ it("counts a transport failure as work", async () => {
1674
+ seedWatermarks({ connector: { jobs: { changedSince: YESTERDAY } } });
1675
+ const res = await run([CHECK], bound({
1676
+ connectorQueueRead: async () => {
1677
+ throw new Error("ETIMEDOUT");
1678
+ },
1679
+ }));
1680
+ expect(res).toMatchObject({ hasWork: true, genuineWork: false });
1681
+ expect(res.errored).toEqual([
1682
+ { kind: "connector", detail: "ETIMEDOUT", source: "connector:operational" },
1683
+ ]);
1684
+ });
1685
+ it("holds one position per connector under a single check", async () => {
1686
+ seedWatermarks({
1687
+ connector: {
1688
+ jobs: { changedSince: YESTERDAY },
1689
+ crm: { changedSince: "2026-08-12T06:00:00.000Z" },
1690
+ },
1691
+ });
1692
+ const res = await run([CHECK], quietSources({
1693
+ connectorQueues: async () => ({
1694
+ pluginPresent: true,
1695
+ bindings: [BINDING, { name: "crm", queue: QUEUE }],
1696
+ }),
1697
+ connectorQueueRead: async (_a, binding) => ({
1698
+ status: 200,
1699
+ items: binding.name === "crm" ? 2 : 0,
1700
+ truncated: false,
1701
+ }),
1702
+ }));
1703
+ expect(res).toMatchObject({ hasWork: true, reasons: ["connector"] });
1704
+ expect(res.observed.connector).toEqual({
1705
+ jobs: { changedSince: AT },
1706
+ crm: { changedSince: AT },
1707
+ });
1708
+ });
1709
+ it("names each connector slot in the watermark helpers", () => {
1710
+ const marks = { connector: { jobs: { changedSince: AT } } };
1711
+ expect(slotsIn(marks)).toEqual([{ kind: "connector", key: "jobs" }]);
1712
+ expect(slotKey({ kind: "connector", key: "jobs" })).toBe("connector:jobs");
1713
+ expect(slotPosition({ kind: "connector", key: "jobs" }, marks)).toBe(AT);
1714
+ const into = {};
1715
+ expect(promoteSlot({ kind: "connector", key: "jobs" }, marks, into)).toBe(true);
1716
+ expect(into.connector).toEqual({ jobs: { changedSince: AT } });
1717
+ });
1718
+ });
1719
+ // Task 2689 — the health-delta checks.
1720
+ describe("sweep check", () => {
1721
+ const CHECK = { type: "sweep" };
1722
+ const FRESH = { newestReportAt: new Date().toISOString(), orphans: 2, citations: 1 };
1723
+ it("is false when the digest matches the stored one", async () => {
1724
+ seedWatermarks({ sweep: { digest: digestSweepState(FRESH, Date.now()) } });
1725
+ const sources = quietSources({ sweepState: async () => FRESH });
1726
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1727
+ });
1728
+ it("is true when a review queue moved", async () => {
1729
+ seedWatermarks({ sweep: { digest: digestSweepState(FRESH, Date.now()) } });
1730
+ const sources = quietSources({ sweepState: async () => ({ ...FRESH, orphans: 3 }) });
1731
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["sweep"] });
1732
+ });
1733
+ it("is true on a first run, when no digest is stored", async () => {
1734
+ const sources = quietSources({ sweepState: async () => FRESH });
1735
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["sweep"] });
1736
+ });
1737
+ it("reports work rather than an error when the source is unreachable", async () => {
1738
+ const sources = quietSources({
1739
+ sweepState: async () => {
1740
+ throw new Error("neo4j unreachable");
1741
+ },
1742
+ });
1743
+ expect(await run([CHECK], sources)).toMatchObject({
1744
+ hasWork: true,
1745
+ reasons: ["sweep"],
1746
+ errored: [{ kind: "sweep", source: "sweep", detail: "neo4j unreachable" }],
1747
+ });
1748
+ });
1749
+ it("changes once when the sweep goes stale, then settles", () => {
1750
+ const now = Date.parse("2026-08-14T12:00:00.000Z");
1751
+ const state = { newestReportAt: "2026-08-14T11:00:00.000Z", orphans: 0, citations: 0 };
1752
+ const healthy = digestSweepState(state, now);
1753
+ const stale = digestSweepState(state, now + 27 * 60 * 60 * 1000);
1754
+ expect(stale).not.toBe(healthy);
1755
+ expect(digestSweepState(state, now + 30 * 60 * 60 * 1000)).toBe(stale);
1756
+ });
1757
+ it("treats a sweep that has never run as stale", () => {
1758
+ const now = Date.parse("2026-08-14T12:00:00.000Z");
1759
+ const absent = { newestReportAt: null, orphans: 0, citations: 0 };
1760
+ const old = { newestReportAt: "2026-08-12T12:00:00.000Z", orphans: 0, citations: 0 };
1761
+ expect(digestSweepState(absent, now)).toBe(digestSweepState(old, now));
1762
+ });
1763
+ it("does not move on the sweep merely running again", () => {
1764
+ const now = Date.parse("2026-08-14T12:00:00.000Z");
1765
+ const before = { newestReportAt: "2026-08-14T10:00:00.000Z", orphans: 4, citations: 2 };
1766
+ const after = { newestReportAt: "2026-08-14T11:00:00.000Z", orphans: 4, citations: 2 };
1767
+ expect(digestSweepState(after, now)).toBe(digestSweepState(before, now));
1768
+ });
1769
+ it("records its digest as a promotable slot", async () => {
1770
+ const sources = quietSources({ sweepState: async () => FRESH });
1771
+ const res = await run([CHECK], sources);
1772
+ expect(slotsIn(res.observed)).toEqual([{ kind: "sweep" }]);
1773
+ const into = {};
1774
+ expect(promoteSlot({ kind: "sweep" }, res.observed, into)).toBe(true);
1775
+ expect(into.sweep?.digest).toBe(res.observed.sweep?.digest);
1776
+ expect(slotKey({ kind: "sweep" })).toBe("sweep");
1777
+ expect(slotPosition({ kind: "sweep" }, into)).toBe(res.observed.sweep.digest.slice(0, 8));
1778
+ });
1779
+ });
1780
+ describe("filesystem check", () => {
1781
+ const CHECK = { type: "filesystem", minGrowthBytes: 1_000 };
1782
+ it("is false when growth is below the threshold", async () => {
1783
+ seedWatermarks({ filesystem: { bytes: 10_000 } });
1784
+ const sources = quietSources({ filesystemBytes: async () => ({ bytes: 10_999, capped: false }) });
1785
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: false, reasons: [], errored: [] });
1786
+ });
1787
+ it("is true when growth reaches the threshold", async () => {
1788
+ seedWatermarks({ filesystem: { bytes: 10_000 } });
1789
+ const sources = quietSources({ filesystemBytes: async () => ({ bytes: 11_000, capped: false }) });
1790
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["filesystem"] });
1791
+ });
1792
+ it("is false when the directory shrank", async () => {
1793
+ seedWatermarks({ filesystem: { bytes: 10_000 } });
1794
+ const sources = quietSources({ filesystemBytes: async () => ({ bytes: 4_000, capped: false }) });
1795
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: false, reasons: [] });
1796
+ });
1797
+ it("is true on a first run, when no size is stored", async () => {
1798
+ const sources = quietSources({ filesystemBytes: async () => ({ bytes: 1, capped: false }) });
1799
+ expect(await run([CHECK], sources)).toMatchObject({ hasWork: true, reasons: ["filesystem"] });
1800
+ });
1801
+ it("reports work and records no position when the walk hit its cap", async () => {
1802
+ seedWatermarks({ filesystem: { bytes: 10_000 } });
1803
+ const sources = quietSources({ filesystemBytes: async () => ({ bytes: 10_001, capped: true }) });
1804
+ const res = await run([CHECK], sources);
1805
+ expect(res).toMatchObject({ hasWork: true, reasons: ["filesystem"] });
1806
+ expect(res.observed.filesystem).toBeUndefined();
1807
+ });
1808
+ it("refuses a non-positive threshold rather than passing on every fire", async () => {
1809
+ const res = await run([{ type: "filesystem", minGrowthBytes: 0 }], quietSources());
1810
+ expect(res).toMatchObject({ hasWork: true, errored: [{ kind: "filesystem" }] });
1811
+ });
1812
+ it("reports work rather than an error when the source is unreachable", async () => {
1813
+ const sources = quietSources({
1814
+ filesystemBytes: async () => {
1815
+ throw new Error("ENOENT: no account directory");
1816
+ },
1817
+ });
1818
+ expect(await run([CHECK], sources)).toMatchObject({
1819
+ hasWork: true,
1820
+ reasons: ["filesystem"],
1821
+ errored: [{ kind: "filesystem", source: "filesystem" }],
1822
+ });
1823
+ });
1824
+ it("records its size as a promotable slot", async () => {
1825
+ const sources = quietSources({ filesystemBytes: async () => ({ bytes: 77, capped: false }) });
1826
+ const res = await run([CHECK], sources);
1827
+ expect(slotsIn(res.observed)).toEqual([{ kind: "filesystem" }]);
1828
+ const into = {};
1829
+ expect(promoteSlot({ kind: "filesystem" }, res.observed, into)).toBe(true);
1830
+ expect(into.filesystem?.bytes).toBe(77);
1831
+ expect(slotKey({ kind: "filesystem" })).toBe("filesystem");
1832
+ expect(slotPosition({ kind: "filesystem" }, into)).toBe("77");
1833
+ });
1834
+ });
1835
+ describe("measureAccountBytes", () => {
1836
+ it("sums nested regular files and never follows a symlink", async () => {
1837
+ const { measureAccountBytes } = await import("../scheduling-gate.js");
1838
+ const root = mkdtempSync(join(tmpdir(), "fs-measure-"));
1839
+ mkdirSync(join(root, "nested"), { recursive: true });
1840
+ writeFileSync(join(root, "a.txt"), "12345");
1841
+ writeFileSync(join(root, "nested", "b.txt"), "678");
1842
+ const outside = mkdtempSync(join(tmpdir(), "fs-outside-"));
1843
+ writeFileSync(join(outside, "big.txt"), "x".repeat(5000));
1844
+ symlinkSync(outside, join(root, "link"));
1845
+ expect(measureAccountBytes(root, 1000)).toEqual({ bytes: 8, capped: false });
1846
+ });
1847
+ it("stops at the cap and says so", async () => {
1848
+ const { measureAccountBytes } = await import("../scheduling-gate.js");
1849
+ const root = mkdtempSync(join(tmpdir(), "fs-cap-"));
1850
+ for (let i = 0; i < 5; i++)
1851
+ writeFileSync(join(root, `f${i}.txt`), "ab");
1852
+ expect(measureAccountBytes(root, 3)).toMatchObject({ capped: true });
1853
+ });
1854
+ it("throws on a missing root rather than reading zero", async () => {
1855
+ const { measureAccountBytes } = await import("../scheduling-gate.js");
1856
+ expect(() => measureAccountBytes(join(tmpdir(), "fs-absent-000"), 10)).toThrow();
1857
+ });
1858
+ });
1859
+ describe("measureAccountBytes cap covers directories too", () => {
1860
+ it("stops on a tree that is wide in directories and holds no file", async () => {
1861
+ const { measureAccountBytes } = await import("../scheduling-gate.js");
1862
+ const root = mkdtempSync(join(tmpdir(), "fs-dirs-"));
1863
+ for (let i = 0; i < 6; i++)
1864
+ mkdirSync(join(root, `d${i}`));
1865
+ // Files alone would never reach a cap here, so a file-only counter would
1866
+ // walk the whole tree however wide it got.
1867
+ expect(measureAccountBytes(root, 4)).toMatchObject({ capped: true });
1868
+ });
1869
+ });
1440
1870
  //# sourceMappingURL=scheduling-gate.test.js.map