@rubytech/create-realagent-code 0.1.575 → 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 (56) hide show
  1. package/dist/__tests__/preserve-house-cloudflare.test.js +2 -72
  2. package/dist/index.js +8 -59
  3. package/dist/preserve-house-cloudflare.js +2 -49
  4. package/package.json +1 -1
  5. package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/active-rules/dist/index.js +32 -1
  7. package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
  8. package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
  9. package/payload/platform/lib/active-rules/src/index.ts +36 -2
  10. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
  11. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
  12. package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
  13. package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
  14. package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
  15. package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
  16. package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
  17. package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
  18. package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
  19. package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
  20. package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
  21. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
  22. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
  23. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
  24. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
  30. package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
  31. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
  32. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
  33. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
  34. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
  35. package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
  36. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
  37. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
  38. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
  39. package/payload/platform/plugins/docs/references/internals.md +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +5 -0
  41. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/http-server.js +29 -0
  43. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  44. package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
  45. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  46. package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
  47. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  48. package/payload/platform/services/telegram-channel/dist/server.js +1 -1
  49. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  50. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  51. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  52. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  53. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
  54. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  55. package/payload/server/server.js +670 -155
  56. package/dist/__tests__/converge-client-admins.test.js +0 -50
@@ -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;
@@ -4259,9 +4301,9 @@ var require_dist5 = __commonJS({
4259
4301
  try {
4260
4302
  await onEmpty(c);
4261
4303
  } catch (err) {
4262
- const id82 = c.elementId.slice(0, 8);
4304
+ const id83 = c.elementId.slice(0, 8);
4263
4305
  const message = err instanceof Error ? err.message : String(err);
4264
- process.stderr.write(`[trash:empty-run] reason=onEmpty-failed elementId=${id82} labels=${c.labels.join(",")} message=${message}
4306
+ process.stderr.write(`[trash:empty-run] reason=onEmpty-failed elementId=${id83} labels=${c.labels.join(",")} message=${message}
4265
4307
  `);
4266
4308
  continue;
4267
4309
  }
@@ -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
@@ -21386,7 +21722,7 @@ init_neo4j_store();
21386
21722
  async function purgeAdminConversationJsonls(args) {
21387
21723
  const fetchImpl = args.fetchImpl ?? fetch;
21388
21724
  const log2 = args.logger ?? ((line) => console.log(line));
21389
- const id82 = args.sessionId.slice(0, 8);
21725
+ const id83 = args.sessionId.slice(0, 8);
21390
21726
  const purged = [];
21391
21727
  for (const sessionId of args.claudeSessionIds) {
21392
21728
  const url = `${args.managerBase}/${encodeURIComponent(sessionId)}`;
@@ -21396,7 +21732,7 @@ async function purgeAdminConversationJsonls(args) {
21396
21732
  } catch (err) {
21397
21733
  const message2 = err instanceof Error ? err.message : String(err);
21398
21734
  log2(
21399
- `[admin-conversation-delete] sessionId=${id82} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message2}`
21735
+ `[admin-conversation-delete] sessionId=${id83} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message2}`
21400
21736
  );
21401
21737
  return {
21402
21738
  ok: false,
@@ -21413,7 +21749,7 @@ async function purgeAdminConversationJsonls(args) {
21413
21749
  }
21414
21750
  if (res.status === 409) {
21415
21751
  log2(
21416
- `[admin-conversation-delete] sessionId=${id82} reason=pty-still-alive claudeSessionId=${sessionId.slice(0, 8)}`
21752
+ `[admin-conversation-delete] sessionId=${id83} reason=pty-still-alive claudeSessionId=${sessionId.slice(0, 8)}`
21417
21753
  );
21418
21754
  return {
21419
21755
  ok: false,
@@ -21430,7 +21766,7 @@ async function purgeAdminConversationJsonls(args) {
21430
21766
  } catch {
21431
21767
  }
21432
21768
  log2(
21433
- `[admin-conversation-delete] sessionId=${id82} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message}`
21769
+ `[admin-conversation-delete] sessionId=${id83} reason=jsonl-purge-failed claudeSessionId=${sessionId.slice(0, 8)} message=${message}`
21434
21770
  );
21435
21771
  return {
21436
21772
  ok: false,
@@ -21449,13 +21785,13 @@ async function cascadeAdminConversationDelete(args) {
21449
21785
  const started2 = Date.now();
21450
21786
  const fetchImpl = args.fetchImpl ?? fetch;
21451
21787
  const log2 = args.logger ?? ((line) => console.log(line));
21452
- const id82 = args.sessionId.slice(0, 8);
21788
+ const id83 = args.sessionId.slice(0, 8);
21453
21789
  let claudeSessionIds;
21454
21790
  try {
21455
21791
  claudeSessionIds = args.claudeSessionIds ?? await getConversationClaudeSessionIds(args.sessionId, args.accountId);
21456
21792
  } catch (err) {
21457
21793
  const message = err instanceof Error ? err.message : String(err);
21458
- log2(`[admin-conversation-delete] sessionId=${id82} reason=graph-delete-failed claudeSessionIds-purged= message=transcript-resolve:${message}`);
21794
+ log2(`[admin-conversation-delete] sessionId=${id83} reason=graph-delete-failed claudeSessionIds-purged= message=transcript-resolve:${message}`);
21459
21795
  return { ok: false, reason: "graph-delete-failed", claudeSessionIdsPurged: [], message };
21460
21796
  }
21461
21797
  const purgeOutcome = await purgeAdminConversationJsonls({
@@ -21486,7 +21822,7 @@ async function cascadeAdminConversationDelete(args) {
21486
21822
  } catch (err) {
21487
21823
  const message = err instanceof Error ? err.message : String(err);
21488
21824
  const csv3 = purged.map((s) => s.slice(0, 8)).join(",");
21489
- log2(`[admin-conversation-delete] sessionId=${id82} reason=graph-delete-failed claudeSessionIds-purged=${csv3} message=${message}`);
21825
+ log2(`[admin-conversation-delete] sessionId=${id83} reason=graph-delete-failed claudeSessionIds-purged=${csv3} message=${message}`);
21490
21826
  return {
21491
21827
  ok: false,
21492
21828
  reason: "graph-delete-failed",
@@ -21499,7 +21835,7 @@ async function cascadeAdminConversationDelete(args) {
21499
21835
  }
21500
21836
  const ms = Date.now() - started2;
21501
21837
  const csv2 = purged.map((s) => s.slice(0, 8)).join(",");
21502
- log2(`[admin-conversation-delete] sessionId=${id82} claudeSessionIds=${csv2} ms=${ms}`);
21838
+ log2(`[admin-conversation-delete] sessionId=${id83} claudeSessionIds=${csv2} ms=${ms}`);
21503
21839
  return { ok: true, claudeSessionIds: purged, ms };
21504
21840
  }
21505
21841
 
@@ -29716,21 +30052,21 @@ app52.get("/booking-link", requireAdminSession, (c) => {
29716
30052
  console.error("[calendar] op=booking-link auth-rejected reason=no-account-for-session accountId=none");
29717
30053
  return c.json({ error: "no account for session", reason: "no-account-for-session" }, 401);
29718
30054
  }
29719
- const id82 = account.accountId.slice(0, 8);
30055
+ const id83 = account.accountId.slice(0, 8);
29720
30056
  try {
29721
30057
  if (!existsSync41(join50(account.accountDir, AVAILABILITY_FILENAME))) {
29722
- console.log(`[calendar] op=booking-link accountId=${id82} bookingDomain=none source=availability-config`);
30058
+ console.log(`[calendar] op=booking-link accountId=${id83} bookingDomain=none source=availability-config`);
29723
30059
  return c.json({ bookingDomain: null });
29724
30060
  }
29725
30061
  const config = readAvailabilityConfig(account.accountDir);
29726
30062
  const bookingDomain = typeof config.bookingDomain === "string" && config.bookingDomain.trim() ? config.bookingDomain.trim() : null;
29727
30063
  console.log(
29728
- `[calendar] op=booking-link accountId=${id82} bookingDomain=${bookingDomain ?? "none"} source=availability-config`
30064
+ `[calendar] op=booking-link accountId=${id83} bookingDomain=${bookingDomain ?? "none"} source=availability-config`
29729
30065
  );
29730
30066
  return c.json({ bookingDomain });
29731
30067
  } catch (err) {
29732
30068
  const message = err instanceof Error ? err.message : String(err);
29733
- console.error(`[calendar] op=booking-link-fail accountId=${id82} err="${message}"`);
30069
+ console.error(`[calendar] op=booking-link-fail accountId=${id83} err="${message}"`);
29734
30070
  return c.json({ bookingDomain: null });
29735
30071
  }
29736
30072
  });
@@ -29750,20 +30086,20 @@ app53.get("/portal-link", requireAdminSession, (c) => {
29750
30086
  console.error("[data-portal] op=portal-link auth-rejected reason=no-account-for-session accountId=none");
29751
30087
  return c.json({ error: "no account for session", reason: "no-account-for-session" }, 401);
29752
30088
  }
29753
- const id82 = account.accountId.slice(0, 8);
30089
+ const id83 = account.accountId.slice(0, 8);
29754
30090
  try {
29755
30091
  const path = join51(account.accountDir, PORTAL_FILENAME);
29756
30092
  if (!existsSync42(path)) {
29757
- console.log(`[data-portal] op=portal-link accountId=${id82} portalDomain=none source=data-portal-config`);
30093
+ console.log(`[data-portal] op=portal-link accountId=${id83} portalDomain=none source=data-portal-config`);
29758
30094
  return c.json({ portalDomain: null });
29759
30095
  }
29760
30096
  const cfg = JSON.parse(readFileSync42(path, "utf-8"));
29761
30097
  const portalDomain = typeof cfg.portalDomain === "string" && cfg.portalDomain.trim() ? cfg.portalDomain.trim() : null;
29762
- console.log(`[data-portal] op=portal-link accountId=${id82} portalDomain=${portalDomain ?? "none"} source=data-portal-config`);
30098
+ console.log(`[data-portal] op=portal-link accountId=${id83} portalDomain=${portalDomain ?? "none"} source=data-portal-config`);
29763
30099
  return c.json({ portalDomain });
29764
30100
  } catch (err) {
29765
30101
  const message = err instanceof Error ? err.message : String(err);
29766
- console.error(`[data-portal] op=portal-link-fail accountId=${id82} err="${message}"`);
30102
+ console.error(`[data-portal] op=portal-link-fail accountId=${id83} err="${message}"`);
29767
30103
  return c.json({ portalDomain: null });
29768
30104
  }
29769
30105
  });
@@ -29785,13 +30121,13 @@ app54.get("/planner-link", requireAdminSession, (c) => {
29785
30121
  );
29786
30122
  return c.json({ error: "no account for session", reason: "no-account-for-session" }, 401);
29787
30123
  }
29788
- const id82 = account.accountId.slice(0, 8);
30124
+ const id83 = account.accountId.slice(0, 8);
29789
30125
  try {
29790
30126
  const portalPath = join52(account.accountDir, PORTAL_FILENAME2);
29791
30127
  const dispatchConfigured = existsSync43(join52(account.accountDir, "dispatch", "config.json"));
29792
30128
  if (!existsSync43(portalPath) || !dispatchConfigured) {
29793
30129
  console.log(
29794
- `[dispatch-planner] op=planner-link accountId=${id82} plannerUrl=none portal=${existsSync43(portalPath)} dispatch=${dispatchConfigured}`
30130
+ `[dispatch-planner] op=planner-link accountId=${id83} plannerUrl=none portal=${existsSync43(portalPath)} dispatch=${dispatchConfigured}`
29795
30131
  );
29796
30132
  return c.json({ plannerUrl: null });
29797
30133
  }
@@ -29799,12 +30135,12 @@ app54.get("/planner-link", requireAdminSession, (c) => {
29799
30135
  const domain = typeof cfg.portalDomain === "string" && cfg.portalDomain.trim() ? cfg.portalDomain.trim() : null;
29800
30136
  const plannerUrl = domain ? `https://${domain}/planner` : null;
29801
30137
  console.log(
29802
- `[dispatch-planner] op=planner-link accountId=${id82} plannerUrl=${plannerUrl ?? "none"}`
30138
+ `[dispatch-planner] op=planner-link accountId=${id83} plannerUrl=${plannerUrl ?? "none"}`
29803
30139
  );
29804
30140
  return c.json({ plannerUrl });
29805
30141
  } catch (err) {
29806
30142
  const message = err instanceof Error ? err.message : String(err);
29807
- console.error(`[dispatch-planner] op=planner-link-fail accountId=${id82} err="${message}"`);
30143
+ console.error(`[dispatch-planner] op=planner-link-fail accountId=${id83} err="${message}"`);
29808
30144
  return c.json({ plannerUrl: null });
29809
30145
  }
29810
30146
  });
@@ -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,
@@ -30385,7 +30721,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30385
30721
  if (!accountId) return c.json({ error: "Account not found for session" }, 401);
30386
30722
  const eventId = c.req.param("eventId");
30387
30723
  const body = await c.req.json().catch(() => ({}));
30388
- const id82 = eventId.slice(0, 8);
30724
+ const id83 = eventId.slice(0, 8);
30389
30725
  const setClauses = [];
30390
30726
  const params = { eventId, accountId };
30391
30727
  const fields = [];
@@ -30397,11 +30733,11 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30397
30733
  const clearing = bindingTouched && body.agentChannel === null && body.agentDestination === null;
30398
30734
  const binding = bindingTouched && typeof body.agentChannel === "string" && body.agentChannel.length > 0 && typeof body.agentDestination === "string" && body.agentDestination.length > 0;
30399
30735
  if (bindingTouched && !clearing && !binding) {
30400
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=partial-binding`);
30736
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=partial-binding`);
30401
30737
  return c.json({ error: "A dispatch binding needs both a channel and a destination, or both cleared." }, 400);
30402
30738
  }
30403
30739
  if (binding && !DISPATCH_CHANNELS2.has(body.agentChannel)) {
30404
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=invalid-channel channel=${body.agentChannel}`);
30740
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=invalid-channel channel=${body.agentChannel}`);
30405
30741
  return c.json({ error: `Unknown dispatch channel "${body.agentChannel}".` }, 400);
30406
30742
  }
30407
30743
  const describing = "description" in body;
@@ -30430,7 +30766,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30430
30766
  storedDescription = pre.records[0]?.get("description") ?? null;
30431
30767
  } catch (err) {
30432
30768
  const message = err instanceof Error ? err.message : String(err);
30433
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=tz-read-fail err="${message}"`);
30769
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=tz-read-fail err="${message}"`);
30434
30770
  return c.json({ error: `Routine unavailable: ${message}` }, 503);
30435
30771
  } finally {
30436
30772
  await preSession.close();
@@ -30438,7 +30774,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30438
30774
  }
30439
30775
  if (binding) {
30440
30776
  if (storedActionPlugin !== null) {
30441
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=action-routine`);
30777
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=action-routine`);
30442
30778
  return c.json(
30443
30779
  { error: "This routine runs a tool. Binding a dispatch would stop the tool running, so it is refused; make a separate routine for the dispatch." },
30444
30780
  400
@@ -30446,7 +30782,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30446
30782
  }
30447
30783
  const effectivePrompt = typeof body.agentPrompt === "string" ? body.agentPrompt : storedPrompt ?? "";
30448
30784
  if (effectivePrompt.trim().length === 0) {
30449
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=no-prompt`);
30785
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=no-prompt`);
30450
30786
  return c.json({ error: "A bound routine needs an instruction, or it will fire and do nothing." }, 400);
30451
30787
  }
30452
30788
  const channel = body.agentChannel;
@@ -30456,12 +30792,12 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30456
30792
  houseAccountId = channel === "whatsapp" ? resolveHouseOrSoleAccountId(ACCOUNTS_DIR) : accountId;
30457
30793
  } catch (err) {
30458
30794
  const message = err instanceof Error ? err.message : String(err);
30459
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=house-unresolved err="${message}"`);
30795
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=house-unresolved err="${message}"`);
30460
30796
  return c.json({ error: `WhatsApp destinations cannot be validated on this install: ${message}` }, 503);
30461
30797
  }
30462
30798
  const check = validateAgentDestination(ACCOUNTS_DIR, houseAccountId, accountId, channel, destination);
30463
30799
  if (!check.valid) {
30464
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=${check.reason}`);
30800
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=${check.reason}`);
30465
30801
  return c.json(
30466
30802
  {
30467
30803
  error: check.reason === "cross-account" ? `That destination belongs to a different account on ${channel}.` : `That destination is not a registered admin or account manager on ${channel}.`
@@ -30478,7 +30814,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30478
30814
  if (clearing) {
30479
30815
  const keepsRow = typeof body.recurrence === "string" || storedRecurrence !== null || storedActionPlugin !== null;
30480
30816
  if (!keepsRow) {
30481
- console.error(`[admin:routines] op=patch id=${id82} fields=agentChannel+agentDestination outcome=rejected reason=clear-would-orphan`);
30817
+ console.error(`[admin:routines] op=patch id=${id83} fields=agentChannel+agentDestination outcome=rejected reason=clear-would-orphan`);
30482
30818
  return c.json(
30483
30819
  { error: "Clearing the dispatch on a one-time routine would leave nothing to show. Give it a schedule first, or delete it." },
30484
30820
  400
@@ -30498,12 +30834,12 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30498
30834
  let nextName = null;
30499
30835
  if (naming) {
30500
30836
  if (typeof body.name !== "string" || body.name.trim().length === 0) {
30501
- console.error(`[admin:routines] op=patch id=${id82} fields=name outcome=rejected reason=empty-name`);
30837
+ console.error(`[admin:routines] op=patch id=${id83} fields=name outcome=rejected reason=empty-name`);
30502
30838
  return c.json({ error: "A routine needs a name." }, 400);
30503
30839
  }
30504
30840
  nextName = body.name.trim();
30505
30841
  if (nextName.length > 200) {
30506
- console.error(`[admin:routines] op=patch id=${id82} fields=name outcome=rejected reason=too-long len=${nextName.length}`);
30842
+ console.error(`[admin:routines] op=patch id=${id83} fields=name outcome=rejected reason=too-long len=${nextName.length}`);
30507
30843
  return c.json({ error: "A routine name is at most 200 characters." }, 400);
30508
30844
  }
30509
30845
  setClauses.push("e.name = $name");
@@ -30516,7 +30852,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30516
30852
  if (describing) {
30517
30853
  const raw = body.description;
30518
30854
  if (typeof raw !== "string" && raw !== null) {
30519
- console.error(`[admin:routines] op=patch id=${id82} fields=description outcome=rejected reason=not-a-string`);
30855
+ console.error(`[admin:routines] op=patch id=${id83} fields=description outcome=rejected reason=not-a-string`);
30520
30856
  return c.json({ error: "A description must be text, or null to clear it." }, 400);
30521
30857
  }
30522
30858
  nextDescription = typeof raw === "string" && raw.trim().length > 0 ? raw : null;
@@ -30537,7 +30873,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30537
30873
  params.embedding = embedding;
30538
30874
  } else {
30539
30875
  embedLog = " embed=failed";
30540
- console.error(`[admin:routines] op=patch id=${id82} fields=${fields.join("+")} embed=failed`);
30876
+ console.error(`[admin:routines] op=patch id=${id83} fields=${fields.join("+")} embed=failed`);
30541
30877
  }
30542
30878
  }
30543
30879
  if (typeof body.recurrence === "string") {
@@ -30551,7 +30887,7 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30551
30887
  nextRunLog = nextRun;
30552
30888
  } catch (err) {
30553
30889
  const reason = err instanceof Error ? err.message : String(err);
30554
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=invalid-cron cron=${body.recurrence}`);
30890
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=invalid-cron cron=${body.recurrence}`);
30555
30891
  return c.json({ error: `Invalid recurrence: ${reason}` }, 400);
30556
30892
  }
30557
30893
  }
@@ -30569,15 +30905,15 @@ app55.patch("/:eventId", requireAdminSession, async (c) => {
30569
30905
  params
30570
30906
  );
30571
30907
  if (res.records.length === 0) {
30572
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=not-found`);
30908
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=not-found`);
30573
30909
  return c.json({ error: "Routine not found" }, 404);
30574
30910
  }
30575
30911
  const nameLog = nextName === null ? "" : ` name=len:${nextName.length} operatorEdited=stamped`;
30576
- console.log(`[admin:routines] op=patch id=${id82} fields=${fields.join("+")} outcome=ok nextRun=${nextRunLog} cron=${params.recurrence ?? "unchanged"}${bindingLog}${nameLog}${embedLog}`);
30912
+ console.log(`[admin:routines] op=patch id=${id83} fields=${fields.join("+")} outcome=ok nextRun=${nextRunLog} cron=${params.recurrence ?? "unchanged"}${bindingLog}${nameLog}${embedLog}`);
30577
30913
  return c.json({ routine: mapRoutine(res.records[0]) });
30578
30914
  } catch (err) {
30579
30915
  const message = err instanceof Error ? err.message : String(err);
30580
- console.error(`[admin:routines] op=patch id=${id82} outcome=rejected reason=${message}`);
30916
+ console.error(`[admin:routines] op=patch id=${id83} outcome=rejected reason=${message}`);
30581
30917
  return c.json({ error: `Update failed: ${message}` }, 503);
30582
30918
  } finally {
30583
30919
  await session.close();
@@ -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") {
@@ -34975,8 +35311,18 @@ async function migrateSidecarAccountIds(opts = {}) {
34975
35311
  init_src2();
34976
35312
 
34977
35313
  // app/lib/whatsapp/gateway/inbound-hub.ts
35314
+ var HUB_SEPARATOR = "\0";
34978
35315
  function hubKey(accountId, senderId) {
34979
- return `${accountId}\0${senderId}`;
35316
+ return `${accountId}${HUB_SEPARATOR}${senderId}`;
35317
+ }
35318
+ function decodeHubKeys(keys) {
35319
+ const out = [];
35320
+ for (const key2 of keys) {
35321
+ const i = key2.indexOf(HUB_SEPARATOR);
35322
+ if (i < 0) continue;
35323
+ out.push({ accountId: key2.slice(0, i), senderId: key2.slice(i + HUB_SEPARATOR.length) });
35324
+ }
35325
+ return out;
34980
35326
  }
34981
35327
  var InboundHub = class {
34982
35328
  senders = /* @__PURE__ */ new Map();
@@ -35479,15 +35825,15 @@ function createWaChannelRoutes(deps) {
35479
35825
  }
35480
35826
  const interactive = body?.interactive === true;
35481
35827
  const finalText = typeof body?.finalText === "string" ? body.finalText : "";
35482
- const num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
35483
- const finalBytes = num4(body?.finalBytes);
35484
- const queueDepth = num4(body?.queueDepth);
35485
- const pendingTurns = num4(body?.pendingTurns);
35486
- 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);
35487
35833
  const source = typeof body?.source === "string" ? body.source : null;
35488
35834
  const sid = sessionId.slice(0, 8);
35489
35835
  console.error(
35490
- `[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)}`
35491
35837
  );
35492
35838
  const role = body?.role;
35493
35839
  if (role !== "admin" && role !== "public") {
@@ -35968,6 +36314,14 @@ var WaGateway = class {
35968
36314
  liveReplyTarget(accountId, senderId) {
35969
36315
  return this.replies.get(hubKey(accountId, normalizeTarget(senderId) ?? senderId)) ?? null;
35970
36316
  }
36317
+ /** Task 2557 — every (account, sender) this gateway currently holds a live
36318
+ * delivery closure for. The follower census reconciles against exactly this
36319
+ * set, so "should have a reader" and "can be sent to" are one fact and
36320
+ * cannot drift. Deriving the set from the follower registry instead would
36321
+ * ask the map that already lost the entry. */
36322
+ boundTargets() {
36323
+ return decodeHubKeys(this.replies.keys());
36324
+ }
35971
36325
  async sendOutbound(accountId, senderId, text) {
35972
36326
  this.outboundSends++;
35973
36327
  const reply = this.replies.get(hubKey(accountId, senderId));
@@ -36441,12 +36795,12 @@ function createWebchatChannelRoutes(deps) {
36441
36795
  const finalBytes = typeof body?.finalBytes === "number" && Number.isFinite(body.finalBytes) ? body.finalBytes : 0;
36442
36796
  const k = key2.slice(0, 8);
36443
36797
  const sid = sessionId.slice(0, 8);
36444
- const num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
36798
+ const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
36445
36799
  console.error(
36446
- `[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)}`
36447
36801
  );
36448
- const segmentStart = num4(body?.segmentStart);
36449
- const segmentEnd = num4(body?.segmentEnd);
36802
+ const segmentStart = num5(body?.segmentStart);
36803
+ const segmentEnd = num5(body?.segmentEnd);
36450
36804
  const resolved = deps.onTurnEnd?.(key2, role, { sessionId, segmentStart, segmentEnd }) ?? "none";
36451
36805
  const resolvedField = role === "public" ? ` resolved=${resolved}` : "";
36452
36806
  console.error(`[webchat-native] op=turn-end channel=webchat key=${k} role=${role} sessionId=${sid} trailing=${finalBytes}${resolvedField}`);
@@ -37188,7 +37542,10 @@ function startEmitter(opts) {
37188
37542
  const abort2 = new AbortController();
37189
37543
  const { entry: entry2, tag } = opts;
37190
37544
  const answers = new AnswerAccumulator();
37545
+ let answersEmitted = 0;
37546
+ let closeReason = "stream-end";
37191
37547
  const emitAnswer = async (answer) => {
37548
+ answersEmitted += 1;
37192
37549
  if (answer.text.trim()) await fanOut2(entry2.subscribers, answer.text, opts.onError, tag);
37193
37550
  if (!opts.onAnswer) return;
37194
37551
  const guarded = { ...answer, text: guardOutbound(entry2.senderId, answer.text) };
@@ -37361,9 +37718,14 @@ function startEmitter(opts) {
37361
37718
  for (const answer of answers.flush()) await emitAnswer(answer);
37362
37719
  } catch (err) {
37363
37720
  if (!abort2.signal.aborted) {
37721
+ closeReason = "error";
37364
37722
  opts.onError?.(`emitter-error: ${err instanceof Error ? err.message : String(err)}`);
37365
37723
  }
37366
37724
  } finally {
37725
+ if (abort2.signal.aborted) closeReason = "aborted";
37726
+ console.error(
37727
+ `${tag} op=follower-closed sessionId=${entry2.sessionId.slice(0, 8)} reason=${closeReason} answers=${answersEmitted}`
37728
+ );
37367
37729
  opts.onClose();
37368
37730
  }
37369
37731
  })();
@@ -37564,6 +37926,12 @@ function startWebchatNativeFileFollower(input) {
37564
37926
  return startEmitter({
37565
37927
  entry: entry2,
37566
37928
  tag: "[webchat-adaptor]",
37929
+ // Task 2557 — the emitter has always had this channel and no follower ever
37930
+ // passed one, so every cause it reported was discarded. The close line
37931
+ // carries the outcome; this carries the cause.
37932
+ onError: (reason) => {
37933
+ console.error(`[webchat-adaptor] op=follower-error sessionId=${input.sessionId.slice(0, 8)} reason=${reason}`);
37934
+ },
37567
37935
  fileDelivery: makeWebchatFileDelivery(entry2),
37568
37936
  // A resumed session's JSONL already holds prior SendUserFile tool_uses;
37569
37937
  // suppress replay so historical calls are not re-reconciled on attach.
@@ -37728,6 +38096,14 @@ function startNativeFileFollower(input) {
37728
38096
  return startEmitter({
37729
38097
  entry: entry2,
37730
38098
  tag: "[whatsapp-adaptor]",
38099
+ // Task 2557 — the emitter has always had this channel and no follower ever
38100
+ // passed one, so every cause it reported (`follow-status-404`,
38101
+ // `emitter-error: …`, `subscriber-error: …`) was discarded. The close line
38102
+ // carries the outcome; this carries the cause, and it can fire without the
38103
+ // stream ending, so neither substitutes for the other.
38104
+ onError: (reason) => {
38105
+ console.error(`[whatsapp-adaptor] op=follower-error sessionId=${input.sessionId.slice(0, 8)} reason=${reason}`);
38106
+ },
37731
38107
  // Task 2521 — admin-only. A public spawn has no tools (Task 2078), so a
37732
38108
  // handler here would reconcile a call that can never fire.
37733
38109
  fileDelivery: input.role === "admin" ? makeWhatsAppFileDelivery(entry2, input.maxyAccountId) : null,
@@ -37752,6 +38128,69 @@ function startNativeFileFollower(input) {
37752
38128
  });
37753
38129
  }
37754
38130
 
38131
+ // app/lib/channel-delivery/follower-registry.ts
38132
+ function createFollowerRegistry(start) {
38133
+ const controllers = /* @__PURE__ */ new Map();
38134
+ const params = /* @__PURE__ */ new Map();
38135
+ return {
38136
+ attach(p) {
38137
+ controllers.get(p.senderId)?.abort();
38138
+ params.set(p.senderId, p);
38139
+ let ac;
38140
+ ac = start(p, () => {
38141
+ if (controllers.get(p.senderId) === ac) controllers.delete(p.senderId);
38142
+ });
38143
+ controllers.set(p.senderId, ac);
38144
+ },
38145
+ live: () => [...controllers.keys()],
38146
+ paramsFor: (senderId) => params.get(senderId) ?? null
38147
+ };
38148
+ }
38149
+
38150
+ // app/lib/channel-delivery/follower-census.ts
38151
+ var CENSUS_MAX_REATTEMPTS = 3;
38152
+ function makeCensusState() {
38153
+ return { attempts: /* @__PURE__ */ new Map() };
38154
+ }
38155
+ var id82 = (s) => s.slice(0, 8);
38156
+ function followerCensusTick(deps, state = makeCensusState()) {
38157
+ const bound = deps.bound();
38158
+ const live = new Set(deps.live());
38159
+ for (const senderId of live) state.attempts.delete(senderId);
38160
+ const missing = bound.filter((senderId) => !live.has(senderId));
38161
+ console.error(
38162
+ `[channel-follower] op=follower-census channel=${deps.channel} bound=${bound.length} attached=${live.size} missing=${missing.length} missingSenders=${missing.map(id82).join(",") || "none"}`
38163
+ );
38164
+ for (const senderId of missing) {
38165
+ const used = state.attempts.get(senderId) ?? 0;
38166
+ if (used >= CENSUS_MAX_REATTEMPTS) {
38167
+ console.error(
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}`
38169
+ );
38170
+ continue;
38171
+ }
38172
+ const p = deps.paramsFor(senderId);
38173
+ if (!p) {
38174
+ console.error(
38175
+ `[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=none senderId=${id82(senderId)} outcome=failed reason=no-attach-params`
38176
+ );
38177
+ continue;
38178
+ }
38179
+ try {
38180
+ state.attempts.set(senderId, used + 1);
38181
+ deps.attach(p);
38182
+ console.error(
38183
+ `[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=attached reason=none`
38184
+ );
38185
+ } catch (err) {
38186
+ console.error(
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)}`
38188
+ );
38189
+ }
38190
+ }
38191
+ return `${deps.channel}: ${bound.length} door(s), ${missing.length} without a reader`;
38192
+ }
38193
+
37755
38194
  // app/lib/telegram/gateway/inbound-hub.ts
37756
38195
  var InboundHub3 = class {
37757
38196
  keys = /* @__PURE__ */ new Map();
@@ -37905,7 +38344,8 @@ function createTelegramChannelRoutes(deps) {
37905
38344
  if (typeof key2 !== "string" || typeof messageId !== "string") {
37906
38345
  return c.json({ error: "key and messageId required" }, 400);
37907
38346
  }
37908
- deps.onReceived?.(key2, messageId);
38347
+ const provenance = typeof body?.provenance === "boolean" ? body.provenance : void 0;
38348
+ deps.onReceived?.(key2, messageId, provenance);
37909
38349
  return c.json({ ok: true });
37910
38350
  });
37911
38351
  app81.post("/tg-channel/turn-end", async (c) => {
@@ -37919,9 +38359,9 @@ function createTelegramChannelRoutes(deps) {
37919
38359
  const interactive = body?.interactive === true;
37920
38360
  const finalText = typeof body?.finalText === "string" ? body.finalText : "";
37921
38361
  const sid = sessionId.slice(0, 8);
37922
- const num4 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
38362
+ const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
37923
38363
  console.error(
37924
- `[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)}`
37925
38365
  );
37926
38366
  const role = body?.role;
37927
38367
  if (role !== "admin" && role !== "public") {
@@ -38039,6 +38479,16 @@ var TelegramGateway = class {
38039
38479
  }
38040
38480
  hub = new InboundHub3();
38041
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();
38042
38492
  spawning = /* @__PURE__ */ new Set();
38043
38493
  /** Task 2428 — keys whose channel server signalled ready while their spawn was
38044
38494
  * still in flight. The harness discards a channel notification that arrives
@@ -38066,7 +38516,7 @@ var TelegramGateway = class {
38066
38516
  }
38067
38517
  this.drain(key2);
38068
38518
  },
38069
- onReceived: (key2, messageId) => {
38519
+ onReceived: (key2, messageId, provenance) => {
38070
38520
  const acked = this.hub.ack(key2, messageId);
38071
38521
  if (acked !== null && (acked.source ?? "user") === "user") {
38072
38522
  noteReceived(key2, parseTelegramChannelKey(key2)?.botId ?? "unknown", messageId, Date.now());
@@ -38074,6 +38524,10 @@ var TelegramGateway = class {
38074
38524
  console.error(
38075
38525
  `[telegram-native] op=channel-received key=${key2} messageId=${messageId}${acked !== null ? "" : " note=unknown"}`
38076
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
+ );
38077
38531
  }
38078
38532
  });
38079
38533
  }
@@ -38146,6 +38600,8 @@ var TelegramGateway = class {
38146
38600
  const willDeliverNow = this.hub.isReady(key2);
38147
38601
  const source = input.source ?? "user";
38148
38602
  this.replies.set(key2, input.reply);
38603
+ if (input.role === "admin") this.adminKeys.add(key2);
38604
+ else this.adminKeys.delete(key2);
38149
38605
  let standingRules;
38150
38606
  if (input.role === "admin" && this.deps.fetchStandingRules) {
38151
38607
  const res = await this.deps.fetchStandingRules(input.accountId);
@@ -38157,7 +38613,21 @@ var TelegramGateway = class {
38157
38613
  );
38158
38614
  }
38159
38615
  this.hub.deliver(
38160
- { 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
+ },
38161
38631
  Date.now()
38162
38632
  );
38163
38633
  const resolvedSessionId = this.sessionIdFor(input);
@@ -38197,6 +38667,18 @@ var TelegramGateway = class {
38197
38667
  liveReplyTarget(key2) {
38198
38668
  return this.replies.get(key2) ?? null;
38199
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
+ }
38200
38682
  async sendOutbound(key2, text) {
38201
38683
  const reply = this.replies.get(key2);
38202
38684
  if (!reply) throw new Error(`telegram-gateway: no reply route for key ${key2}`);
@@ -38291,24 +38773,13 @@ function buildTelegramEnsureSession(deps) {
38291
38773
  `${TAG62} op=spawn botId=${input.botId} sessionId=${req.sessionId} targetAccountId=${input.accountId} role=admin`
38292
38774
  );
38293
38775
  noteSpawnOk2(input.botId);
38294
- deps.followers.get(req.channelKey)?.abort();
38295
- let ac;
38296
- ac = deps.startFileFollower({
38776
+ deps.followers.attach({
38297
38777
  sessionId: req.sessionId,
38298
38778
  senderId: req.channelKey,
38299
38779
  accountId: input.accountId,
38300
38780
  chatId: input.chatId,
38301
- botToken: input.botToken,
38302
- // Task 2521 — resolved per answer, not captured once, so a door that goes
38303
- // live after this session started still receives.
38304
- fanTargets: () => deps.fanTargets(req.channelKey),
38305
- // Identity-guarded eviction: only delete if this controller is still the
38306
- // current one, so a stale self-close never removes a live follower.
38307
- onClose: () => {
38308
- if (deps.followers.get(req.channelKey) === ac) deps.followers.delete(req.channelKey);
38309
- }
38781
+ botToken: input.botToken
38310
38782
  });
38311
- deps.followers.set(req.channelKey, ac);
38312
38783
  };
38313
38784
  }
38314
38785
 
@@ -38449,6 +38920,12 @@ function startTelegramNativeFileFollower(input) {
38449
38920
  return startEmitter({
38450
38921
  entry: entry2,
38451
38922
  tag: "[telegram-adaptor]",
38923
+ // Task 2557 — the emitter has always had this channel and no follower ever
38924
+ // passed one, so every cause it reported was discarded. The close line
38925
+ // carries the outcome; this carries the cause.
38926
+ onError: (reason) => {
38927
+ console.error(`[telegram-adaptor] op=follower-error sessionId=${input.sessionId.slice(0, 8)} reason=${reason}`);
38928
+ },
38452
38929
  fileDelivery: makeTelegramFileDelivery(entry2, input.botToken),
38453
38930
  // A resumed session's JSONL already holds prior SendUserFile tool_uses;
38454
38931
  // suppress replay so historical files are not re-sent on attach.
@@ -39367,7 +39844,27 @@ function resolveChannelOverride(accountId, channel, senderId) {
39367
39844
  return null;
39368
39845
  }
39369
39846
  }
39370
- var nativeFileFollowers = /* @__PURE__ */ new Map();
39847
+ var nativeFileFollowers = createFollowerRegistry(
39848
+ (p, onClose) => startNativeFileFollower({
39849
+ sessionId: p.sessionId,
39850
+ senderId: p.senderId,
39851
+ // Task 1390 — the file-path validation scope is the sender's effective
39852
+ // session account (the sub-account for an account-manager), not the house
39853
+ // socket account. `accountId` stays the getSocket key.
39854
+ accountId: p.accountId,
39855
+ maxyAccountId: p.effectiveAccountId,
39856
+ // Task 2521 — file delivery is admin-only; the text emitter is not.
39857
+ role: p.role,
39858
+ // Task 2521 — resolved per answer, not captured once, so a door that goes
39859
+ // live after this session started still receives.
39860
+ // Task 2531 — `effectiveAccountId`, NOT `accountId`. The reply map is keyed
39861
+ // on the platform UUID (wa-gateway.ts:567) while `accountId` here is the
39862
+ // Baileys credential dirname. Passing the dirname missed every lookup and
39863
+ // delivered session 8e5bc4e8's answer to nothing.
39864
+ fanTargets: () => whatsappFanTargets(p.effectiveAccountId, p.senderId),
39865
+ onClose
39866
+ })
39867
+ );
39371
39868
  var webchatFileFollowers = /* @__PURE__ */ new Map();
39372
39869
  async function fetchAccountStandingRules(accountId) {
39373
39870
  const session = getSession();
@@ -39598,32 +40095,13 @@ var waGateway = new WaGateway({
39598
40095
  console.error(`[whatsapp-native] spawn-failed senderId=${senderId} role=${role} error=${result.error} status=${result.status}`);
39599
40096
  return;
39600
40097
  }
39601
- const prior = nativeFileFollowers.get(senderId);
39602
- if (prior) prior.abort();
39603
- let ac;
39604
- ac = startNativeFileFollower({
40098
+ nativeFileFollowers.attach({
39605
40099
  sessionId: req.sessionId,
39606
40100
  senderId,
39607
40101
  accountId,
39608
- // Task 1390 — the file-path validation scope is the sender's effective
39609
- // session account (the sub-account for an account-manager), not the house
39610
- // socket account. accountId stays the getSocket key.
39611
- maxyAccountId: effectiveAccountId,
39612
- // Task 2521 — file delivery is admin-only; the text emitter is not.
39613
- role,
39614
- // Task 2521 — resolved per answer, not captured once, so a door that goes
39615
- // live after this session started still receives.
39616
- // Task 2531 — `effectiveAccountId`, NOT `accountId`. The reply map is
39617
- // keyed on the platform UUID (wa-gateway.ts:567) while `accountId` here is
39618
- // the Baileys credential dirname (see the Task 1850 note above). Passing
39619
- // the dirname missed every lookup and delivered session 8e5bc4e8's answer
39620
- // to nothing. `accountId` stays correct on line 718: that is the socket.
39621
- fanTargets: () => whatsappFanTargets(effectiveAccountId, senderId),
39622
- onClose: () => {
39623
- if (nativeFileFollowers.get(senderId) === ac) nativeFileFollowers.delete(senderId);
39624
- }
40102
+ effectiveAccountId,
40103
+ role
39625
40104
  });
39626
- nativeFileFollowers.set(senderId, ac);
39627
40105
  }
39628
40106
  });
39629
40107
  app80.route("/", waGateway.routes());
@@ -39793,7 +40271,6 @@ setWebchatGateway(webchatGateway);
39793
40271
  app80.route("/", webchatGateway.routes());
39794
40272
  webchatGateway.startSweeper();
39795
40273
  webchatGateway.startPublicReaper();
39796
- var telegramFileFollowers = /* @__PURE__ */ new Map();
39797
40274
  var telegramFanTargets = (key2) => {
39798
40275
  const account = listValidAccounts().find(
39799
40276
  (a) => listBotEntries(a.config.telegram).some((e) => e.id === key2.slice(0, key2.indexOf(":")))
@@ -39810,6 +40287,19 @@ var telegramFanTargets = (key2) => {
39810
40287
  deps: adminFanDeps("telegram", account.accountId)
39811
40288
  });
39812
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
+ );
39813
40303
  var telegramGateway = new TelegramGateway({
39814
40304
  fetchStandingRules: fetchAccountStandingRules,
39815
40305
  // Task 2353 — no turn-instruction resolver on this door. See the dep comment in
@@ -39830,14 +40320,39 @@ var telegramGateway = new TelegramGateway({
39830
40320
  managerRcSpawn,
39831
40321
  managerSpawn,
39832
40322
  resolveSessionOverride: (accountId, channelKey) => resolveChannelOverride(accountId, "telegram", channelKey),
39833
- startFileFollower: startTelegramNativeFileFollower,
39834
- fanTargets: telegramFanTargets,
39835
40323
  followers: telegramFileFollowers
39836
40324
  })
39837
40325
  });
39838
40326
  setTelegramGateway(telegramGateway);
39839
40327
  app80.route("/", telegramGateway.routes());
39840
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
+ });
39841
40356
  var chatRoutes = createChatRoutes({
39842
40357
  handleInbound: (input) => webchatGateway.handleInbound(input),
39843
40358
  awaitReply: (key2, timeoutMs) => webchatGateway.awaitReply(key2, timeoutMs)