updates on the ui and updates agents

This commit is contained in:
2026-10-09 13:39:14 +05:30
parent 3d07ea8170
commit 5faf0e1deb
13 changed files with 1572 additions and 45 deletions

View File

@@ -165,7 +165,7 @@ export const AGENTS = [
load: 0,
blurb: 'Builds delivery routes from zones and available hubs. Ten-minute cache.',
detail:
'Sequences waypoints into routes using zone membership and hub availability. This is the piece the Go backend has no equivalent for — HubBatchAssign decides who gets a booking, never what order a rider runs their stops in.',
'Sequences waypoints into routes using zone membership and hub availability. SIMULATION: its zones and traffic patterns are hard-coded in _init_zones()/_init_traffic_patterns() and it makes no backend or routing-API call at all. Real stop ordering is done by internal/routing against the Valhalla-backed Route Optimization API, not here. The registry records it with status simulation.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',
@@ -186,7 +186,7 @@ export const AGENTS = [
load: 0,
blurb: 'Hub operations — transit records, capacity, what is sitting where.',
detail:
'Tracks inventory at each hub and the transit records moving between them, and answers capacity questions about whether a hub can absorb more volume.',
'Tracks inventory at each hub and the transit records moving between them, and answers capacity questions about whether a hub can absorb more volume. SIMULATION: its hubs are 8 hard-coded entries in _init_hubs() (DL-HUB-01 and friends, with invented connected_hubs graphs) — not the hubs table. The registry records it with status simulation.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',
@@ -207,7 +207,7 @@ export const AGENTS = [
load: 0,
blurb: 'Vehicles: status, capacity and live tracking.',
detail:
"Holds vehicle state and assignment, and answers which vehicle can carry a given load. It overlaps with the Go backend's own Vehicle model, which is the live source today.",
"Holds vehicle state and assignment, and answers which vehicle can carry a given load. It overlaps with the Go backend's own Vehicle model, which is the live source today. SIMULATION: its fleet is 19 vehicles hard-coded in _init_fleet() across Delhi, Mumbai, Bangalore, Hyderabad, Pune and Kolkata — not the vehicles table, and not the cities Doormile serves. The registry records it with status simulation for this reason.",
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',

View File

@@ -17,7 +17,7 @@
// • A partial success is a partial success. Notifying six riders out of eight
// is reported as six out of eight, with the two failures named.
import { getMilers, buildMilerLookup, notifyRider } from '@/api/doormile';
import { getMilers, buildMilerLookup, notifyRider, batchAssignBookings } from '@/api/doormile';
/** A stable, human-facing reference for a row. */
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
@@ -86,6 +86,9 @@ const executeNotifyRider = async (scope, message) => {
return {
ok: notified.length > 0,
// Same contract as executeAssignMiler: say whether this was partial rather
// than leaving every caller to work it out from a different field.
partial: notified.length > 0 && unreachable.length > 0,
message: parts.join(' ') || 'Nothing was sent.',
notified,
unreachable,
@@ -93,18 +96,86 @@ const executeNotifyRider = async (scope, message) => {
};
};
// ---- assignMiler: deliberately NOT an executor --------------------------------
// ---- assignMiler ------------------------------------------------------------
//
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. Shipping it would have
// put a button on the Exceptions banner that could never succeed.
// This WAS review-only, and the note explaining why is worth keeping because
// it names the actual constraint:
//
// Findings that propose `assignMiler` therefore render "Review only". The
// admin route that could back it, POST /admin/bookings/:id/assign-miler, needs
// a chosen rider per booking, which a finding does not pick. Wiring this is a
// backend decision (an admin batch-assign route), not a console one.
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. The admin route that
// could back it, POST /admin/bookings/:id/assign-miler, needs a chosen rider
// per booking, which a finding does not pick.
//
// The resolution was the backend decision that note called for:
// POST /admin/bookings/batch-assign now runs the same solver as the hub route
// (controllers/batchAssignService.go) under admin auth, so the console gets the
// solver that PICKS riders rather than one that needs them named.
//
// ⚠ This commits. There is no preview/reconcile step on either batch-assign
// route, so the proposal gate in the UI is the only thing between a finding and
// a real assignment. canExecuteProposal must stay the gate, and the card must
// keep requiring an explicit click — do not make this run automatically.
const executeAssignMiler = async (scope) => {
const rows = (scope || []).filter((r) => r?.bookingid);
if (!rows.length) {
return { ok: false, message: 'None of these findings carries a booking to assign.', sourceCalls: [] };
}
const bookingIds = [...new Set(rows.map((r) => Number(r.bookingid)))];
const target = 'POST /admin/bookings/batch-assign';
try {
const res = await batchAssignBookings(bookingIds);
// The backend reports per booking, and a partial success is a partial
// success — the same rule executeNotifyRider follows. Reporting "assigned"
// when four of nine landed sends a dispatcher away from five parcels that
// still have nobody.
const data = res?.data || res || {};
const assigned = Number(data.assigned ?? 0);
const skipped = Number(data.skipped ?? 0);
const sequenced = Number(data.riderssequenced ?? 0);
const reasons = (data.results || [])
.filter((r) => !r?.assigned && r?.reason)
.map((r) => `${r.bookingno || `#${r.bookingid}`} (${r.reason})`);
const parts = [];
if (assigned) parts.push(`Assigned ${assigned} order${assigned === 1 ? '' : 's'}.`);
if (skipped) parts.push(`Could not assign ${skipped}: ${reasons.join('; ') || 'no reason given'}.`);
// Stop order needs ROUTE_OPTIMIZER_URL configured. Saying nothing when it
// is zero would leave the operator assuming routes were ordered.
if (assigned && !sequenced) parts.push('Stops were not sequenced (route optimiser unavailable).');
else if (sequenced) parts.push(`Sequenced ${sequenced} rider${sequenced === 1 ? '' : 's'}.`);
return {
ok: assigned > 0,
// Declared, not inferred. reportActed used to detect a partial run by
// looking for an `unreachable` array, which only the notify executor
// returns — so a batch that assigned two of three was recorded as a
// clean success. The message said "partial", the telemetry said "ok",
// and the measurement the findings table exists for was wrong.
partial: assigned > 0 && skipped > 0,
message: parts.join(' ') || 'Nothing was assigned.',
sourceCalls: [{
name: 'batchAssignBookings',
target,
status: assigned > 0 ? 'complete' : 'error',
stats: `${assigned} assigned, ${skipped} skipped, ${sequenced} sequenced`,
...(assigned === 0 ? { errorMessage: reasons.join('; ') || 'no rider under capacity' } : {})
}]
};
} catch (err) {
return {
ok: false,
message: err?.message || 'The assignment did not complete.',
sourceCalls: [{
name: 'batchAssignBookings', target, status: 'error',
errorMessage: err?.message || 'assignment failed'
}]
};
}
};
// ---- registry ---------------------------------------------------------------
@@ -127,8 +198,38 @@ const EXECUTORS = {
finding?.severity === 'critical'
? 'Urgent: this delivery needs attention now. Please check the app.'
: 'Please check this delivery in the app when you can.'
)
// assignMiler: none — see the note above. Its findings render "Review only".
),
// alert_low_battery_rider: the same endpoint notifyRider already uses, so
// this is a message, not a new capability. The registry seed names
// POST /admin/milers/:id/notify as its target and it was marked REVIEW ONLY
// only because nothing had been wired to it.
//
// The tool key is snake_case here, unlike notifyRider above, because
// RiderBatterySafetySkill.js:84 emits `alert_low_battery_rider` and this map
// is keyed on the verb a finding actually carries — not on a convention.
// Renaming the skill's verb to match would be the tidier fix and a wider
// change; it is not worth touching a shipped skill's output for.
alert_low_battery_rider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
'Low battery: please charge now, or report to the nearest hub if you cannot.'
),
assignMiler: (finding) => executeAssignMiler(finding?.proposal?.scope),
// trigger_auto_dispatch: LateDispatchSkill proposes "auto-assign orders that
// have waited too long for dispatch", which is exactly what batch-assign
// does. seed.go marked it Target: "none yet" because no route picked riders
// for an admin login; /admin/bookings/batch-assign now does, so this needed
// no new capability — only the realisation that it is the same action under
// a different name.
//
// Deliberately NOT given its own executor: two code paths assigning riders
// would be a second definition of the same write, and the codebase already
// carries three (internal/assignment's AI path, AssignMilerToBooking, and
// the batch solver). One more would be the one that drifts.
trigger_auto_dispatch: (finding) => executeAssignMiler(finding?.proposal?.scope)
};
/** Whether a finding's proposal can actually be carried out. */

View File

@@ -0,0 +1,150 @@
// ==============================|| Doormile AI — finding persistence ||============================== //
//
// Reports what the rule skills noticed to the backend, so it survives the
// render that produced it.
//
// The eight skills run here, in the operator's browser, on a 60-second React
// Query interval — and their output was discarded every cycle. Three questions
// had no answer at all:
//
// • Has this booking been flagged before, and how many times?
// • Which skills fire most, and which are ignored every single time?
// • Did the proposal an operator carried out actually clear the finding?
//
// The third is the one worth having. AgentOperationsBanner already re-runs its
// scan after executing a proposal precisely to see whether the finding
// disappears — that evidence existed for one render and then vanished.
//
// ─── Rules this module follows ───────────────────────────────────────────────
//
// • It is FIRE-AND-FORGET and never throws. Reporting is observability; the
// briefing must render identically whether or not the POST lands. A
// dispatcher looking at a stalled parcel does not care that telemetry is
// down, and must not be shown an error about it.
//
// • It sends the CLEARED set too. Only the console knows the full set it
// evaluated, so only it can tell the backend which fingerprints are gone.
// The backend cannot distinguish "resolved" from "the operator closed the
// tab".
//
// • It sends no personal data. Scope goes as booking ids only — no names,
// phones or addresses. The findings table is for measuring the skills, not
// a second copy of the bookings.
import { reportFindings, reportFindingActed } from '@/api/doormile';
/**
* The identity of a finding across polls.
*
* Deliberately only the skill and its scope's booking ids, sorted. NOT severity
* and NOT counts: both drift while the underlying problem is unchanged (an
* ageing SLA breach climbs from warning to critical), and including them would
* make every escalation look like a brand-new finding and reset the "how long
* has this been open" measurement — which is the single most useful thing the
* table records.
*
* Sorted because scope order is not stable across scans.
*/
export const fingerprintOf = (finding) => {
const skill = finding?.skillId || finding?.skill || 'unknown';
// The PROPOSED ACTION is part of the identity.
//
// One skill legitimately raises several findings over the same booking set —
// SlaGuardian emits both "notify the assigned riders" and "assign these
// orders" for the same three parcels. Keyed on skill + scope alone those
// collide, and because the fingerprint is the upsert key the second finding
// silently overwrites the first: one of the two disappears from the table and
// from every measurement built on it.
//
// Caught by running the real Exceptions page against a mock backend; the unit
// tests used a distinct skill per fingerprint and never produced a collision.
const tool = finding?.proposal?.tool || 'none';
const ids = (finding?.proposal?.scope || [])
.map((r) => r?.bookingid)
.filter((id) => id != null)
.map(Number)
.sort((a, b) => a - b);
return `${skill}:${tool}:${ids.join(',')}`;
};
const toItem = (finding) => ({
skillid: finding?.skillId || finding?.skill || 'unknown',
fingerprint: fingerprintOf(finding),
severity: finding?.severity || 'info',
title: String(finding?.title || finding?.headline || '').slice(0, 300),
proposaltool: finding?.proposal?.tool || '',
// Ids only. See the no-personal-data rule above.
bookingids: (finding?.proposal?.scope || [])
.map((r) => r?.bookingid)
.filter((id) => id != null)
.map(Number),
tenantid: Number(localStorage.getItem('tenantid')) || null
});
// Fingerprints seen on the previous scan, so this one can report what went
// away. Module-level rather than component state: the banner remounts on
// navigation and a remount must not look like every finding clearing at once.
let previous = new Set();
/**
* Report one scan's findings. Returns nothing and never rejects.
*
* Call AFTER the findings are rendered, not before — this must never sit in
* front of what the operator sees.
*/
export const reportScan = async (findings) => {
try {
const items = (findings || []).map(toItem).filter((i) => i.fingerprint && i.skillid);
const current = new Set(items.map((i) => i.fingerprint));
const cleared = [...previous].filter((fp) => !current.has(fp));
// Nothing open and nothing cleared is the common case on a quiet board;
// skip the round trip entirely.
if (!items.length && !cleared.length) {
previous = current;
return;
}
await reportFindings({ findings: items, cleared });
previous = current;
} catch {
// Silent by design. A failed report is not something an operator can act
// on, and the previous set is deliberately NOT updated — so the next scan
// retries the same clear set rather than losing it.
}
};
/**
* Record that an operator carried out a proposal, and how it went.
*
* `result` mirrors what the executors actually return: a partial success is a
* partial success. Flattening six-riders-notified-out-of-eight to "ok" would
* lose exactly the distinction actions.js preserves.
*/
export const reportActed = async (finding, executionResult) => {
try {
const fp = fingerprintOf(finding);
if (!fp) return;
let result = 'failed';
if (executionResult?.ok) {
// `partial` is declared by the executor. The fallback covers an executor
// that predates the field; without the declaration a batch-assign that
// skipped a booking reported as a clean success, because `unreachable`
// is a notify-only field.
const partial = typeof executionResult.partial === 'boolean'
? executionResult.partial
: (Array.isArray(executionResult?.unreachable) && executionResult.unreachable.length > 0);
result = partial ? 'partial' : 'ok';
}
await reportFindingActed(fp, result);
} catch {
// Same reasoning as reportScan: the action itself already happened and was
// reported to the operator. Losing its telemetry must not surface as a
// failure of the action.
}
};
/** Tests only — the module-level previous set would otherwise leak between them. */
export const __resetForTests = () => {
previous = new Set();
};

View File

@@ -38,6 +38,29 @@ export function generateClientPassword(length = 14) {
}
/** Client-side mirror of the server's validation, for inline errors. */
/**
* What a client ships. The SAME eight values the backend prices against
* (constants.DeliveryCategories) — a tenant whose category is not a pricing
* category cannot be priced, so this must not drift from that list.
*/
export const DELIVERY_CATEGORIES = [
'General', 'Documents', 'Electronics', 'Clothing',
'Fragile', 'Medical', 'Automotive', 'Food'
];
/**
* Whether a return journey makes sense for what this client ships.
*
* Food is the exception: a meal that comes back is waste, not inventory.
* Mirrors constants.ReverseLogisticsAllowed, which ENFORCES this in
* InitiateConsignmentRTO — this copy only decides what the form shows, and
* hiding a control is a courtesy, not a rule.
*
* Unknown or empty allows returns, matching the server: the field is new, and
* new information must not withdraw a capability existing clients already use.
*/
export const reverseLogisticsAllowed = (category) => category !== 'Food';
export function validateOnboarding(form) {
const errors = {};
const phone = String(form.phone || '').replace(/\D/g, '');
@@ -46,6 +69,12 @@ export function validateOnboarding(form) {
if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(String(form.email || '').trim())) errors.email = 'Enter a valid email address';
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = 'Enter a 10-digit mobile number';
if (!form.applocationid) errors.applocationid = "Choose the client's operating city";
// Required, matching the server. Not defaulted to General: the category sets
// the client's pricing AND whether their parcels can be returned, so
// guessing it decides both on the operator's behalf without asking.
if (!DELIVERY_CATEGORIES.includes(String(form.deliverycategory || ''))) {
errors.deliverycategory = 'Choose what this client delivers';
}
const pw = String(form.password || '');
if (pw.length < 8) errors.password = 'At least 8 characters';
else if (pw.length > 72) errors.password = 'At most 72 characters';