@rubytech/create-maxy-code 0.1.576 → 0.1.577

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 (75) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
  3. package/payload/platform/lib/active-rules/dist/index.js +32 -1
  4. package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
  5. package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
  6. package/payload/platform/lib/active-rules/src/index.ts +36 -2
  7. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
  8. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
  9. package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
  10. package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
  11. package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
  12. package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
  13. package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
  14. package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
  15. package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
  16. package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
  17. package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
  18. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
  19. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
  20. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
  21. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
  24. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
  27. package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
  28. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
  29. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
  30. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
  31. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
  32. package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
  33. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
  34. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
  35. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
  36. package/payload/platform/plugins/docs/references/internals.md +1 -1
  37. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts +2 -0
  38. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts.map +1 -0
  39. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js +19 -0
  40. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js.map +1 -0
  41. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts +2 -0
  42. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts.map +1 -0
  43. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js +110 -0
  44. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js.map +1 -0
  45. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts +2 -0
  46. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts.map +1 -0
  47. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js +22 -0
  48. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js.map +1 -0
  49. package/payload/platform/plugins/telegram/mcp/dist/index.js +19 -163
  50. package/payload/platform/plugins/telegram/mcp/dist/index.js.map +1 -1
  51. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts +9 -0
  52. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts.map +1 -1
  53. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js +11 -0
  54. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js.map +1 -1
  55. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts +30 -3
  56. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts.map +1 -1
  57. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js +29 -4
  58. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js.map +1 -1
  59. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts +33 -0
  60. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts.map +1 -0
  61. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js +145 -0
  62. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js.map +1 -0
  63. package/payload/platform/plugins/telegram/skills/configure/SKILL.md +4 -2
  64. package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
  65. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  66. package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
  67. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  68. package/payload/platform/services/telegram-channel/dist/server.js +1 -1
  69. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  70. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  71. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  72. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  73. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
  74. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  75. package/payload/server/server.js +508 -117
@@ -757,7 +757,7 @@ var init_parse_transcript = __esm({
757
757
  webchat: "webchat",
758
758
  "webchat-channel": "webchat"
759
759
  };
760
- COMPOSED_ADMIN_FRAME = /^(?:## Scheduled automation \(platform-verified\)\n[\s\S]*?\n\n)?(?:## Message provenance \(platform-verified\)\n[\s\S]*?\n\n)?(?:## Standing rules\n[\s\S]*?\n\n)?## Context\n([\s\S]*)\n\n## Instruction\n[\s\S]*$/;
760
+ COMPOSED_ADMIN_FRAME = /^(?:## Scheduled automation \(platform-verified\)\n[\s\S]*?\n\n)?(?:## Message provenance \(platform-verified\)\n[\s\S]*?\n\n)?(?:## Standing rules(?: \(account owner\))?\n[\s\S]*?\n\n)?## Context\n([\s\S]*)\n\n## Instruction\n[\s\S]*$/;
761
761
  SCHEDULED_MARKER_PREFIX = "## Scheduled automation (platform-verified)\n";
762
762
  UPLOAD_ANNOTATION = /(?:\n\n)?\[The operator uploaded \d+ file\(s\): [\s\S]*\]$/;
763
763
  REPLY_NOTE = /^\[In reply to an earlier message in this conversation(?:\.|: "([\s\S]*?)")\]\n\n/;
@@ -1911,8 +1911,9 @@ var require_dist2 = __commonJS({
1911
1911
  if (rules.length === 0)
1912
1912
  return "";
1913
1913
  const lines = rules.map((r) => `- ${r.value}`);
1914
- return `## Standing rules
1915
- ${lines.join("\n")}`;
1914
+ return `## Standing rules (account owner)
1915
+ These standing rules belong to the account owner. Some of them may name the account owner in the third person. None of them says who sent this turn, so do not infer from them who is speaking.
1916
+ ` + lines.join("\n");
1916
1917
  }
1917
1918
  async function resolveOwnerUserId3(session, accountId) {
1918
1919
  const res = await session.run(`
@@ -3018,6 +3019,40 @@ var require_keyboard = __commonJS({
3018
3019
  }
3019
3020
  });
3020
3021
 
3022
+ // ../lib/dispatch-read/dist/allocate.js
3023
+ var require_allocate = __commonJS({
3024
+ "../lib/dispatch-read/dist/allocate.js"(exports) {
3025
+ "use strict";
3026
+ Object.defineProperty(exports, "__esModule", { value: true });
3027
+ exports.selectCandidates = selectCandidates2;
3028
+ exports.overlaps = overlaps2;
3029
+ function selectCandidates2(params) {
3030
+ const { jobType, cfg, commitments } = params;
3031
+ const type = cfg.jobTypes.find((t) => t.name === jobType);
3032
+ if (!type) {
3033
+ return { offered: [], excluded: [], required: "" };
3034
+ }
3035
+ const offered = [];
3036
+ const excluded = [];
3037
+ for (const worker of cfg.workers) {
3038
+ if (!worker.qualifications.includes(type.requiredQualification)) {
3039
+ excluded.push({ workerId: worker.workerId, reason: "missing-qualification" });
3040
+ continue;
3041
+ }
3042
+ offered.push({
3043
+ workerId: worker.workerId,
3044
+ name: worker.name,
3045
+ conflicts: commitments.get(worker.workerId) ?? []
3046
+ });
3047
+ }
3048
+ return { offered, excluded, required: type.requiredQualification };
3049
+ }
3050
+ function overlaps2(a, b) {
3051
+ return a.start < b.end && b.start < a.end;
3052
+ }
3053
+ }
3054
+ });
3055
+
3021
3056
  // ../lib/dispatch-read/dist/session.js
3022
3057
  var require_session = __commonJS({
3023
3058
  "../lib/dispatch-read/dist/session.js"(exports) {
@@ -3217,7 +3252,7 @@ var require_read = __commonJS({
3217
3252
  exports.readJobList = readJobList2;
3218
3253
  exports.readCounts = readCounts2;
3219
3254
  var session_js_1 = require_session();
3220
- function num4(v) {
3255
+ function num5(v) {
3221
3256
  if (typeof v === "number")
3222
3257
  return v;
3223
3258
  if (v && typeof v === "object" && "low" in v) {
@@ -3241,7 +3276,7 @@ var require_read = __commonJS({
3241
3276
  workerId: String(a.workerId),
3242
3277
  arrivedPressedAt: str2(a.arrivedPressedAt),
3243
3278
  arrivedObservedAt: str2(a.arrivedObservedAt),
3244
- arrivedObservedDistanceM: a.arrivedObservedDistanceM == null ? null : num4(a.arrivedObservedDistanceM)
3279
+ arrivedObservedDistanceM: a.arrivedObservedDistanceM == null ? null : num5(a.arrivedObservedDistanceM)
3245
3280
  })).sort((x, y) => x.workerId < y.workerId ? -1 : x.workerId > y.workerId ? 1 : 0);
3246
3281
  const sole = attendances.length === 1 ? attendances[0] : null;
3247
3282
  return {
@@ -3334,12 +3369,12 @@ var require_read = __commonJS({
3334
3369
  }
3335
3370
  const g = r.records[0];
3336
3371
  return {
3337
- notes: num4(g.get("notes")),
3338
- media: num4(g.get("media")),
3339
- parts: num4(g.get("parts")),
3340
- checklistDone: num4(g.get("checklistDone")),
3341
- checklistTotal: num4(g.get("checklistTotal")),
3342
- signatures: num4(g.get("signatures"))
3372
+ notes: num5(g.get("notes")),
3373
+ media: num5(g.get("media")),
3374
+ parts: num5(g.get("parts")),
3375
+ checklistDone: num5(g.get("checklistDone")),
3376
+ checklistTotal: num5(g.get("checklistTotal")),
3377
+ signatures: num5(g.get("signatures"))
3343
3378
  };
3344
3379
  }
3345
3380
  async function readVisitsForJob3(session, accountId, jobId) {
@@ -3392,7 +3427,7 @@ var require_read = __commonJS({
3392
3427
  workerId,
3393
3428
  arrivedPressedAt: str2(g.get("ownArrivedPressedAt")),
3394
3429
  arrivedObservedAt: str2(g.get("ownArrivedObservedAt")),
3395
- arrivedObservedDistanceM: dist == null ? null : num4(dist)
3430
+ arrivedObservedDistanceM: dist == null ? null : num5(dist)
3396
3431
  };
3397
3432
  });
3398
3433
  }
@@ -3517,9 +3552,9 @@ var require_read = __commonJS({
3517
3552
  const rad = g.get("geofenceRadiusM");
3518
3553
  return {
3519
3554
  siteId: String(g.get("siteId")),
3520
- latitude: lat == null ? null : num4(lat),
3521
- longitude: lon == null ? null : num4(lon),
3522
- geofenceRadiusM: rad == null ? null : num4(rad)
3555
+ latitude: lat == null ? null : num5(lat),
3556
+ longitude: lon == null ? null : num5(lon),
3557
+ geofenceRadiusM: rad == null ? null : num5(rad)
3523
3558
  };
3524
3559
  }
3525
3560
  var DRAFT_RETURN = `d.draftId AS draftId, d.receivedAt AS receivedAt, d.source AS source,
@@ -3584,7 +3619,7 @@ var require_read = __commonJS({
3584
3619
  notes: g.get("notes") ?? [],
3585
3620
  // A count, not the references. A worker at a door needs to know whether
3586
3621
  // photographs exist before deciding to load them on a bad signal.
3587
- mediaCount: num4(g.get("mediaCount") ?? 0),
3622
+ mediaCount: num5(g.get("mediaCount") ?? 0),
3588
3623
  ...projectAttendances2(g)
3589
3624
  }));
3590
3625
  }
@@ -3709,7 +3744,7 @@ var require_read = __commonJS({
3709
3744
  workerId,
3710
3745
  arrivedPressedAt: str2(g.get("ownArrivedPressedAt")),
3711
3746
  arrivedObservedAt: str2(g.get("ownArrivedObservedAt")),
3712
- arrivedObservedDistanceM: dist == null ? null : num4(dist)
3747
+ arrivedObservedDistanceM: dist == null ? null : num5(dist)
3713
3748
  });
3714
3749
  });
3715
3750
  });
@@ -3749,9 +3784,9 @@ var require_read = __commonJS({
3749
3784
  sum(CASE WHEN overdue THEN 1 ELSE 0 END) AS overdue`, { accountId, nowIso, terminalJob: terminalJobStatuses2 });
3750
3785
  const g = r.records[0];
3751
3786
  return {
3752
- total: num4(g?.get("total")),
3753
- unallocated: num4(g?.get("unallocated")),
3754
- overdue: num4(g?.get("overdue"))
3787
+ total: num5(g?.get("total")),
3788
+ unallocated: num5(g?.get("unallocated")),
3789
+ overdue: num5(g?.get("overdue"))
3755
3790
  };
3756
3791
  });
3757
3792
  }
@@ -3792,7 +3827,7 @@ var require_dist3 = __commonJS({
3792
3827
  "../lib/dispatch-read/dist/index.js"(exports) {
3793
3828
  "use strict";
3794
3829
  Object.defineProperty(exports, "__esModule", { value: true });
3795
- exports.readWorkerDay = exports.readJobList = exports.readDay = exports.readCounts = exports.nonZuluStarts = exports.geofenceVerdict = exports.distanceM = exports.readVisitsInWindow = exports.readVisitsForJob = exports.readVisitRecordState = exports.readVisit = exports.readSiteLocation = exports.readSiteHistory = exports.readPreviousVisits = exports.readOpenVisitsForWorker = exports.readNodeId = exports.readJobs = exports.readJob = exports.readFieldVisit = exports.readDrafts = exports.readDraft = exports.readCommitments = exports.projectVisit = exports.projectJob = exports.projectFieldVisit = exports.buildJobListQuery = exports.zonedRangeBounds = exports.zonedDayStartUtc = exports.ymdInZone = exports.minutesIntoDay = exports.daysBetween = exports.currentWeekYmd = exports.addDaysYmd = exports.MAX_RANGE_DAYS = exports.resolveAccountZone = exports.withSession = exports.parseDispatchCallback = exports.dispatchCallbackData = exports.buildVisitKeyboard = exports.CALLBACK_DATA_MAX_BYTES = exports.workerForTelegramUser = exports.terminalVisitStatuses = exports.terminalJobStatuses = exports.loadDispatchConfig = exports.RECORDABLE_FIELDS = void 0;
3830
+ exports.readWorkerDay = exports.readJobList = exports.readDay = exports.readCounts = exports.nonZuluStarts = exports.geofenceVerdict = exports.distanceM = exports.readVisitsInWindow = exports.readVisitsForJob = exports.readVisitRecordState = exports.readVisit = exports.readSiteLocation = exports.readSiteHistory = exports.readPreviousVisits = exports.readOpenVisitsForWorker = exports.readNodeId = exports.readJobs = exports.readJob = exports.readFieldVisit = exports.readDrafts = exports.readDraft = exports.readCommitments = exports.projectVisit = exports.projectJob = exports.projectFieldVisit = exports.buildJobListQuery = exports.zonedRangeBounds = exports.zonedDayStartUtc = exports.ymdInZone = exports.minutesIntoDay = exports.daysBetween = exports.currentWeekYmd = exports.addDaysYmd = exports.MAX_RANGE_DAYS = exports.resolveAccountZone = exports.withSession = exports.selectCandidates = exports.overlaps = exports.parseDispatchCallback = exports.dispatchCallbackData = exports.buildVisitKeyboard = exports.CALLBACK_DATA_MAX_BYTES = exports.workerForTelegramUser = exports.terminalVisitStatuses = exports.terminalJobStatuses = exports.loadDispatchConfig = exports.RECORDABLE_FIELDS = void 0;
3796
3831
  var config_js_1 = require_config();
3797
3832
  Object.defineProperty(exports, "RECORDABLE_FIELDS", { enumerable: true, get: function() {
3798
3833
  return config_js_1.RECORDABLE_FIELDS;
@@ -3822,6 +3857,13 @@ var require_dist3 = __commonJS({
3822
3857
  Object.defineProperty(exports, "parseDispatchCallback", { enumerable: true, get: function() {
3823
3858
  return keyboard_js_1.parseDispatchCallback;
3824
3859
  } });
3860
+ var allocate_js_1 = require_allocate();
3861
+ Object.defineProperty(exports, "overlaps", { enumerable: true, get: function() {
3862
+ return allocate_js_1.overlaps;
3863
+ } });
3864
+ Object.defineProperty(exports, "selectCandidates", { enumerable: true, get: function() {
3865
+ return allocate_js_1.selectCandidates;
3866
+ } });
3825
3867
  var session_js_1 = require_session();
3826
3868
  Object.defineProperty(exports, "withSession", { enumerable: true, get: function() {
3827
3869
  return session_js_1.withSession;
@@ -12706,6 +12748,32 @@ function buildVisitKeyboard(p) {
12706
12748
  return { rows, dropped };
12707
12749
  }
12708
12750
 
12751
+ // ../lib/dispatch-read/src/allocate.ts
12752
+ function selectCandidates(params) {
12753
+ const { jobType, cfg, commitments } = params;
12754
+ const type = cfg.jobTypes.find((t) => t.name === jobType);
12755
+ if (!type) {
12756
+ return { offered: [], excluded: [], required: "" };
12757
+ }
12758
+ const offered = [];
12759
+ const excluded = [];
12760
+ for (const worker of cfg.workers) {
12761
+ if (!worker.qualifications.includes(type.requiredQualification)) {
12762
+ excluded.push({ workerId: worker.workerId, reason: "missing-qualification" });
12763
+ continue;
12764
+ }
12765
+ offered.push({
12766
+ workerId: worker.workerId,
12767
+ name: worker.name,
12768
+ conflicts: commitments.get(worker.workerId) ?? []
12769
+ });
12770
+ }
12771
+ return { offered, excluded, required: type.requiredQualification };
12772
+ }
12773
+ function overlaps(a, b) {
12774
+ return a.start < b.end && b.start < a.end;
12775
+ }
12776
+
12709
12777
  // ../lib/dispatch-read/src/session.ts
12710
12778
  import { readFileSync as readFileSync15 } from "fs";
12711
12779
  import { resolve as resolve12 } from "path";
@@ -12989,6 +13057,30 @@ async function readOpenVisitsForWorker(session, accountId, workerId, terminalSta
12989
13057
  };
12990
13058
  });
12991
13059
  }
13060
+ async function readCommitments(session, accountId, from, to) {
13061
+ const r = await session.run(
13062
+ // One row per ATTENDANCE is correct here and nowhere else: a commitment
13063
+ // belongs to a worker, so a two-worker visit legitimately commits both.
13064
+ `MATCH (v:Visit {accountId: $accountId})-[:HAS_ATTENDANCE]->(:Attendance)-[:BY_ENGINEER]->(e:Engineer)
13065
+ WHERE v.startDate < $to AND coalesce(v.endDate, v.startDate) > $from
13066
+ RETURN e.engineerId AS workerId, v.visitId AS visitId,
13067
+ v.startDate AS start, coalesce(v.endDate, v.startDate) AS end`,
13068
+ { accountId, from, to }
13069
+ );
13070
+ const out = /* @__PURE__ */ new Map();
13071
+ for (const g of r.records) {
13072
+ const workerId = str(g.get("workerId"));
13073
+ if (!workerId) continue;
13074
+ const list = out.get(workerId) ?? [];
13075
+ list.push({
13076
+ visitId: String(g.get("visitId")),
13077
+ start: String(g.get("start")),
13078
+ end: String(g.get("end"))
13079
+ });
13080
+ out.set(workerId, list);
13081
+ }
13082
+ return out;
13083
+ }
12992
13084
  async function readSiteLocation(session, accountId, siteId) {
12993
13085
  const r = await session.run(
12994
13086
  `MATCH (s:Site {accountId: $accountId, siteId: $siteId})
@@ -13170,6 +13262,7 @@ var ZONE_FALLBACK = "UTC";
13170
13262
  var EMPTY_META = {
13171
13263
  vocabulary: { job: "", visit: "", worker: "" },
13172
13264
  statuses: [],
13265
+ transitions: [],
13173
13266
  zone: ""
13174
13267
  };
13175
13268
  var PORTAL_VISITS_PUSH_INTERVAL_MS = 6e4;
@@ -13185,17 +13278,28 @@ function parseVisitRows(raw) {
13185
13278
  const visitId = typeof o.visitId === "string" ? o.visitId : "";
13186
13279
  if (!visitId) continue;
13187
13280
  const str2 = (v) => typeof v === "string" ? v : "";
13281
+ const cnt = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
13188
13282
  out.push({
13189
13283
  visitId,
13190
13284
  ownerId: str2(o.ownerId),
13191
13285
  jobId: str2(o.jobId),
13192
13286
  status: str2(o.status),
13193
13287
  startDate: str2(o.startDate),
13194
- purpose: str2(o.purpose)
13288
+ purpose: str2(o.purpose),
13289
+ notes: cnt(o.notes),
13290
+ media: cnt(o.media),
13291
+ parts: cnt(o.parts),
13292
+ checklistDone: cnt(o.checklistDone),
13293
+ checklistTotal: cnt(o.checklistTotal),
13294
+ signatures: cnt(o.signatures)
13195
13295
  });
13196
13296
  }
13197
13297
  return out;
13198
13298
  }
13299
+ function num2(v) {
13300
+ const n = Number(v?.toNumber?.() ?? v);
13301
+ return Number.isFinite(n) ? n : 0;
13302
+ }
13199
13303
  async function readVisitsForAccount(accountId) {
13200
13304
  const session = getSession();
13201
13305
  try {
@@ -13207,6 +13311,12 @@ async function readVisitsForAccount(accountId) {
13207
13311
  // the worker sees no job and has nothing to report but confusion, where a
13208
13312
  // duplicate on the account-scope list is visible and reads as what it is.
13209
13313
  // The duplicate is real and is Task 2551.
13314
+ // The record counts ride the visit row (Task 2511). One projection rather
13315
+ // than a `readVisitRecordState` call per visit: this query already runs
13316
+ // once for the whole account, and a per-visit round trip would multiply
13317
+ // every tick by the account's whole visit history. The six expressions
13318
+ // are `readVisitRecordState`'s own, so the two cannot disagree about what
13319
+ // a count means.
13210
13320
  `MATCH (j:Job {accountId: $accountId})-[:HAS_VISIT]->(v:Visit {accountId: $accountId})
13211
13321
  OPTIONAL MATCH (v)-[:HAS_ATTENDANCE]->(:Attendance)-[:BY_ENGINEER]->(e:Engineer)
13212
13322
  RETURN toString(v.visitId) AS visitId,
@@ -13214,7 +13324,13 @@ async function readVisitsForAccount(accountId) {
13214
13324
  coalesce(toString(j.jobId), '') AS jobId,
13215
13325
  coalesce(toString(v.status), '') AS status,
13216
13326
  coalesce(toString(v.startDate), '') AS startDate,
13217
- coalesce(toString(v.purpose), '') AS purpose`,
13327
+ coalesce(toString(v.purpose), '') AS purpose,
13328
+ size(coalesce(v.notes, [])) AS notes,
13329
+ size(coalesce(v.media, [])) AS media,
13330
+ size(coalesce(v.parts, [])) AS parts,
13331
+ size(coalesce(v.checklistDone, [])) AS checklistDone,
13332
+ size(coalesce(v.checklist, [])) AS checklistTotal,
13333
+ size(coalesce(v.signatures, [])) AS signatures`,
13218
13334
  { accountId }
13219
13335
  );
13220
13336
  return parseVisitRows([
@@ -13225,7 +13341,13 @@ async function readVisitsForAccount(accountId) {
13225
13341
  jobId: rec.get("jobId"),
13226
13342
  status: rec.get("status"),
13227
13343
  startDate: rec.get("startDate"),
13228
- purpose: rec.get("purpose")
13344
+ purpose: rec.get("purpose"),
13345
+ notes: num2(rec.get("notes")),
13346
+ media: num2(rec.get("media")),
13347
+ parts: num2(rec.get("parts")),
13348
+ checklistDone: num2(rec.get("checklistDone")),
13349
+ checklistTotal: num2(rec.get("checklistTotal")),
13350
+ signatures: num2(rec.get("signatures"))
13229
13351
  }))
13230
13352
  }
13231
13353
  ]);
@@ -13256,8 +13378,72 @@ async function readRosterForAccount(accountId, workers) {
13256
13378
  await session.close();
13257
13379
  }
13258
13380
  }
13381
+ async function readCandidatesForAccount(accountId, cfg) {
13382
+ const session = getSession();
13383
+ try {
13384
+ const open3 = await session.run(
13385
+ `MATCH (j:Job {accountId: $accountId})-[:HAS_VISIT]->(v:Visit {accountId: $accountId})
13386
+ WHERE NOT (v)-[:HAS_ATTENDANCE]->()
13387
+ RETURN toString(v.visitId) AS visitId,
13388
+ coalesce(toString(j.jobType), '') AS jobType,
13389
+ coalesce(toString(v.startDate), '') AS startDate,
13390
+ coalesce(toString(v.endDate), toString(v.startDate), '') AS endDate`,
13391
+ { accountId }
13392
+ );
13393
+ if (open3.records.length === 0) return [];
13394
+ const windows = open3.records.map((rec) => ({
13395
+ visitId: String(rec.get("visitId")),
13396
+ jobType: String(rec.get("jobType")),
13397
+ start: String(rec.get("startDate")),
13398
+ end: String(rec.get("endDate"))
13399
+ }));
13400
+ const dated = windows.filter((w) => w.start !== "");
13401
+ const from = dated.reduce((lo, w) => lo === "" || w.start < lo ? w.start : lo, "");
13402
+ const to = dated.reduce((hi, w) => w.end > hi ? w.end : hi, "");
13403
+ const committed = from === "" ? /* @__PURE__ */ new Map() : await readCommitments(session, accountId, from, to);
13404
+ const out = [];
13405
+ const nameOf = new Map(cfg.workers.map((w) => [w.workerId, w.name]));
13406
+ for (const w of windows) {
13407
+ const windowed = new Map(
13408
+ [...committed].map(([workerId, list]) => [
13409
+ workerId,
13410
+ // A visit with no start overlaps nothing rather than everything: an
13411
+ // undated visit is already listed separately by the page, and
13412
+ // reporting the whole roster as clashing with it would be false.
13413
+ w.start === "" ? [] : list.filter((c) => overlaps({ start: w.start, end: w.end }, c))
13414
+ ])
13415
+ );
13416
+ const selection = selectCandidates({ jobType: w.jobType, cfg, commitments: windowed });
13417
+ for (const c of selection.offered) {
13418
+ out.push({
13419
+ visitId: w.visitId,
13420
+ workerId: c.workerId,
13421
+ name: c.name,
13422
+ kind: "offered",
13423
+ reason: "",
13424
+ conflicts: c.conflicts
13425
+ });
13426
+ }
13427
+ for (const e of selection.excluded) {
13428
+ out.push({
13429
+ visitId: w.visitId,
13430
+ workerId: e.workerId,
13431
+ // The config is the authority on a worker's name, and an excluded
13432
+ // worker is never in `selection.offered` to carry one.
13433
+ name: nameOf.get(e.workerId) ?? "",
13434
+ kind: "excluded",
13435
+ reason: e.reason,
13436
+ conflicts: []
13437
+ });
13438
+ }
13439
+ }
13440
+ return out;
13441
+ } finally {
13442
+ await session.close();
13443
+ }
13444
+ }
13259
13445
  async function pushVisitsForAccount(opts) {
13260
- const { accountId, client, rows, roster, meta, log: log2 } = opts;
13446
+ const { accountId, client, rows, roster, meta, candidates, log: log2 } = opts;
13261
13447
  const unbound = rows.filter((r) => r.ownerId === "").length;
13262
13448
  const seen = /* @__PURE__ */ new Set();
13263
13449
  const shared = /* @__PURE__ */ new Set();
@@ -13268,7 +13454,7 @@ async function pushVisitsForAccount(opts) {
13268
13454
  const unresolved = roster.filter((w) => w.ownerId === "").length;
13269
13455
  if (rows.length === 0 && roster.length === 0) {
13270
13456
  log2(`${TAG19} op=skip account=${accountId} reason=no-visits-no-roster`);
13271
- return { rows: 0, unbound: 0, roster: 0, unresolved: 0 };
13457
+ return { rows: 0, unbound: 0, roster: 0, unresolved: 0, candidates: 0 };
13272
13458
  }
13273
13459
  const state = await client.query(
13274
13460
  "SELECT currentGeneration FROM visits_state WHERE accountId = ?",
@@ -13281,8 +13467,23 @@ async function pushVisitsForAccount(opts) {
13281
13467
  const next = Math.max(current, Number(staged[0]?.g ?? 0)) + 1;
13282
13468
  for (const row of rows) {
13283
13469
  await client.query(
13284
- "INSERT INTO visits (accountId, visitId, ownerId, jobId, status, startDate, purpose, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
13285
- [accountId, row.visitId, row.ownerId, row.jobId, row.status, row.startDate, row.purpose, next]
13470
+ "INSERT INTO visits (accountId, visitId, ownerId, jobId, status, startDate, purpose, notes, media, parts, checklistDone, checklistTotal, signatures, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
13471
+ [
13472
+ accountId,
13473
+ row.visitId,
13474
+ row.ownerId,
13475
+ row.jobId,
13476
+ row.status,
13477
+ row.startDate,
13478
+ row.purpose,
13479
+ row.notes,
13480
+ row.media,
13481
+ row.parts,
13482
+ row.checklistDone,
13483
+ row.checklistTotal,
13484
+ row.signatures,
13485
+ next
13486
+ ]
13286
13487
  );
13287
13488
  }
13288
13489
  for (const w of roster) {
@@ -13293,19 +13494,26 @@ async function pushVisitsForAccount(opts) {
13293
13494
  }
13294
13495
  if (roster.length > 0) {
13295
13496
  await client.query(
13296
- "INSERT INTO dispatch_meta (accountId, vocabulary, statuses, zone, publishedAt, generation) VALUES (?, ?, ?, ?, ?, ?)",
13497
+ "INSERT INTO dispatch_meta (accountId, vocabulary, statuses, transitions, zone, publishedAt, generation) VALUES (?, ?, ?, ?, ?, ?, ?)",
13297
13498
  [
13298
13499
  accountId,
13299
13500
  JSON.stringify(meta.vocabulary),
13300
13501
  JSON.stringify(meta.statuses),
13502
+ JSON.stringify(meta.transitions),
13301
13503
  meta.zone,
13302
13504
  (/* @__PURE__ */ new Date()).toISOString(),
13303
13505
  next
13304
13506
  ]
13305
13507
  );
13306
13508
  }
13509
+ for (const c of candidates) {
13510
+ await client.query(
13511
+ "INSERT INTO visit_candidates (accountId, visitId, workerId, name, kind, reason, conflicts, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
13512
+ [accountId, c.visitId, c.workerId, c.name, c.kind, c.reason, JSON.stringify(c.conflicts), next]
13513
+ );
13514
+ }
13307
13515
  log2(
13308
- `${TAG19} op=stage account=${accountId} generation=${next} rows=${rows.length} visits=${seen.size} shared=${shared.size} roster=${roster.length}`
13516
+ `${TAG19} op=stage account=${accountId} generation=${next} rows=${rows.length} visits=${seen.size} shared=${shared.size} roster=${roster.length} candidates=${candidates.length}`
13309
13517
  );
13310
13518
  await client.query(
13311
13519
  "INSERT INTO visits_state (accountId, currentGeneration) VALUES (?, ?) ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration WHERE excluded.currentGeneration > visits_state.currentGeneration",
@@ -13325,6 +13533,7 @@ async function pushVisitsForAccount(opts) {
13325
13533
  unbound,
13326
13534
  roster: roster.length,
13327
13535
  unresolved,
13536
+ candidates: candidates.length,
13328
13537
  generation: next,
13329
13538
  superseded: true
13330
13539
  };
@@ -13345,12 +13554,23 @@ async function pushVisitsForAccount(opts) {
13345
13554
  accountId,
13346
13555
  next
13347
13556
  ]);
13557
+ await client.query("DELETE FROM visit_candidates WHERE accountId = ? AND generation < ?", [
13558
+ accountId,
13559
+ next
13560
+ ]);
13348
13561
  } catch (err) {
13349
13562
  log2(
13350
13563
  `${TAG19} op=sweep account=${accountId} generation=${next} result=failed err="${err instanceof Error ? err.message : String(err)}"`
13351
13564
  );
13352
13565
  }
13353
- return { rows: rows.length, unbound, roster: roster.length, unresolved, generation: next };
13566
+ return {
13567
+ rows: rows.length,
13568
+ unbound,
13569
+ roster: roster.length,
13570
+ unresolved,
13571
+ candidates: candidates.length,
13572
+ generation: next
13573
+ };
13354
13574
  }
13355
13575
  function visitsStateFilePath(platformRoot5) {
13356
13576
  return resolve13(platformRoot5, "..", "data", "portal-visits-state.json");
@@ -13358,17 +13578,40 @@ function visitsStateFilePath(platformRoot5) {
13358
13578
  function visitsDigest(rows) {
13359
13579
  const h = createHash3("sha256");
13360
13580
  for (const r of [...rows].sort((a, b) => a.visitId.localeCompare(b.visitId))) {
13361
- h.update(JSON.stringify([r.visitId, r.ownerId, r.jobId, r.status, r.startDate, r.purpose]));
13581
+ h.update(
13582
+ JSON.stringify([
13583
+ r.visitId,
13584
+ r.ownerId,
13585
+ r.jobId,
13586
+ r.status,
13587
+ r.startDate,
13588
+ r.purpose,
13589
+ // Task 2511's counts are fields of a visit, so they belong in the
13590
+ // visits half. Listed explicitly rather than spread, because the array
13591
+ // literal is what fixes field order here rather than key insertion.
13592
+ r.notes,
13593
+ r.media,
13594
+ r.parts,
13595
+ r.checklistDone,
13596
+ r.checklistTotal,
13597
+ r.signatures
13598
+ ])
13599
+ );
13362
13600
  }
13363
13601
  return h.digest("hex");
13364
13602
  }
13365
- function payloadDigest(rows, roster, meta) {
13603
+ function payloadDigest(rows, roster, meta, candidates) {
13366
13604
  const h = createHash3("sha256");
13367
13605
  h.update(visitsDigest(rows));
13368
13606
  for (const w of [...roster].sort((a, b) => a.workerId.localeCompare(b.workerId))) {
13369
13607
  h.update(JSON.stringify([w.workerId, w.ownerId, w.name, w.workingHours]));
13370
13608
  }
13371
- h.update(JSON.stringify([meta.vocabulary, meta.statuses, meta.zone]));
13609
+ h.update(JSON.stringify([meta.vocabulary, meta.statuses, meta.transitions, meta.zone]));
13610
+ for (const c of [...candidates].sort(
13611
+ (a, b) => a.visitId.localeCompare(b.visitId) || a.workerId.localeCompare(b.workerId)
13612
+ )) {
13613
+ h.update(JSON.stringify([c.visitId, c.workerId, c.name, c.kind, c.reason, c.conflicts]));
13614
+ }
13372
13615
  return h.digest("hex");
13373
13616
  }
13374
13617
  function readVisitsPushState(platformRoot5) {
@@ -13416,9 +13659,11 @@ async function runPortalVisitsPush(root) {
13416
13659
  const meta = cfg.present ? {
13417
13660
  vocabulary: cfg.value.vocabulary,
13418
13661
  statuses: cfg.value.statuses.visit,
13662
+ transitions: cfg.value.statuses.visitTransitions,
13419
13663
  zone: await resolveAccountZone(portal.accountId) ?? ZONE_FALLBACK
13420
13664
  } : EMPTY_META;
13421
- const digest = payloadDigest(rows, roster, meta);
13665
+ const candidates = cfg.present ? await readCandidatesForAccount(portal.accountId, cfg.value) : [];
13666
+ const digest = payloadDigest(rows, roster, meta, candidates);
13422
13667
  const unresolved = roster.filter((w) => w.ownerId === "").length;
13423
13668
  if (state[portal.accountId]?.digest === digest && (rows.length > 0 || roster.length > 0)) {
13424
13669
  recordVisitsPush(root, portal.accountId, {
@@ -13443,10 +13688,11 @@ async function runPortalVisitsPush(root) {
13443
13688
  rows,
13444
13689
  roster,
13445
13690
  meta,
13691
+ candidates,
13446
13692
  log: (l) => console.error(l)
13447
13693
  });
13448
13694
  console.error(
13449
- `${TAG19} op=push account=${portal.accountId} visits=${rows.length} pushed=${r.rows} roster=${r.roster} workers-unresolved=${r.unresolved} ms=${Date.now() - startedAt}`
13695
+ `${TAG19} op=push account=${portal.accountId} visits=${rows.length} pushed=${r.rows} roster=${r.roster} workers-unresolved=${r.unresolved} transitions=${meta.transitions.length} candidates=${r.candidates} ms=${Date.now() - startedAt}`
13450
13696
  );
13451
13697
  if (r.generation !== void 0) {
13452
13698
  recordVisitsPush(root, portal.accountId, {
@@ -13491,12 +13737,15 @@ function parsePendingStatusRows(raw) {
13491
13737
  statusAt: str2(o.statusAt),
13492
13738
  dayHundredths: typeof o.dayHundredths === "number" ? o.dayHundredths : 0,
13493
13739
  workDate: str2(o.workDate),
13494
- workNote: str2(o.workNote)
13740
+ workNote: str2(o.workNote),
13741
+ assignedOwnerId: str2(o.assignedOwnerId),
13742
+ startDate: str2(o.startDate)
13495
13743
  });
13496
13744
  }
13497
13745
  return out;
13498
13746
  }
13499
13747
  var WORK_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
13748
+ var INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
13500
13749
  function buildVisitStatusWrite(accountId, row) {
13501
13750
  const base = {
13502
13751
  accountId,
@@ -13504,6 +13753,72 @@ function buildVisitStatusWrite(accountId, row) {
13504
13753
  status: row.status,
13505
13754
  statusAt: row.statusAt
13506
13755
  };
13756
+ if (row.assignedOwnerId !== "" || row.startDate !== "") {
13757
+ if (row.startDate !== "" && !INSTANT_RE.test(row.startDate)) return { kind: "bad-instant" };
13758
+ if (row.dayHundredths > 0) return { kind: "move-with-labour" };
13759
+ return {
13760
+ kind: "move",
13761
+ // EXACTLY ONE ATTENDANCE to replace, and EXACTLY ONE ENGINEER to replace
13762
+ // it with, or nothing runs at all.
13763
+ //
13764
+ // A visit two workers share cannot be re-assigned by replacing "the"
13765
+ // attendance: whichever went, the other worker would silently lose the
13766
+ // job and the arrival times they had already pressed. A per-worker move
13767
+ // is Task 2551. The engineer gate is the mirror: the `portalOwnerId`
13768
+ // binding is a property and is not unique by construction, so an id
13769
+ // naming several must not resolve to whichever came back first. Both turn
13770
+ // into the zero count this function already returns for a visit missing
13771
+ // its job, so the row stays unclaimed and counted.
13772
+ //
13773
+ // NO `CALL` SUBQUERY, deliberately. A returning subquery replaces each
13774
+ // outer row with the rows it yields, so one that yields nothing on a
13775
+ // status-only or re-time-only edit would eliminate the visit before the
13776
+ // SET ran: the re-timing would write nothing, return 0, and the row would
13777
+ // never drain. A unit subquery does not filter, but then an unresolvable
13778
+ // ownerId would leave the SET running and the row CLAIMED with nobody
13779
+ // moved. Both gates therefore sit in the main query's WHERE, where they
13780
+ // fail the whole statement, and the writes ride FOREACH over lists that
13781
+ // are empty when this edit re-assigns nobody.
13782
+ //
13783
+ // The re-assignment REPLACES rather than adds, because a drag onto
13784
+ // another worker's row means "move this", not "add a second person". The
13785
+ // attendance key is `engineerId + '-' + visitId`, the key
13786
+ // `dispatch-allocate` merges on, so allocating that worker to that visit
13787
+ // afterwards is a no-op rather than a second attendance — and the delete
13788
+ // skips an attendance already held by the incoming engineer, so
13789
+ // re-assigning somebody to the visit they are already on keeps the
13790
+ // arrival times they pressed instead of resetting them.
13791
+ cypher: `MATCH (v:Visit {accountId: $accountId, visitId: $visitId})
13792
+ OPTIONAL MATCH (v)-[:HAS_ATTENDANCE]->(a:Attendance)
13793
+ WITH v, collect(a) AS atts
13794
+ OPTIONAL MATCH (e:Engineer {accountId: $accountId})
13795
+ WHERE $assignedOwnerId <> ''
13796
+ AND e.portalOwnerId = $assignedOwnerId
13797
+ AND e.engineerId IS NOT NULL
13798
+ WITH v, atts, collect(e) AS es
13799
+ WHERE $assignedOwnerId = '' OR (size(atts) = 1 AND size(es) = 1)
13800
+ SET v.status = $status, v.statusAt = $statusAt
13801
+ FOREACH (_ IN CASE WHEN $startDate <> '' THEN [1] ELSE [] END |
13802
+ SET v.startDate = $startDate)
13803
+ FOREACH (old IN [x IN atts
13804
+ WHERE NOT x.engineerId IN [eng IN es | eng.engineerId]] |
13805
+ DETACH DELETE old)
13806
+ FOREACH (eng IN es |
13807
+ MERGE (a2:Attendance {
13808
+ accountId: $accountId,
13809
+ attendanceId: eng.engineerId + '-' + $visitId
13810
+ })
13811
+ ON CREATE SET a2.engineerId = eng.engineerId, a2.createdAt = $statusAt
13812
+ MERGE (v)-[:HAS_ATTENDANCE]->(a2)
13813
+ MERGE (a2)-[:BY_ENGINEER]->(eng))
13814
+ RETURN count(v) AS n`,
13815
+ params: {
13816
+ ...base,
13817
+ assignedOwnerId: row.assignedOwnerId,
13818
+ startDate: row.startDate
13819
+ }
13820
+ };
13821
+ }
13507
13822
  if (row.dayHundredths <= 0) {
13508
13823
  return {
13509
13824
  kind: "status",
@@ -13562,6 +13877,14 @@ async function pullVisitStatusForAccount(opts) {
13562
13877
  await claim(row.id);
13563
13878
  merged += 1;
13564
13879
  log2(`${TAG20} op=merged account=${accountId} id=${row.id} labour=${row.dayHundredths > 0}`);
13880
+ const fields = ["status"];
13881
+ if (row.workDate !== "") fields.push("workDate");
13882
+ if (row.workNote !== "") fields.push("workNote");
13883
+ if (row.assignedOwnerId !== "") fields.push("assignedOwnerId");
13884
+ if (row.startDate !== "") fields.push("startDate");
13885
+ for (const field2 of fields) {
13886
+ log2(`${TAG20} op=applied visitId=${row.visitId} field=${field2}`);
13887
+ }
13565
13888
  } catch (err) {
13566
13889
  failed2 += 1;
13567
13890
  const detail = err.message.split("\n")[0].replaceAll('"', "'").slice(0, 160);
@@ -13589,7 +13912,7 @@ async function runPortalVisitsPull(root) {
13589
13912
  const pending = parsePendingStatusRows(
13590
13913
  await cf.d1Exec(
13591
13914
  portal.portalDbName,
13592
- "SELECT id, visitId, ownerId, status, statusAt, dayHundredths, workDate, workNote FROM visit_status WHERE accountId = ? AND claimed = 0",
13915
+ "SELECT id, visitId, ownerId, status, statusAt, dayHundredths, workDate, workNote, assignedOwnerId, startDate FROM visit_status WHERE accountId = ? AND claimed = 0",
13593
13916
  [portal.accountId]
13594
13917
  )
13595
13918
  );
@@ -13598,7 +13921,9 @@ async function runPortalVisitsPull(root) {
13598
13921
  pending,
13599
13922
  applyToGraph: async (accountId, row) => {
13600
13923
  const write = buildVisitStatusWrite(accountId, row);
13601
- if (write.kind === "undated") return false;
13924
+ if (write.kind === "undated" || write.kind === "bad-instant" || write.kind === "move-with-labour") {
13925
+ return false;
13926
+ }
13602
13927
  const session = getSession();
13603
13928
  try {
13604
13929
  const res = await session.run(write.cypher, write.params);
@@ -13642,7 +13967,7 @@ function buildVisitsAuditLine(input) {
13642
13967
  input.d1Error !== null ? `d1:${auditErrorText(input.d1Error)}` : null,
13643
13968
  input.neo4jError !== null ? `neo4j:${auditErrorText(input.neo4jError)}` : null
13644
13969
  ].filter(Boolean).join(" | ");
13645
- return `${TAG21} account=${input.accountId} pushedRows=${na(input.pushedRows)} graphVisits=${na(input.graphVisits)} unboundVisits=${na(input.unboundVisits)} pendingStatusRows=${na(input.pendingStatusRows)} oldestPendingHrs=${na(input.oldestPendingHrs, 1)} unresolvedVisitIds=${na(input.unresolvedVisitIds)} labourClaims=${na(input.labourClaims)} labourCosts=${na(input.labourCosts)} undatedClaims=${na(input.undatedClaims)} lastPushAgeSec=${na(input.lastPushAgeSec)}` + (degradation ? ` degraded=${degradation} err="${reasons}"` : "");
13970
+ return `${TAG21} account=${input.accountId} pushedRows=${na(input.pushedRows)} graphVisits=${na(input.graphVisits)} unboundVisits=${na(input.unboundVisits)} pendingStatusRows=${na(input.pendingStatusRows)} oldestPendingHrs=${na(input.oldestPendingHrs, 1)} unresolvedVisitIds=${na(input.unresolvedVisitIds)} labourClaims=${na(input.labourClaims)} labourCosts=${na(input.labourCosts)} undatedClaims=${na(input.undatedClaims)} stalePendingRows=${na(input.stalePendingRows)} lastPushAgeSec=${na(input.lastPushAgeSec)}` + (degradation ? ` degraded=${degradation} err="${reasons}"` : "");
13646
13971
  }
13647
13972
  function readLastVisitsPushAgeSec(platformRoot5, accountId, nowMs) {
13648
13973
  try {
@@ -13655,7 +13980,7 @@ function readLastVisitsPushAgeSec(platformRoot5, accountId, nowMs) {
13655
13980
  return null;
13656
13981
  }
13657
13982
  }
13658
- var num2 = (raw, field2) => {
13983
+ var num3 = (raw, field2) => {
13659
13984
  const envelope = Array.isArray(raw) ? raw[0] : null;
13660
13985
  const results = envelope?.results;
13661
13986
  if (!Array.isArray(results)) {
@@ -13681,12 +14006,13 @@ async function runPortalVisitsAudit(root) {
13681
14006
  let unresolvedVisitIds = null;
13682
14007
  let labourClaims = null;
13683
14008
  let undatedClaims = null;
14009
+ let stalePendingRows = null;
13684
14010
  let d1Error = null;
13685
14011
  try {
13686
14012
  if (!SAFE_ACCOUNT_ID3.test(portal.accountId)) {
13687
14013
  throw new Error(`refusing to query for unsafe accountId "${portal.accountId}"`);
13688
14014
  }
13689
- pushedRows = num2(
14015
+ pushedRows = num3(
13690
14016
  await cf.d1Exec(
13691
14017
  portal.portalDbName,
13692
14018
  "SELECT COUNT(*) AS n FROM visits v JOIN visits_state s ON s.accountId = v.accountId AND s.currentGeneration = v.generation WHERE v.accountId = ?",
@@ -13694,7 +14020,7 @@ async function runPortalVisitsAudit(root) {
13694
14020
  ),
13695
14021
  "n"
13696
14022
  );
13697
- unboundVisits = num2(
14023
+ unboundVisits = num3(
13698
14024
  await cf.d1Exec(
13699
14025
  portal.portalDbName,
13700
14026
  "SELECT COUNT(*) AS n FROM visits v JOIN visits_state s ON s.accountId = v.accountId AND s.currentGeneration = v.generation WHERE v.accountId = ? AND v.ownerId = ''",
@@ -13707,12 +14033,12 @@ async function runPortalVisitsAudit(root) {
13707
14033
  "SELECT COUNT(*) AS n, MIN(statusAt) AS oldest FROM visit_status WHERE accountId = ? AND claimed = 0",
13708
14034
  [portal.accountId]
13709
14035
  );
13710
- pendingStatusRows = num2(pending, "n");
14036
+ pendingStatusRows = num3(pending, "n");
13711
14037
  const envelope = Array.isArray(pending) ? pending[0] : null;
13712
14038
  const oldest = envelope?.results?.[0]?.oldest;
13713
14039
  const oldestMs = typeof oldest === "string" ? Date.parse(oldest) : NaN;
13714
14040
  oldestPendingHrs = Number.isNaN(oldestMs) ? pendingStatusRows === 0 ? 0 : null : Math.max(0, (nowMs - oldestMs) / 36e5);
13715
- unresolvedVisitIds = num2(
14041
+ unresolvedVisitIds = num3(
13716
14042
  await cf.d1Exec(
13717
14043
  portal.portalDbName,
13718
14044
  "SELECT COUNT(*) AS n FROM visit_status vs WHERE vs.accountId = ? AND vs.claimed = 0 AND NOT EXISTS (SELECT 1 FROM visits v JOIN visits_state s ON s.accountId = v.accountId AND s.currentGeneration = v.generation WHERE v.accountId = vs.accountId AND v.visitId = vs.visitId)",
@@ -13720,7 +14046,7 @@ async function runPortalVisitsAudit(root) {
13720
14046
  ),
13721
14047
  "n"
13722
14048
  );
13723
- labourClaims = num2(
14049
+ labourClaims = num3(
13724
14050
  await cf.d1Exec(
13725
14051
  portal.portalDbName,
13726
14052
  "SELECT COUNT(*) AS n FROM visit_status WHERE accountId = ? AND claimed = 0 AND dayHundredths > 0",
@@ -13728,7 +14054,7 @@ async function runPortalVisitsAudit(root) {
13728
14054
  ),
13729
14055
  "n"
13730
14056
  );
13731
- undatedClaims = num2(
14057
+ undatedClaims = num3(
13732
14058
  await cf.d1Exec(
13733
14059
  portal.portalDbName,
13734
14060
  "SELECT COUNT(*) AS n FROM visit_status WHERE accountId = ? AND claimed = 0 AND dayHundredths > 0 AND workDate NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'",
@@ -13736,6 +14062,15 @@ async function runPortalVisitsAudit(root) {
13736
14062
  ),
13737
14063
  "n"
13738
14064
  );
14065
+ const staleCutoff = new Date(nowMs - 2 * PORTAL_VISITS_PULL_INTERVAL_MS).toISOString();
14066
+ stalePendingRows = num3(
14067
+ await cf.d1Exec(
14068
+ portal.portalDbName,
14069
+ "SELECT COUNT(*) AS n FROM visit_status WHERE accountId = ? AND claimed = 0 AND statusAt < ?",
14070
+ [portal.accountId, staleCutoff]
14071
+ ),
14072
+ "n"
14073
+ );
13739
14074
  } catch (err) {
13740
14075
  d1Error = err.message;
13741
14076
  }
@@ -13774,6 +14109,7 @@ async function runPortalVisitsAudit(root) {
13774
14109
  labourClaims,
13775
14110
  labourCosts,
13776
14111
  undatedClaims,
14112
+ stalePendingRows,
13777
14113
  lastPushAgeSec: readLastVisitsPushAgeSec(root, portal.accountId, nowMs),
13778
14114
  d1Error,
13779
14115
  neo4jError
@@ -30168,7 +30504,7 @@ app55.get("/", requireAdminSession, async (c) => {
30168
30504
  }
30169
30505
  });
30170
30506
  function mapRun(props) {
30171
- const num4 = (v) => {
30507
+ const num5 = (v) => {
30172
30508
  if (v === null || v === void 0) return null;
30173
30509
  if (typeof v === "number") return v;
30174
30510
  if (typeof v === "object" && typeof v.toNumber === "function") {
@@ -30188,11 +30524,11 @@ function mapRun(props) {
30188
30524
  startedAt: props.startedAt,
30189
30525
  endedAt: props.endedAt ?? null,
30190
30526
  finishedAt: props.finishedAt ?? null,
30191
- ranMs: num4(props.ranMs),
30192
- httpStatus: num4(props.httpStatus),
30527
+ ranMs: num5(props.ranMs),
30528
+ httpStatus: num5(props.httpStatus),
30193
30529
  dispatchResult: props.dispatchResult ?? null,
30194
30530
  sessionKey: props.sessionKey ?? null,
30195
- transcriptOffset: num4(props.transcriptOffset),
30531
+ transcriptOffset: num5(props.transcriptOffset),
30196
30532
  // Strict identity: an absent or null `bounded` means the boundary was never
30197
30533
  // established, which is the false case.
30198
30534
  bounded: props.bounded === true,
@@ -33931,7 +34267,7 @@ function formatConfigAuditLine(a, accountId) {
33931
34267
  function formatRecordCensus(f, accountId) {
33932
34268
  return `${RECORD_CENSUS_TAG}${accountId ? ` account=${accountId}` : ""} closedVisits=${f.closedVisits} withNote=${f.withNote} withMedia=${f.withMedia} medianRecordLagS=${f.medianRecordLagS} p90RecordLagS=${f.p90RecordLagS}`;
33933
34269
  }
33934
- function num3(v) {
34270
+ function num4(v) {
33935
34271
  if (typeof v === "number") return v;
33936
34272
  if (v && typeof v === "object" && "low" in v) {
33937
34273
  return Number(v.low);
@@ -34027,15 +34363,15 @@ async function readCensusCounts(session, accountId, terminal, now) {
34027
34363
  { accountId, canonical: `^${CANONICAL_INSTANT_PATTERN}$` }
34028
34364
  );
34029
34365
  return {
34030
- jobs: num3(g?.get("jobs")),
34031
- unallocated: num3(g?.get("unallocated")),
34366
+ jobs: num4(g?.get("jobs")),
34367
+ unallocated: num4(g?.get("unallocated")),
34032
34368
  oldestUnallocatedAgeH,
34033
- overdue: num3(g?.get("overdue")),
34034
- visitsOpen: num3(vg?.get("visitsOpen")),
34035
- visitsOpenOver24h: num3(vg?.get("visitsOpenOver24h")),
34036
- intakeUnreviewed: num3(dg?.get("intakeUnreviewed")),
34369
+ overdue: num4(g?.get("overdue")),
34370
+ visitsOpen: num4(vg?.get("visitsOpen")),
34371
+ visitsOpenOver24h: num4(vg?.get("visitsOpenOver24h")),
34372
+ intakeUnreviewed: num4(dg?.get("intakeUnreviewed")),
34037
34373
  oldestIntakeAgeH,
34038
- nonCanonicalInstants: num3(nc.records[0]?.get("nonCanonicalInstants"))
34374
+ nonCanonicalInstants: num4(nc.records[0]?.get("nonCanonicalInstants"))
34039
34375
  };
34040
34376
  }
34041
34377
  async function readRecordCensus(session, accountId, terminal, now) {
@@ -34053,8 +34389,8 @@ async function readRecordCensus(session, accountId, terminal, now) {
34053
34389
  let withMedia = 0;
34054
34390
  const lags = [];
34055
34391
  for (const g of r.records) {
34056
- if (num3(g.get("notes")) > 0) withNote++;
34057
- if (num3(g.get("media")) > 0) withMedia++;
34392
+ if (num4(g.get("notes")) > 0) withNote++;
34393
+ if (num4(g.get("media")) > 0) withMedia++;
34058
34394
  const closedAt = g.get("closedAt");
34059
34395
  const dueToLeaveAt = g.get("dueToLeaveAt");
34060
34396
  if (typeof closedAt === "string" && typeof dueToLeaveAt === "string") {
@@ -35489,15 +35825,15 @@ function createWaChannelRoutes(deps) {
35489
35825
  }
35490
35826
  const interactive = body?.interactive === true;
35491
35827
  const finalText = typeof body?.finalText === "string" ? body.finalText : "";
35492
- const num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
35493
- const finalBytes = num4(body?.finalBytes);
35494
- const queueDepth = num4(body?.queueDepth);
35495
- const pendingTurns = num4(body?.pendingTurns);
35496
- const consumed2 = num4(body?.consumed);
35828
+ const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
35829
+ const finalBytes = num5(body?.finalBytes);
35830
+ const queueDepth = num5(body?.queueDepth);
35831
+ const pendingTurns = num5(body?.pendingTurns);
35832
+ const consumed2 = num5(body?.consumed);
35497
35833
  const source = typeof body?.source === "string" ? body.source : null;
35498
35834
  const sid = sessionId.slice(0, 8);
35499
35835
  console.error(
35500
- `[whatsapp-native] op=turn-context sessionId=${sid} cacheReadTokens=${num4(body?.cacheReadTokens)} inputTokens=${num4(body?.inputTokens)} outputTokens=${num4(body?.outputTokens)} thinkingBlocks=${num4(body?.thinkingBlocks)} thinkingChars=${num4(body?.thinkingChars)}`
35836
+ `[whatsapp-native] op=turn-context sessionId=${sid} cacheReadTokens=${num5(body?.cacheReadTokens)} inputTokens=${num5(body?.inputTokens)} outputTokens=${num5(body?.outputTokens)} thinkingBlocks=${num5(body?.thinkingBlocks)} thinkingChars=${num5(body?.thinkingChars)}`
35501
35837
  );
35502
35838
  const role = body?.role;
35503
35839
  if (role !== "admin" && role !== "public") {
@@ -36459,12 +36795,12 @@ function createWebchatChannelRoutes(deps) {
36459
36795
  const finalBytes = typeof body?.finalBytes === "number" && Number.isFinite(body.finalBytes) ? body.finalBytes : 0;
36460
36796
  const k = key2.slice(0, 8);
36461
36797
  const sid = sessionId.slice(0, 8);
36462
- const num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
36798
+ const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
36463
36799
  console.error(
36464
- `[webchat-native] op=turn-context sessionId=${sid} cacheReadTokens=${num4(body?.cacheReadTokens)} inputTokens=${num4(body?.inputTokens)} outputTokens=${num4(body?.outputTokens)} thinkingBlocks=${num4(body?.thinkingBlocks)} thinkingChars=${num4(body?.thinkingChars)}`
36800
+ `[webchat-native] op=turn-context sessionId=${sid} cacheReadTokens=${num5(body?.cacheReadTokens)} inputTokens=${num5(body?.inputTokens)} outputTokens=${num5(body?.outputTokens)} thinkingBlocks=${num5(body?.thinkingBlocks)} thinkingChars=${num5(body?.thinkingChars)}`
36465
36801
  );
36466
- const segmentStart = num4(body?.segmentStart);
36467
- const segmentEnd = num4(body?.segmentEnd);
36802
+ const segmentStart = num5(body?.segmentStart);
36803
+ const segmentEnd = num5(body?.segmentEnd);
36468
36804
  const resolved = deps.onTurnEnd?.(key2, role, { sessionId, segmentStart, segmentEnd }) ?? "none";
36469
36805
  const resolvedField = role === "public" ? ` resolved=${resolved}` : "";
36470
36806
  console.error(`[webchat-native] op=turn-end channel=webchat key=${k} role=${role} sessionId=${sid} trailing=${finalBytes}${resolvedField}`);
@@ -37821,38 +38157,38 @@ function followerCensusTick(deps, state = makeCensusState()) {
37821
38157
  const bound = deps.bound();
37822
38158
  const live = new Set(deps.live());
37823
38159
  for (const senderId of live) state.attempts.delete(senderId);
37824
- const missing = bound.filter((b) => !live.has(b.senderId));
38160
+ const missing = bound.filter((senderId) => !live.has(senderId));
37825
38161
  console.error(
37826
- `[channel-follower] op=follower-census bound=${bound.length} attached=${live.size} missing=${missing.length} missingSenders=${missing.map((m) => id82(m.senderId)).join(",") || "none"}`
38162
+ `[channel-follower] op=follower-census channel=${deps.channel} bound=${bound.length} attached=${live.size} missing=${missing.length} missingSenders=${missing.map(id82).join(",") || "none"}`
37827
38163
  );
37828
- for (const m of missing) {
37829
- const used = state.attempts.get(m.senderId) ?? 0;
38164
+ for (const senderId of missing) {
38165
+ const used = state.attempts.get(senderId) ?? 0;
37830
38166
  if (used >= CENSUS_MAX_REATTEMPTS) {
37831
38167
  console.error(
37832
- `[channel-follower] op=follower-reattach sessionId=${id82(deps.paramsFor(m.senderId)?.sessionId ?? "none")} senderId=${id82(m.senderId)} outcome=skipped reason=reattach-exhausted attempts=${used}`
38168
+ `[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=${id82(deps.paramsFor(senderId)?.sessionId ?? "none")} senderId=${id82(senderId)} outcome=skipped reason=reattach-exhausted attempts=${used}`
37833
38169
  );
37834
38170
  continue;
37835
38171
  }
37836
- const p = deps.paramsFor(m.senderId);
38172
+ const p = deps.paramsFor(senderId);
37837
38173
  if (!p) {
37838
38174
  console.error(
37839
- `[channel-follower] op=follower-reattach sessionId=none senderId=${id82(m.senderId)} outcome=failed reason=no-attach-params`
38175
+ `[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=none senderId=${id82(senderId)} outcome=failed reason=no-attach-params`
37840
38176
  );
37841
38177
  continue;
37842
38178
  }
37843
38179
  try {
37844
- state.attempts.set(m.senderId, used + 1);
38180
+ state.attempts.set(senderId, used + 1);
37845
38181
  deps.attach(p);
37846
38182
  console.error(
37847
- `[channel-follower] op=follower-reattach sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=attached reason=none`
38183
+ `[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=attached reason=none`
37848
38184
  );
37849
38185
  } catch (err) {
37850
38186
  console.error(
37851
- `[channel-follower] op=follower-reattach sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=failed reason=${err instanceof Error ? err.message : String(err)}`
38187
+ `[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=failed reason=${err instanceof Error ? err.message : String(err)}`
37852
38188
  );
37853
38189
  }
37854
38190
  }
37855
- return `${bound.length} door(s), ${missing.length} without a reader`;
38191
+ return `${deps.channel}: ${bound.length} door(s), ${missing.length} without a reader`;
37856
38192
  }
37857
38193
 
37858
38194
  // app/lib/telegram/gateway/inbound-hub.ts
@@ -38008,7 +38344,8 @@ function createTelegramChannelRoutes(deps) {
38008
38344
  if (typeof key2 !== "string" || typeof messageId !== "string") {
38009
38345
  return c.json({ error: "key and messageId required" }, 400);
38010
38346
  }
38011
- deps.onReceived?.(key2, messageId);
38347
+ const provenance = typeof body?.provenance === "boolean" ? body.provenance : void 0;
38348
+ deps.onReceived?.(key2, messageId, provenance);
38012
38349
  return c.json({ ok: true });
38013
38350
  });
38014
38351
  app81.post("/tg-channel/turn-end", async (c) => {
@@ -38022,9 +38359,9 @@ function createTelegramChannelRoutes(deps) {
38022
38359
  const interactive = body?.interactive === true;
38023
38360
  const finalText = typeof body?.finalText === "string" ? body.finalText : "";
38024
38361
  const sid = sessionId.slice(0, 8);
38025
- const num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
38362
+ const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
38026
38363
  console.error(
38027
- `[telegram-native] op=turn-context sessionId=${sid} cacheReadTokens=${num4(body?.cacheReadTokens)} inputTokens=${num4(body?.inputTokens)} outputTokens=${num4(body?.outputTokens)} thinkingBlocks=${num4(body?.thinkingBlocks)} thinkingChars=${num4(body?.thinkingChars)}`
38364
+ `[telegram-native] op=turn-context sessionId=${sid} cacheReadTokens=${num5(body?.cacheReadTokens)} inputTokens=${num5(body?.inputTokens)} outputTokens=${num5(body?.outputTokens)} thinkingBlocks=${num5(body?.thinkingBlocks)} thinkingChars=${num5(body?.thinkingChars)}`
38028
38365
  );
38029
38366
  const role = body?.role;
38030
38367
  if (role !== "admin" && role !== "public") {
@@ -38142,6 +38479,16 @@ var TelegramGateway = class {
38142
38479
  }
38143
38480
  hub = new InboundHub3();
38144
38481
  replies = /* @__PURE__ */ new Map();
38482
+ /** Task 2559 — the keys whose last inbound was `role:'admin'`. The follower
38483
+ * census's bound set is the doors that SHOULD have a reader, and a public
38484
+ * Telegram session is given none by design: the public branch of
38485
+ * `buildTelegramEnsureSession` returns before the attach block, because the
38486
+ * file follower is an admin surface. Including a public key would report it
38487
+ * missing on every tick for ever and stop `missing=` ever reaching zero,
38488
+ * which is the one signal the census exists to give. Removed on a public
38489
+ * inbound, because the role is read from account config on every inbound and
38490
+ * a bot can be flipped between the two. */
38491
+ adminKeys = /* @__PURE__ */ new Set();
38145
38492
  spawning = /* @__PURE__ */ new Set();
38146
38493
  /** Task 2428 — keys whose channel server signalled ready while their spawn was
38147
38494
  * still in flight. The harness discards a channel notification that arrives
@@ -38169,7 +38516,7 @@ var TelegramGateway = class {
38169
38516
  }
38170
38517
  this.drain(key2);
38171
38518
  },
38172
- onReceived: (key2, messageId) => {
38519
+ onReceived: (key2, messageId, provenance) => {
38173
38520
  const acked = this.hub.ack(key2, messageId);
38174
38521
  if (acked !== null && (acked.source ?? "user") === "user") {
38175
38522
  noteReceived(key2, parseTelegramChannelKey(key2)?.botId ?? "unknown", messageId, Date.now());
@@ -38177,6 +38524,10 @@ var TelegramGateway = class {
38177
38524
  console.error(
38178
38525
  `[telegram-native] op=channel-received key=${key2} messageId=${messageId}${acked !== null ? "" : " note=unknown"}`
38179
38526
  );
38527
+ const prov = provenance === void 0 ? "unknown" : provenance ? "yes" : "no";
38528
+ console.error(
38529
+ `[telegram:reframe] op=compose key=${key2} provenance=${prov} source=${acked?.source ?? "unknown"}`
38530
+ );
38180
38531
  }
38181
38532
  });
38182
38533
  }
@@ -38249,6 +38600,8 @@ var TelegramGateway = class {
38249
38600
  const willDeliverNow = this.hub.isReady(key2);
38250
38601
  const source = input.source ?? "user";
38251
38602
  this.replies.set(key2, input.reply);
38603
+ if (input.role === "admin") this.adminKeys.add(key2);
38604
+ else this.adminKeys.delete(key2);
38252
38605
  let standingRules;
38253
38606
  if (input.role === "admin" && this.deps.fetchStandingRules) {
38254
38607
  const res = await this.deps.fetchStandingRules(input.accountId);
@@ -38260,7 +38613,21 @@ var TelegramGateway = class {
38260
38613
  );
38261
38614
  }
38262
38615
  this.hub.deliver(
38263
- { key: key2, text: input.text, media: input.media, messageId: `tg-${++this.seq}`, source, standingRules, scheduleProvenance: input.scheduleProvenance },
38616
+ {
38617
+ key: key2,
38618
+ text: input.text,
38619
+ media: input.media,
38620
+ messageId: `tg-${++this.seq}`,
38621
+ source,
38622
+ // Task 2561 — passed explicitly rather than parsed out of `key`:
38623
+ // accountId is not in the key, and the channel service imports nothing
38624
+ // from app/lib. Without them the composer cannot name who is speaking,
38625
+ // and the turn opens straight into rules written about the owner.
38626
+ senderId: input.senderId,
38627
+ accountId: input.accountId,
38628
+ standingRules,
38629
+ scheduleProvenance: input.scheduleProvenance
38630
+ },
38264
38631
  Date.now()
38265
38632
  );
38266
38633
  const resolvedSessionId = this.sessionIdFor(input);
@@ -38300,6 +38667,18 @@ var TelegramGateway = class {
38300
38667
  liveReplyTarget(key2) {
38301
38668
  return this.replies.get(key2) ?? null;
38302
38669
  }
38670
+ /** Task 2559 — every admin door this gateway currently holds a live delivery
38671
+ * closure for, as the `<botId>:<senderId>` channel key the follower registry
38672
+ * keys on. The twin of `WaGateway.boundTargets()`: the census reconciles
38673
+ * against exactly this set, so "should have a reader" and "can be sent to"
38674
+ * are one fact and cannot drift. A key `parseTelegramChannelKey` rejects is
38675
+ * the pre-2368 bare-sender shape, skipped rather than halved at an arbitrary
38676
+ * offset, which would yield a door id nothing keys on. */
38677
+ boundTargets() {
38678
+ return [...this.replies.keys()].filter(
38679
+ (key2) => this.adminKeys.has(key2) && parseTelegramChannelKey(key2) !== null
38680
+ );
38681
+ }
38303
38682
  async sendOutbound(key2, text) {
38304
38683
  const reply = this.replies.get(key2);
38305
38684
  if (!reply) throw new Error(`telegram-gateway: no reply route for key ${key2}`);
@@ -38394,24 +38773,13 @@ function buildTelegramEnsureSession(deps) {
38394
38773
  `${TAG62} op=spawn botId=${input.botId} sessionId=${req.sessionId} targetAccountId=${input.accountId} role=admin`
38395
38774
  );
38396
38775
  noteSpawnOk2(input.botId);
38397
- deps.followers.get(req.channelKey)?.abort();
38398
- let ac;
38399
- ac = deps.startFileFollower({
38776
+ deps.followers.attach({
38400
38777
  sessionId: req.sessionId,
38401
38778
  senderId: req.channelKey,
38402
38779
  accountId: input.accountId,
38403
38780
  chatId: input.chatId,
38404
- botToken: input.botToken,
38405
- // Task 2521 — resolved per answer, not captured once, so a door that goes
38406
- // live after this session started still receives.
38407
- fanTargets: () => deps.fanTargets(req.channelKey),
38408
- // Identity-guarded eviction: only delete if this controller is still the
38409
- // current one, so a stale self-close never removes a live follower.
38410
- onClose: () => {
38411
- if (deps.followers.get(req.channelKey) === ac) deps.followers.delete(req.channelKey);
38412
- }
38781
+ botToken: input.botToken
38413
38782
  });
38414
- deps.followers.set(req.channelKey, ac);
38415
38783
  };
38416
38784
  }
38417
38785
 
@@ -39736,20 +40104,6 @@ var waGateway = new WaGateway({
39736
40104
  });
39737
40105
  }
39738
40106
  });
39739
- var followerCensusState = makeCensusState();
39740
- registerLoop({
39741
- name: "channel-follower",
39742
- intervalMs: 6e4,
39743
- run: () => followerCensusTick(
39744
- {
39745
- bound: () => waGateway.boundTargets(),
39746
- live: () => nativeFileFollowers.live(),
39747
- paramsFor: (senderId) => nativeFileFollowers.paramsFor(senderId),
39748
- attach: (p) => nativeFileFollowers.attach(p)
39749
- },
39750
- followerCensusState
39751
- )
39752
- });
39753
40107
  app80.route("/", waGateway.routes());
39754
40108
  waGateway.startSweeper();
39755
40109
  registerLoop({
@@ -39917,7 +40271,6 @@ setWebchatGateway(webchatGateway);
39917
40271
  app80.route("/", webchatGateway.routes());
39918
40272
  webchatGateway.startSweeper();
39919
40273
  webchatGateway.startPublicReaper();
39920
- var telegramFileFollowers = /* @__PURE__ */ new Map();
39921
40274
  var telegramFanTargets = (key2) => {
39922
40275
  const account = listValidAccounts().find(
39923
40276
  (a) => listBotEntries(a.config.telegram).some((e) => e.id === key2.slice(0, key2.indexOf(":")))
@@ -39934,6 +40287,19 @@ var telegramFanTargets = (key2) => {
39934
40287
  deps: adminFanDeps("telegram", account.accountId)
39935
40288
  });
39936
40289
  };
40290
+ var telegramFileFollowers = createFollowerRegistry(
40291
+ (p, onClose) => startTelegramNativeFileFollower({
40292
+ sessionId: p.sessionId,
40293
+ senderId: p.senderId,
40294
+ accountId: p.accountId,
40295
+ chatId: p.chatId,
40296
+ botToken: p.botToken,
40297
+ // Task 2521 — resolved per answer, not captured once, so a door that goes
40298
+ // live after this session started still receives.
40299
+ fanTargets: () => telegramFanTargets(p.senderId),
40300
+ onClose
40301
+ })
40302
+ );
39937
40303
  var telegramGateway = new TelegramGateway({
39938
40304
  fetchStandingRules: fetchAccountStandingRules,
39939
40305
  // Task 2353 — no turn-instruction resolver on this door. See the dep comment in
@@ -39954,14 +40320,39 @@ var telegramGateway = new TelegramGateway({
39954
40320
  managerRcSpawn,
39955
40321
  managerSpawn,
39956
40322
  resolveSessionOverride: (accountId, channelKey) => resolveChannelOverride(accountId, "telegram", channelKey),
39957
- startFileFollower: startTelegramNativeFileFollower,
39958
- fanTargets: telegramFanTargets,
39959
40323
  followers: telegramFileFollowers
39960
40324
  })
39961
40325
  });
39962
40326
  setTelegramGateway(telegramGateway);
39963
40327
  app80.route("/", telegramGateway.routes());
39964
40328
  telegramGateway.startSweeper();
40329
+ var followerCensusState = { whatsapp: makeCensusState(), telegram: makeCensusState() };
40330
+ registerLoop({
40331
+ name: "channel-follower",
40332
+ intervalMs: 6e4,
40333
+ run: () => [
40334
+ followerCensusTick(
40335
+ {
40336
+ channel: "whatsapp",
40337
+ bound: () => waGateway.boundTargets().map((t) => t.senderId),
40338
+ live: () => nativeFileFollowers.live(),
40339
+ paramsFor: (senderId) => nativeFileFollowers.paramsFor(senderId),
40340
+ attach: (p) => nativeFileFollowers.attach(p)
40341
+ },
40342
+ followerCensusState.whatsapp
40343
+ ),
40344
+ followerCensusTick(
40345
+ {
40346
+ channel: "telegram",
40347
+ bound: () => telegramGateway.boundTargets(),
40348
+ live: () => telegramFileFollowers.live(),
40349
+ paramsFor: (senderId) => telegramFileFollowers.paramsFor(senderId),
40350
+ attach: (p) => telegramFileFollowers.attach(p)
40351
+ },
40352
+ followerCensusState.telegram
40353
+ )
40354
+ ].join("; ")
40355
+ });
39965
40356
  var chatRoutes = createChatRoutes({
39966
40357
  handleInbound: (input) => webchatGateway.handleInbound(input),
39967
40358
  awaitReply: (key2, timeoutMs) => webchatGateway.awaitReply(key2, timeoutMs)