@rubytech/create-maxy-code 0.1.604 → 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.
- package/package.json +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.d.ts +30 -2
- package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.js +76 -32
- 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 +22 -1
- package/payload/platform/lib/routine-templates/src/roster.ts +108 -35
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -3
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +5 -0
- package/payload/platform/plugins/connector/PLUGIN.md +9 -1
- package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts +2 -0
- package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts.map +1 -0
- package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js +67 -0
- package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js.map +1 -0
- package/payload/platform/plugins/connector/mcp/dist/index.js +36 -5
- package/payload/platform/plugins/connector/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts +35 -0
- package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts.map +1 -1
- package/payload/platform/plugins/connector/mcp/dist/lib/store.js +33 -0
- package/payload/platform/plugins/connector/mcp/dist/lib/store.js.map +1 -1
- package/payload/platform/plugins/connector/mcp/package.json +4 -2
- package/payload/platform/plugins/connector/mcp/vitest.config.ts +8 -0
- package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
- package/payload/platform/plugins/email/PLUGIN.md +4 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts +2 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js +233 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/main.js +19 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/main.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts +3 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js +146 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts +150 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/retention.js +199 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/retention.js.map +1 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +8 -1
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +40 -0
- 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 +322 -4
- 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.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +21 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +236 -6
- 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 +452 -7
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
|
@@ -19,12 +19,37 @@
|
|
|
19
19
|
* on every surface, which is the Task 2313 class this must not recreate.
|
|
20
20
|
*/
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
-
import { dirname, resolve } from "node:path";
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
24
|
import { int } from "neo4j-driver";
|
|
25
25
|
import { spawnProviderCall } from "../lib/connector-freebusy.js";
|
|
26
26
|
import { unflaggedLabel } from "../lib/gate-flag.js";
|
|
27
27
|
import { getSession } from "../lib/neo4j.js";
|
|
28
|
+
/**
|
|
29
|
+
* Task 2689 — how long after its newest report the memory sweep counts as
|
|
30
|
+
* stale.
|
|
31
|
+
*
|
|
32
|
+
* The sweep runs hourly and `graph-health`'s own silence condition is "the
|
|
33
|
+
* sweep ran within the past day", so 26 hours gives it two missed runs of slack
|
|
34
|
+
* before the routine is woken.
|
|
35
|
+
*/
|
|
36
|
+
export const SWEEP_STALE_AFTER_MS = 26 * 60 * 60 * 1000;
|
|
37
|
+
/**
|
|
38
|
+
* Task 2689 — one week of growth worth a person's attention on a Pi-class disk.
|
|
39
|
+
*
|
|
40
|
+
* The shipped `filesystem-health` template names its threshold explicitly; a
|
|
41
|
+
* gate written by hand that omits the field inherits this.
|
|
42
|
+
*/
|
|
43
|
+
export const FS_DEFAULT_MIN_GROWTH_BYTES = 64 * 1024 * 1024;
|
|
44
|
+
/**
|
|
45
|
+
* Task 2689 — the most files one account-directory measurement walks.
|
|
46
|
+
*
|
|
47
|
+
* A bound, not a budget. A directory above it is exactly what
|
|
48
|
+
* `filesystem-health` exists to report, and the walk that stopped there has
|
|
49
|
+
* measured a floor rather than a total, so the check reports work and records
|
|
50
|
+
* no position.
|
|
51
|
+
*/
|
|
52
|
+
export const FS_WALK_ENTRY_CAP = 200_000;
|
|
28
53
|
/**
|
|
29
54
|
* Task 2684 — the most node ids one `graph-unflagged` check hands over per fire.
|
|
30
55
|
*
|
|
@@ -53,7 +78,19 @@ export const ACCEPTED_CHECK_TYPES = [
|
|
|
53
78
|
"telegram",
|
|
54
79
|
"graph",
|
|
55
80
|
"graph-unflagged",
|
|
81
|
+
"connector",
|
|
82
|
+
"sweep",
|
|
83
|
+
"filesystem",
|
|
56
84
|
];
|
|
85
|
+
/**
|
|
86
|
+
* Task 2688 — the accepted `scope` values on a `connector` check, in one place.
|
|
87
|
+
*
|
|
88
|
+
* The same single-sourcing `ACCEPTED_MAIL_PROVIDERS` has: the probe asks the
|
|
89
|
+
* registry for queues of this kind, and `validateGate` refuses anything outside
|
|
90
|
+
* it at write time. A scope outside the set matches no queue, so the gate would
|
|
91
|
+
* answer quiet on every fire and the routine could never run.
|
|
92
|
+
*/
|
|
93
|
+
export const ACCEPTED_CONNECTOR_SCOPES = ["operational", "accounting"];
|
|
57
94
|
/**
|
|
58
95
|
* Task 2657 — the accepted `provider` values on a `mail` check, in one place.
|
|
59
96
|
*
|
|
@@ -88,6 +125,9 @@ export const ACCEPTED_CHECK_FIELDS = {
|
|
|
88
125
|
telegram: ["type", "scope", "botId"],
|
|
89
126
|
graph: ["type", "scope"],
|
|
90
127
|
"graph-unflagged": ["type", "label"],
|
|
128
|
+
connector: ["type", "scope"],
|
|
129
|
+
sweep: ["type"],
|
|
130
|
+
filesystem: ["type", "minGrowthBytes"],
|
|
91
131
|
};
|
|
92
132
|
/**
|
|
93
133
|
* Rooted at the INSTALL, not at the account.
|
|
@@ -241,6 +281,10 @@ export function slotKey(slot) {
|
|
|
241
281
|
// commit over two bots names two entries rather than one repeated twice.
|
|
242
282
|
if (slot.kind === "telegram" && slot.botId !== undefined)
|
|
243
283
|
return `telegram:${slot.botId}`;
|
|
284
|
+
// Task 2688 — the connector's name, so a commit over two connectors names two
|
|
285
|
+
// entries rather than one repeated twice.
|
|
286
|
+
if (slot.kind === "connector")
|
|
287
|
+
return `connector:${slot.key}`;
|
|
244
288
|
return slot.kind;
|
|
245
289
|
}
|
|
246
290
|
/**
|
|
@@ -273,6 +317,19 @@ export function slotPosition(slot, marks) {
|
|
|
273
317
|
// The ISO `updatedAt` verbatim, as the Telegram position is: it IS the
|
|
274
318
|
// value the comparison is made on.
|
|
275
319
|
return marks.graph?.updatedAt ?? null;
|
|
320
|
+
case "connector":
|
|
321
|
+
// The ISO instant sent as the queue's changed-since argument, printed
|
|
322
|
+
// verbatim for the same reason.
|
|
323
|
+
return marks.connector?.[slot.key]?.changedSince ?? null;
|
|
324
|
+
case "sweep":
|
|
325
|
+
// Truncated for the reason the calendar digest is: the whole sha256 tells
|
|
326
|
+
// a reader nothing the first eight do not, and the file holds it all.
|
|
327
|
+
return marks.sweep?.digest?.slice(0, 8) ?? null;
|
|
328
|
+
case "filesystem":
|
|
329
|
+
// The optional chain, as every arm above has it: a file holding
|
|
330
|
+
// `"filesystem": {}` would otherwise print the string "undefined" as this
|
|
331
|
+
// routine's position in `op=gate-read` and in `positions=`.
|
|
332
|
+
return marks.filesystem?.bytes === undefined ? null : String(marks.filesystem.bytes);
|
|
276
333
|
}
|
|
277
334
|
}
|
|
278
335
|
/**
|
|
@@ -299,6 +356,12 @@ export function slotsIn(marks) {
|
|
|
299
356
|
slots.push({ kind: "calendar" });
|
|
300
357
|
if (marks.graph?.updatedAt !== undefined)
|
|
301
358
|
slots.push({ kind: "graph" });
|
|
359
|
+
for (const key of Object.keys(marks.connector ?? {}))
|
|
360
|
+
slots.push({ kind: "connector", key });
|
|
361
|
+
if (marks.sweep?.digest !== undefined)
|
|
362
|
+
slots.push({ kind: "sweep" });
|
|
363
|
+
if (marks.filesystem?.bytes !== undefined)
|
|
364
|
+
slots.push({ kind: "filesystem" });
|
|
302
365
|
return slots;
|
|
303
366
|
}
|
|
304
367
|
/**
|
|
@@ -409,6 +472,107 @@ export function promoteSlot(slot, from, into) {
|
|
|
409
472
|
into.graph = { ...(into.graph ?? {}), updatedAt };
|
|
410
473
|
return true;
|
|
411
474
|
}
|
|
475
|
+
case "connector": {
|
|
476
|
+
const entry = from.connector?.[slot.key];
|
|
477
|
+
if (entry === undefined)
|
|
478
|
+
return false;
|
|
479
|
+
into.connector = { ...(into.connector ?? {}), [slot.key]: entry };
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
case "sweep": {
|
|
483
|
+
const digest = from.sweep?.digest;
|
|
484
|
+
if (digest === undefined)
|
|
485
|
+
return false;
|
|
486
|
+
into.sweep = { ...(into.sweep ?? {}), digest };
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
case "filesystem": {
|
|
490
|
+
const bytes = from.filesystem?.bytes;
|
|
491
|
+
if (bytes === undefined)
|
|
492
|
+
return false;
|
|
493
|
+
into.filesystem = { ...(into.filesystem ?? {}), bytes };
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Task 2689 — the memory sweep's state as one comparable value.
|
|
500
|
+
*
|
|
501
|
+
* The two review-queue counts and a staleness flag, never the newest report's
|
|
502
|
+
* identity. The sweep runs hourly and `graph-health` is proposed weekly, so a
|
|
503
|
+
* digest carrying the report id would differ on essentially every fire and the
|
|
504
|
+
* gate could never go quiet. A digest over the queue counts alone would go
|
|
505
|
+
* quiet correctly, but a sweep that STOPPED running moves no queue, so the
|
|
506
|
+
* routine could never report the one failure it exists to catch. The flag gives
|
|
507
|
+
* both: a constant value while the sweep is healthy, one change at the
|
|
508
|
+
* transition into staleness, and one more on the way out.
|
|
509
|
+
*
|
|
510
|
+
* `now` is a parameter rather than a `Date.now()` read inside, so the boundary
|
|
511
|
+
* is testable without moving the clock. A `newestReportAt` this cannot parse
|
|
512
|
+
* throws, through `instantOf`, and a throw counts as work.
|
|
513
|
+
*/
|
|
514
|
+
export function digestSweepState(state, now) {
|
|
515
|
+
const stale = state.newestReportAt === null || now - instantOf(state.newestReportAt) > SWEEP_STALE_AFTER_MS;
|
|
516
|
+
return createHash("sha256").update(`${state.orphans}|${state.citations}|${stale}`).digest("hex");
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Task 2689 — total bytes of the regular files under `root`.
|
|
520
|
+
*
|
|
521
|
+
* A symlink is never followed, so the walk cannot leave the account directory
|
|
522
|
+
* even when something inside points out of it — the rule the filesystem
|
|
523
|
+
* plugin's own walker holds to, and the reason this does not shell out to `du`.
|
|
524
|
+
*
|
|
525
|
+
* A vanished entry (`ENOENT` on a stat, or on a nested directory read) is
|
|
526
|
+
* SKIPPED: an account directory is written to continuously, and a file deleted
|
|
527
|
+
* between the read and the stat contributes nothing to a size. Every other
|
|
528
|
+
* error propagates. The ROOT is read outside that rule, so a missing account
|
|
529
|
+
* directory throws: a wrongly derived root that read zero would answer "nothing
|
|
530
|
+
* grew" for ever, which is the permanent mute this design forbids.
|
|
531
|
+
*/
|
|
532
|
+
export function measureAccountBytes(root, cap) {
|
|
533
|
+
let bytes = 0;
|
|
534
|
+
// Every entry the walk touches, not only the files. Counting files alone
|
|
535
|
+
// would leave the directories unbounded, and the pending stack grows by one
|
|
536
|
+
// per directory: a tree that is wide in directories and thin in files would
|
|
537
|
+
// then walk all of it with the cap never reached.
|
|
538
|
+
let seen = 0;
|
|
539
|
+
const pending = [];
|
|
540
|
+
let dir = root;
|
|
541
|
+
let entries = readdirSync(root, { withFileTypes: true });
|
|
542
|
+
for (;;) {
|
|
543
|
+
for (const entry of entries) {
|
|
544
|
+
if (entry.isSymbolicLink())
|
|
545
|
+
continue;
|
|
546
|
+
if (seen >= cap)
|
|
547
|
+
return { bytes, capped: true };
|
|
548
|
+
seen++;
|
|
549
|
+
const full = join(dir, entry.name);
|
|
550
|
+
if (entry.isDirectory()) {
|
|
551
|
+
pending.push(full);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!entry.isFile())
|
|
555
|
+
continue;
|
|
556
|
+
try {
|
|
557
|
+
bytes += statSync(full).size;
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
if (err.code !== "ENOENT")
|
|
561
|
+
throw err;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const next = pending.pop();
|
|
565
|
+
if (next === undefined)
|
|
566
|
+
return { bytes, capped: false };
|
|
567
|
+
dir = next;
|
|
568
|
+
try {
|
|
569
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
if (err.code !== "ENOENT")
|
|
573
|
+
throw err;
|
|
574
|
+
entries = [];
|
|
575
|
+
}
|
|
412
576
|
}
|
|
413
577
|
}
|
|
414
578
|
// ---------------------------------------------------------------------------
|
|
@@ -623,6 +787,30 @@ const realMail = async (check, sinceUid, accountId) => {
|
|
|
623
787
|
}
|
|
624
788
|
}
|
|
625
789
|
};
|
|
790
|
+
/**
|
|
791
|
+
* Task 2688 — one call to the connector plugin's own tools.
|
|
792
|
+
*
|
|
793
|
+
* A spawn rather than a loopback route or a direct read of the registry files,
|
|
794
|
+
* because the credential, the host binding and the SSRF guard all live inside
|
|
795
|
+
* that plugin, and reimplementing any of them here would put a second copy of a
|
|
796
|
+
* security control in a second package. It is the transport the Outlook mail
|
|
797
|
+
* probe already uses from this same heartbeat: same timeout, same ACCOUNT_ID
|
|
798
|
+
* scoping, same captured child stderr, same PLATFORM_PORT bridge.
|
|
799
|
+
*
|
|
800
|
+
* The import is dynamic because `lib/gate.ts` imports this module's types; a
|
|
801
|
+
* static value import would close that loop at run time.
|
|
802
|
+
*/
|
|
803
|
+
async function callConnectorTool(accountId, tool, args) {
|
|
804
|
+
const { callPluginTool } = await import("../lib/gate.js");
|
|
805
|
+
const call = await callPluginTool("connector", tool, JSON.stringify(args), accountId);
|
|
806
|
+
const text = (Array.isArray(call.result.content) ? call.result.content : [])
|
|
807
|
+
.filter((c) => c.type === "text")
|
|
808
|
+
.map((c) => String(c.text))
|
|
809
|
+
.join("\n");
|
|
810
|
+
if (call.result.isError === true)
|
|
811
|
+
throw new Error(`${tool} returned an error: ${text}`);
|
|
812
|
+
return JSON.parse(text);
|
|
813
|
+
}
|
|
626
814
|
export const REAL_SOURCES = {
|
|
627
815
|
mail: realMail,
|
|
628
816
|
// Task 2657 — the provider owns the decryption of its own tokens, so this is
|
|
@@ -796,6 +984,122 @@ export const REAL_SOURCES = {
|
|
|
796
984
|
await session.close();
|
|
797
985
|
}
|
|
798
986
|
},
|
|
987
|
+
connectorQueues: async (accountId, kind) => {
|
|
988
|
+
// The connector plugin is optional. Its absence is an ANSWER (nothing is
|
|
989
|
+
// registered, so nothing registered can have moved), so it is detected
|
|
990
|
+
// before any spawn rather than by catching the transport error, which is
|
|
991
|
+
// indistinguishable from a child that crashed on boot.
|
|
992
|
+
const serverPath = resolve(process.env.PLATFORM_ROOT, "plugins", "connector", "mcp", "dist", "index.js");
|
|
993
|
+
if (!existsSync(serverPath))
|
|
994
|
+
return { pluginPresent: false, bindings: [] };
|
|
995
|
+
const payload = await callConnectorTool(accountId, "connector-list", {});
|
|
996
|
+
if (!Array.isArray(payload)) {
|
|
997
|
+
throw new Error(`connector-list returned ${typeof payload}, expected an array`);
|
|
998
|
+
}
|
|
999
|
+
const bindings = [];
|
|
1000
|
+
for (const entry of payload) {
|
|
1001
|
+
const e = entry;
|
|
1002
|
+
const q = e.queue;
|
|
1003
|
+
if (typeof e.name !== "string" || !q || q.kind !== kind)
|
|
1004
|
+
continue;
|
|
1005
|
+
// A descriptor missing a field it needs THROWS rather than being called
|
|
1006
|
+
// with `undefined` in the URL: that call would 404, the 404 would count as
|
|
1007
|
+
// work, and the routine would wake on every fire with nothing to show for
|
|
1008
|
+
// it. The throw names the connector once and counts as work once.
|
|
1009
|
+
if (typeof q.path !== "string" ||
|
|
1010
|
+
typeof q.changedSinceParam !== "string" ||
|
|
1011
|
+
typeof q.itemsField !== "string") {
|
|
1012
|
+
throw new Error(`connector ${e.name} carries an incomplete queue descriptor`);
|
|
1013
|
+
}
|
|
1014
|
+
bindings.push({ name: e.name, queue: q });
|
|
1015
|
+
}
|
|
1016
|
+
return { pluginPresent: true, bindings };
|
|
1017
|
+
},
|
|
1018
|
+
connectorQueueRead: async (accountId, binding, changedSince) => {
|
|
1019
|
+
const payload = await callConnectorTool(accountId, "connector-call", {
|
|
1020
|
+
name: binding.name,
|
|
1021
|
+
method: "GET",
|
|
1022
|
+
path: binding.queue.path,
|
|
1023
|
+
query: { [binding.queue.changedSinceParam]: changedSince },
|
|
1024
|
+
});
|
|
1025
|
+
const answer = payload;
|
|
1026
|
+
if (typeof answer.status !== "number") {
|
|
1027
|
+
throw new Error(`connector-call returned no status for ${binding.name}`);
|
|
1028
|
+
}
|
|
1029
|
+
// Every non-2xx is handed back with its status and no count. The probe
|
|
1030
|
+
// decides which of them is an answer (401 and 403) and which is a failure,
|
|
1031
|
+
// so that rule lives in one place rather than being split across the source
|
|
1032
|
+
// and its caller.
|
|
1033
|
+
if (answer.status < 200 || answer.status >= 300) {
|
|
1034
|
+
return { status: answer.status, items: 0, truncated: false };
|
|
1035
|
+
}
|
|
1036
|
+
// A truncated body cannot be counted — the array it carries is a prefix of
|
|
1037
|
+
// the real one, and the cut usually lands mid-token, so it will not even
|
|
1038
|
+
// parse. It is reported rather than thrown, because the cap is itself proof
|
|
1039
|
+
// the queue moved: the probe counts it as work and records the position, and
|
|
1040
|
+
// a throw would record none, leaving the same window to truncate again on
|
|
1041
|
+
// every fire for ever.
|
|
1042
|
+
if (answer.truncated === true)
|
|
1043
|
+
return { status: answer.status, items: 0, truncated: true };
|
|
1044
|
+
const parsed = typeof answer.body === "string" ? JSON.parse(answer.body) : answer.body;
|
|
1045
|
+
const items = parsed?.[binding.queue.itemsField];
|
|
1046
|
+
// A shape this cannot read throws, and a throw counts as work. Coercing it
|
|
1047
|
+
// to zero would answer "quiet" about a queue this never understood, which
|
|
1048
|
+
// is the one answer that loses a record.
|
|
1049
|
+
if (!Array.isArray(items)) {
|
|
1050
|
+
throw new Error(`connector ${binding.name} queue field ${binding.queue.itemsField} is not an array`);
|
|
1051
|
+
}
|
|
1052
|
+
return { status: answer.status, items: items.length, truncated: false };
|
|
1053
|
+
},
|
|
1054
|
+
sweepState: async (accountId) => {
|
|
1055
|
+
// Three reads on this install's own Neo4j, the way `calendarDigest` reads
|
|
1056
|
+
// `:ConnectorBusy`: the nodes are local and there is no cross-plugin call.
|
|
1057
|
+
// `dream-cycle` is the keyword `memory-dream-run` puts on every report it
|
|
1058
|
+
// writes, and it is the same keyword `graph-health`'s own prompt reads
|
|
1059
|
+
// through `memory-report-read-latest`.
|
|
1060
|
+
const session = getSession();
|
|
1061
|
+
try {
|
|
1062
|
+
const reportRes = await session.run(`MATCH (r:Report)
|
|
1063
|
+
WHERE r.accountId = $accountId AND 'dream-cycle' IN r.keywords
|
|
1064
|
+
RETURN toString(r.occurredAt) AS at
|
|
1065
|
+
ORDER BY r.occurredAt DESC LIMIT 1`, { accountId });
|
|
1066
|
+
const at = reportRes.records[0]?.get("at") ?? null;
|
|
1067
|
+
// An account whose sweep has never run holds no report, which reads as
|
|
1068
|
+
// stale. A value this cannot read THROWS rather than coercing to null:
|
|
1069
|
+
// a throw counts as work, while coercing would report a sweep age this
|
|
1070
|
+
// never established.
|
|
1071
|
+
if (at !== null && typeof at !== "string") {
|
|
1072
|
+
throw new Error(`sweep report occurredAt is type ${typeof at}, expected an ISO string`);
|
|
1073
|
+
}
|
|
1074
|
+
// The labels are literals in this file, never operator input, so
|
|
1075
|
+
// interpolating them cannot inject; the account is a parameter.
|
|
1076
|
+
const count = async (label) => {
|
|
1077
|
+
const res = await session.run(`MATCH (n:${label}) WHERE n.accountId = $accountId RETURN count(n) AS c`, { accountId });
|
|
1078
|
+
// neo4j returns a Cypher Integer. `Number` on a shape this cannot read
|
|
1079
|
+
// is NaN, which would digest as the string "NaN" and then never change
|
|
1080
|
+
// again — a gate that has gone quiet for the wrong reason.
|
|
1081
|
+
const n = Number(res.records[0]?.get("c") ?? 0);
|
|
1082
|
+
if (!Number.isFinite(n))
|
|
1083
|
+
throw new Error(`${label} count is not a number`);
|
|
1084
|
+
return n;
|
|
1085
|
+
};
|
|
1086
|
+
return {
|
|
1087
|
+
newestReportAt: at,
|
|
1088
|
+
orphans: await count("OrphanCandidate"),
|
|
1089
|
+
citations: await count("CitationProposal"),
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
finally {
|
|
1093
|
+
await session.close();
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
filesystemBytes: async (accountId) => {
|
|
1097
|
+
// `$PLATFORM_ROOT/../data/accounts` is the derivation `dispatcher-env.ts`
|
|
1098
|
+
// and `agent-dispatch.ts` already use. Re-deriving it differently here is
|
|
1099
|
+
// how the gate would come to measure a directory the routine never reads.
|
|
1100
|
+
const root = resolve(process.env.PLATFORM_ROOT, "..", "data/accounts", accountId);
|
|
1101
|
+
return measureAccountBytes(root, FS_WALK_ENTRY_CAP);
|
|
1102
|
+
},
|
|
799
1103
|
calendarDigest: async (check, accountId) => {
|
|
800
1104
|
const session = getSession();
|
|
801
1105
|
try {
|
|
@@ -860,6 +1164,18 @@ export function checkSource(check) {
|
|
|
860
1164
|
// going unreadable is not the other.
|
|
861
1165
|
case "graph-unflagged":
|
|
862
1166
|
return `graph:${check.label}`;
|
|
1167
|
+
// Task 2688 — the scope rather than a connector name: which connectors the
|
|
1168
|
+
// scope resolves to is a property of the account and can change between
|
|
1169
|
+
// fires, so a set built from a name would read as a new outage whenever the
|
|
1170
|
+
// operator registered another one.
|
|
1171
|
+
case "connector":
|
|
1172
|
+
return `connector:${check.scope}`;
|
|
1173
|
+
// Task 2689 — one sweep and one account directory per account, so each
|
|
1174
|
+
// names itself, the way `whatsapp` and `graph` do.
|
|
1175
|
+
case "sweep":
|
|
1176
|
+
return "sweep";
|
|
1177
|
+
case "filesystem":
|
|
1178
|
+
return "filesystem";
|
|
863
1179
|
default:
|
|
864
1180
|
return String(check.type);
|
|
865
1181
|
}
|
|
@@ -1033,6 +1349,124 @@ flagKey) {
|
|
|
1033
1349
|
const { ids } = await sources.graphUnflagged(accountId, label, flagKey, UNFLAGGED_ID_CAP);
|
|
1034
1350
|
return { hasWork: ids.length > 0, mark: null, unflagged: { label, ids } };
|
|
1035
1351
|
}
|
|
1352
|
+
case "connector": {
|
|
1353
|
+
// The instant this arm STARTED, recorded as the new position of every
|
|
1354
|
+
// connector that answers. Taken once, before the first call, so a slow
|
|
1355
|
+
// queue cannot advance its own mark past records that arrived while it
|
|
1356
|
+
// was answering.
|
|
1357
|
+
const startedAt = new Date().toISOString();
|
|
1358
|
+
const { pluginPresent, bindings } = await sources.connectorQueues(accountId, check.scope);
|
|
1359
|
+
// Two quiet answers, each with its own reason. Neither is an error: an
|
|
1360
|
+
// install with no connector plugin, and an account that has described no
|
|
1361
|
+
// queue of this kind, both mean nothing registered here can have moved.
|
|
1362
|
+
// The line is load-bearing — a routine that will never fire has to be
|
|
1363
|
+
// visible somewhere, and `op=checked` alone reads exactly like a healthy
|
|
1364
|
+
// quiet fire.
|
|
1365
|
+
if (!pluginPresent) {
|
|
1366
|
+
console.error(`[scheduling-gate] op=connector-quiet reason=no-plugin scope=${check.scope}`);
|
|
1367
|
+
return { hasWork: false, mark: null };
|
|
1368
|
+
}
|
|
1369
|
+
if (bindings.length === 0) {
|
|
1370
|
+
console.error(`[scheduling-gate] op=connector-quiet reason=no-queue scope=${check.scope}`);
|
|
1371
|
+
return { hasWork: false, mark: null };
|
|
1372
|
+
}
|
|
1373
|
+
let hasWork = false;
|
|
1374
|
+
const observed = [];
|
|
1375
|
+
for (const binding of bindings) {
|
|
1376
|
+
const record = () => observed.push({
|
|
1377
|
+
slot: { kind: "connector", key: binding.name },
|
|
1378
|
+
write: (m) => {
|
|
1379
|
+
m.connector = { ...(m.connector ?? {}), [binding.name]: { changedSince: startedAt } };
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
const since = marks.connector?.[binding.name]?.changedSince;
|
|
1383
|
+
if (since === undefined) {
|
|
1384
|
+
// A first run: everything already queued counts as work, the same as
|
|
1385
|
+
// every other watermarked check. No call is made, because a read with
|
|
1386
|
+
// no changed-since filter is precisely the response `connector-call`
|
|
1387
|
+
// caps, and a capped body cannot be counted.
|
|
1388
|
+
console.error(`[scheduling-gate] op=connector-first-run connector=${binding.name}`);
|
|
1389
|
+
hasWork = true;
|
|
1390
|
+
record();
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
const res = await sources.connectorQueueRead(accountId, binding, since);
|
|
1394
|
+
// An unauthorised connector is an ANSWER: it reports no work, records no
|
|
1395
|
+
// position, and says so on its own line. No position, because a
|
|
1396
|
+
// connector this gate could not read must not be advanced past — the
|
|
1397
|
+
// records that arrived while the credential was dead are still unseen.
|
|
1398
|
+
if (res.status === 401 || res.status === 403) {
|
|
1399
|
+
console.error(`[scheduling-gate] op=connector-quiet reason=unauthorised ` +
|
|
1400
|
+
`connector=${binding.name} status=${res.status}`);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
// Every other non-2xx throws, which counts as work and fires. A 500 or a
|
|
1404
|
+
// 404 is a source this gate could not read at all, and answering quiet
|
|
1405
|
+
// about it would mute the routine for the length of an outage.
|
|
1406
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1407
|
+
throw new Error(`connector ${binding.name} queue answered ${res.status}`);
|
|
1408
|
+
}
|
|
1409
|
+
console.error(`[scheduling-gate] op=connector-probe connector=${binding.name} ` +
|
|
1410
|
+
`status=${res.status} items=${res.items} truncated=${res.truncated}`);
|
|
1411
|
+
// A capped body is proof of movement that cannot be counted, so it reads
|
|
1412
|
+
// as work and still records its position: recording none would leave the
|
|
1413
|
+
// same window in place, it would truncate again, and the routine would
|
|
1414
|
+
// wake the model on every fire with no path back to quiet.
|
|
1415
|
+
if (res.items > 0 || res.truncated)
|
|
1416
|
+
hasWork = true;
|
|
1417
|
+
record();
|
|
1418
|
+
}
|
|
1419
|
+
return { hasWork, mark: observed.length > 0 ? observed : null };
|
|
1420
|
+
}
|
|
1421
|
+
case "sweep": {
|
|
1422
|
+
const state = await sources.sweepState(accountId);
|
|
1423
|
+
const digest = digestSweepState(state, Date.now());
|
|
1424
|
+
const stored = marks.sweep?.digest;
|
|
1425
|
+
return {
|
|
1426
|
+
// An absent digest is a first run, which reports work exactly as every
|
|
1427
|
+
// other stored-position check does on its first fire.
|
|
1428
|
+
hasWork: stored !== digest,
|
|
1429
|
+
mark: {
|
|
1430
|
+
slot: { kind: "sweep" },
|
|
1431
|
+
write: (m) => {
|
|
1432
|
+
m.sweep = { ...(m.sweep ?? {}), digest };
|
|
1433
|
+
},
|
|
1434
|
+
},
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
case "filesystem": {
|
|
1438
|
+
const minGrowth = check.minGrowthBytes ?? FS_DEFAULT_MIN_GROWTH_BYTES;
|
|
1439
|
+
// Refused HERE, before the source is reached, exactly as the mail arm
|
|
1440
|
+
// refuses an unknown provider: the throw lands inside
|
|
1441
|
+
// `runSchedulingGate`'s try, where it counts as work and names itself.
|
|
1442
|
+
// Zero would make every fire pass, because growth is never below zero,
|
|
1443
|
+
// and a gate that can never go quiet is the class this design removes.
|
|
1444
|
+
if (!Number.isInteger(minGrowth) || minGrowth <= 0) {
|
|
1445
|
+
throw new Error(`minGrowthBytes must be a positive integer, got ${JSON.stringify(check.minGrowthBytes)}`);
|
|
1446
|
+
}
|
|
1447
|
+
const { bytes, capped } = await sources.filesystemBytes(accountId);
|
|
1448
|
+
if (capped) {
|
|
1449
|
+
// A floor, not a total, so it records NO position: promoting a floor
|
|
1450
|
+
// would make the next fire measure growth from a number the directory
|
|
1451
|
+
// never held, and the error would be in the muting direction.
|
|
1452
|
+
console.error(`[scheduling-gate] op=fs-measure capped=true bytes=${bytes} treatedAs=has-work`);
|
|
1453
|
+
return { hasWork: true, mark: null };
|
|
1454
|
+
}
|
|
1455
|
+
const stored = marks.filesystem?.bytes;
|
|
1456
|
+
return {
|
|
1457
|
+
// An absent position is a first run: whatever is already there counts
|
|
1458
|
+
// as work, the way every other watermarked check reads its first fire.
|
|
1459
|
+
// A directory that SHRANK reports no work — growth is the question, and
|
|
1460
|
+
// the stored value stays put until an accepted wake moves it.
|
|
1461
|
+
hasWork: stored === undefined || bytes - stored >= minGrowth,
|
|
1462
|
+
mark: {
|
|
1463
|
+
slot: { kind: "filesystem" },
|
|
1464
|
+
write: (m) => {
|
|
1465
|
+
m.filesystem = { ...(m.filesystem ?? {}), bytes };
|
|
1466
|
+
},
|
|
1467
|
+
},
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1036
1470
|
// Task 2653 — a type outside the union. The switch was exhaustive over
|
|
1037
1471
|
// `GateCheck` and had no default, so `probe` returned `undefined` and the
|
|
1038
1472
|
// caller's `outcome.hasWork` read threw OUTSIDE its own try: the TypeError
|
|
@@ -1105,18 +1539,29 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
|
|
|
1105
1539
|
}
|
|
1106
1540
|
// A check that threw took the `continue` above, so it records no position:
|
|
1107
1541
|
// its state is unknown, not absent.
|
|
1108
|
-
|
|
1542
|
+
//
|
|
1543
|
+
// Task 2688 — one check may hand back several positions (one per registered
|
|
1544
|
+
// connector), so the single mark is normalised to a list and each is logged
|
|
1545
|
+
// on its own line.
|
|
1546
|
+
const observedMarks = outcome.mark === null ? [] : Array.isArray(outcome.mark) ? outcome.mark : [outcome.mark];
|
|
1547
|
+
for (const mark of observedMarks) {
|
|
1109
1548
|
// Every probe records what it saw, whatever the verdict: the position is a
|
|
1110
1549
|
// fact about the source.
|
|
1111
|
-
|
|
1550
|
+
mark.write(fresh);
|
|
1112
1551
|
// Task 2663 — BOTH sides of the comparison. This line printed only
|
|
1113
1552
|
// `position=`, which was `fresh` — this probe's own reading — so the value
|
|
1114
1553
|
// the verdict was actually made against appeared in no log line anywhere,
|
|
1115
1554
|
// and a gate stuck for ever on an empty slot read exactly like a healthy
|
|
1116
1555
|
// one. Measured on 2026-08-10: 18 wakes whose `position=` never moved.
|
|
1117
|
-
console.error(
|
|
1118
|
-
|
|
1119
|
-
|
|
1556
|
+
console.error(
|
|
1557
|
+
// Task 2688 — `slot=` is appended rather than inserted, because one
|
|
1558
|
+
// check can now print several of these lines and only the slot key
|
|
1559
|
+
// tells them apart. It goes last so the fields every existing reader
|
|
1560
|
+
// greps for keep their positions.
|
|
1561
|
+
`[scheduling-gate] op=gate-read eventId=${id8} check=${check.type} ` +
|
|
1562
|
+
`stored=${slotPosition(mark.slot, marks) ?? "null"} ` +
|
|
1563
|
+
`observed=${slotPosition(mark.slot, fresh) ?? "null"} ` +
|
|
1564
|
+
`slot=${slotKey(mark.slot)}`);
|
|
1120
1565
|
}
|
|
1121
1566
|
// Task 2684 — an empty set carries nothing to flag, so it contributes no
|
|
1122
1567
|
// entry: the dispatcher's `op=gate-flag` then names only labels that
|