@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.
- package/dist/__tests__/email-sampler-unit.test.js +22 -0
- package/dist/index.js +3 -0
- package/dist/port-resolution.js +9 -1
- package/package.json +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.d.ts +46 -2
- package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
- package/payload/platform/lib/routine-templates/dist/roster.js +84 -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 +48 -2
- package/payload/platform/lib/routine-templates/src/roster.ts +133 -35
- package/payload/platform/lib/telegram-store-read/dist/index.d.ts +59 -0
- package/payload/platform/lib/telegram-store-read/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/telegram-store-read/dist/index.js +219 -0
- package/payload/platform/lib/telegram-store-read/dist/index.js.map +1 -0
- package/payload/platform/lib/telegram-store-read/src/__tests__/fold.test.ts +124 -0
- package/payload/platform/lib/telegram-store-read/src/index.ts +262 -0
- package/payload/platform/lib/telegram-store-read/tsconfig.json +9 -0
- package/payload/platform/lib/telegram-store-read/vitest.config.ts +9 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +39 -6
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +11 -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/docs/references/outlook-guide.md +21 -2
- package/payload/platform/plugins/docs/references/telegram-guide.md +15 -1
- package/payload/platform/plugins/email/PLUGIN.md +14 -1
- 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/__tests__/sample-mailbox.test.js +30 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/sample-mailbox.test.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/sampler/main.js +68 -3
- 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/email/mcp/dist/sampler/sample-mailbox.d.ts +5 -0
- package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.d.ts.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js +10 -5
- package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js +11 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +8 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js +324 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js +102 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js +125 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js +109 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts +25 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js +94 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts +41 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js +122 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts +73 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.js +162 -0
- package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.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/lib/__tests__/gate-flag.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js +90 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +26 -4
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts +78 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js +76 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +8 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +9 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js +15 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +12 -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 +34 -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 +9 -1
- 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 +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 +435 -5
- 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 +294 -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 +553 -11
- package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/server/public/operator.html +1 -1
- package/payload/server/server.js +69 -5
|
@@ -19,10 +19,48 @@
|
|
|
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
|
+
import { int } from "neo4j-driver";
|
|
24
25
|
import { spawnProviderCall } from "../lib/connector-freebusy.js";
|
|
26
|
+
import { unflaggedLabel } from "../lib/gate-flag.js";
|
|
25
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;
|
|
53
|
+
/**
|
|
54
|
+
* Task 2684 — the most node ids one `graph-unflagged` check hands over per fire.
|
|
55
|
+
*
|
|
56
|
+
* A cap is needed because the ids travel to the dispatcher inside the tool's
|
|
57
|
+
* `structuredContent`, and a routine bound against an account that already holds
|
|
58
|
+
* a large archive would otherwise put every one of them on the wire on its first
|
|
59
|
+
* fire. It fails in the only safe direction: nodes above the cap stay unflagged,
|
|
60
|
+
* so the next fire counts them again and the backlog drains over several fires.
|
|
61
|
+
* Nothing is skipped — that is the whole property this check exists for.
|
|
62
|
+
*/
|
|
63
|
+
export const UNFLAGGED_ID_CAP = 500;
|
|
26
64
|
/**
|
|
27
65
|
* Task 2653 — the accepted `type` values, in one place.
|
|
28
66
|
*
|
|
@@ -32,7 +70,27 @@ import { getSession } from "../lib/neo4j.js";
|
|
|
32
70
|
* time. A second hand-maintained copy is how a type accepted by the writer
|
|
33
71
|
* becomes a type the prober cannot answer.
|
|
34
72
|
*/
|
|
35
|
-
export const ACCEPTED_CHECK_TYPES = [
|
|
73
|
+
export const ACCEPTED_CHECK_TYPES = [
|
|
74
|
+
"mail",
|
|
75
|
+
"d1",
|
|
76
|
+
"whatsapp",
|
|
77
|
+
"calendar",
|
|
78
|
+
"telegram",
|
|
79
|
+
"graph",
|
|
80
|
+
"graph-unflagged",
|
|
81
|
+
"connector",
|
|
82
|
+
"sweep",
|
|
83
|
+
"filesystem",
|
|
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"];
|
|
36
94
|
/**
|
|
37
95
|
* Task 2657 — the accepted `provider` values on a `mail` check, in one place.
|
|
38
96
|
*
|
|
@@ -66,6 +124,10 @@ export const ACCEPTED_CHECK_FIELDS = {
|
|
|
66
124
|
calendar: ["type", "connector"],
|
|
67
125
|
telegram: ["type", "scope", "botId"],
|
|
68
126
|
graph: ["type", "scope"],
|
|
127
|
+
"graph-unflagged": ["type", "label"],
|
|
128
|
+
connector: ["type", "scope"],
|
|
129
|
+
sweep: ["type"],
|
|
130
|
+
filesystem: ["type", "minGrowthBytes"],
|
|
69
131
|
};
|
|
70
132
|
/**
|
|
71
133
|
* Rooted at the INSTALL, not at the account.
|
|
@@ -219,6 +281,10 @@ export function slotKey(slot) {
|
|
|
219
281
|
// commit over two bots names two entries rather than one repeated twice.
|
|
220
282
|
if (slot.kind === "telegram" && slot.botId !== undefined)
|
|
221
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}`;
|
|
222
288
|
return slot.kind;
|
|
223
289
|
}
|
|
224
290
|
/**
|
|
@@ -251,6 +317,19 @@ export function slotPosition(slot, marks) {
|
|
|
251
317
|
// The ISO `updatedAt` verbatim, as the Telegram position is: it IS the
|
|
252
318
|
// value the comparison is made on.
|
|
253
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);
|
|
254
333
|
}
|
|
255
334
|
}
|
|
256
335
|
/**
|
|
@@ -277,6 +356,12 @@ export function slotsIn(marks) {
|
|
|
277
356
|
slots.push({ kind: "calendar" });
|
|
278
357
|
if (marks.graph?.updatedAt !== undefined)
|
|
279
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" });
|
|
280
365
|
return slots;
|
|
281
366
|
}
|
|
282
367
|
/**
|
|
@@ -387,6 +472,107 @@ export function promoteSlot(slot, from, into) {
|
|
|
387
472
|
into.graph = { ...(into.graph ?? {}), updatedAt };
|
|
388
473
|
return true;
|
|
389
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
|
+
}
|
|
390
576
|
}
|
|
391
577
|
}
|
|
392
578
|
// ---------------------------------------------------------------------------
|
|
@@ -601,6 +787,30 @@ const realMail = async (check, sinceUid, accountId) => {
|
|
|
601
787
|
}
|
|
602
788
|
}
|
|
603
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
|
+
}
|
|
604
814
|
export const REAL_SOURCES = {
|
|
605
815
|
mail: realMail,
|
|
606
816
|
// Task 2657 — the provider owns the decryption of its own tokens, so this is
|
|
@@ -740,6 +950,156 @@ export const REAL_SOURCES = {
|
|
|
740
950
|
await session.close();
|
|
741
951
|
}
|
|
742
952
|
},
|
|
953
|
+
graphUnflagged: async (accountId, label, flagKey, limit) => {
|
|
954
|
+
// The label is resolved through the shared accepted set and interpolated;
|
|
955
|
+
// it is never operator text reaching the statement, and `unflaggedLabel`
|
|
956
|
+
// throws on anything outside that set, which the probe treats as work.
|
|
957
|
+
// Everything else is a parameter.
|
|
958
|
+
const cypherLabel = unflaggedLabel(label);
|
|
959
|
+
const session = getSession();
|
|
960
|
+
try {
|
|
961
|
+
const res = await session.run(`MATCH (n:${cypherLabel})
|
|
962
|
+
WHERE n.accountId = $accountId
|
|
963
|
+
AND NOT $flagKey IN coalesce(n.gateProcessedBy, [])
|
|
964
|
+
RETURN elementId(n) AS id
|
|
965
|
+
LIMIT $limit`,
|
|
966
|
+
// `int` because `LIMIT` needs a Cypher Integer: a bare JS number arrives
|
|
967
|
+
// as a Float and the statement is rejected at run time.
|
|
968
|
+
{ accountId, flagKey, limit: int(limit) });
|
|
969
|
+
const ids = [];
|
|
970
|
+
for (const record of res.records) {
|
|
971
|
+
const id = record.get("id");
|
|
972
|
+
// A shape this cannot read THROWS rather than being dropped from the
|
|
973
|
+
// set. Dropping it would leave the node counted as work by the probe
|
|
974
|
+
// and unflagged by the dispatcher, so the routine would wake on it for
|
|
975
|
+
// ever; a throw counts as work once and names itself in the log.
|
|
976
|
+
if (typeof id !== "string") {
|
|
977
|
+
throw new Error(`graphUnflagged id is type ${typeof id}, expected a string`);
|
|
978
|
+
}
|
|
979
|
+
ids.push(id);
|
|
980
|
+
}
|
|
981
|
+
return { ids };
|
|
982
|
+
}
|
|
983
|
+
finally {
|
|
984
|
+
await session.close();
|
|
985
|
+
}
|
|
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
|
+
},
|
|
743
1103
|
calendarDigest: async (check, accountId) => {
|
|
744
1104
|
const session = getSession();
|
|
745
1105
|
try {
|
|
@@ -800,11 +1160,31 @@ export function checkSource(check) {
|
|
|
800
1160
|
return check.botId ?? "telegram";
|
|
801
1161
|
case "graph":
|
|
802
1162
|
return "graph";
|
|
1163
|
+
// Task 2684 — the label, because one routine may watch two of them and one
|
|
1164
|
+
// going unreadable is not the other.
|
|
1165
|
+
case "graph-unflagged":
|
|
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";
|
|
803
1179
|
default:
|
|
804
1180
|
return String(check.type);
|
|
805
1181
|
}
|
|
806
1182
|
}
|
|
807
|
-
async function probe(check, marks, accountId, sources
|
|
1183
|
+
async function probe(check, marks, accountId, sources,
|
|
1184
|
+
// Task 2684 — the routine's identity on a node's `gateProcessedBy` list. The
|
|
1185
|
+
// event id, which is also what names the watermark file, so one routine has
|
|
1186
|
+
// one memory whichever form its checks take.
|
|
1187
|
+
flagKey) {
|
|
808
1188
|
switch (check.type) {
|
|
809
1189
|
case "mail": {
|
|
810
1190
|
// Task 2635 — the folder segment lower-cases, so a binding that writes
|
|
@@ -953,6 +1333,140 @@ async function probe(check, marks, accountId, sources) {
|
|
|
953
1333
|
},
|
|
954
1334
|
};
|
|
955
1335
|
}
|
|
1336
|
+
case "graph-unflagged": {
|
|
1337
|
+
// A label outside the accepted set is refused HERE, before the source is
|
|
1338
|
+
// reached, exactly as the mail arm refuses an unknown provider: the throw
|
|
1339
|
+
// lands inside `runSchedulingGate`'s try, where it counts as work and
|
|
1340
|
+
// names itself. Leaving it to the real source would make the refusal a
|
|
1341
|
+
// property of which source is injected, and an injected source answering
|
|
1342
|
+
// quiet about a label nothing can read is the mute this design forbids.
|
|
1343
|
+
const label = unflaggedLabel(check.label);
|
|
1344
|
+
// No watermark and no comparison: the answer IS the set the label holds
|
|
1345
|
+
// that this routine has not flagged. `mark: null` for the reason `d1` has
|
|
1346
|
+
// it — there is no position in a stream to record — but with the opposite
|
|
1347
|
+
// consequence, because the flag the dispatcher writes on these very nodes
|
|
1348
|
+
// is what makes the next fire quiet.
|
|
1349
|
+
const { ids } = await sources.graphUnflagged(accountId, label, flagKey, UNFLAGGED_ID_CAP);
|
|
1350
|
+
return { hasWork: ids.length > 0, mark: null, unflagged: { label, ids } };
|
|
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
|
+
}
|
|
956
1470
|
// Task 2653 — a type outside the union. The switch was exhaustive over
|
|
957
1471
|
// `GateCheck` and had no default, so `probe` returned `undefined` and the
|
|
958
1472
|
// caller's `outcome.hasWork` read threw OUTSIDE its own try: the TypeError
|
|
@@ -990,6 +1504,10 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
|
|
|
990
1504
|
// read watermarks, so a check that is not named this fire leaves no stale
|
|
991
1505
|
// position behind.
|
|
992
1506
|
const fresh = {};
|
|
1507
|
+
// Task 2684 — the same contract as `fresh`, for the checks whose state is a
|
|
1508
|
+
// per-node flag: only what THIS call read, so nothing a check did not count
|
|
1509
|
+
// can be flagged.
|
|
1510
|
+
const unflagged = [];
|
|
993
1511
|
const reasons = [];
|
|
994
1512
|
const errored = [];
|
|
995
1513
|
// Task 2699 — set ONLY by a check that answered. `hasWork` below stays the
|
|
@@ -999,7 +1517,7 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
|
|
|
999
1517
|
for (const check of params.checks) {
|
|
1000
1518
|
let outcome;
|
|
1001
1519
|
try {
|
|
1002
|
-
outcome = await probe(check, marks, accountId, sources);
|
|
1520
|
+
outcome = await probe(check, marks, accountId, sources, params.eventId);
|
|
1003
1521
|
}
|
|
1004
1522
|
catch (err) {
|
|
1005
1523
|
const detail = err instanceof Error ? err.message : String(err);
|
|
@@ -1021,18 +1539,42 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
|
|
|
1021
1539
|
}
|
|
1022
1540
|
// A check that threw took the `continue` above, so it records no position:
|
|
1023
1541
|
// its state is unknown, not absent.
|
|
1024
|
-
|
|
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) {
|
|
1025
1548
|
// Every probe records what it saw, whatever the verdict: the position is a
|
|
1026
1549
|
// fact about the source.
|
|
1027
|
-
|
|
1550
|
+
mark.write(fresh);
|
|
1028
1551
|
// Task 2663 — BOTH sides of the comparison. This line printed only
|
|
1029
1552
|
// `position=`, which was `fresh` — this probe's own reading — so the value
|
|
1030
1553
|
// the verdict was actually made against appeared in no log line anywhere,
|
|
1031
1554
|
// and a gate stuck for ever on an empty slot read exactly like a healthy
|
|
1032
1555
|
// one. Measured on 2026-08-10: 18 wakes whose `position=` never moved.
|
|
1033
|
-
console.error(
|
|
1034
|
-
|
|
1035
|
-
|
|
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)}`);
|
|
1565
|
+
}
|
|
1566
|
+
// Task 2684 — an empty set carries nothing to flag, so it contributes no
|
|
1567
|
+
// entry: the dispatcher's `op=gate-flag` then names only labels that
|
|
1568
|
+
// actually held work, and a zero line cannot be read as a flag that failed.
|
|
1569
|
+
if (outcome.unflagged && outcome.unflagged.ids.length > 0) {
|
|
1570
|
+
unflagged.push(outcome.unflagged);
|
|
1571
|
+
// Its OWN op, not `op=gate-read`. That line names both sides of a
|
|
1572
|
+
// comparison against a stored position (`stored=`/`observed=`) and this
|
|
1573
|
+
// check makes no comparison, so sharing the name would hand a grep for
|
|
1574
|
+
// `op=gate-read` lines carrying neither field.
|
|
1575
|
+
console.error(`[scheduling-gate] op=gate-outstanding eventId=${id8} label=${outcome.unflagged.label} ` +
|
|
1576
|
+
`outstanding=${outcome.unflagged.ids.length} ` +
|
|
1577
|
+
`capped=${outcome.unflagged.ids.length >= UNFLAGGED_ID_CAP}`);
|
|
1036
1578
|
}
|
|
1037
1579
|
}
|
|
1038
1580
|
// Task 2699 — the disjunction, unchanged in value and now written in one
|
|
@@ -1042,6 +1584,6 @@ export async function runSchedulingGate(params, accountId, platformRoot, sources
|
|
|
1042
1584
|
console.error(`[scheduling-gate] op=checked hasWork=${hasWork} genuineWork=${genuineWork} ` +
|
|
1043
1585
|
`reasons=${reasons.join(",")} errored=[${errored.map((e) => e.source).join(",")}] ` +
|
|
1044
1586
|
`checks=${params.checks.length} ms=${Date.now() - started}`);
|
|
1045
|
-
return { hasWork, genuineWork, reasons, errored, observed: fresh };
|
|
1587
|
+
return { hasWork, genuineWork, reasons, errored, observed: fresh, unflagged };
|
|
1046
1588
|
}
|
|
1047
1589
|
//# sourceMappingURL=scheduling-gate.js.map
|