@rubytech/create-realagent-code 0.1.576 → 0.1.577
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/active-rules/dist/index.js +32 -1
- package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
- package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
- package/payload/platform/lib/active-rules/src/index.ts +36 -2
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
- package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
- package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
- package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
- package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +2 -2
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
- package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
- package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
- package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
- package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/server/server.js +508 -117
package/payload/server/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 :
|
|
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:
|
|
3338
|
-
media:
|
|
3339
|
-
parts:
|
|
3340
|
-
checklistDone:
|
|
3341
|
-
checklistTotal:
|
|
3342
|
-
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 :
|
|
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 :
|
|
3521
|
-
longitude: lon == null ? null :
|
|
3522
|
-
geofenceRadiusM: rad == null ? null :
|
|
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:
|
|
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 :
|
|
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:
|
|
3753
|
-
unallocated:
|
|
3754
|
-
overdue:
|
|
3787
|
+
total: num5(g?.get("total")),
|
|
3788
|
+
unallocated: num5(g?.get("unallocated")),
|
|
3789
|
+
overdue: num5(g?.get("overdue"))
|
|
3755
3790
|
};
|
|
3756
3791
|
});
|
|
3757
3792
|
}
|
|
@@ -3792,7 +3827,7 @@ var require_dist3 = __commonJS({
|
|
|
3792
3827
|
"../lib/dispatch-read/dist/index.js"(exports) {
|
|
3793
3828
|
"use strict";
|
|
3794
3829
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3795
|
-
exports.readWorkerDay = exports.readJobList = exports.readDay = exports.readCounts = exports.nonZuluStarts = exports.geofenceVerdict = exports.distanceM = exports.readVisitsInWindow = exports.readVisitsForJob = exports.readVisitRecordState = exports.readVisit = exports.readSiteLocation = exports.readSiteHistory = exports.readPreviousVisits = exports.readOpenVisitsForWorker = exports.readNodeId = exports.readJobs = exports.readJob = exports.readFieldVisit = exports.readDrafts = exports.readDraft = exports.readCommitments = exports.projectVisit = exports.projectJob = exports.projectFieldVisit = exports.buildJobListQuery = exports.zonedRangeBounds = exports.zonedDayStartUtc = exports.ymdInZone = exports.minutesIntoDay = exports.daysBetween = exports.currentWeekYmd = exports.addDaysYmd = exports.MAX_RANGE_DAYS = exports.resolveAccountZone = exports.withSession = exports.parseDispatchCallback = exports.dispatchCallbackData = exports.buildVisitKeyboard = exports.CALLBACK_DATA_MAX_BYTES = exports.workerForTelegramUser = exports.terminalVisitStatuses = exports.terminalJobStatuses = exports.loadDispatchConfig = exports.RECORDABLE_FIELDS = void 0;
|
|
3830
|
+
exports.readWorkerDay = exports.readJobList = exports.readDay = exports.readCounts = exports.nonZuluStarts = exports.geofenceVerdict = exports.distanceM = exports.readVisitsInWindow = exports.readVisitsForJob = exports.readVisitRecordState = exports.readVisit = exports.readSiteLocation = exports.readSiteHistory = exports.readPreviousVisits = exports.readOpenVisitsForWorker = exports.readNodeId = exports.readJobs = exports.readJob = exports.readFieldVisit = exports.readDrafts = exports.readDraft = exports.readCommitments = exports.projectVisit = exports.projectJob = exports.projectFieldVisit = exports.buildJobListQuery = exports.zonedRangeBounds = exports.zonedDayStartUtc = exports.ymdInZone = exports.minutesIntoDay = exports.daysBetween = exports.currentWeekYmd = exports.addDaysYmd = exports.MAX_RANGE_DAYS = exports.resolveAccountZone = exports.withSession = exports.selectCandidates = exports.overlaps = exports.parseDispatchCallback = exports.dispatchCallbackData = exports.buildVisitKeyboard = exports.CALLBACK_DATA_MAX_BYTES = exports.workerForTelegramUser = exports.terminalVisitStatuses = exports.terminalJobStatuses = exports.loadDispatchConfig = exports.RECORDABLE_FIELDS = void 0;
|
|
3796
3831
|
var config_js_1 = require_config();
|
|
3797
3832
|
Object.defineProperty(exports, "RECORDABLE_FIELDS", { enumerable: true, get: function() {
|
|
3798
3833
|
return config_js_1.RECORDABLE_FIELDS;
|
|
@@ -3822,6 +3857,13 @@ var require_dist3 = __commonJS({
|
|
|
3822
3857
|
Object.defineProperty(exports, "parseDispatchCallback", { enumerable: true, get: function() {
|
|
3823
3858
|
return keyboard_js_1.parseDispatchCallback;
|
|
3824
3859
|
} });
|
|
3860
|
+
var allocate_js_1 = require_allocate();
|
|
3861
|
+
Object.defineProperty(exports, "overlaps", { enumerable: true, get: function() {
|
|
3862
|
+
return allocate_js_1.overlaps;
|
|
3863
|
+
} });
|
|
3864
|
+
Object.defineProperty(exports, "selectCandidates", { enumerable: true, get: function() {
|
|
3865
|
+
return allocate_js_1.selectCandidates;
|
|
3866
|
+
} });
|
|
3825
3867
|
var session_js_1 = require_session();
|
|
3826
3868
|
Object.defineProperty(exports, "withSession", { enumerable: true, get: function() {
|
|
3827
3869
|
return session_js_1.withSession;
|
|
@@ -12706,6 +12748,32 @@ function buildVisitKeyboard(p) {
|
|
|
12706
12748
|
return { rows, dropped };
|
|
12707
12749
|
}
|
|
12708
12750
|
|
|
12751
|
+
// ../lib/dispatch-read/src/allocate.ts
|
|
12752
|
+
function selectCandidates(params) {
|
|
12753
|
+
const { jobType, cfg, commitments } = params;
|
|
12754
|
+
const type = cfg.jobTypes.find((t) => t.name === jobType);
|
|
12755
|
+
if (!type) {
|
|
12756
|
+
return { offered: [], excluded: [], required: "" };
|
|
12757
|
+
}
|
|
12758
|
+
const offered = [];
|
|
12759
|
+
const excluded = [];
|
|
12760
|
+
for (const worker of cfg.workers) {
|
|
12761
|
+
if (!worker.qualifications.includes(type.requiredQualification)) {
|
|
12762
|
+
excluded.push({ workerId: worker.workerId, reason: "missing-qualification" });
|
|
12763
|
+
continue;
|
|
12764
|
+
}
|
|
12765
|
+
offered.push({
|
|
12766
|
+
workerId: worker.workerId,
|
|
12767
|
+
name: worker.name,
|
|
12768
|
+
conflicts: commitments.get(worker.workerId) ?? []
|
|
12769
|
+
});
|
|
12770
|
+
}
|
|
12771
|
+
return { offered, excluded, required: type.requiredQualification };
|
|
12772
|
+
}
|
|
12773
|
+
function overlaps(a, b) {
|
|
12774
|
+
return a.start < b.end && b.start < a.end;
|
|
12775
|
+
}
|
|
12776
|
+
|
|
12709
12777
|
// ../lib/dispatch-read/src/session.ts
|
|
12710
12778
|
import { readFileSync as readFileSync15 } from "fs";
|
|
12711
12779
|
import { resolve as resolve12 } from "path";
|
|
@@ -12989,6 +13057,30 @@ async function readOpenVisitsForWorker(session, accountId, workerId, terminalSta
|
|
|
12989
13057
|
};
|
|
12990
13058
|
});
|
|
12991
13059
|
}
|
|
13060
|
+
async function readCommitments(session, accountId, from, to) {
|
|
13061
|
+
const r = await session.run(
|
|
13062
|
+
// One row per ATTENDANCE is correct here and nowhere else: a commitment
|
|
13063
|
+
// belongs to a worker, so a two-worker visit legitimately commits both.
|
|
13064
|
+
`MATCH (v:Visit {accountId: $accountId})-[:HAS_ATTENDANCE]->(:Attendance)-[:BY_ENGINEER]->(e:Engineer)
|
|
13065
|
+
WHERE v.startDate < $to AND coalesce(v.endDate, v.startDate) > $from
|
|
13066
|
+
RETURN e.engineerId AS workerId, v.visitId AS visitId,
|
|
13067
|
+
v.startDate AS start, coalesce(v.endDate, v.startDate) AS end`,
|
|
13068
|
+
{ accountId, from, to }
|
|
13069
|
+
);
|
|
13070
|
+
const out = /* @__PURE__ */ new Map();
|
|
13071
|
+
for (const g of r.records) {
|
|
13072
|
+
const workerId = str(g.get("workerId"));
|
|
13073
|
+
if (!workerId) continue;
|
|
13074
|
+
const list = out.get(workerId) ?? [];
|
|
13075
|
+
list.push({
|
|
13076
|
+
visitId: String(g.get("visitId")),
|
|
13077
|
+
start: String(g.get("start")),
|
|
13078
|
+
end: String(g.get("end"))
|
|
13079
|
+
});
|
|
13080
|
+
out.set(workerId, list);
|
|
13081
|
+
}
|
|
13082
|
+
return out;
|
|
13083
|
+
}
|
|
12992
13084
|
async function readSiteLocation(session, accountId, siteId) {
|
|
12993
13085
|
const r = await session.run(
|
|
12994
13086
|
`MATCH (s:Site {accountId: $accountId, siteId: $siteId})
|
|
@@ -13170,6 +13262,7 @@ var ZONE_FALLBACK = "UTC";
|
|
|
13170
13262
|
var EMPTY_META = {
|
|
13171
13263
|
vocabulary: { job: "", visit: "", worker: "" },
|
|
13172
13264
|
statuses: [],
|
|
13265
|
+
transitions: [],
|
|
13173
13266
|
zone: ""
|
|
13174
13267
|
};
|
|
13175
13268
|
var PORTAL_VISITS_PUSH_INTERVAL_MS = 6e4;
|
|
@@ -13185,17 +13278,28 @@ function parseVisitRows(raw) {
|
|
|
13185
13278
|
const visitId = typeof o.visitId === "string" ? o.visitId : "";
|
|
13186
13279
|
if (!visitId) continue;
|
|
13187
13280
|
const str2 = (v) => typeof v === "string" ? v : "";
|
|
13281
|
+
const cnt = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
13188
13282
|
out.push({
|
|
13189
13283
|
visitId,
|
|
13190
13284
|
ownerId: str2(o.ownerId),
|
|
13191
13285
|
jobId: str2(o.jobId),
|
|
13192
13286
|
status: str2(o.status),
|
|
13193
13287
|
startDate: str2(o.startDate),
|
|
13194
|
-
purpose: str2(o.purpose)
|
|
13288
|
+
purpose: str2(o.purpose),
|
|
13289
|
+
notes: cnt(o.notes),
|
|
13290
|
+
media: cnt(o.media),
|
|
13291
|
+
parts: cnt(o.parts),
|
|
13292
|
+
checklistDone: cnt(o.checklistDone),
|
|
13293
|
+
checklistTotal: cnt(o.checklistTotal),
|
|
13294
|
+
signatures: cnt(o.signatures)
|
|
13195
13295
|
});
|
|
13196
13296
|
}
|
|
13197
13297
|
return out;
|
|
13198
13298
|
}
|
|
13299
|
+
function num2(v) {
|
|
13300
|
+
const n = Number(v?.toNumber?.() ?? v);
|
|
13301
|
+
return Number.isFinite(n) ? n : 0;
|
|
13302
|
+
}
|
|
13199
13303
|
async function readVisitsForAccount(accountId) {
|
|
13200
13304
|
const session = getSession();
|
|
13201
13305
|
try {
|
|
@@ -13207,6 +13311,12 @@ async function readVisitsForAccount(accountId) {
|
|
|
13207
13311
|
// the worker sees no job and has nothing to report but confusion, where a
|
|
13208
13312
|
// duplicate on the account-scope list is visible and reads as what it is.
|
|
13209
13313
|
// The duplicate is real and is Task 2551.
|
|
13314
|
+
// The record counts ride the visit row (Task 2511). One projection rather
|
|
13315
|
+
// than a `readVisitRecordState` call per visit: this query already runs
|
|
13316
|
+
// once for the whole account, and a per-visit round trip would multiply
|
|
13317
|
+
// every tick by the account's whole visit history. The six expressions
|
|
13318
|
+
// are `readVisitRecordState`'s own, so the two cannot disagree about what
|
|
13319
|
+
// a count means.
|
|
13210
13320
|
`MATCH (j:Job {accountId: $accountId})-[:HAS_VISIT]->(v:Visit {accountId: $accountId})
|
|
13211
13321
|
OPTIONAL MATCH (v)-[:HAS_ATTENDANCE]->(:Attendance)-[:BY_ENGINEER]->(e:Engineer)
|
|
13212
13322
|
RETURN toString(v.visitId) AS visitId,
|
|
@@ -13214,7 +13324,13 @@ async function readVisitsForAccount(accountId) {
|
|
|
13214
13324
|
coalesce(toString(j.jobId), '') AS jobId,
|
|
13215
13325
|
coalesce(toString(v.status), '') AS status,
|
|
13216
13326
|
coalesce(toString(v.startDate), '') AS startDate,
|
|
13217
|
-
coalesce(toString(v.purpose), '') AS purpose
|
|
13327
|
+
coalesce(toString(v.purpose), '') AS purpose,
|
|
13328
|
+
size(coalesce(v.notes, [])) AS notes,
|
|
13329
|
+
size(coalesce(v.media, [])) AS media,
|
|
13330
|
+
size(coalesce(v.parts, [])) AS parts,
|
|
13331
|
+
size(coalesce(v.checklistDone, [])) AS checklistDone,
|
|
13332
|
+
size(coalesce(v.checklist, [])) AS checklistTotal,
|
|
13333
|
+
size(coalesce(v.signatures, [])) AS signatures`,
|
|
13218
13334
|
{ accountId }
|
|
13219
13335
|
);
|
|
13220
13336
|
return parseVisitRows([
|
|
@@ -13225,7 +13341,13 @@ async function readVisitsForAccount(accountId) {
|
|
|
13225
13341
|
jobId: rec.get("jobId"),
|
|
13226
13342
|
status: rec.get("status"),
|
|
13227
13343
|
startDate: rec.get("startDate"),
|
|
13228
|
-
purpose: rec.get("purpose")
|
|
13344
|
+
purpose: rec.get("purpose"),
|
|
13345
|
+
notes: num2(rec.get("notes")),
|
|
13346
|
+
media: num2(rec.get("media")),
|
|
13347
|
+
parts: num2(rec.get("parts")),
|
|
13348
|
+
checklistDone: num2(rec.get("checklistDone")),
|
|
13349
|
+
checklistTotal: num2(rec.get("checklistTotal")),
|
|
13350
|
+
signatures: num2(rec.get("signatures"))
|
|
13229
13351
|
}))
|
|
13230
13352
|
}
|
|
13231
13353
|
]);
|
|
@@ -13256,8 +13378,72 @@ async function readRosterForAccount(accountId, workers) {
|
|
|
13256
13378
|
await session.close();
|
|
13257
13379
|
}
|
|
13258
13380
|
}
|
|
13381
|
+
async function readCandidatesForAccount(accountId, cfg) {
|
|
13382
|
+
const session = getSession();
|
|
13383
|
+
try {
|
|
13384
|
+
const open3 = await session.run(
|
|
13385
|
+
`MATCH (j:Job {accountId: $accountId})-[:HAS_VISIT]->(v:Visit {accountId: $accountId})
|
|
13386
|
+
WHERE NOT (v)-[:HAS_ATTENDANCE]->()
|
|
13387
|
+
RETURN toString(v.visitId) AS visitId,
|
|
13388
|
+
coalesce(toString(j.jobType), '') AS jobType,
|
|
13389
|
+
coalesce(toString(v.startDate), '') AS startDate,
|
|
13390
|
+
coalesce(toString(v.endDate), toString(v.startDate), '') AS endDate`,
|
|
13391
|
+
{ accountId }
|
|
13392
|
+
);
|
|
13393
|
+
if (open3.records.length === 0) return [];
|
|
13394
|
+
const windows = open3.records.map((rec) => ({
|
|
13395
|
+
visitId: String(rec.get("visitId")),
|
|
13396
|
+
jobType: String(rec.get("jobType")),
|
|
13397
|
+
start: String(rec.get("startDate")),
|
|
13398
|
+
end: String(rec.get("endDate"))
|
|
13399
|
+
}));
|
|
13400
|
+
const dated = windows.filter((w) => w.start !== "");
|
|
13401
|
+
const from = dated.reduce((lo, w) => lo === "" || w.start < lo ? w.start : lo, "");
|
|
13402
|
+
const to = dated.reduce((hi, w) => w.end > hi ? w.end : hi, "");
|
|
13403
|
+
const committed = from === "" ? /* @__PURE__ */ new Map() : await readCommitments(session, accountId, from, to);
|
|
13404
|
+
const out = [];
|
|
13405
|
+
const nameOf = new Map(cfg.workers.map((w) => [w.workerId, w.name]));
|
|
13406
|
+
for (const w of windows) {
|
|
13407
|
+
const windowed = new Map(
|
|
13408
|
+
[...committed].map(([workerId, list]) => [
|
|
13409
|
+
workerId,
|
|
13410
|
+
// A visit with no start overlaps nothing rather than everything: an
|
|
13411
|
+
// undated visit is already listed separately by the page, and
|
|
13412
|
+
// reporting the whole roster as clashing with it would be false.
|
|
13413
|
+
w.start === "" ? [] : list.filter((c) => overlaps({ start: w.start, end: w.end }, c))
|
|
13414
|
+
])
|
|
13415
|
+
);
|
|
13416
|
+
const selection = selectCandidates({ jobType: w.jobType, cfg, commitments: windowed });
|
|
13417
|
+
for (const c of selection.offered) {
|
|
13418
|
+
out.push({
|
|
13419
|
+
visitId: w.visitId,
|
|
13420
|
+
workerId: c.workerId,
|
|
13421
|
+
name: c.name,
|
|
13422
|
+
kind: "offered",
|
|
13423
|
+
reason: "",
|
|
13424
|
+
conflicts: c.conflicts
|
|
13425
|
+
});
|
|
13426
|
+
}
|
|
13427
|
+
for (const e of selection.excluded) {
|
|
13428
|
+
out.push({
|
|
13429
|
+
visitId: w.visitId,
|
|
13430
|
+
workerId: e.workerId,
|
|
13431
|
+
// The config is the authority on a worker's name, and an excluded
|
|
13432
|
+
// worker is never in `selection.offered` to carry one.
|
|
13433
|
+
name: nameOf.get(e.workerId) ?? "",
|
|
13434
|
+
kind: "excluded",
|
|
13435
|
+
reason: e.reason,
|
|
13436
|
+
conflicts: []
|
|
13437
|
+
});
|
|
13438
|
+
}
|
|
13439
|
+
}
|
|
13440
|
+
return out;
|
|
13441
|
+
} finally {
|
|
13442
|
+
await session.close();
|
|
13443
|
+
}
|
|
13444
|
+
}
|
|
13259
13445
|
async function pushVisitsForAccount(opts) {
|
|
13260
|
-
const { accountId, client, rows, roster, meta, log: log2 } = opts;
|
|
13446
|
+
const { accountId, client, rows, roster, meta, candidates, log: log2 } = opts;
|
|
13261
13447
|
const unbound = rows.filter((r) => r.ownerId === "").length;
|
|
13262
13448
|
const seen = /* @__PURE__ */ new Set();
|
|
13263
13449
|
const shared = /* @__PURE__ */ new Set();
|
|
@@ -13268,7 +13454,7 @@ async function pushVisitsForAccount(opts) {
|
|
|
13268
13454
|
const unresolved = roster.filter((w) => w.ownerId === "").length;
|
|
13269
13455
|
if (rows.length === 0 && roster.length === 0) {
|
|
13270
13456
|
log2(`${TAG19} op=skip account=${accountId} reason=no-visits-no-roster`);
|
|
13271
|
-
return { rows: 0, unbound: 0, roster: 0, unresolved: 0 };
|
|
13457
|
+
return { rows: 0, unbound: 0, roster: 0, unresolved: 0, candidates: 0 };
|
|
13272
13458
|
}
|
|
13273
13459
|
const state = await client.query(
|
|
13274
13460
|
"SELECT currentGeneration FROM visits_state WHERE accountId = ?",
|
|
@@ -13281,8 +13467,23 @@ async function pushVisitsForAccount(opts) {
|
|
|
13281
13467
|
const next = Math.max(current, Number(staged[0]?.g ?? 0)) + 1;
|
|
13282
13468
|
for (const row of rows) {
|
|
13283
13469
|
await client.query(
|
|
13284
|
-
"INSERT INTO visits (accountId, visitId, ownerId, jobId, status, startDate, purpose, generation) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
13285
|
-
[
|
|
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 {
|
|
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(
|
|
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
|
|
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")
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
14057
|
+
undatedClaims = num3(
|
|
13732
14058
|
await cf.d1Exec(
|
|
13733
14059
|
portal.portalDbName,
|
|
13734
14060
|
"SELECT COUNT(*) AS n FROM visit_status WHERE accountId = ? AND claimed = 0 AND dayHundredths > 0 AND workDate NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'",
|
|
@@ -13736,6 +14062,15 @@ async function runPortalVisitsAudit(root) {
|
|
|
13736
14062
|
),
|
|
13737
14063
|
"n"
|
|
13738
14064
|
);
|
|
14065
|
+
const staleCutoff = new Date(nowMs - 2 * PORTAL_VISITS_PULL_INTERVAL_MS).toISOString();
|
|
14066
|
+
stalePendingRows = num3(
|
|
14067
|
+
await cf.d1Exec(
|
|
14068
|
+
portal.portalDbName,
|
|
14069
|
+
"SELECT COUNT(*) AS n FROM visit_status WHERE accountId = ? AND claimed = 0 AND statusAt < ?",
|
|
14070
|
+
[portal.accountId, staleCutoff]
|
|
14071
|
+
),
|
|
14072
|
+
"n"
|
|
14073
|
+
);
|
|
13739
14074
|
} catch (err) {
|
|
13740
14075
|
d1Error = err.message;
|
|
13741
14076
|
}
|
|
@@ -13774,6 +14109,7 @@ async function runPortalVisitsAudit(root) {
|
|
|
13774
14109
|
labourClaims,
|
|
13775
14110
|
labourCosts,
|
|
13776
14111
|
undatedClaims,
|
|
14112
|
+
stalePendingRows,
|
|
13777
14113
|
lastPushAgeSec: readLastVisitsPushAgeSec(root, portal.accountId, nowMs),
|
|
13778
14114
|
d1Error,
|
|
13779
14115
|
neo4jError
|
|
@@ -30168,7 +30504,7 @@ app55.get("/", requireAdminSession, async (c) => {
|
|
|
30168
30504
|
}
|
|
30169
30505
|
});
|
|
30170
30506
|
function mapRun(props) {
|
|
30171
|
-
const
|
|
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:
|
|
30192
|
-
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:
|
|
30531
|
+
transcriptOffset: num5(props.transcriptOffset),
|
|
30196
30532
|
// Strict identity: an absent or null `bounded` means the boundary was never
|
|
30197
30533
|
// established, which is the false case.
|
|
30198
30534
|
bounded: props.bounded === true,
|
|
@@ -33931,7 +34267,7 @@ function formatConfigAuditLine(a, accountId) {
|
|
|
33931
34267
|
function formatRecordCensus(f, accountId) {
|
|
33932
34268
|
return `${RECORD_CENSUS_TAG}${accountId ? ` account=${accountId}` : ""} closedVisits=${f.closedVisits} withNote=${f.withNote} withMedia=${f.withMedia} medianRecordLagS=${f.medianRecordLagS} p90RecordLagS=${f.p90RecordLagS}`;
|
|
33933
34269
|
}
|
|
33934
|
-
function
|
|
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:
|
|
34031
|
-
unallocated:
|
|
34366
|
+
jobs: num4(g?.get("jobs")),
|
|
34367
|
+
unallocated: num4(g?.get("unallocated")),
|
|
34032
34368
|
oldestUnallocatedAgeH,
|
|
34033
|
-
overdue:
|
|
34034
|
-
visitsOpen:
|
|
34035
|
-
visitsOpenOver24h:
|
|
34036
|
-
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:
|
|
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 (
|
|
34057
|
-
if (
|
|
34392
|
+
if (num4(g.get("notes")) > 0) withNote++;
|
|
34393
|
+
if (num4(g.get("media")) > 0) withMedia++;
|
|
34058
34394
|
const closedAt = g.get("closedAt");
|
|
34059
34395
|
const dueToLeaveAt = g.get("dueToLeaveAt");
|
|
34060
34396
|
if (typeof closedAt === "string" && typeof dueToLeaveAt === "string") {
|
|
@@ -35489,15 +35825,15 @@ function createWaChannelRoutes(deps) {
|
|
|
35489
35825
|
}
|
|
35490
35826
|
const interactive = body?.interactive === true;
|
|
35491
35827
|
const finalText = typeof body?.finalText === "string" ? body.finalText : "";
|
|
35492
|
-
const
|
|
35493
|
-
const finalBytes =
|
|
35494
|
-
const queueDepth =
|
|
35495
|
-
const pendingTurns =
|
|
35496
|
-
const consumed2 =
|
|
35828
|
+
const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
|
|
35829
|
+
const finalBytes = num5(body?.finalBytes);
|
|
35830
|
+
const queueDepth = num5(body?.queueDepth);
|
|
35831
|
+
const pendingTurns = num5(body?.pendingTurns);
|
|
35832
|
+
const consumed2 = num5(body?.consumed);
|
|
35497
35833
|
const source = typeof body?.source === "string" ? body.source : null;
|
|
35498
35834
|
const sid = sessionId.slice(0, 8);
|
|
35499
35835
|
console.error(
|
|
35500
|
-
`[whatsapp-native] op=turn-context sessionId=${sid} cacheReadTokens=${
|
|
35836
|
+
`[whatsapp-native] op=turn-context sessionId=${sid} cacheReadTokens=${num5(body?.cacheReadTokens)} inputTokens=${num5(body?.inputTokens)} outputTokens=${num5(body?.outputTokens)} thinkingBlocks=${num5(body?.thinkingBlocks)} thinkingChars=${num5(body?.thinkingChars)}`
|
|
35501
35837
|
);
|
|
35502
35838
|
const role = body?.role;
|
|
35503
35839
|
if (role !== "admin" && role !== "public") {
|
|
@@ -36459,12 +36795,12 @@ function createWebchatChannelRoutes(deps) {
|
|
|
36459
36795
|
const finalBytes = typeof body?.finalBytes === "number" && Number.isFinite(body.finalBytes) ? body.finalBytes : 0;
|
|
36460
36796
|
const k = key2.slice(0, 8);
|
|
36461
36797
|
const sid = sessionId.slice(0, 8);
|
|
36462
|
-
const
|
|
36798
|
+
const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
|
|
36463
36799
|
console.error(
|
|
36464
|
-
`[webchat-native] op=turn-context sessionId=${sid} cacheReadTokens=${
|
|
36800
|
+
`[webchat-native] op=turn-context sessionId=${sid} cacheReadTokens=${num5(body?.cacheReadTokens)} inputTokens=${num5(body?.inputTokens)} outputTokens=${num5(body?.outputTokens)} thinkingBlocks=${num5(body?.thinkingBlocks)} thinkingChars=${num5(body?.thinkingChars)}`
|
|
36465
36801
|
);
|
|
36466
|
-
const segmentStart =
|
|
36467
|
-
const segmentEnd =
|
|
36802
|
+
const segmentStart = num5(body?.segmentStart);
|
|
36803
|
+
const segmentEnd = num5(body?.segmentEnd);
|
|
36468
36804
|
const resolved = deps.onTurnEnd?.(key2, role, { sessionId, segmentStart, segmentEnd }) ?? "none";
|
|
36469
36805
|
const resolvedField = role === "public" ? ` resolved=${resolved}` : "";
|
|
36470
36806
|
console.error(`[webchat-native] op=turn-end channel=webchat key=${k} role=${role} sessionId=${sid} trailing=${finalBytes}${resolvedField}`);
|
|
@@ -37821,38 +38157,38 @@ function followerCensusTick(deps, state = makeCensusState()) {
|
|
|
37821
38157
|
const bound = deps.bound();
|
|
37822
38158
|
const live = new Set(deps.live());
|
|
37823
38159
|
for (const senderId of live) state.attempts.delete(senderId);
|
|
37824
|
-
const missing = bound.filter((
|
|
38160
|
+
const missing = bound.filter((senderId) => !live.has(senderId));
|
|
37825
38161
|
console.error(
|
|
37826
|
-
`[channel-follower] op=follower-census bound=${bound.length} attached=${live.size} missing=${missing.length} missingSenders=${missing.map(
|
|
38162
|
+
`[channel-follower] op=follower-census channel=${deps.channel} bound=${bound.length} attached=${live.size} missing=${missing.length} missingSenders=${missing.map(id82).join(",") || "none"}`
|
|
37827
38163
|
);
|
|
37828
|
-
for (const
|
|
37829
|
-
const used = state.attempts.get(
|
|
38164
|
+
for (const senderId of missing) {
|
|
38165
|
+
const used = state.attempts.get(senderId) ?? 0;
|
|
37830
38166
|
if (used >= CENSUS_MAX_REATTEMPTS) {
|
|
37831
38167
|
console.error(
|
|
37832
|
-
`[channel-follower] op=follower-reattach sessionId=${id82(deps.paramsFor(
|
|
38168
|
+
`[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=${id82(deps.paramsFor(senderId)?.sessionId ?? "none")} senderId=${id82(senderId)} outcome=skipped reason=reattach-exhausted attempts=${used}`
|
|
37833
38169
|
);
|
|
37834
38170
|
continue;
|
|
37835
38171
|
}
|
|
37836
|
-
const p = deps.paramsFor(
|
|
38172
|
+
const p = deps.paramsFor(senderId);
|
|
37837
38173
|
if (!p) {
|
|
37838
38174
|
console.error(
|
|
37839
|
-
`[channel-follower] op=follower-reattach sessionId=none senderId=${id82(
|
|
38175
|
+
`[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=none senderId=${id82(senderId)} outcome=failed reason=no-attach-params`
|
|
37840
38176
|
);
|
|
37841
38177
|
continue;
|
|
37842
38178
|
}
|
|
37843
38179
|
try {
|
|
37844
|
-
state.attempts.set(
|
|
38180
|
+
state.attempts.set(senderId, used + 1);
|
|
37845
38181
|
deps.attach(p);
|
|
37846
38182
|
console.error(
|
|
37847
|
-
`[channel-follower] op=follower-reattach sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=attached reason=none`
|
|
38183
|
+
`[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=attached reason=none`
|
|
37848
38184
|
);
|
|
37849
38185
|
} catch (err) {
|
|
37850
38186
|
console.error(
|
|
37851
|
-
`[channel-follower] op=follower-reattach sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=failed reason=${err instanceof Error ? err.message : String(err)}`
|
|
38187
|
+
`[channel-follower] op=follower-reattach channel=${deps.channel} sessionId=${id82(p.sessionId)} senderId=${id82(p.senderId)} outcome=failed reason=${err instanceof Error ? err.message : String(err)}`
|
|
37852
38188
|
);
|
|
37853
38189
|
}
|
|
37854
38190
|
}
|
|
37855
|
-
return `${bound.length} door(s), ${missing.length} without a reader`;
|
|
38191
|
+
return `${deps.channel}: ${bound.length} door(s), ${missing.length} without a reader`;
|
|
37856
38192
|
}
|
|
37857
38193
|
|
|
37858
38194
|
// app/lib/telegram/gateway/inbound-hub.ts
|
|
@@ -38008,7 +38344,8 @@ function createTelegramChannelRoutes(deps) {
|
|
|
38008
38344
|
if (typeof key2 !== "string" || typeof messageId !== "string") {
|
|
38009
38345
|
return c.json({ error: "key and messageId required" }, 400);
|
|
38010
38346
|
}
|
|
38011
|
-
|
|
38347
|
+
const provenance = typeof body?.provenance === "boolean" ? body.provenance : void 0;
|
|
38348
|
+
deps.onReceived?.(key2, messageId, provenance);
|
|
38012
38349
|
return c.json({ ok: true });
|
|
38013
38350
|
});
|
|
38014
38351
|
app81.post("/tg-channel/turn-end", async (c) => {
|
|
@@ -38022,9 +38359,9 @@ function createTelegramChannelRoutes(deps) {
|
|
|
38022
38359
|
const interactive = body?.interactive === true;
|
|
38023
38360
|
const finalText = typeof body?.finalText === "string" ? body.finalText : "";
|
|
38024
38361
|
const sid = sessionId.slice(0, 8);
|
|
38025
|
-
const
|
|
38362
|
+
const num5 = (v) => typeof v === "number" && Number.isFinite(v) ? v : -1;
|
|
38026
38363
|
console.error(
|
|
38027
|
-
`[telegram-native] op=turn-context sessionId=${sid} cacheReadTokens=${
|
|
38364
|
+
`[telegram-native] op=turn-context sessionId=${sid} cacheReadTokens=${num5(body?.cacheReadTokens)} inputTokens=${num5(body?.inputTokens)} outputTokens=${num5(body?.outputTokens)} thinkingBlocks=${num5(body?.thinkingBlocks)} thinkingChars=${num5(body?.thinkingChars)}`
|
|
38028
38365
|
);
|
|
38029
38366
|
const role = body?.role;
|
|
38030
38367
|
if (role !== "admin" && role !== "public") {
|
|
@@ -38142,6 +38479,16 @@ var TelegramGateway = class {
|
|
|
38142
38479
|
}
|
|
38143
38480
|
hub = new InboundHub3();
|
|
38144
38481
|
replies = /* @__PURE__ */ new Map();
|
|
38482
|
+
/** Task 2559 — the keys whose last inbound was `role:'admin'`. The follower
|
|
38483
|
+
* census's bound set is the doors that SHOULD have a reader, and a public
|
|
38484
|
+
* Telegram session is given none by design: the public branch of
|
|
38485
|
+
* `buildTelegramEnsureSession` returns before the attach block, because the
|
|
38486
|
+
* file follower is an admin surface. Including a public key would report it
|
|
38487
|
+
* missing on every tick for ever and stop `missing=` ever reaching zero,
|
|
38488
|
+
* which is the one signal the census exists to give. Removed on a public
|
|
38489
|
+
* inbound, because the role is read from account config on every inbound and
|
|
38490
|
+
* a bot can be flipped between the two. */
|
|
38491
|
+
adminKeys = /* @__PURE__ */ new Set();
|
|
38145
38492
|
spawning = /* @__PURE__ */ new Set();
|
|
38146
38493
|
/** Task 2428 — keys whose channel server signalled ready while their spawn was
|
|
38147
38494
|
* still in flight. The harness discards a channel notification that arrives
|
|
@@ -38169,7 +38516,7 @@ var TelegramGateway = class {
|
|
|
38169
38516
|
}
|
|
38170
38517
|
this.drain(key2);
|
|
38171
38518
|
},
|
|
38172
|
-
onReceived: (key2, messageId) => {
|
|
38519
|
+
onReceived: (key2, messageId, provenance) => {
|
|
38173
38520
|
const acked = this.hub.ack(key2, messageId);
|
|
38174
38521
|
if (acked !== null && (acked.source ?? "user") === "user") {
|
|
38175
38522
|
noteReceived(key2, parseTelegramChannelKey(key2)?.botId ?? "unknown", messageId, Date.now());
|
|
@@ -38177,6 +38524,10 @@ var TelegramGateway = class {
|
|
|
38177
38524
|
console.error(
|
|
38178
38525
|
`[telegram-native] op=channel-received key=${key2} messageId=${messageId}${acked !== null ? "" : " note=unknown"}`
|
|
38179
38526
|
);
|
|
38527
|
+
const prov = provenance === void 0 ? "unknown" : provenance ? "yes" : "no";
|
|
38528
|
+
console.error(
|
|
38529
|
+
`[telegram:reframe] op=compose key=${key2} provenance=${prov} source=${acked?.source ?? "unknown"}`
|
|
38530
|
+
);
|
|
38180
38531
|
}
|
|
38181
38532
|
});
|
|
38182
38533
|
}
|
|
@@ -38249,6 +38600,8 @@ var TelegramGateway = class {
|
|
|
38249
38600
|
const willDeliverNow = this.hub.isReady(key2);
|
|
38250
38601
|
const source = input.source ?? "user";
|
|
38251
38602
|
this.replies.set(key2, input.reply);
|
|
38603
|
+
if (input.role === "admin") this.adminKeys.add(key2);
|
|
38604
|
+
else this.adminKeys.delete(key2);
|
|
38252
38605
|
let standingRules;
|
|
38253
38606
|
if (input.role === "admin" && this.deps.fetchStandingRules) {
|
|
38254
38607
|
const res = await this.deps.fetchStandingRules(input.accountId);
|
|
@@ -38260,7 +38613,21 @@ var TelegramGateway = class {
|
|
|
38260
38613
|
);
|
|
38261
38614
|
}
|
|
38262
38615
|
this.hub.deliver(
|
|
38263
|
-
{
|
|
38616
|
+
{
|
|
38617
|
+
key: key2,
|
|
38618
|
+
text: input.text,
|
|
38619
|
+
media: input.media,
|
|
38620
|
+
messageId: `tg-${++this.seq}`,
|
|
38621
|
+
source,
|
|
38622
|
+
// Task 2561 — passed explicitly rather than parsed out of `key`:
|
|
38623
|
+
// accountId is not in the key, and the channel service imports nothing
|
|
38624
|
+
// from app/lib. Without them the composer cannot name who is speaking,
|
|
38625
|
+
// and the turn opens straight into rules written about the owner.
|
|
38626
|
+
senderId: input.senderId,
|
|
38627
|
+
accountId: input.accountId,
|
|
38628
|
+
standingRules,
|
|
38629
|
+
scheduleProvenance: input.scheduleProvenance
|
|
38630
|
+
},
|
|
38264
38631
|
Date.now()
|
|
38265
38632
|
);
|
|
38266
38633
|
const resolvedSessionId = this.sessionIdFor(input);
|
|
@@ -38300,6 +38667,18 @@ var TelegramGateway = class {
|
|
|
38300
38667
|
liveReplyTarget(key2) {
|
|
38301
38668
|
return this.replies.get(key2) ?? null;
|
|
38302
38669
|
}
|
|
38670
|
+
/** Task 2559 — every admin door this gateway currently holds a live delivery
|
|
38671
|
+
* closure for, as the `<botId>:<senderId>` channel key the follower registry
|
|
38672
|
+
* keys on. The twin of `WaGateway.boundTargets()`: the census reconciles
|
|
38673
|
+
* against exactly this set, so "should have a reader" and "can be sent to"
|
|
38674
|
+
* are one fact and cannot drift. A key `parseTelegramChannelKey` rejects is
|
|
38675
|
+
* the pre-2368 bare-sender shape, skipped rather than halved at an arbitrary
|
|
38676
|
+
* offset, which would yield a door id nothing keys on. */
|
|
38677
|
+
boundTargets() {
|
|
38678
|
+
return [...this.replies.keys()].filter(
|
|
38679
|
+
(key2) => this.adminKeys.has(key2) && parseTelegramChannelKey(key2) !== null
|
|
38680
|
+
);
|
|
38681
|
+
}
|
|
38303
38682
|
async sendOutbound(key2, text) {
|
|
38304
38683
|
const reply = this.replies.get(key2);
|
|
38305
38684
|
if (!reply) throw new Error(`telegram-gateway: no reply route for key ${key2}`);
|
|
@@ -38394,24 +38773,13 @@ function buildTelegramEnsureSession(deps) {
|
|
|
38394
38773
|
`${TAG62} op=spawn botId=${input.botId} sessionId=${req.sessionId} targetAccountId=${input.accountId} role=admin`
|
|
38395
38774
|
);
|
|
38396
38775
|
noteSpawnOk2(input.botId);
|
|
38397
|
-
deps.followers.
|
|
38398
|
-
let ac;
|
|
38399
|
-
ac = deps.startFileFollower({
|
|
38776
|
+
deps.followers.attach({
|
|
38400
38777
|
sessionId: req.sessionId,
|
|
38401
38778
|
senderId: req.channelKey,
|
|
38402
38779
|
accountId: input.accountId,
|
|
38403
38780
|
chatId: input.chatId,
|
|
38404
|
-
botToken: input.botToken
|
|
38405
|
-
// Task 2521 — resolved per answer, not captured once, so a door that goes
|
|
38406
|
-
// live after this session started still receives.
|
|
38407
|
-
fanTargets: () => deps.fanTargets(req.channelKey),
|
|
38408
|
-
// Identity-guarded eviction: only delete if this controller is still the
|
|
38409
|
-
// current one, so a stale self-close never removes a live follower.
|
|
38410
|
-
onClose: () => {
|
|
38411
|
-
if (deps.followers.get(req.channelKey) === ac) deps.followers.delete(req.channelKey);
|
|
38412
|
-
}
|
|
38781
|
+
botToken: input.botToken
|
|
38413
38782
|
});
|
|
38414
|
-
deps.followers.set(req.channelKey, ac);
|
|
38415
38783
|
};
|
|
38416
38784
|
}
|
|
38417
38785
|
|
|
@@ -39736,20 +40104,6 @@ var waGateway = new WaGateway({
|
|
|
39736
40104
|
});
|
|
39737
40105
|
}
|
|
39738
40106
|
});
|
|
39739
|
-
var followerCensusState = makeCensusState();
|
|
39740
|
-
registerLoop({
|
|
39741
|
-
name: "channel-follower",
|
|
39742
|
-
intervalMs: 6e4,
|
|
39743
|
-
run: () => followerCensusTick(
|
|
39744
|
-
{
|
|
39745
|
-
bound: () => waGateway.boundTargets(),
|
|
39746
|
-
live: () => nativeFileFollowers.live(),
|
|
39747
|
-
paramsFor: (senderId) => nativeFileFollowers.paramsFor(senderId),
|
|
39748
|
-
attach: (p) => nativeFileFollowers.attach(p)
|
|
39749
|
-
},
|
|
39750
|
-
followerCensusState
|
|
39751
|
-
)
|
|
39752
|
-
});
|
|
39753
40107
|
app80.route("/", waGateway.routes());
|
|
39754
40108
|
waGateway.startSweeper();
|
|
39755
40109
|
registerLoop({
|
|
@@ -39917,7 +40271,6 @@ setWebchatGateway(webchatGateway);
|
|
|
39917
40271
|
app80.route("/", webchatGateway.routes());
|
|
39918
40272
|
webchatGateway.startSweeper();
|
|
39919
40273
|
webchatGateway.startPublicReaper();
|
|
39920
|
-
var telegramFileFollowers = /* @__PURE__ */ new Map();
|
|
39921
40274
|
var telegramFanTargets = (key2) => {
|
|
39922
40275
|
const account = listValidAccounts().find(
|
|
39923
40276
|
(a) => listBotEntries(a.config.telegram).some((e) => e.id === key2.slice(0, key2.indexOf(":")))
|
|
@@ -39934,6 +40287,19 @@ var telegramFanTargets = (key2) => {
|
|
|
39934
40287
|
deps: adminFanDeps("telegram", account.accountId)
|
|
39935
40288
|
});
|
|
39936
40289
|
};
|
|
40290
|
+
var telegramFileFollowers = createFollowerRegistry(
|
|
40291
|
+
(p, onClose) => startTelegramNativeFileFollower({
|
|
40292
|
+
sessionId: p.sessionId,
|
|
40293
|
+
senderId: p.senderId,
|
|
40294
|
+
accountId: p.accountId,
|
|
40295
|
+
chatId: p.chatId,
|
|
40296
|
+
botToken: p.botToken,
|
|
40297
|
+
// Task 2521 — resolved per answer, not captured once, so a door that goes
|
|
40298
|
+
// live after this session started still receives.
|
|
40299
|
+
fanTargets: () => telegramFanTargets(p.senderId),
|
|
40300
|
+
onClose
|
|
40301
|
+
})
|
|
40302
|
+
);
|
|
39937
40303
|
var telegramGateway = new TelegramGateway({
|
|
39938
40304
|
fetchStandingRules: fetchAccountStandingRules,
|
|
39939
40305
|
// Task 2353 — no turn-instruction resolver on this door. See the dep comment in
|
|
@@ -39954,14 +40320,39 @@ var telegramGateway = new TelegramGateway({
|
|
|
39954
40320
|
managerRcSpawn,
|
|
39955
40321
|
managerSpawn,
|
|
39956
40322
|
resolveSessionOverride: (accountId, channelKey) => resolveChannelOverride(accountId, "telegram", channelKey),
|
|
39957
|
-
startFileFollower: startTelegramNativeFileFollower,
|
|
39958
|
-
fanTargets: telegramFanTargets,
|
|
39959
40323
|
followers: telegramFileFollowers
|
|
39960
40324
|
})
|
|
39961
40325
|
});
|
|
39962
40326
|
setTelegramGateway(telegramGateway);
|
|
39963
40327
|
app80.route("/", telegramGateway.routes());
|
|
39964
40328
|
telegramGateway.startSweeper();
|
|
40329
|
+
var followerCensusState = { whatsapp: makeCensusState(), telegram: makeCensusState() };
|
|
40330
|
+
registerLoop({
|
|
40331
|
+
name: "channel-follower",
|
|
40332
|
+
intervalMs: 6e4,
|
|
40333
|
+
run: () => [
|
|
40334
|
+
followerCensusTick(
|
|
40335
|
+
{
|
|
40336
|
+
channel: "whatsapp",
|
|
40337
|
+
bound: () => waGateway.boundTargets().map((t) => t.senderId),
|
|
40338
|
+
live: () => nativeFileFollowers.live(),
|
|
40339
|
+
paramsFor: (senderId) => nativeFileFollowers.paramsFor(senderId),
|
|
40340
|
+
attach: (p) => nativeFileFollowers.attach(p)
|
|
40341
|
+
},
|
|
40342
|
+
followerCensusState.whatsapp
|
|
40343
|
+
),
|
|
40344
|
+
followerCensusTick(
|
|
40345
|
+
{
|
|
40346
|
+
channel: "telegram",
|
|
40347
|
+
bound: () => telegramGateway.boundTargets(),
|
|
40348
|
+
live: () => telegramFileFollowers.live(),
|
|
40349
|
+
paramsFor: (senderId) => telegramFileFollowers.paramsFor(senderId),
|
|
40350
|
+
attach: (p) => telegramFileFollowers.attach(p)
|
|
40351
|
+
},
|
|
40352
|
+
followerCensusState.telegram
|
|
40353
|
+
)
|
|
40354
|
+
].join("; ")
|
|
40355
|
+
});
|
|
39965
40356
|
var chatRoutes = createChatRoutes({
|
|
39966
40357
|
handleInbound: (input) => webchatGateway.handleInbound(input),
|
|
39967
40358
|
awaitReply: (key2, timeoutMs) => webchatGateway.awaitReply(key2, timeoutMs)
|