From 53199127bac1d894496658290b5df596c20ae18c Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 30 Sep 2026 12:03:13 +0530 Subject: [PATCH] update on the agents and registry side --- docs/agent-platform-plan.md | 724 ++++++++++++++++++ src/App.jsx | 3 + src/api/doormile/endpoints.js | 102 +++ .../assistant/DoormileAI/pageContext.jsx | 7 +- .../doormile/AgentOperationsBanner.jsx | 196 +++++ .../doormile/SlaRemediationCard.jsx | 137 ++++ src/layouts/AdminLayout.jsx | 21 +- src/lib/assistant/agent/AgentFactory.js | 92 +++ src/lib/assistant/agent/actions.js | 153 ++++ src/lib/assistant/agent/briefing.js | 112 +++ src/lib/assistant/agent/normalise.js | 53 ++ src/lib/assistant/agent/signals.js | 44 ++ src/lib/assistant/intents.js | 43 ++ src/lib/assistant/skills/SkillRegistry.js | 123 +++ .../skills/definitions/CashExposureSkill.js | 161 ++++ .../skills/definitions/DoorstepStallSkill.js | 90 +++ .../skills/definitions/FleetBalancerSkill.js | 87 +++ .../skills/definitions/HighValueCodSkill.js | 93 +++ .../skills/definitions/HubCongestionSkill.js | 105 +++ .../skills/definitions/LateDispatchSkill.js | 133 ++++ .../definitions/RiderBatterySafetySkill.js | 91 +++ .../skills/definitions/SlaGuardianSkill.js | 157 ++++ src/lib/assistant/skills/index.js | 10 + .../assistant/skills/useSkillRegistrySync.js | 27 + src/lib/clientOnboarding.js | 75 ++ src/lib/distance.js | 31 + src/lib/doormileHooks.js | 79 ++ src/pages/Login.jsx | 2 +- .../doormile/agents/AgentInspectorDrawer.jsx | 5 +- src/pages/doormile/agents/AgentPipeline.jsx | 6 - .../doormile/agents/AgentWorkflowView.jsx | 5 - src/pages/doormile/agents/Agents.jsx | 65 +- .../doormile/clients/ClientOnboarding.jsx | 381 +++++++++ src/pages/doormile/clients/Tenants.jsx | 11 +- src/pages/doormile/deliveries/Deliveries.jsx | 40 +- src/pages/doormile/exceptions/Exceptions.jsx | 4 + src/pages/doormile/orders/CreateOrder.jsx | 48 +- src/pages/doormile/settings/Settings.jsx | 19 +- .../agentStudio/AgentConfigAndInsights.jsx | 551 ++++++++----- .../settings/agentStudio/AgentPlayground.jsx | 281 ++++--- .../agentStudio/AgentScopeNavigator.jsx | 42 +- .../settings/agentStudio/AgentStudio.jsx | 361 +++++---- .../settings/agentStudio/CreateSkillModal.jsx | 237 +++--- .../settings/agentStudio/SkillCard.jsx | 2 +- .../settings/agentStudio/SkillCardsGrid.jsx | 8 +- .../agentStudio/SkillDetailsDrawer.jsx | 141 +++- .../settings/agentStudio/ToolRegistryView.jsx | 25 +- .../settings/agentStudio/agentRegistryData.js | 230 +----- .../settings/agentStudio/registryAdapters.js | 133 ++++ tests/integration/agentStudio.test.jsx | 625 ++++++++++----- tests/integration/agentsPage.test.jsx | 132 ++-- tests/integration/clientOnboarding.test.jsx | 221 ++++++ .../createOrderRouteSummary.test.jsx | 108 +++ .../createOrderSavedContacts.test.jsx | 3 + tests/lib/AgentFactory.test.js | 66 ++ tests/lib/AgentOperationsBanner.test.jsx | 134 ++++ tests/lib/SkillRegistry.test.js | 82 ++ tests/lib/SlaRemediationCard.test.jsx | 125 +++ tests/lib/agentActions.test.js | 200 +++++ tests/lib/agentBriefing.test.js | 141 ++++ tests/lib/agentClickThrough.test.jsx | 172 +++++ tests/lib/agentRouting.test.js | 167 ++++ tests/lib/agentSingleEngine.test.js | 164 ++++ tests/lib/distance.test.js | 63 +- tests/lib/skills/CashExposureSkill.test.js | 67 ++ tests/lib/skills/DoorstepStallSkill.test.js | 42 + tests/lib/skills/FleetBalancerSkill.test.js | 39 + tests/lib/skills/HighValueCodSkill.test.js | 37 + tests/lib/skills/HubCongestionSkill.test.js | 50 ++ tests/lib/skills/LateDispatchSkill.test.js | 53 ++ .../skills/RiderBatterySafetySkill.test.js | 39 + tests/lib/skills/SlaGuardianSkill.test.js | 78 ++ tests/lib/useSkillRegistrySync.test.jsx | 48 ++ 73 files changed, 7268 insertions(+), 1134 deletions(-) create mode 100644 docs/agent-platform-plan.md create mode 100644 src/components/doormile/AgentOperationsBanner.jsx create mode 100644 src/components/doormile/SlaRemediationCard.jsx create mode 100644 src/lib/assistant/agent/AgentFactory.js create mode 100644 src/lib/assistant/agent/actions.js create mode 100644 src/lib/assistant/agent/briefing.js create mode 100644 src/lib/assistant/agent/normalise.js create mode 100644 src/lib/assistant/agent/signals.js create mode 100644 src/lib/assistant/skills/SkillRegistry.js create mode 100644 src/lib/assistant/skills/definitions/CashExposureSkill.js create mode 100644 src/lib/assistant/skills/definitions/DoorstepStallSkill.js create mode 100644 src/lib/assistant/skills/definitions/FleetBalancerSkill.js create mode 100644 src/lib/assistant/skills/definitions/HighValueCodSkill.js create mode 100644 src/lib/assistant/skills/definitions/HubCongestionSkill.js create mode 100644 src/lib/assistant/skills/definitions/LateDispatchSkill.js create mode 100644 src/lib/assistant/skills/definitions/RiderBatterySafetySkill.js create mode 100644 src/lib/assistant/skills/definitions/SlaGuardianSkill.js create mode 100644 src/lib/assistant/skills/index.js create mode 100644 src/lib/assistant/skills/useSkillRegistrySync.js create mode 100644 src/lib/clientOnboarding.js create mode 100644 src/pages/doormile/clients/ClientOnboarding.jsx create mode 100644 src/pages/doormile/settings/agentStudio/registryAdapters.js create mode 100644 tests/integration/clientOnboarding.test.jsx create mode 100644 tests/integration/createOrderRouteSummary.test.jsx create mode 100644 tests/lib/AgentFactory.test.js create mode 100644 tests/lib/AgentOperationsBanner.test.jsx create mode 100644 tests/lib/SkillRegistry.test.js create mode 100644 tests/lib/SlaRemediationCard.test.jsx create mode 100644 tests/lib/agentActions.test.js create mode 100644 tests/lib/agentBriefing.test.js create mode 100644 tests/lib/agentClickThrough.test.jsx create mode 100644 tests/lib/agentRouting.test.js create mode 100644 tests/lib/agentSingleEngine.test.js create mode 100644 tests/lib/skills/CashExposureSkill.test.js create mode 100644 tests/lib/skills/DoorstepStallSkill.test.js create mode 100644 tests/lib/skills/FleetBalancerSkill.test.js create mode 100644 tests/lib/skills/HighValueCodSkill.test.js create mode 100644 tests/lib/skills/HubCongestionSkill.test.js create mode 100644 tests/lib/skills/LateDispatchSkill.test.js create mode 100644 tests/lib/skills/RiderBatterySafetySkill.test.js create mode 100644 tests/lib/skills/SlaGuardianSkill.test.js create mode 100644 tests/lib/useSkillRegistrySync.test.jsx diff --git a/docs/agent-platform-plan.md b/docs/agent-platform-plan.md new file mode 100644 index 0000000..8e356b9 --- /dev/null +++ b/docs/agent-platform-plan.md @@ -0,0 +1,724 @@ +# Doormile Agent Platform — Plan & Agent Registry + +Status: **Phases 0–4 done (uncommitted, not deployed), Phase 5 next** · Written 2026-09-29 · Scope: `krow_talent_app` (the +Doormile console), `doormile_backend` (Go API), `AI_engine` (Python agent swarm). + +This is the document `src/pages/doormile/settings/Settings.jsx` has been pointing +at ("Phase 6 of docs/agent-platform-plan.md"). That file never existed until now, +so the phase numbers below replace the ones the comment assumed. + +--- + +## 1. Where things actually stand (verified 2026-09-29) + +### Console — `krow_talent_app` +- **Settings → Skills & Tools (Agent Studio) is a mock.** `agentRegistryData.js` + holds 2 agents, 3 surfaces, 4 skills and 6 tools. Everything is saved to + `localStorage` and nowhere else. The Test tab (`AgentPlayground.jsx:37`) is a + `setTimeout` that always replies "succeeded with 100% confidence". Configure and + Insights use hard-coded figures ("99.4%", "540 calls") and a model + `claude-3-5-sonnet` that nothing reads. +- **Three of the six mock tools are Krow leftovers:** `open_add_skill_training`, + `open_add_training` and `query_learning_analytics` (workforce training, not + logistics). Delete them; don't migrate them. +- **The Agents page** (`/doormile/agents`) runs on a static snapshot in + `src/lib/agentNetwork.js`, dated 16–20 Sep 2026. It is honest about being a + snapshot, but its test (`tests/integration/agentsPage.test.jsx`) is stale: that + is the 1 failing suite and 9 failing tests out of 45 suites. +- **Unmerged branch `feat/agentic-ops-layer`** (dharaneesh, 2026-09-01) is the only + real client-side agent work: + - `SkillRegistry` with 8 rule-based skills: SlaGuardian, DoorstepStall, + FleetBalancer, HighValueCod, RiderBatterySafety, HubCongestion, LateDispatch, + CashExposure. + - `tools.js` with 5 tools, following the rule "read-only tools execute, mutating + tools return a Proposal". + - AgentFactory, the ops briefing, proposal executors with a verify pass, and 20+ + test files. + - It is **29 commits behind main and conflicts in 10 files**. Its skill config is + also stored in `localStorage`. +- **The Home assistant** runs on the regex catalogue `src/lib/assistant/intents.js` + (109 KB) and has no concept of skills. +- **Other state:** lint shows 35 auto-fixable errors, and `Deliveries.jsx` has an + uncommitted cosmetic change (a tidy-up of the batch filter). + +### Backend — `doormile_backend` +- Build, vet and test all pass (Go 1.26.4). There are 233 routes; CLAUDE.md is + stale on counts, retry, routing, PIN auth and SMS (see its drift list). +- **Nothing for an agent registry exists yet.** `internal/ai/` is an empty, + untracked directory. +- **What does exist:** + - `models.AgentDecision` (table `agent_decisions`, pgvector 1536-dim — the + 384-vs-1536 question is still open). + - Internal routes, all behind `X-Internal-Key`: + - `POST /internal/agent-decisions` + - `GET /internal/agent-decisions/similar` + - `PATCH /internal/agent-decisions/:id/outcome` + - `/internal/express/{riders,bookings,assign}` +- **Admin authorization is flat.** Roles 1, 3 and 4 get identical access, and no + handler checks the role further. Many admin write handlers have no tenant guard. + So any registry write endpoint must add its own role check (see §5). + +### Engine — `AI_engine` +- **It has no registry to read from.** Agents are hard-coded classes in + `main.py:55-70`. `core/tool_registry.py` is an in-memory dict holding 3 tools, + and none of them load in production. The two "skills" in `core/skills/` are + stubs that nothing imports. +- **The agent process has no HTTP API.** The Command Center (FastAPI, :8600) is a + separate, unauthenticated NATS tap. +- **Runs, escalations and decisions live in memory only.** They are capped at 500 + and lost on restart. +- **Tests:** 72 pass, but only with pytest installed by hand; `requirements.txt` + lacks pytest. + +**Conclusion:** the registry must be **built**, not wired up. It belongs in +`doormile_backend` (Postgres). The console reads it, and the engine reads it. +Neither of them owns it. + +--- + +## 2. The Agent Registry — what gets seeded + +This is the real inventory. Every row here becomes a seed row in Phase 1. +"Status" is what the code does today, not what the docs claim. + +### 2.1 Agents + +| id | Class / file | Purpose | Trigger | Writes to system | LLM | Status | +|---|---|---|---|---|---|---| +| `JARVIS` | `MasterAgent` · `core/agent.py:196` | Orchestrator, escalation inbox | `logistics.direct.JARVIS` | none | — | Partial. The inbox works; `orchestrate_order` sends the wrong payload key | +| `DISPATCH_AGENT` | `agents/dispatch_agent.py:72` | Watches assignment outcomes, flags coverage gaps | JetStream `booking.assigned`, `booking.assignment_failed` | Alerts; customer notify only when autonomous | `decide_assignment_failure` | **Production-grade.** Idle until `assignment_failed` is confirmed as published (see §7) | +| `EXCEPTION_AGENT` | `agents/exception_agent.py:106` | Stalled-rider detection and response | TRACKING `miler.location.updated`, `miler.stalled`, plus a 60 s DB sweep | `POST /internal/bookings/:id/reassign` (needs autonomy flag and confidence ≥ 0.7), `/internal/notify` | `decide_stall_response` | **Production-grade** (the stall path only) | +| `EXPRESS_DISPATCH_AGENT` | `agents/express_dispatch_agent.py:80` | Tenant batch assign plus road sequencing | JetStream `express.dispatch_requested` | `POST /internal/express/assign` | — (greedy) | Implemented. Autonomy defaults to `true` in code and `false` in compose | +| `CUSTOMER_AGENT` | `agents/customer_agent.py:50` | Customer notifications | Direct tasks, only from an autonomous Dispatch | `/internal/notify` | — | Implemented, rarely reached | +| `ORDER_AGENT` | `agents/order_agent.py:15` | Order intake and validation | Direct tasks (no sender in production) | `/admin/crmbooking` — a **dead path, no auth header** | — | Broken. `update_status` crashes (`order_agent.py:182`) | +| `HUB_AGENT` | `agents/hub_agent.py:35` | Hub capacity | Direct tasks | none | — | **Simulation** (8 fictional hubs) | +| `FLEET_AGENT` | `agents/fleet_agent.py:34` | Vehicles | Direct tasks | none | — | **Simulation** (19 fake vehicles) | +| `ROUTE_OPTIMIZER` | `agents/route_optimizer_agent.py:41` | Routing | Direct tasks | none | — | **Simulation** (haversine only) | +| `CONSOLE_ASSISTANT` | `src/lib/assistant/*` (console) | Home chat: orders, bulk, assign, repeat | Operator prompt | Via proposals the operator confirms | RAG sidecar (off) | Live, regex-based | +| `CONSOLE_OPS_AGENT` | `feat/agentic-ops-layer` | Ops briefing plus 8 monitoring skills | Page load / poll | Via proposals the operator confirms | — (rules) | **Unmerged** | + +The registry shows `status` as one of `live | partial | simulation | broken | +unmerged | retired`. **The console must display this badge.** A simulation agent +that looks live is the exact failure the Agents page comment warns about. + +### 2.2 Tools (the real capabilities, not the stubs) + +`kind` is `read`, `write` or `notify`. Every `write` or `notify` tool has +`requires_confirmation = true` unless its agent is explicitly autonomous. + +| name | Owner agent(s) | Kind | Target | Today lives at | +|---|---|---|---|---| +| `reassign_booking` | EXCEPTION | write | Go `/internal/bookings/:id/reassign` | `exception_agent.py:466` | +| `notify_customer` | EXCEPTION, CUSTOMER | notify | Go `/internal/notify` | `exception_agent.py:476`, `customer_agent.py:349` | +| `list_express_bookings` | EXPRESS | read | Go `/internal/express/bookings` | `express_dispatch_agent.py:351` | +| `list_express_riders` | EXPRESS | read | Go `/internal/express/riders` | `express_dispatch_agent.py:342` | +| `assign_express_batch` | EXPRESS | write | Go `/internal/express/assign` | `express_dispatch_agent.py:222` | +| `sequence_stops` | EXPRESS | read (compute) | `routes.workolik.com /optimization/doormile/sequence` | `express_dispatch_agent.py:308` | +| `get_booking_cache` | CUSTOMER | read | Go `/bookings/cache/:id` | `customer_agent.py:243` | +| `nearby_milers` | DISPATCH, EXCEPTION | read | Redis GEO `milers:locations` | `dispatch_agent.py:170-240` | +| `publish_miler_stalled` | EXCEPTION | write (event) | NATS `miler.stalled` | `exception_agent.py:377` | +| `decide_stall_response` | EXCEPTION | read (LLM) | Claude | `core/llm.py:157` | +| `decide_assignment_failure` | DISPATCH | read (LLM) | Claude | `core/llm.py:227` | +| `record_agent_decision` | all | write | Go `/internal/agent-decisions` | Go route exists | +| `diagnose_operations` | CONSOLE_OPS | read | Console queries | branch `tools.js` | +| `lookup_order` | CONSOLE_OPS | read | `/admin/bookings` | branch `tools.js` | +| `lookup_miler` | CONSOLE_OPS | read | `/admin/milers` | branch `tools.js` | +| `propose_miler_reassignment` | CONSOLE_OPS | write → proposal | `/admin/bookings/:id/assign-miler` | branch `tools.js` | +| `simulate_pricing_quote` | CONSOLE_OPS, CONSOLE_ASSISTANT | read | `/admin/pricing/simulate` | branch `tools.js` | +| `create_single_order` | CONSOLE_ASSISTANT | write → proposal | `/admin/expressbooking` | current mock | +| `rebalance_riders` | CONSOLE_OPS | write → proposal | `/hub/bookings/batch-assign` | current mock (no backing code) | + +**Not seeded:** +- `ask_question`, `order_intake_skill`, `repeat_run_skill` (engine stubs that are + never loaded). +- The 3 Krow training tools. +- ORDER_AGENT's `crmbooking` calls: that route was renamed to `expressbooking`, so + those calls hit nothing. + +### 2.3 Skills + +A skill is a named behaviour that belongs to one agent and uses one or more tools. +It has a switch (`enabled`) and tunable `thresholds` (JSON, validated against a +per-skill schema). + +| id | Agent | Tools | Source | +|---|---|---|---| +| `stall_response` | EXCEPTION | nearby_milers, decide_stall_response, reassign_booking, notify_customer | engine | +| `assignment_failure_triage` | DISPATCH | nearby_milers, decide_assignment_failure | engine | +| `express_batch_dispatch` | EXPRESS | list_express_*, assign_express_batch, sequence_stops | engine | +| `sla_guardian` · `doorstep_stall` · `fleet_balancer` · `high_value_cod` · `rider_battery_safety` · `hub_congestion` · `late_dispatch` · `cash_exposure` | CONSOLE_OPS | branch `tools.js` set | branch (thresholds move from localStorage to the registry) | +| `order_intake_auto_schedule` | CONSOLE_ASSISTANT | create_single_order, simulate_pricing_quote | current mock, real behaviour in `orderFlow.js` | +| `dispatch_rebalance` | CONSOLE_OPS | rebalance_riders | current mock. **Keep disabled** until a backing endpoint exists | + +### 2.4 Surfaces +`/doormile/home` (assistant), `/doormile/control-x` (dispatch board), +`/doormile/agents` (status board), and the ops banner (branch +`AgentOperationsBanner`). `/doormile/dispatch` is only a redirect to Control X, so +it is not a separate surface. + +--- + +## 3. Schema (Phase 1) — ⚠ additive schema change, review before deploy + +New tables in `doormile_backend`, added through GORM AutoMigrate. They are +additive only and touch no existing table. + +``` +ai_agents id text PK, name, class_ref, runtime ('engine'|'console'), + purpose, trigger jsonb, status, autonomous bool, + model text NULL, created_at, updated_at +ai_tools name text PK, description, kind ('read'|'write'|'notify'), + target text, input_schema jsonb, requires_confirmation bool, + enabled bool, created_at, updated_at +ai_skills id text PK, agent_id FK→ai_agents, title, category, + description, sample_prompt, enabled bool, + thresholds jsonb, thresholds_schema jsonb, + version int, updated_by int NULL, updated_at +ai_skill_tools skill_id FK, tool_name FK, PRIMARY KEY (skill_id, tool_name) +ai_registry_audit id bigserial, entity, entity_id, field, old jsonb, new jsonb, + changed_by int, changed_at +``` + +Rules: +- **Tools and agents are code-defined.** The console can toggle `enabled` and + `autonomous`, but it cannot invent a tool. A tool with no implementation is a lie + on screen. "New skill" in the console therefore picks from existing tools only. +- **Every write goes into `ai_registry_audit` in the same transaction.** +- **Seeding is idempotent** (upsert by id) and lives in `migrations/`, not + `scratch/`. +- **Never store a secret.** Env var *names* only; the engine's `.env` stays out of + the registry. + +--- + +## 4. API contract (Phase 1) + +All handlers go in `controllers/aiRegistryController.go` and the logic in +`internal/ai/registry`. Responses use `utils.OK` / `utils.List`, following the +console conventions. + +| Method | Path | Auth | Notes | +|---|---|---|---| +| GET | `/admin/ai/agents` | admin (1,3,4), Doormile staff only | Includes a `status` badge and skill/tool counts | +| GET | `/admin/ai/agents/:id` | same | Agent plus its skills and tools | +| GET | `/admin/ai/skills` | same | `?agent=` filter | +| GET | `/admin/ai/tools` | same | `?kind=` filter | +| PATCH | `/admin/ai/skills/:id` | **roleid 1 only** | Allowed fields: `enabled`, `thresholds` (validated against schema). Bumps `version` and writes an audit row | +| POST | `/admin/ai/skills` | roleid 1 only | New skill from existing tools only | +| PATCH | `/admin/ai/agents/:id` | roleid 1 only | Allowed fields: `autonomous`, `model`. **Extra confirm in UI** — this changes what an agent does without a human | +| GET | `/admin/ai/audit` | admin | Registry change history | +| GET | `/admin/ai/decisions` | admin | Paged `agent_decisions` (Phase 4) | +| GET | `/internal/ai/registry` | `X-Internal-Key` | The engine pulls its config from here (Phase 5). It sends an ETag so the engine can poll cheaply | + +"Doormile staff only" means `consoleTenantID == 0`. A tenant or client login gets +403. It does **not** get an empty list, because an empty list would hide a +misconfiguration. + +--- + +## 5. Phases + +Each phase ships on its own and leaves the system working. Nothing is committed or +pushed without an explicit ask. + +### Phase 0 — Prerequisites and hygiene (small, do first) +1. **Rotate secrets and untrack them.** + - `doormile_backend`: `.env` and `doormile-abee7-*.json` are tracked; the + `.gitignore` line `#.env` is commented out. + - `AI_engine`: `.env` is tracked. + - `config/config.go:60-71` has hard-coded fallback secrets. Fail at boot + instead. + - This is a **user action** (rotation needs the providers' consoles). I can do + the untracking and ignore rules. +2. **Decide `feat/agentic-ops-layer`** (see §6, decision A). +3. **Console:** fix the stale `agentsPage.test.jsx`, run `lint:fix` for the 35 + unused imports, and delete the 3 Krow training tools from + `agentRegistryData.js`. +4. **Add a PREVIEW banner** on the Agent Studio tab itself. Today only a code + comment says so; Agents shows a snapshot label, Agent Studio shows nothing. +5. **Backend:** remove the broken `.claude/skills/*` symlink stubs, `.agents/`, + `skills.md` and `skills-lock.json`. The global plugin already provides these + skills. + +**Done when:** tests are green, lint is clean, and no secret is in `git ls-files`. + +**Phase 0 result (2026-09-29, uncommitted):** +- Console: lint clean; **46/46 suites, 1192 tests pass**. +- Agent Studio: 4 Krow tools and 2 Krow skills removed (the plan said 3 tools; + `open_miletruth_ai` was a fourth). `dispatch_rebalance` ships disabled. + Storage keys moved to `_v3`. An on-screen Preview note was added. +- Agents page: this was not just a stale test. The 24–25 Sep rebuild presented + a simulation as live. Per decision, the design was kept and labelled: + - a `Snapshot · 16–20 Sep 2026` stamp; + - "Sample Activity — Simulation · not live data"; + - no pulsing dot; + - status counts taken from `networkStats()`; + - "Autonomy gates on: 0 / 3", where the old "0 / 8" implied 8 gates. + The tests were rewritten, keeping the honesty checks. +- Backend: + - **Reverted 2026-09-29 at Suriya's request:** these are back in git + exactly as at HEAD: + - the untracking of `.env` and the service-account key (the key is + still committed, so rotation still stands); + - the `.gitignore` edit; + - the removal of `skills.md`, `skills-lock.json`, `.agents/` and + `.claude/skills/`. + AI_engine's `.env` is tracked again too. Do not redo any of this without + asking. + - `main.go` now refuses to boot when `ENV=production` and `JWT_SECRET_KEY`, + `DB_PASSWORD` or `NATS_PASSWORD` is unset. + - build, vet and test are green. +- AI_engine: `.env` was untracked (it was already in `.gitignore`). +- **Still yours:** + - Rotate every secret that was committed. Git history still holds the + values. + - **Before the next backend deploy,** confirm that production sets all + three secrets. If it has been running on a fallback, the new check stops + it from starting. + +### Phase 1 — Registry in the backend +- Add the §3 tables, the idempotent seed from §2, and the §4 read endpoints plus + PATCH/POST with the role check and audit trail. +- **Tests:** a seed-idempotency test, a role test (roles 3 and 4 get 403 on PATCH; + a tenant login gets 403 on GET), a threshold-schema validation test, and an audit + test. +- **Done when:** `go build/vet/test` is green and `curl` against a staging DB + returns the §2 inventory. + +**Phase 1 result (2026-09-29, uncommitted, NOT deployed, no real DB touched):** +- **Tables.** They follow the codebase's naming, not the names in §3: + `aiagents`, `aitools`, `aiskills`, `aiskilltools`, `airegistryaudit`. + The columns are as in §3, with a few changes: + - The agent's trigger column is named `wakeon`. + - `hasautonomygate` is new. Autonomy can only be set on Dispatch, + Exception and Express. + - `source` (engine/console/custom) is on skills. + - There is no `enabled` flag on tools. +- **Code.** + - `internal/ai/registry`: seed, rules, store. + - `controllers/aiRegistryController.go` + - `middlewares/staff_only.go` (`DoormileStaffOnly`) + - Routes are under `/admin/ai/*` and `/internal/ai/registry`, as in §4. + - The seed runs in `migrations.Migrate`. It logs a failure and does not + stop the boot. +- **Seed.** 11 agents, 26 tools and 15 skills. + - Four skills were added to §2.3 so that every tool belongs to a skill: + `customer_notifications`, `ops_briefing`, and the 8 branch tools + folded into their skills. + - The console ops skills keep the branch ids and threshold keys, so + Phase 3 is a 1:1 mapping. + - `record_agent_decision` was dropped. It is a log the Go side writes, not + a capability. +- **Rules enforced server-side.** + - Switching autonomy ON needs `confirm` set to the agent id. + - Model ids must match `claude-*`, and only engine agents have one. + - Thresholds are validated for range and step, and a patch is all-or-nothing. + - Custom skills can be added to console agents only, and only from existing + tools. + - A patch that changes nothing does not bump the version or write an audit + row. +- **Tests.** + - 26 unit tests and 6 HTTP gate tests always run. + - 10 Postgres integration tests and 1 HTTP end-to-end test run only when + `REGISTRY_TEST_DSN` is set. They need a throwaway database; each package + uses its own schema. + - All of them passed against a disposable `postgres:16-alpine` container. +- **Bug the Postgres run caught.** With `enabled` tagged `default:true`, gorm + dropped `false` from the INSERT, so `dispatch_rebalance` came up + **enabled**. Fixed by removing the column default. +- **Not yet proven.** The migration has not run against the real database; + that happens on your next deploy. It is 5 new tables and touches nothing + existing. + +### Phase 2 — Console Agent Studio reads the registry +- Replace `agentRegistryData.js` and its `localStorage` with React Query hooks + (`useAiAgents`, `useAiSkills`, `useAiTools`) in `src/lib/doormileHooks.js`, and + add the endpoints to `src/api/doormile/endpoints.js`. +- Keep the existing components; only the data source changes. Also show the status + badge, the tool `kind`, and a confirmation marker. +- The skill toggle and "New skill" become real PATCH and POST calls. Add a + `loading`/`error` state; remove the optimistic toast that claims success before + the server answers. +- Configure tab: model and autonomy from the registry. The temperature slider is + **dropped**, because nothing reads it. +- Insights and Test stay behind the PREVIEW banner until Phases 4 and 6. +- Rewrite `tests/integration/agentStudio.test.jsx` against mocked hooks. +- **Done when:** a toggle made in one browser shows in another, and survives a + reload. + +**Phase 2 result (2026-09-29, uncommitted, NOT deployed):** +- **Console.** + - Endpoints were added to `api/doormile/endpoints.js`: `getAiAgents`, + `getAiSkills`, `getAiTools`, `updateAiSkill`, `createAiSkill`, + `updateAiAgent` and `getAiRegistryAudit`. + - Hooks were added in `lib/doormileHooks.js`: `useAiAgents`, `useAiSkills`, + `useAiTools` and three mutations. They share one `['doormile','ai']` key. + - The adapter layer is `agentStudio/registryAdapters.js`, a set of pure + functions. + - `agentRegistryData.js` now keeps only the surfaces list and the + selected-agent preference. +- **Changes on screen.** + - Skills are filtered to the selected agent; before, every agent's skills + showed. + - Agent status badges appear in the switcher. + - The skill drawer has a real enable toggle, a threshold editor (range and + step checked in the browser, then on the server), and shows source and + version. + - The tool table shows the kind, "Used by", the system each tool touches + and where it is implemented. It no longer calls a read-only tool + "Autonomous". + - Configure shows the agent's record, a model picker (engine agents only) + and an autonomy switch (gated agents only) with a typed confirmation. + The fake temperature slider and GPT/DeepSeek list are gone. + - Insights shows "No run data yet" instead of invented figures. + - "New skill" is disabled on AI_engine agents and for anyone who is not an + admin. + - With no saved choice the page opens on the first agent that has skills, + not on JARVIS, which has none. +- **Backend addition.** `airegistryaudit.changedbyemail` records the token's + email. The end-to-end run showed every audit row with `changedby = 0`: an + admin login without an appusers row carries user id 0. +- **Tests.** + - Console: 18 Agent Studio tests (adapters plus the page with only HTTP + mocked). The full suite is 46/46 suites and 1193 tests, and lint is clean. + - Backend: everything is green with the database attached, including the + parallel run that clashed before per-package schemas. +- **End-to-end, in a real browser, fully local.** + - Setup: a throwaway Postgres; the backend running with no `.env` and every + host pinned to localhost; a second console on :5174; throwaway admin and + manager logins. + - Checked in the database: seed counts, the toggle, the threshold change, + autonomy on (with confirmation) and off, and custom skill creation, each + with its audit row. + - Checked in the browser: the manager view is read-only. + - Checked by direct API call: a manager's write gets 403. + - Everything was removed afterwards: container, image, scripts, test + credentials and temporary launch entries. + +### Phase 3 — Land the ops-layer skills on main +- Port the 8 rule-based skills, `tools.js`, the proposal executors and the banner + from the branch onto current main. Resolve the 10 conflicts; the branch's + `Deliveries.jsx` edits collide with today's uncommitted change. +- `SkillRegistry` then reads `enabled`/`thresholds` from `/admin/ai/skills` and + falls back to code defaults when offline. It stops reading `localStorage`. +- **Keep the branch invariant:** write tools return Proposals, and a human + confirms. +- **Done when:** the branch's 20+ test files pass on main, and a threshold changed + in Agent Studio changes the banner's output. + +**Phase 3 result (2026-09-29, uncommitted, NOT deployed):** +- **Ported, as unstaged file copies (no merge).** + - The 8 skill definitions. + - `agent/{AgentFactory,signals,normalise,briefing,actions}.js`. + - `SlaRemediationCard`. + - `AgentOperationsBanner`, mounted on the Exceptions page. + - The `opsBriefing` chat intent in `lib/assistant/intents.js`. + - The "Needs attention" chip on the Exceptions context. + - 11 branch test suites. +- **Settings.** `SkillRegistry` now takes enabled and thresholds from + `/admin/ai/skills` through `useSkillRegistrySync`, mounted once in + `AdminLayout`. Nothing is kept in localStorage. It falls back to code + defaults, and says so on the banner, when the registry cannot be read. +- **Not ported.** + - `tools.js`: nothing imported it. + - `AgentStudioModal`: a second, localStorage-only settings UI. "Configure + skills" goes to Settings → Skills & Tools instead. + - `AgentDecisionDrawer` and the `/internal/agent-decisions` endpoints: they + return 403 from the console. + - The AI-panel "Autonomous Fleet Agent" card, and the branch's cosmetic + edits. +- **Defects found and fixed while porting.** + 1. `OpenToast('success', msg)` has its arguments swapped; the signature is + `(message, variant)`. Every successful action would have shown a red + error toast reading "success". The branch's tests asserted the same + wrong order. + 2. The `assignMiler` executor posted to `/hub/bookings/batch-assign`, which + is behind `HubStaffAuth` (role 6 only). Every console click would 403. + It is now review-only, with the reason in `actions.js`. + 3. **Three skills could never fire.** High-Value COD, Cash Exposure and + Battery Safety read payment and battery fields that `/admin/bookings` + rows do not carry. They would report a false all-clear. They ship **off** + (`dataGap` in code, `Enabled: false` plus the reason in the seed). + 4. The chat trigger was narrowed. It no longer claims "late/delayed orders" + or "operations summary", which would have replaced existing answers. + Routing tests pin both directions. + 5. `SlaRemediationCard.test.jsx` could not have run: no `lucide-react` stub. +- **Registry seed updated to match main.** + - `CONSOLE_OPS_AGENT` is `live`. + - Paths point at main. + - The console tools are now the proposal verbs: `scan_bookings`, + `notify_riders` (the only executor), `assign_riders` (review-only), and + five review-only actions, each labelled REVIEW ONLY. + - New backend tests guard the no-data skills and the review-only labels. +- **Tests.** + - Console: 64/64 suites, 1308 tests; lint is clean and the build is green. + - Backend: build, vet and all tests are green. The Postgres-gated tests were + not re-run; the seed change is data-only and unit-tested. + - `Deliveries.jsx` was untouched; the uncommitted change there is still only + Suriya's. +- **Open items.** + - Feeding the three off skills needs `/admin/bookings` to include payment + amounts and mode, and the rider's battery. That is a backend + response-shape change. + - "Assign riders" needs an admin batch-assign route. `useBatchAssignBookings` + has the same 403 problem, but nothing calls it. + - `lib/assistant/CLAUDE.md` is now stale: it says proactive alerts were + "not started" and that the `components/assistant` copies are live, but + `AIPanel` imports `lib/assistant`. + +### Phase 4 — Persist decisions and runs (makes Insights real) +- A Go NATS consumer on `telemetry.task` plus the engine's LLM decisions, written to + `agent_decisions`, plus a new `ai_agent_runs` table (⚠ schema change). +- `/admin/ai/decisions` and run stats feed the Insights tab and the Agents page, + replacing the `agentNetwork.js` snapshot. +- **Resolve before relying on vector search:** the `context_embedding` + 1536-vs-384 dimension question. + +**Phase 4 result (2026-09-29, uncommitted, NOT deployed):** +- **Finding.** Nothing in doormile_backend or AI_engine writes + `agent_decisions`. routemate (external) returns an `agent_decision_id` from + `/decide-assignment`, so it presumably writes through + `POST /internal/agent-decisions`. Whether production has rows is unverified. + AI_engine's two LLM decisions (stall, assignment-failure) are **not** + persisted anywhere; they appear only in logs. +- **Backend.** + - One new table, `aiagentruns`: append-only, unique on (agentid, taskid), + pruned after 30 days. + - `internal/ai/telemetry` queue-subscribes (`doormile-backend-telemetry`) to + `telemetry.task`, and writes runs in batches (2 s / 200). The NATS callback + never blocks: a full buffer drops the event, counts it and logs it. + - `telemetry.agent` heartbeats go to Redis (`ai:agent:state:`, 5-minute + TTL), not Postgres. + - New endpoints, Doormile staff only: `GET /admin/ai/insights?days=1..30` + (runs, failures and average time per agent; decisions by type and outcome; + live state; a `receiving` flag) and `GET /admin/ai/decisions` + (keyset-paged; the `context` column is excluded because it holds rider + data). + - Windows use the backend clock (`utils.DBNow`). The engine's naive + timestamp is stored for display only. +- **Console.** Insights shows those figures with a 24 h / 7 d / 30 d window. + Silent agents appear as "silent" with zero runs rather than being left out. + When `receiving` is false, the page says telemetry is not received rather + than showing "0 runs" as if nothing happened. +- **Tests.** + - Backend: 11 telemetry unit tests, a Postgres-gated suite, and the new + routes in the gate tests. + - All 14 Postgres-gated tests (Phases 1 and 4) pass against a throwaway + `postgres:16-alpine`. This includes the parallel `go test ./...` run. + - Console: 64/64 suites, 1310 tests, lint clean, build green. +- **Live end-to-end run (2026-09-29, local, throwaway; all removed after).** + - Setup: throwaway Postgres and NATS; the real backend binary with no + `.env`; engine-shaped telemetry published over raw NATS. + - Six messages produced three rows. The redelivered task was ignored by the + unique index. The malformed event was dropped. The heartbeat was not + written to Postgres. + - `/admin/ai/insights` returned the right totals, failures and averages. + `/admin/ai/decisions` paged correctly and did not leak `context`. + - The Insights tab rendered the same figures with correct IST times. +- **Bugs the live run caught, fixed.** + 1. The recorder stamped `utils.DBNow()` into a **timestamptz** column, which + AutoMigrate creates for new tables. A run received at 20:57 IST read back + as 02:27 the next day. It now uses `time.Now()`, and the window cutoffs + do too. `TestRecorderStampsARealInstant` fails with "5h30m off" if + DBNow comes back. + **Wider note:** `utils.DBNow` is only correct for the legacy + timestamp-WITHOUT-zone columns. Any table AutoMigrate creates fresh is + timestamptz, so audit other new tables before using DBNow in them. + 2. Two Phase 1 Postgres fixtures still used `lookup_order`/`lookup_miler`, + which Phase 3 removed from the seed. + 3. The Skills & Tools note still said the console skills were "not merged". + It now says they run on these settings, and that AI_engine does not + read them yet. +- **Deploy prerequisites.** + 1. The backend's `NATS_URL` must point at the same NATS server AI_engine + publishes to (`NATS_HOST`/`NATS_PORT` there). Otherwise Insights shows + "Not receiving agent telemetry". + 2. The Agents page still uses the 16–20 Sep snapshot. Moving it onto + `/admin/ai/insights` is a follow-up; it is dharaneesh's page. + 3. To see AI_engine's LLM decisions in Insights, the engine would have to + POST them to `/internal/agent-decisions`. That is engine work (Phase 5). + +### Phase 5 — Engine reads the registry +- The engine polls `GET /internal/ai/registry` (ETag, around 30 s) and applies + `enabled`, `autonomous`, `model` and thresholds without a restart. Today the + autonomy flags are read once at import. +- **Fix before any agent is shown as live:** + - `order_agent.py:182` (the enum does not exist) + - the JARVIS→ORDER payload key (`agent.py:279` vs `order_agent.py:144`) + - the DISPATCH→HUB id mismatch (`dispatch_agent.py:268` vs `hub_agent.py:151`) + - `release_vehicle_for_cancel` has no handler (`exception_agent.py:692`) + - messages without a `task_type` are silently dropped + - ORDER_AGENT still calls the renamed `crmbooking` route, with no auth header +- Mark HUB, FLEET and ROUTE_OPTIMIZER as `simulation` in the seed, or retire them. +- Add `pytest` and `pytest-asyncio` to `requirements.txt`. + +#### Phase 5 — result (2026-09-29, uncommitted, not deployed) +- **Registry client** (`AI_engine/core/registry.py`). + - Polls `/internal/ai/registry` every `REGISTRY_POLL_SECONDS` (30 by default) + with `If-None-Match`. It is started from `main.py --production`. + - Precedence: once the registry has loaded, its value applies. Before that, + or if it never loads, the old env default applies. + - The last good copy survives 401/5xx/timeouts, so autonomy cannot flip + mid-shift. + - With no `INTERNAL_API_KEY`, it logs once and the engine runs on env + defaults. +- **What each agent now reads.** + + | Agent | Skill gate | Autonomy | Other settings | + |---|---|---|---| + | Exception | `stall_response` | `EXCEPTION_AGENT` | `stallMinutes`, `reassignConfidence`, model | + | Dispatch | `assignment_failure_triage` | `DISPATCH_AGENT` | `realertEvery`, model | + | Express Dispatch | `express_batch_dispatch` | `EXPRESS_DISPATCH_AGENT` | `maxPerRider`, `maxRadiusKm`, `loadPenaltyKm` | + | Customer | `customer_notifications` | — | — | + + A disabled skill means the agent logs the event and does nothing. +- **Model override.** + - `core/llm.request_params(model)` uses the agent's pinned model, or + `LLM_MODEL` when none is pinned. + - For a Haiku pin, thinking and effort are left out, because Haiku rejects + them (400). + - The console picker offers Opus 5.5, Sonnet 5.5 and Opus 4.8, plus "Engine + default". Haiku is left out because it is too weak for these decisions. +- **Decisions logged.** + - Stall and assignment-failure decisions are POSTed to + `/internal/agent-decisions` as `{decision_type, booking_id, context:{facts, model}, decision:{action, confidence}, reasoning}`. + - The post is fire-and-forget, so a slow backend never delays the reaction. + - These decisions now appear in Insights → Latest decisions. +- **Behaviour fix.** When the LLM is down and the agent is *not* autonomous, + it now escalates to a human. Before, it reassigned regardless of the + autonomy flag. +- **Message bugs fixed.** + 1. `ORDER_STATUS_UPDATE` was added to the enum. + 2. JARVIS→ORDER now sends `order_id`. + 3. DISPATCH no longer forwards to HUB `prepare_receiving`. It sent a booking + id to a fictional-hub simulation. + 4. FLEET handles `release_vehicle_for_cancel`, finding the vehicle by order + id. + 5. CUSTOMER records `ORDER_CANCELLED` and `NOTIFICATION_SENT` instead of + dropping them. These come from simulated records, so no real customer + message is sent. + 6. ORDER_AGENT refuses its backend calls and logs why. The seed now says it + is not connected. It stays `broken`. +- **Simulation agents.** HUB, FLEET and ROUTE_OPTIMIZER stay seeded as + `simulation`, and the Studio note says so. +- **Tests.** + - AI_engine: 95 unittest tests. 28 are new, in + `tests/test_registry_phase5.py`, all with no network. Three + `test_dispatch_agent` mocks were updated for the `model` argument. + - The only failures are the two modules that import pytest, and they failed + before this work. `pytest` and `pytest-asyncio` are now in + `requirements.txt` but are **not installed** in the venv. + - Console: Agent Studio suite green. Backend: `internal/ai/...` green. +- **Deploy prerequisites.** + 1. The engine needs `GO_API_BASE_URL` and `INTERNAL_API_KEY`, the same key + the backend checks. + 2. Before deploying, check the registry's current `enabled` and + `autonomous` values. On deploy they replace the env flags + (`AUTONOMOUS_REASSIGN` and the others). + +### Phase 6 — Real Test playground +- Replace the `setTimeout` simulation with a backend endpoint that runs one prompt + through Claude tool-use, using the tools from the selected skill (their + `input_schema` from the registry). +- **Read tools execute. Write tools return a Proposal only** — the playground never + mutates production. +- Show the real trace: tool calls, arguments, results, latency and tokens. The + model comes from the registry. +#### Phase 6 — result (2026-09-29, uncommitted, not deployed) +- **Decisions:** use the official Go SDK, and redact personal data before + anything reaches Claude. **The SDK download was blocked by this machine's + permission check.** Everything else is built behind a `Model` interface. + The one missing piece is the ~80-line adapter from `playground.Request` to + `anthropic.MessageNewParams`. It needs + `go get github.com/anthropics/anthropic-sdk-go`, run or approved by a person. + Until then, `controllers.PlaygroundModel` is nil. The endpoint answers 503 + `PLAYGROUND_NOT_CONFIGURED` and the Test tab says so; it never pretends to + run. +- **Backend** (`internal/ai/playground`, `controllers/aiPlaygroundController.go`). + - `POST /admin/ai/playground/run {agentid, skillid?, prompt}`, for Doormile + staff with roleid 1 only. The limit is 10 runs per user per 10 minutes, + prompts are at most 2000 characters, and a run times out after 120 s. + - The loop runs at most 6 turns with max_tokens 4096. The model comes from + the agent's registry pin, or `claude-opus-5-5` if none. The skill's tools + come from the registry, with `input_schema` taken from `inputschema`. + - Tool outcomes: + + | Tool | Outcome | + |---|---| + | read, served by the backend (`get_booking_cache`, `scan_bookings`, `nearby_milers`) | **executed**, 5 s timeout | + | read, engine-only or external (`decide_*`, `sequence_stops`, `simulate_pricing_quote`, `list_express_*`) | **unavailable** | + | write / notify / event | **proposed**. Never executed; the model gets `{executed:false, proposal}` | + | not in the selected skill | **rejected** | + + - **Redaction:** + - Executors select named non-personal columns only. There is no address, + name, phone or notes column, and coordinates are rounded to 2 dp. + - `Redact` then masks personal keys (name, phone, address, email, note, + reason, …) plus any email or Indian mobile number found in a string. + - Results are capped at 16 KB. + - The seed now gives `nearby_milers` (lat, lon, radius_km) and + `scan_bookings` (status, limit) real input schemas. +- **Console:** + - The Test tab calls the endpoint and shows the server's trace: each tool + call with its input, outcome, result and ms, plus the model, turns, + tokens and time. + - It has a skill picker, and "Test in Playground" preselects that skill. + Non-admins can't run it. + - 503, 429 and 403 errors each show a plain message. + - The setTimeout simulation is gone. +- **Tests:** + - Backend: 11 unit tests. They cover Prepare, every outcome, the turn limit, + model errors, truncation and redaction, including that dates are not + masked. + - A Postgres-gated test proves that personal columns in the table are never + selected. It passed on a throwaway `postgres:16-alpine`, which was removed + afterwards. + - Route tests: the gates, the 503 with no client, and bad input refused + before the model is called. + - Console: 5 new tests. Now 64/64 suites and 1315 tests; build green. +- **To switch it on:** + 1. Add the SDK and the adapter. + 2. Set `ANTHROPIC_API_KEY` on the backend. + 3. Wire `controllers.PlaygroundModel` in `main.go`. +- **Earlier notes:** + 1. **Official Go SDK or raw HTTP.** The Go SDK + (`github.com/anthropics/anthropic-sdk-go`) is not in the module cache, so + using it means a module download plus a new `go.mod` dependency. + 2. **Data leaving for the Claude API.** The read tools (`scan_bookings`, + `get_booking_cache`, `nearby_milers`, `list_express_*`) return live + customer names, phones and addresses. Running them in the playground sends + that data to Anthropic. The options are: allow it; redact PII before it is + sent; or run against fixtures only. + - The backend also needs `ANTHROPIC_API_KEY`. Without it, the endpoint + returns 503 and the Test tab stays a labelled simulation. + +--- + +## 6. Decisions + +**Ratified 2026-09-29:** A = port, C = roleid 1 only. The Agents page is kept +and labelled. The secret check fails at boot in production only. B, D and E +follow the recommendations below unless changed. + +| # | Decision | My recommendation | +|---|---|---| +| A | `feat/agentic-ops-layer`: merge or port? | **Port** the skills, tools and executors onto current main (Phase 3). Don't merge a 29-commit-stale branch with 10 conflicts. First confirm with dharaneesh that nothing newer exists elsewhere | +| B | Where does the registry live? | **`doormile_backend`/Postgres.** The engine has no API or persistence, and the console must not be the source of truth | +| C | Who may edit skills and autonomy? | **roleid 1 only.** Roles 3 and 4 read only. Today 1, 3 and 4 are identical everywhere, so this needs an explicit check | +| D | May the console toggle agent autonomy (auto-reassign riders, auto-notify customers)? | Yes, but only with a typed confirmation and an audit row. It stays **off** by default, matching compose | +| E | Delete or relabel the simulation agents (HUB, FLEET, ROUTE_OPTIMIZER)? | Seed them as `simulation`. Delete later if nobody objects | + +--- + +## 7. Open questions (need checking, not guessing) +- **Is `booking.assignment_failed` published?** The engine's handoff doc says Go + doesn't publish it yet. Backend CLAUDE.md §4 names `publishAssignmentFailed`, and + the new retry window says `assignment_failed` fires after the first round. Check + which stream and subject it actually uses against what DISPATCH_AGENT binds to. +- **Which model is live?** The engine defaults to `LLM_MODEL=claude-opus-4-8`; the + console mock shows `claude-3-5-sonnet`. The registry's `model` field should hold + the id that is actually deployed. Set per agent, a cheaper model such as Haiku is + enough for the stall and assignment decisions. +- **Registry write safety:** admin handlers today are not tenant-guarded for + global data (pricing, hubs, app users). The registry endpoints must not copy that + pattern. diff --git a/src/App.jsx b/src/App.jsx index 674745a..d71422f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -28,6 +28,7 @@ const Deliveries = lazy(() => import('@/pages/doormile/deliveries/Deliveries')); const Tenants = lazy(() => import('@/pages/doormile/clients/Tenants')); const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient')); +const ClientOnboarding = lazy(() => import('@/pages/doormile/clients/ClientOnboarding')); const CreateCustomer = lazy(() => import('@/pages/doormile/clients/CreateCustomer')); const Pricing = lazy(() => import('@/pages/doormile/pricing/Pricing')); @@ -124,6 +125,8 @@ const AuthenticatedApp = () => { } /> } /> + {/* Owner-only (admin@doormile.com); the page and the server both check. */} + } /> } /> } /> diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index 042c0b9..c068798 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -166,6 +166,31 @@ export const createAdminTenant = async (data) => { return response.data; }; +/** + * Onboard a client: the tenant, its console login (doormile_auth) and its + * appusers row, in one server transaction. Only the onboarding owner login + * (admin@doormile.com) may call it; anyone else gets 403. + * @param {{ companyname: string, contactname: string, email: string, phone: string, + * password: string, applocationid: number, requiredeliveryotp?: boolean }} data + * @returns {Promise<{ success: boolean, data: { tenant: object, login: object } }>} + */ +export const onboardClient = async (data) => { + const response = await doormileAxios.post('/admin/clients/onboard', data); + return response.data; +}; + +/** Operating cities a new client can be placed in (applocations; owner login only). */ +export const getOnboardingCities = async () => { + const response = await doormileAxios.get('/admin/clients/cities'); + return response.data.data; +}; + +/** Clients that have a console login, newest first (owner login only). */ +export const getOnboardedClients = async () => { + const response = await doormileAxios.get('/admin/clients/onboarded'); + return response.data.data; +}; + export const getAdminTenant = async (id) => { const response = await doormileAxios.get(`/admin/tenants/${id}`); return response.data.data; @@ -728,3 +753,80 @@ export const deleteCarrierPricing = async (id) => { const response = await doormileAxios.delete(`/admin/carrier-pricing/${id}`); return response.data; }; + +// ==============================|| AI agent registry ||============================== // +// /admin/ai/* — Doormile staff only (a partner-tenant login gets 403). Reads +// are open to admin/manager/executive; every write is admin-only and audited +// server-side. See docs/agent-platform-plan.md. + +export const getAiAgents = async () => { + const response = await doormileAxios.get('/admin/ai/agents'); + return response.data.data; +}; + +export const getAiSkills = async () => { + const response = await doormileAxios.get('/admin/ai/skills'); + return response.data.data; +}; + +export const getAiTools = async () => { + const response = await doormileAxios.get('/admin/ai/tools'); + return response.data.data; +}; + +export const getAiRegistryAudit = async (limit) => { + const response = await doormileAxios.get(`/admin/ai/audit${buildQuery({ limit })}`); + return response.data.data; +}; + +/** @param {string} id @param {{ enabled?: boolean, thresholds?: Record }} patch */ +export const updateAiSkill = async (id, patch) => { + const response = await doormileAxios.patch(`/admin/ai/skills/${encodeURIComponent(id)}`, patch); + return response.data; +}; + +/** @param {{ agentid: string, title: string, category?: string, description?: string, sampleprompt?: string, tools: string[] }} skill */ +export const createAiSkill = async (skill) => { + const response = await doormileAxios.post('/admin/ai/skills', skill); + return response.data; +}; + +/** + * Autonomy ON needs `confirm` set to the agent id — the server refuses it + * otherwise, so a stray click cannot let an agent act without a human. + * @param {string} id @param {{ autonomous?: boolean, model?: string, confirm?: string }} patch + */ +export const updateAiAgent = async (id, patch) => { + const response = await doormileAxios.patch(`/admin/ai/agents/${encodeURIComponent(id)}`, patch); + return response.data; +}; + +/** + * What the agents did over the last `days` (1–30): runs and failures per + * AI_engine agent, decisions by type/outcome, live heartbeat. `receiving` is + * false when the backend is not subscribed to AI_engine telemetry. + * @param {number} days + */ +export const getAiInsights = async (days = 7) => { + const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`); + return response.data.data; +}; + +/** + * Agent Studio's Test tab: one prompt through Claude with a skill's tools. + * Read tools run with personal data redacted; write tools come back as + * proposals and are never executed. Admin only. 503 (code + * PLAYGROUND_NOT_CONFIGURED) when the server has no Claude client; 429 when + * the per-user limit is reached. + * @param {{ agentid: string, skillid?: string, prompt: string }} body + */ +export const runAiPlayground = async (body) => { + const response = await doormileAxios.post('/admin/ai/playground/run', body); + return response.data.data; +}; + +/** Recent agent decisions, newest first. `before` is the last id of the previous page. */ +export const getAiDecisions = async ({ type, before, limit } = {}) => { + const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`); + return response.data.data; +}; diff --git a/src/components/assistant/DoormileAI/pageContext.jsx b/src/components/assistant/DoormileAI/pageContext.jsx index 5b47e2f..7bead39 100644 --- a/src/components/assistant/DoormileAI/pageContext.jsx +++ b/src/components/assistant/DoormileAI/pageContext.jsx @@ -12,7 +12,8 @@ import { LuUserPlus, LuPackagePlus, LuListPlus, - LuRepeat + LuRepeat, + LuShieldAlert } from 'react-icons/lu'; // ==============================|| Doormile AI — page context ||============================== // @@ -45,6 +46,7 @@ import { // suggestion with no entry here simply shows its full text. // --------------------------------------------------------------------------- export const CHIP_LABELS = { + 'What needs attention right now?': 'Needs attention', "Give me today's operations summary": "Today's summary", 'How many orders today?': 'Orders today', 'Which orders are delayed?': 'Delayed orders', @@ -171,6 +173,9 @@ const TRIPSHEETS = { const EXCEPTIONS = { label: 'Exceptions', suggestions: [ + // The ops briefing (intents.js opsBriefing) — the same rules as the early + // warnings banner on this page, asked for in words. + { icon: LuShieldAlert, text: 'What needs attention right now?' }, { icon: LuCircleDot, text: 'How many open exceptions?' }, { icon: LuTimerOff, text: 'Which orders are delayed?' } ], diff --git a/src/components/doormile/AgentOperationsBanner.jsx b/src/components/doormile/AgentOperationsBanner.jsx new file mode 100644 index 0000000..e30c2c1 --- /dev/null +++ b/src/components/doormile/AgentOperationsBanner.jsx @@ -0,0 +1,196 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Sparkles, SlidersHorizontal, RefreshCw } from 'lucide-react'; +import SlaRemediationCard from './SlaRemediationCard'; +import { scanBookings } from '@/lib/assistant/scan'; +import { toAgentRows } from '@/lib/assistant/agent/normalise'; +import { AgentFactory } from '@/lib/assistant/agent/AgentFactory'; +import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry'; +import { wallClockNow } from '@/lib/assistant/agent/signals'; +import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions'; +import { Button } from '@/components/ui/button'; + +/** + * AgentOperationsBanner + * + * Early warnings from the console's monitoring skills. It evaluates the live + * booking scan against every skill ENABLED IN THE AGENT REGISTRY, with the + * registry's thresholds (Settings → Skills & Tools). Nothing here acts on its + * own: a finding's action runs only when an operator clicks it, and actions + * with no working endpoint render "Review only". + * + * Ported from feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3). + * The branch opened a localStorage-only settings modal from here; that modal + * was not ported — "Configure skills" goes to the registry page instead. + * + * Props: + * - category: string | string[] (e.g. 'sla_management', 'rider_safety', 'fleet_optimization', 'loss_prevention') + * - skillIds: string[] (e.g. ['skill_sla_guardian', 'skill_fleet_balancer']) + * - title: string (Custom banner title) + * - maxItems: number (Default 4) + * - hideWhenClean: boolean (If true, renders nothing when all clear) + */ +export default function AgentOperationsBanner({ + category, + skillIds, + title = 'Early warnings', + maxItems = 4, + hideWhenClean = false, + className = '' +}) { + const navigate = useNavigate(); + // Which settings the rules are running on — the registry's, or the code + // defaults while it is unreachable. Said on screen, so a tuned threshold is + // never assumed when it is not in force. + const [settingsSource, setSettingsSource] = useState(() => SkillRegistry.getSource()); + + const { + data: allFindings = [], + isLoading, + refetch, + isFetching + } = useQuery({ + queryKey: ['doormile', 'agent-operations-briefing'], + queryFn: async () => { + const scan = await scanBookings(); + const agentRows = toAgentRows(scan?.rows); + const agent = AgentFactory.synthesizeDefaultAgent(); + return agent.evaluateTelemetry(agentRows, wallClockNow()); + }, + staleTime: 30_000, + refetchInterval: 60_000, + }); + + // Carry out a proposal, then re-evaluate. + // + // The refetch is the point, not politeness: an agent that fires an action and + // never looks again is a button. Re-running the scan is what turns this into + // a loop — the finding either disappears, proving the action landed, or it + // survives and stays on the board. + // + // Errors are re-thrown so SlaRemediationCard shows its failure state; a + // partial success (six riders reached out of eight) resolves ok and the + // message names the ones that were missed. + const handleRemediate = useCallback( + async (finding) => { + const result = await executeProposal(finding); + if (!result?.ok) { + throw new Error(result?.message || 'The action did not complete.'); + } + await refetch(); + return result; + }, + [refetch] + ); + + // Re-evaluate when the registry's settings arrive or change (a save in + // Settings → Skills & Tools refetches the registry, which lands here). + useEffect(() => { + const unsubscribe = SkillRegistry.subscribe(() => { + setSettingsSource(SkillRegistry.getSource()); + refetch(); + }); + return unsubscribe; + }, [refetch]); + + // Contextual page-level filtering + const findings = useMemo(() => { + if (!category && !skillIds) { + return allFindings; + } + + const targetCategories = category + ? Array.isArray(category) + ? category + : [category] + : null; + + const targetSkills = skillIds ? new Set(skillIds) : null; + + return allFindings.filter((finding) => { + const skill = SkillRegistry.getSkill(finding.skillId); + if (targetSkills && finding.skillId && !targetSkills.has(finding.skillId)) { + return false; + } + if (targetCategories && skill && !targetCategories.includes(skill.category)) { + return false; + } + return true; + }); + }, [allFindings, category, skillIds]); + + if (isLoading) { + return null; + } + + const hasFindings = findings.length > 0; + + if (hideWhenClean && !hasFindings) { + return null; + } + + return ( + <> +
+
+
+ + {title} + ({findings.length} detected) +
+ +
+ + + +
+
+ + {settingsSource !== 'registry' && ( +

+ Running on default thresholds — the skill settings from Settings → Skills & Tools could not be loaded. +

+ )} + + {hasFindings ? ( +
+ {findings.slice(0, maxItems).map((finding, idx) => ( + + ))} +
+ ) : ( +
+ + Nothing flagged by the {SkillRegistry.getActiveSkills().length} enabled skills. +
+ )} +
+ + ); +} diff --git a/src/components/doormile/SlaRemediationCard.jsx b/src/components/doormile/SlaRemediationCard.jsx new file mode 100644 index 0000000..e89cbda --- /dev/null +++ b/src/components/doormile/SlaRemediationCard.jsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react'; +import { + Sparkles, + ShieldAlert, + CheckCircle2, + RefreshCw, + Zap +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { OpenToast } from '@/api/doormile/notify'; + +/** + * Proactive SLA Risk & Self-Healing Remediation Card. + * Detects aging, stalled, or at-risk parcels and presents a 1-click mitigation plan. + */ +export default function SlaRemediationCard({ finding, onRemediate }) { + const [remediated, setRemediated] = useState(false); + const [loading, setLoading] = useState(false); + + if (!finding) return null; + + const { title, why, severity = 'warning', count = 1, proposal } = finding; + + // A proposal is only actionable when a caller supplies an executor. Without + // one there is no write path — the agent's mutating tools deliberately return + // proposals rather than performing the write themselves. + const canExecute = typeof onRemediate === 'function'; + + const handleApply = async () => { + // Guarded rather than merely disabled: a click that cannot act must never + // reach the success path. + // + // This previously fell through to `setTimeout(600)` and then reported + // "Remediation executed" with a green card — and no caller has ever passed + // onRemediate, so EVERY click took that branch. An operator was told a + // stalled rider had been messaged when nothing had been sent. A button that + // lies about acting is worse than no button, exactly as a wrong number is + // worse than no answer (assistant/CLAUDE.md §3). + if (!canExecute) return; + + setLoading(true); + try { + const result = await onRemediate(finding); + setRemediated(true); + // OpenToast is (message, type). The branch called it ('success', msg), so + // every toast would have read just "success". The executor's own message + // is shown because it counts partial success ("Notified 6 … could not + // reach 2: …"), which a fixed string would hide. + OpenToast(result?.message || `Done: ${proposal.label}`, 'success'); + } catch (err) { + OpenToast(err?.message || 'The action did not complete', 'error'); + } finally { + setLoading(false); + } + }; + + const isCritical = severity === 'critical'; + + return ( +
+
+
+
+ {remediated ? : } +
+
+
+ + {/* Not "Critical SLA Risk" / "Autonomous Finding": findings come + from cash and fleet rules too, and nothing here acts on its own. */} + {remediated ? 'Action sent' : isCritical ? 'Needs attention now' : 'Worth a look'} + + + {count} {count === 1 ? 'Order' : 'Orders'} + +
+

{title}

+
+
+ + {!remediated && proposal && ( + + )} +
+ +

{why}

+ + {proposal && !remediated && ( +
+ + + Proposed Action: {proposal.label} + + {proposal.blastRadius} +
+ )} +
+ ); +} diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx index 6a8d24e..f982bc7 100644 --- a/src/layouts/AdminLayout.jsx +++ b/src/layouts/AdminLayout.jsx @@ -4,7 +4,7 @@ import { motion } from 'framer-motion'; import { Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet, FileText, ListTodo, LogOut, Menu, Search, Settings, Shield, - ShieldAlert, User, UserCheck, Warehouse, + ShieldAlert, User, UserCheck, UserPlus, Warehouse, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { DOORMILE_MARK_URL } from '@/assets/brand'; @@ -16,9 +16,11 @@ import { } from '@/components/ui/dropdown-menu'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { useAuth } from '@/lib/AuthContext'; +import { canOnboardClients } from '@/lib/clientOnboarding'; import { useExceptions } from '@/lib/doormileHooks'; import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; import AIPanel from '@/components/assistant/DoormileAI/AIPanel'; +import { useSkillRegistrySync } from '@/lib/assistant/skills/useSkillRegistrySync'; import AILauncher from '@/components/assistant/DoormileAI/AILauncher'; /** @@ -221,6 +223,10 @@ export default function AdminLayout() { const location = useLocation(); const navigate = useNavigate(); const { user, isClient, logout } = useAuth(); + // One sync for the whole console, so the Exceptions banner and the chat's + // "what needs attention" briefing run on the same registry settings. Partner + // logins cannot read the registry (403) and run on code defaults. + useSkillRegistrySync(!isClient); const [menuOpen, setMenuOpen] = useState(false); const [assistantOpen, setAssistantOpen] = useState(() => { try { @@ -251,12 +257,21 @@ export default function AdminLayout() { return NAV; }, [isClient]); + const canOnboard = canOnboardClients(user, isClient); + const navGroups = useMemo(() => { if (isClient) { return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops'); } - return NAV_GROUPS; - }, [isClient]); + if (!canOnboard) return NAV_GROUPS; + // Only the onboarding owner login sees this entry; the server refuses + // everyone else regardless. + return NAV_GROUPS.map((group) => + group.label === 'Fleet Ops' + ? { ...group, items: [...group.items, { label: 'Client Onboarding', path: '/doormile/clients/onboard', icon: UserPlus }] } + : group + ); + }, [isClient, canOnboard]); const allDestinations = useMemo(() => { return [...navItems, ...navGroups.flatMap((group) => group.items)]; diff --git a/src/lib/assistant/agent/AgentFactory.js b/src/lib/assistant/agent/AgentFactory.js new file mode 100644 index 0000000..2aaefbb --- /dev/null +++ b/src/lib/assistant/agent/AgentFactory.js @@ -0,0 +1,92 @@ +// ==============================|| Doormile AI — Autonomous Agent Factory ||============================== // +import { SkillRegistry } from '../skills/SkillRegistry'; +import { wallClockNow } from './signals'; + +const SEVERITY_RANK = { critical: 0, warning: 1, watch: 2 }; + +export class AgentFactory { + /** + * Compiles an Agent configuration from a collection of skills. + */ + static createAgent(skills = SkillRegistry.getActiveSkills()) { + const activeSkills = Array.isArray(skills) ? skills : []; + + // 1. Synthesize System Prompt + const instructions = activeSkills + .map((s) => `### ${s.name} (${s.category})\n${(s.instructions || '').trim()}`) + .join('\n\n'); + + const systemPrompt = ` +You are the Doormile Autonomous Logistics Fleet Orchestrator. +You operate with the following active operational skill modules: + +${instructions || 'Standard logistics routing and telemetry diagnostics.'} + +Guiding Invariant: +Read-only queries execute instantly. Mutating actions (reassignments, pings, OTP enforcement) MUST return human-in-the-loop proposals with clear blast radius. +`.trim(); + + // 2. Aggregate Tools from active skills + const tools = activeSkills.flatMap((s) => s.tools || []); + const toolMap = new Map(); + tools.forEach((t) => toolMap.set(t.name, t)); + + // 3. Telemetry Evaluation Engine + const evaluateTelemetry = (rows = [], now = wallClockNow()) => { + const allFindings = []; + + for (const skill of activeSkills) { + if (typeof skill.evaluate === 'function') { + // Extract current threshold values + const rawThresholds = {}; + if (skill.effectiveThresholds) { + Object.entries(skill.effectiveThresholds).forEach(([k, v]) => { + rawThresholds[k] = v.currentValue; + }); + } else if (skill.thresholds) { + Object.entries(skill.thresholds).forEach(([k, v]) => { + rawThresholds[k] = v.value; + }); + } + + const findings = skill.evaluate(rows, now, rawThresholds); + if (Array.isArray(findings)) { + allFindings.push(...findings); + } + } + } + + // Sort by severity (critical > warning > watch), then by affected row count descending + return allFindings.sort( + (a, b) => + (SEVERITY_RANK[a.severity] ?? 99) - (SEVERITY_RANK[b.severity] ?? 99) || + (b.count || 0) - (a.count || 0) + ); + }; + + return { + name: 'Doormile Fleet Orchestrator', + version: '2.0.0', + activeSkillCount: activeSkills.length, + skills: activeSkills, + tools, + toolMap, + systemPrompt, + evaluateTelemetry, + executeSkillTool: async (toolName, args) => { + const tool = toolMap.get(toolName); + if (!tool || typeof tool.handler !== 'function') { + return { success: false, error: `Skill tool "${toolName}" not found or has no handler.` }; + } + return tool.handler(args); + } + }; + } + + /** + * Helper to build the default active agent runtime based on current SkillRegistry state. + */ + static synthesizeDefaultAgent() { + return AgentFactory.createAgent(SkillRegistry.getActiveSkills()); + } +} diff --git a/src/lib/assistant/agent/actions.js b/src/lib/assistant/agent/actions.js new file mode 100644 index 0000000..e17123f --- /dev/null +++ b/src/lib/assistant/agent/actions.js @@ -0,0 +1,153 @@ +// ==============================|| Doormile AI — proposal executors ||============================== // +// +// The layer that turns a finding's proposal into a real API call. +// +// It exists as its own module because the skills deliberately have no API +// access at all — not one of them imports an endpoint, and their tool handlers +// only build proposal objects. That separation is worth keeping: a skill stays +// a pure function of rows and thresholds, which is why all eight are testable +// without a network. Execution is the part with consequences, so it lives in +// one place where the failure modes can be handled once. +// +// Two rules, both learned the hard way in assignActions.js: +// +// • Never report an action that did not happen. A card that says "rider +// notified" when the push returned 400 is worse than one that says nothing: +// a dispatcher who believes a rider was pinged does not follow up. +// • 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'; + +/** A stable, human-facing reference for a row. */ +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +/** + * Rider pushes key off milerprofileid; findings carry mileruserid. + * + * These are two different small integers on the same record, and using the + * wrong one fails silently in both directions — the notify 404s, or a different + * rider is paged. buildMilerLookup is the same bridge the Orders page uses; + * this must never grow a second one. + */ +const profileIdFor = (row, lookup) => lookup.byUserId.get(String(row?.userid))?.milerprofileid; + +// ---- notifyRider ------------------------------------------------------------ + +const executeNotifyRider = async (scope, message) => { + const rows = (scope || []).filter((r) => r?.userid); + if (!rows.length) { + return { ok: false, message: 'None of these parcels has an assigned rider to notify.', sourceCalls: [] }; + } + + // One fetch for the whole batch. Per row would be an identical request each + // time, and the lookup is the same for all of them. + const milers = await getMilers(); + const lookup = buildMilerLookup(milers); + + const sourceCalls = []; + const notified = []; + const unreachable = []; + + for (const row of rows) { + const profileId = profileIdFor(row, lookup); + + // No profile id means no push is possible. Recorded rather than skipped, + // so the operator is not left assuming a phone buzzed. + if (!profileId) { + unreachable.push(`${ref(row)} (${row.ridername || 'rider'}: no device registered)`); + continue; + } + + try { + // eslint-disable-next-line no-await-in-loop + await notifyRider(profileId, 'Doormile Ops', message); + notified.push(ref(row)); + sourceCalls.push({ + name: 'notifyRider', + target: `POST /admin/milers/${profileId}/notify`, + status: 'complete', + stats: `${ref(row)} → ${row.ridername || 'rider'}` + }); + } catch (err) { + unreachable.push(`${ref(row)} (${err?.message || 'push failed'})`); + sourceCalls.push({ + name: 'notifyRider', + target: `POST /admin/milers/${profileId}/notify`, + status: 'error', + errorMessage: err?.message || 'notification failed' + }); + } + } + + const parts = []; + if (notified.length) parts.push(`Notified ${notified.length} rider${notified.length === 1 ? '' : 's'}.`); + if (unreachable.length) parts.push(`Could not reach ${unreachable.length}: ${unreachable.join('; ')}.`); + + return { + ok: notified.length > 0, + message: parts.join(' ') || 'Nothing was sent.', + notified, + unreachable, + sourceCalls + }; +}; + +// ---- assignMiler: deliberately NOT an executor -------------------------------- +// +// 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. +// +// 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. + +// ---- registry --------------------------------------------------------------- + +/** + * Keyed on the `tool` a finding's proposal actually carries. + * + * Deliberately NOT the skills' declared tool names (ping_stalled_rider, + * reassign_sla_critical_order, …). Those belong to a separate, currently unused + * layer whose handlers only build proposals; the UI renders `proposal.tool`, + * and that is what an operator's click has to resolve. + * + * Verbs with no entry here have no endpoint behind them yet. That is not an + * error — the card renders them disabled and says "Review only" rather than + * pretending. + */ +const EXECUTORS = { + notifyRider: (finding) => + executeNotifyRider( + finding?.proposal?.scope, + 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". +}; + +/** Whether a finding's proposal can actually be carried out. */ +export const canExecuteProposal = (finding) => Boolean(EXECUTORS[finding?.proposal?.tool]); + +/** Every verb that currently has a real executor behind it. */ +export const executableTools = () => Object.keys(EXECUTORS); + +/** + * Runs a finding's proposal. + * + * Throws when there is no executor rather than resolving quietly, so a caller + * cannot mistake "nothing happened" for success. The UI is expected to have + * checked canExecuteProposal first and disabled the control. + */ +export const executeProposal = async (finding) => { + const run = EXECUTORS[finding?.proposal?.tool]; + if (!run) { + throw new Error(`No executor for "${finding?.proposal?.tool}" — this proposal is review-only.`); + } + return run(finding); +}; diff --git a/src/lib/assistant/agent/briefing.js b/src/lib/assistant/agent/briefing.js new file mode 100644 index 0000000..d9be95d --- /dev/null +++ b/src/lib/assistant/agent/briefing.js @@ -0,0 +1,112 @@ +// ==============================|| Doormile AI — the ops briefing ||============================== // +// +// Turns the findings from signals.js into the shape the assistant panel already +// renders: a headline, a detail body, and a metric. Pure — it takes rows and an +// instant and returns an object, so the whole briefing can be tested without a +// network, a clock, or React. +// +// This is the agent's "explain" step. Every line it produces is traceable to a +// rule in signals.js and to the rows that rule matched, because an operator who +// cannot see why the agent believes something will not act on it — and an +// unexplained instruction from a bot is exactly the thing CLAUDE.md §3 refuses +// to ship. + +import { AgentFactory } from './AgentFactory'; +import { ALL_CLEAR, wallClockNow } from './signals'; +import { toAgentRows } from './normalise'; + +const SEVERITY_LABEL = { + critical: 'Needs attention now', + warning: 'Worth looking at', + watch: 'Keep an eye on' +}; + +/** + * A truncated scan is a floor, not a total. + * + * `/admin/bookings` caps pagesize, so a scan drains pages up to a budget and + * reports whether it ran out. Every count built on a truncated scan says "at + * least", because the alternative — stating a total that is quietly a subset — + * is the exact failure the assistant's verifiability contract exists to + * prevent. A missed breach is worse than an approximate one, so the briefing + * still runs; it just stops claiming completeness. + */ +export const countPrefix = (truncated) => (truncated ? 'at least ' : ''); + +/** One finding rendered as a block of lines. */ +const renderFinding = (finding, truncated) => { + const lines = []; + lines.push(`${SEVERITY_LABEL[finding.severity]} — ${finding.title}`); + lines.push(finding.why); + + if (finding.detail?.length) { + lines.push(...finding.detail.map((d) => ` • ${d}`)); + const shown = finding.detail.length; + if (finding.count > shown) { + lines.push(` • …and ${countPrefix(truncated)}${finding.count - shown} more`); + } + } + + if (finding.proposal) { + lines.push(` → ${finding.proposal.label} (${finding.proposal.blastRadius})`); + } + + return lines.join('\n'); +}; + +/** + * Builds the full briefing. + * + * `scan` is the result of the assistant's own booking scan — `{ rows, + * truncated }` — so the briefing inherits the same data the rest of the + * catalog answers from and cannot disagree with it. + */ +export const buildBriefing = (scan, now = wallClockNow()) => { + // Raw `/admin/bookings` rows in, agent rows out. Without this the rules read + // undefined on every field and the briefing reports an all-clear board. + const rows = toAgentRows(scan?.rows); + const truncated = Boolean(scan?.truncated); + + // The SAME engine the Exceptions banner runs, deliberately. + // + // This used to call a private set of rules in signals.js that duplicated five + // of the skills and hardcoded their thresholds. The two drifted immediately — + // signals.js flagged a stalled rider at 25 minutes while DoorstepStallSkill + // used 20 — so the chat panel and the banner disagreed about the same parcel, + // and a threshold moved in Agent Studio changed one surface but not the other. + // + // Going through the registry means there is one definition of every rule, one + // place thresholds live, and the chat panel sees every enabled skill rather + // than the five somebody happened to reimplement here. + const findings = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(rows, now); + + if (!findings.length) { + return { + headline: ALL_CLEAR, + detail: truncated + ? 'Note: the booking scan hit its page budget, so this covers the most recent orders rather than every one.' + : `Checked ${rows.length} open and recent bookings.`, + findings, + metric: { value: 0, label: 'issues found' } + }; + } + + const critical = findings.filter((f) => f.severity === 'critical'); + const affected = new Set(findings.flatMap((f) => f.rows.map((r) => r.bookingid))).size; + + const headline = critical.length + ? `${critical.length} thing${critical.length === 1 ? '' : 's'} need${critical.length === 1 ? 's' : ''} attention now, across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.` + : `Nothing critical, but ${findings.length} thing${findings.length === 1 ? '' : 's'} worth looking at across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`; + + const body = findings.map((f) => renderFinding(f, truncated)).join('\n\n'); + const footer = truncated + ? '\n\nThe booking scan hit its page budget, so these counts are a floor — there may be more.' + : ''; + + return { + headline, + detail: `${body}${footer}`, + findings, + metric: { value: findings.length, label: findings.length === 1 ? 'issue found' : 'issues found' } + }; +}; diff --git a/src/lib/assistant/agent/normalise.js b/src/lib/assistant/agent/normalise.js new file mode 100644 index 0000000..1b52571 --- /dev/null +++ b/src/lib/assistant/agent/normalise.js @@ -0,0 +1,53 @@ +// ==============================|| Doormile AI — scan row adapter ||============================== // +// +// The assistant's booking scan returns RAW rows straight off `/admin/bookings` +// — `status`, `createdat`, `assignedmileruserid`, `serviceoptions[0]` — not the +// normalised delivery rows the Deliveries page renders. The two shapes share +// almost no field names, so the agent's rules cannot read a scan row directly. +// +// This adapter is the seam. It exists as its own file because getting it wrong +// fails silently in the worst possible way: every rule reads `undefined`, every +// rule matches nothing, and the agent cheerfully reports "nothing needs +// attention" on a board that is on fire. That is precisely the failure mode +// CLAUDE.md §3 exists to prevent, and it is invisible without a test. +// +// The status derivation deliberately goes through the console's own +// `mapBookingStatusToDeliveryStatus` rather than a local copy — the same +// function the Deliveries page's table calls. That is the house rule (never +// hand-roll a second data path) and it is also what guarantees the agent and +// the screen can never disagree about what state a parcel is in. + +import { mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries'; + +/** The rider's arrival stamp, which the API has spelled several ways. */ +const reachedAtOf = (b) => b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time; + +/** Rider display name, from whichever join carried it. */ +const riderNameOf = (b) => + b.milername || b.ridername || b.assignedmilername || b.miler?.name || undefined; + +/** + * One raw booking row → the shape signals.js reads. + * + * Every field is derived, never invented: a raw row that carries no service + * option yields `expecteddeliverytime: undefined`, and the SLA rules skip it + * rather than treating a missing promise as a kept or broken one. + */ +export const toAgentRow = (b) => ({ + bookingid: b.bookingid ?? b.id, + orderid: b.bookingno || (b.bookingid ? `#${b.bookingid}` : '—'), + + // Booking status, consignment status and reachedat resolved by the console's + // own mapper — including the Converted_To_Consignment handoff, where the + // lifecycle moves onto the consignment and the booking's status freezes. + orderstatus: mapBookingStatusToDeliveryStatus(b), + + orderdate: b.createdat || b.orderdate || b.updatedat, + expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat, + reachedat: reachedAtOf(b), + userid: b.assignedmileruserid ?? b.mileruserid, + ridername: riderNameOf(b) +}); + +/** A whole scan's rows, adapted. Non-array input yields an empty list. */ +export const toAgentRows = (rows) => (Array.isArray(rows) ? rows.map(toAgentRow) : []); diff --git a/src/lib/assistant/agent/signals.js b/src/lib/assistant/agent/signals.js new file mode 100644 index 0000000..80f9d06 --- /dev/null +++ b/src/lib/assistant/agent/signals.js @@ -0,0 +1,44 @@ +// ==============================|| Doormile AI — shared agent primitives ||============================== // +// +// What is left after the rules moved out. +// +// This file used to hold its own copy of five detection rules — breached SLA, +// at-risk SLA, aging unassigned work, doorstep stalls, rider saturation — each +// with a hardcoded threshold in a local THRESHOLDS object. Every one of those +// rules already existed as a skill in skills/definitions/, with the same +// finding id and a threshold an operator can tune in Agent Studio. +// +// Two implementations of one rule is a bug with a delay fuse, and this one had +// already fired: signals.js flagged a doorstep stall at 25 minutes while +// DoorstepStallSkill used 20, so the chat panel and the Exceptions banner +// disagreed about the same rider, out of the box, before anyone touched a +// slider. Worse, the hardcoded copy could not see the registry at all — tuning +// a threshold moved the banner and left the chat panel where it was. +// +// The rules now live in exactly one place: skills/definitions/. Everything that +// needs findings goes through AgentFactory, which reads the registry, so there +// is one definition per rule and one place a threshold can be changed. +// +// What remains here is the small shared vocabulary that is genuinely common to +// every consumer and belongs to none of them. + +import dayjs from 'dayjs'; + +/** + * "Now", in the same form every Doormile timestamp is stored in. + * + * NOT `new Date().toISOString()`. That returns UTC, and parseDoormileTimestamp + * deliberately strips any zone marker and reads the remaining digits as IST + * wall-clock — so an ISO string would hand the rules a clock running 5h30m + * slow, and every parcel less than five and a half hours overdue would look + * like it was still in the future. The agent would report an all-clear board + * through most of a working day. + * + * This was a live bug. It passed every unit test, because the tests all passed + * `now` explicitly and only the default was wrong. + */ +export const wallClockNow = () => dayjs().format('YYYY-MM-DD HH:mm:ss'); + +/** Shown when every enabled skill returns no findings. */ +export const ALL_CLEAR = + 'Nothing needs attention right now — no breached promises, no stalled riders, and no aging unassigned work.'; diff --git a/src/lib/assistant/intents.js b/src/lib/assistant/intents.js index 85a0672..918a3f5 100644 --- a/src/lib/assistant/intents.js +++ b/src/lib/assistant/intents.js @@ -22,6 +22,7 @@ import { getTenantLocations } from 'pages/api/doormileApi'; import { getalltenants, getallridersummary } from 'pages/api/api'; +import { buildBriefing } from '@/lib/assistant/agent/briefing'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket'; import { STATUS } from 'themes/dt/tokens'; @@ -745,6 +746,48 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i; const MULTI_SPLIT = /\band\b|,|\+|&/i; const INTENTS = [ + { + // The ops briefing: every monitoring skill enabled in the agent registry, + // run over the same booking scan the rest of the catalog answers from — + // the SAME engine as the Exceptions banner (AgentFactory), so the chat and + // the banner can never disagree about a parcel. Ported from + // feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3). + // + // FIRST on purpose: later intents match bare "orders"/"riders", which + // appear in most phrasings of a sweep. + // + // Narrower than the branch on purpose. The branch also claimed + // "late/overdue/delayed orders", which would have stolen the existing + // delayed-orders answer (a count on the Orders taxonomy) and replaced it + // with a briefing. Only an explicit request for a sweep lands here. + id: 'opsBriefing', + label: 'What needs attention right now — e.g. "what needs attention", "anything going wrong", "ops check"', + match: (text) => { + const t = String(text).toLowerCase(); + const asksForSweep = + /\b(what|anything|any)\b.{0,24}\b(needs?\s+(my\s+)?attention|going\s+wrong|at\s+risk|urgent|on\s+fire)\b/.test(t) || + /\b(ops|operations|status|health|daily)\s+(check|sweep|briefing|brief)\b/.test(t) || + /\bwhat\s+should\s+i\s+(do|look\s+at)\b/.test(t); + return asksForSweep ? {} : null; + }, + run: async () => { + const scan = await scanBookings(); + const briefing = buildBriefing(scan); + return { + headline: briefing.headline, + detail: briefing.detail, + metric: briefing.metric, + sourceCalls: [ + scanCall( + scan, + briefing.findings.length + ? `${briefing.findings.length} finding${briefing.findings.length === 1 ? '' : 's'} over ${scan.rows.length} rows` + : `no issues over ${scan.rows.length} rows` + ) + ] + }; + } + }, { // Ordered ahead of BOTH create triggers. "repeat yesterday's orders" // contains "orders", so createBulkOrders and createOrder would otherwise diff --git a/src/lib/assistant/skills/SkillRegistry.js b/src/lib/assistant/skills/SkillRegistry.js new file mode 100644 index 0000000..a807f0d --- /dev/null +++ b/src/lib/assistant/skills/SkillRegistry.js @@ -0,0 +1,123 @@ +// ==============================|| Doormile AI — Skill Registry ||============================== // +// +// The console's eight monitoring skills: their RULES live in code +// (./definitions), their SETTINGS — enabled, thresholds — live in the backend +// agent registry (/admin/ai/skills, edited in Settings → Skills & Tools). This +// class joins the two. +// +// On the feat/agentic-ops-layer branch these settings were kept in the +// browser's localStorage, so a threshold tuned on one machine changed nothing +// on any other, and two operators could look at the same board through +// different rules. Ported onto main in Phase 3 of docs/agent-platform-plan.md, +// the settings come from the registry instead, and nothing is stored locally. +// +// Until the registry answers (first load, a network error, a partner login +// that is not allowed to read it) every skill runs on its code defaults, and +// `getSource()` says so, so the banner can tell the operator which settings it +// is running on rather than implying they are the tuned ones. + +import { SlaGuardianSkill } from './definitions/SlaGuardianSkill'; +import { DoorstepStallSkill } from './definitions/DoorstepStallSkill'; +import { FleetBalancerSkill } from './definitions/FleetBalancerSkill'; +import { HighValueCodSkill } from './definitions/HighValueCodSkill'; +import { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill'; +import { HubCongestionSkill } from './definitions/HubCongestionSkill'; +import { LateDispatchSkill } from './definitions/LateDispatchSkill'; +import { CashExposureSkill } from './definitions/CashExposureSkill'; + +export const DEFAULT_SKILLS = [ + SlaGuardianSkill, + DoorstepStallSkill, + FleetBalancerSkill, + HighValueCodSkill, + RiderBatterySafetySkill, + HubCongestionSkill, + LateDispatchSkill, + CashExposureSkill +]; + +/** A stored value outside the definition's range falls back to its default. */ +const inRange = (config, v) => + typeof v === 'number' && Number.isFinite(v) && v >= config.min && v <= config.max; + +class SkillRegistryClass { + constructor() { + this.skills = new Map(); + this.listeners = new Set(); + this.config = {}; // { [skillId]: { enabled: boolean, thresholds: { [key]: number } } } + this.source = 'defaults'; + DEFAULT_SKILLS.forEach((skill) => this.skills.set(skill.id, skill)); + } + + /** + * Adopt the registry's settings. `rows` is the /admin/ai/skills response; + * rows for skills this console does not implement are ignored. + */ + applyRegistryConfig(rows) { + const next = {}; + (Array.isArray(rows) ? rows : []).forEach((row) => { + if (!row || !this.skills.has(row.skillid)) return; + next[row.skillid] = { + enabled: Boolean(row.enabled), + thresholds: row.thresholds && typeof row.thresholds === 'object' ? row.thresholds : {} + }; + }); + this.config = next; + this.source = 'registry'; + this.notify(); + } + + /** Forget the registry's settings and run on code defaults. */ + clearRegistryConfig() { + if (this.source === 'defaults' && !Object.keys(this.config).length) return; + this.config = {}; + this.source = 'defaults'; + this.notify(); + } + + /** 'registry' when running on the tuned settings, 'defaults' otherwise. */ + getSource() { + return this.source; + } + + getAllSkills() { + return Array.from(this.skills.values()).map((skill) => { + const cfg = this.config[skill.id]; + const enabled = cfg ? cfg.enabled : (skill.defaultEnabled ?? true); + + const effectiveThresholds = {}; + Object.entries(skill.thresholds || {}).forEach(([key, config]) => { + const stored = cfg?.thresholds?.[key]; + effectiveThresholds[key] = { ...config, currentValue: inRange(config, stored) ? stored : config.value }; + }); + + return { ...skill, enabled, effectiveThresholds }; + }); + } + + getActiveSkills() { + return this.getAllSkills().filter((s) => s.enabled); + } + + getSkill(skillId) { + return this.getAllSkills().find((s) => s.id === skillId); + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + notify() { + const all = this.getAllSkills(); + this.listeners.forEach((fn) => { + try { + fn(all); + } catch { + // One listener failing must not stop the others hearing the change. + } + }); + } +} + +export const SkillRegistry = new SkillRegistryClass(); diff --git a/src/lib/assistant/skills/definitions/CashExposureSkill.js b/src/lib/assistant/skills/definitions/CashExposureSkill.js new file mode 100644 index 0000000..59ba9ec --- /dev/null +++ b/src/lib/assistant/skills/definitions/CashExposureSkill.js @@ -0,0 +1,161 @@ +// ==============================|| Skill: Cash Exposure Agent ||============================== // +// +// Monitors the total live COD (Cash on Delivery) cash a single rider is carrying +// across ALL their active orders. When total exposure exceeds the safe limit, +// proposes a mandatory cash handoff stop at the nearest hub. +// +// This is distinct from HighValueCodSkill, which monitors per-order value. +// This skill monitors per-rider TOTAL cash accumulation across their full run. + +const ACTIVE_COD_STATUSES = new Set(['accepted', 'picked', 'active', 'arrived']); + +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +export const CashExposureSkill = { + id: 'skill_cash_exposure', + name: 'Cash Exposure Agent', + description: 'Monitors total live COD cash per rider across all active orders. Flags when a rider carries unsafe cash levels and proposes hub handoff.', + category: 'loss_prevention', + icon: 'ShieldCheck', + // OFF by default (2026-09-29): the rows this rule reads carry no COD amount per order: /admin/bookings does not preload bookingpayments. + // Enabled, it would read undefined on every row and report an all-clear board — + // the silent failure normalise.js warns about. Turn it on only once the data exists. + dataGap: 'no COD amount per order: /admin/bookings does not preload bookingpayments', + defaultEnabled: false, + + thresholds: { + maxCashPerRider: { + label: 'Max Safe Cash Per Rider', + description: 'Maximum total COD cash (₹) a single rider should carry simultaneously across all active orders.', + value: 10000, + min: 2000, + max: 50000, + step: 1000, + unit: '₹' + }, + warningCashPercent: { + label: 'Warning Threshold (%)', + description: 'Percentage of max cash limit at which to issue a warning (before it becomes critical).', + value: 75, + min: 50, + max: 95, + step: 5, + unit: '%' + } + }, + + instructions: ` + - A rider carrying excessive COD cash is a major financial risk if the phone dies, the rider is delayed, or there is a dispute. + - Flag riders above the cash threshold for a mandatory hub cash handoff before continuing deliveries. + - Critical: stop new COD orders being assigned to flagged riders until handoff is confirmed. + `, + + tools: [ + { + name: 'enforce_cash_handoff', + description: 'Proposes a mandatory cash handoff stop at the nearest hub for an overexposed rider.', + parameters: { + type: 'object', + properties: { + milerId: { type: 'number', description: 'Rider user ID carrying excessive cash' }, + totalCash: { type: 'number', description: 'Total COD cash the rider currently holds (₹)' }, + hubId: { type: 'string', description: 'Nearest hub for handoff' } + }, + required: ['milerId', 'totalCash'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'cash_handoff', + milerId: args.milerId, + totalCash: args.totalCash, + hubId: args.hubId || 'nearest', + blastRadius: `Reroutes rider to nearest hub for cash handoff of ₹${args.totalCash?.toLocaleString('en-IN')}. Pauses new COD order assignments to this rider until confirmed.` + } + }) + } + ], + + evaluate: (rows = [], _now, currentThresholds = {}) => { + const maxCash = currentThresholds.maxCashPerRider ?? 10000; + const warningPct = (currentThresholds.warningCashPercent ?? 75) / 100; + const warningLimit = maxCash * warningPct; + const findings = []; + + // Group active COD orders by rider + const byRider = {}; + rows + .filter((r) => + ACTIVE_COD_STATUSES.has(String(r?.orderstatus || '').toLowerCase()) && + r?.paymenttype && + String(r.paymenttype).toLowerCase().includes('cod') && + r?.userid + ) + .forEach((r) => { + const uid = r.userid; + if (!byRider[uid]) { + byRider[uid] = { riderId: uid, riderName: r.ridername || r.miler_name || `Rider ${uid}`, orders: [] }; + } + byRider[uid].orders.push(r); + }); + + const criticalRiders = []; + const warningRiders = []; + + Object.values(byRider).forEach((riderData) => { + const total = riderData.orders.reduce((sum, r) => { + const amt = parseFloat(r.cod_amount || r.orderamount || r.totalamount || 0); + return sum + (isNaN(amt) ? 0 : amt); + }, 0); + + riderData.totalCash = total; + + if (total >= maxCash) { + criticalRiders.push(riderData); + } else if (total >= warningLimit) { + warningRiders.push(riderData); + } + }); + + criticalRiders.sort((a, b) => b.totalCash - a.totalCash); + warningRiders.sort((a, b) => b.totalCash - a.totalCash); + + if (criticalRiders.length) { + findings.push({ + id: 'cash_exposure_critical', + skillId: 'skill_cash_exposure', + severity: 'critical', + title: `${criticalRiders.length} rider${criticalRiders.length === 1 ? '' : 's'} carrying ≥₹${maxCash.toLocaleString('en-IN')} in COD cash`, + why: `Total COD cash per rider exceeds the safe limit of ₹${maxCash.toLocaleString('en-IN')}. Immediate hub handoff required.`, + count: criticalRiders.length, + rows: criticalRiders.flatMap((rd) => rd.orders), + detail: criticalRiders.slice(0, 8).map( + (rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} active COD order${rd.orders.length === 1 ? '' : 's'}` + ), + proposal: { + label: 'Enforce cash handoff at hub', + tool: 'enforce_cash_handoff', + scope: criticalRiders.flatMap((rd) => rd.orders), + blastRadius: 'Reroutes affected riders to nearest hub for mandatory cash handoff. New COD assignments paused.' + } + }); + } + + if (warningRiders.length) { + findings.push({ + id: 'cash_exposure_warning', + skillId: 'skill_cash_exposure', + severity: 'warning', + title: `${warningRiders.length} rider${warningRiders.length === 1 ? '' : 's'} approaching cash limit (>${Math.round(warningPct * 100)}% of ₹${maxCash.toLocaleString('en-IN')})`, + why: `Total COD cash is approaching the safe threshold. Plan a hub handoff stop for these riders.`, + count: warningRiders.length, + rows: warningRiders.flatMap((rd) => rd.orders), + detail: warningRiders.slice(0, 8).map( + (rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} orders` + ) + }); + } + + return findings; + } +}; diff --git a/src/lib/assistant/skills/definitions/DoorstepStallSkill.js b/src/lib/assistant/skills/definitions/DoorstepStallSkill.js new file mode 100644 index 0000000..c0b4273 --- /dev/null +++ b/src/lib/assistant/skills/definitions/DoorstepStallSkill.js @@ -0,0 +1,90 @@ +// ==============================|| Skill: Doorstep Stall Rescuer ||============================== // +import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; + +const minutesBetween = (from, to) => { + const a = parseDoormileTimestamp(from); + const b = parseDoormileTimestamp(to); + if (!a.isValid() || !b.isValid()) return null; + return b.diff(a, 'minute'); +}; + +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +export const DoorstepStallSkill = { + id: 'skill_doorstep_stall', + name: 'Doorstep Stall Rescuer', + description: 'Flags riders who stamped arrival at consignee doorstep but have been stalled with no progress.', + category: 'rider_operations', + icon: 'MapPinOff', + defaultEnabled: true, + + thresholds: { + arrivedStalledMin: { + label: 'Doorstep Stall Timeout', + description: 'Minutes after arriving at customer location before flagging as stalled.', + value: 20, + min: 10, + max: 60, + step: 5, + unit: 'min' + } + }, + + instructions: ` + - When a rider is stalled at a delivery location, suggest verifying customer contact or initiating automated phone verification. + `, + + tools: [ + { + name: 'ping_stalled_rider', + description: 'Sends an in-app ping to check if the rider needs help with gate access or customer contact.', + parameters: { + type: 'object', + properties: { + milerId: { type: 'number', description: 'Miler ID to reach' }, + message: { type: 'string', description: 'Optional prompt text' } + }, + required: ['milerId'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'notify_miler', + milerId: args.milerId, + blastRadius: `Pings rider #${args.milerId} with doorstep assistance prompt.` + } + }) + } + ], + + evaluate: (rows = [], now, currentThresholds = {}) => { + const stallLimit = currentThresholds.arrivedStalledMin ?? 20; + + const stalled = rows + .filter((r) => String(r.orderstatus || '').toLowerCase() === 'arrived' && r.reachedat) + .map((r) => ({ row: r, waitingMin: minutesBetween(r.reachedat, now) })) + .filter((m) => m.waitingMin !== null && m.waitingMin >= stallLimit) + .sort((a, b) => b.waitingMin - a.waitingMin); + + if (!stalled.length) return []; + + return [ + { + id: 'stalled_at_door', + skillId: 'skill_doorstep_stall', + severity: 'critical', + title: `${stalled.length} rider${stalled.length === 1 ? '' : 's'} stalled at door for > ${stallLimit}m`, + why: 'Rider app reported arrival but delivery confirmation has not completed. Possible gate, address, or absent consignee issue.', + count: stalled.length, + rows: stalled.map((m) => m.row), + detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — ${m.row.ridername || 'Rider'} waiting ${m.waitingMin} min`), + proposal: { + label: 'Message waiting riders & support', + tool: 'notifyRider', + scope: stalled.filter((m) => m.row.userid).map((m) => m.row), + blastRadius: 'Sends an assistance check-in to stalled delivery riders.' + } + } + ]; + } +}; diff --git a/src/lib/assistant/skills/definitions/FleetBalancerSkill.js b/src/lib/assistant/skills/definitions/FleetBalancerSkill.js new file mode 100644 index 0000000..ac76531 --- /dev/null +++ b/src/lib/assistant/skills/definitions/FleetBalancerSkill.js @@ -0,0 +1,87 @@ +// ==============================|| Skill: Fleet Load Balancer ||============================== // + +const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']); +const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase()); + +export const FleetBalancerSkill = { + id: 'skill_fleet_balancer', + name: 'Fleet Load Balancer', + description: 'Prevents rider overload by flagging riders at maximum active parcel capacity while queue work waits.', + category: 'fleet_optimization', + icon: 'Scale', + defaultEnabled: true, + + thresholds: { + riderActiveCap: { + label: 'Rider Active Capacity Cap', + description: 'Maximum concurrent active orders a single rider should carry simultaneously.', + value: 3, + min: 1, + max: 6, + step: 1, + unit: 'orders' + } + }, + + instructions: ` + - Keep rider concurrent workload at or below the specified active cap to prevent cumulative delivery delays. + - Proactively redistribute un-picked orders from overloaded riders to available nearby fleet. + `, + + tools: [ + { + name: 'balance_rider_load', + description: 'Reallocates queued unpicked orders from a saturated rider to an available rider.', + parameters: { + type: 'object', + properties: { + fromMilerId: { type: 'number', description: 'Overloaded miler ID' }, + toMilerId: { type: 'number', description: 'Available miler ID' } + }, + required: ['fromMilerId', 'toMilerId'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'rebalance_workload', + fromMilerId: args.fromMilerId, + toMilerId: args.toMilerId, + blastRadius: `Transfers queued parcels from Rider #${args.fromMilerId} to Rider #${args.toMilerId}.` + } + }) + } + ], + + evaluate: (rows = [], now, currentThresholds = {}) => { + const activeCap = currentThresholds.riderActiveCap ?? 3; + const safeRows = Array.isArray(rows) ? rows : []; + + const unassignedCount = safeRows.filter((r) => String(r?.orderstatus || '').toLowerCase() === 'pending').length; + const byRider = new Map(); + + safeRows.filter((r) => isOpen(r) && r.userid).forEach((r) => { + const key = String(r.userid); + if (!byRider.has(key)) byRider.set(key, { riderId: r.userid, name: r.ridername || `Rider ${r.userid}`, rows: [] }); + byRider.get(key).rows.push(r); + }); + + const saturated = [...byRider.values()] + .filter((entry) => entry.rows.length >= activeCap) + .sort((a, b) => b.rows.length - a.rows.length); + + if (!saturated.length || unassignedCount === 0) return []; + + return [ + { + id: 'rider_saturation', + skillId: 'skill_fleet_balancer', + severity: 'watch', + title: `${saturated.length} rider${saturated.length === 1 ? ' is' : 's are'} at capacity (≥${activeCap} jobs) while orders queue`, + why: `Riders carrying ${activeCap}+ in-flight jobs are capped and will not receive newly queued orders.`, + count: saturated.length, + rows: saturated.flatMap((entry) => entry.rows), + detail: saturated.slice(0, 8).map((entry) => `${entry.name} — ${entry.rows.length} concurrent orders`) + } + ]; + } +}; diff --git a/src/lib/assistant/skills/definitions/HighValueCodSkill.js b/src/lib/assistant/skills/definitions/HighValueCodSkill.js new file mode 100644 index 0000000..50ca895 --- /dev/null +++ b/src/lib/assistant/skills/definitions/HighValueCodSkill.js @@ -0,0 +1,93 @@ +// ==============================|| Skill: High-Value COD Escort ||============================== // + +const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']); +const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase()); +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +export const HighValueCodSkill = { + id: 'skill_high_value_cod', + name: 'High-Value Cash Guardian', + description: 'Audits large Cash-on-Delivery consignments to prevent loss, fraud, or unverified deliveries.', + category: 'loss_prevention', + icon: 'ShieldCheck', + // OFF by default (2026-09-29): the rows this rule reads carry no payment amount or payment mode: /admin/bookings does not preload bookingpayments. + // Enabled, it would read undefined on every row and report an all-clear board — + // the silent failure normalise.js warns about. Turn it on only once the data exists. + dataGap: 'no payment amount or payment mode: /admin/bookings does not preload bookingpayments', + defaultEnabled: false, + + thresholds: { + codRiskThresholdAmount: { + label: 'High-Value COD Threshold (₹)', + description: 'Cash-on-Delivery amount above which an order is classified as high-risk cash asset.', + value: 3000, + min: 1000, + max: 20000, + step: 500, + unit: '₹' + } + }, + + instructions: ` + - High-value COD consignments must require OTP verification at the doorstep and verified senior rider assignment. + `, + + tools: [ + { + name: 'enforce_otp_verification', + description: 'Flags an order as strictly requiring consignee OTP delivery confirmation before handover.', + parameters: { + type: 'object', + properties: { + bookingId: { type: 'number', description: 'Target booking ID' } + }, + required: ['bookingId'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'require_otp', + bookingId: args.bookingId, + blastRadius: `Enables mandatory OTP confirmation on mobile app for parcel #${args.bookingId}.` + } + }) + } + ], + + evaluate: (rows = [], now, currentThresholds = {}) => { + const minAmount = currentThresholds.codRiskThresholdAmount ?? 3000; + const safeRows = Array.isArray(rows) ? rows : []; + + const highValueCod = safeRows.filter((r) => { + if (!isOpen(r)) return false; + const isCod = String(r.paymenttype || r.payment_mode || '').toLowerCase().includes('cod') || + String(r.paymentstatus || '').toLowerCase() === 'pending'; + const amount = Number(r.orderamount || r.totalamount || r.price || 0); + return isCod && amount >= minAmount; + }); + + if (!highValueCod.length) return []; + + return [ + { + id: 'high_value_cod_alert', + skillId: 'skill_high_value_cod', + severity: 'watch', + title: `${highValueCod.length} high-value COD consignment${highValueCod.length === 1 ? '' : 's'} (≥ ₹${minAmount.toLocaleString()}) active`, + why: `Large cash collection in transit requires mandatory OTP handover verification.`, + count: highValueCod.length, + rows: highValueCod, + detail: highValueCod.slice(0, 8).map((r) => { + const amt = Number(r.orderamount || r.totalamount || r.price || 0); + return `${ref(r)} — ₹${amt.toLocaleString()} (${r.orderstatus || 'active'})`; + }), + proposal: { + label: 'Verify OTP requirements', + tool: 'enforce_otp_verification', + scope: highValueCod, + blastRadius: 'Sets strict OTP delivery guard on high-value cash deliveries.' + } + } + ]; + } +}; diff --git a/src/lib/assistant/skills/definitions/HubCongestionSkill.js b/src/lib/assistant/skills/definitions/HubCongestionSkill.js new file mode 100644 index 0000000..d8dbc63 --- /dev/null +++ b/src/lib/assistant/skills/definitions/HubCongestionSkill.js @@ -0,0 +1,105 @@ +// ==============================|| Skill: Hub Congestion Agent ||============================== // +// +// Detects parcels that have been sitting at a hub for too long without +// being picked up by a rider. These represent blocked capacity at the hub +// and direct SLA risk for the customer. This is critical for hub operations + + + +import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; + +const HUB_STATUSES = new Set(['pending', 'accepted']); // at hub, not yet picked + +const minutesBetween = (from, to) => { + const a = parseDoormileTimestamp(from); + const b = parseDoormileTimestamp(to); + if (!a.isValid() || !b.isValid()) return null; + return b.diff(a, 'minute'); +}; + +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +export const HubCongestionSkill = { + id: 'skill_hub_congestion', + name: 'Hub Congestion Agent', + description: 'Detects parcels sitting at a hub without rider pickup beyond the dwell threshold. Dispatches nearest idle rider.', + category: 'sla_management', + icon: 'ShieldAlert', + defaultEnabled: true, + + thresholds: { + hubDwellMinutes: { + label: 'Hub Dwell Threshold', + description: 'Minutes a parcel can sit at hub without a rider pickup before raising a congestion alert.', + value: 45, + min: 15, + max: 120, + step: 5, + unit: 'min' + } + }, + + instructions: ` + - Parcels sitting at a hub beyond the dwell threshold block capacity for the next batch. + - Identify the nearest idle rider in the same zone and dispatch immediately. + - Prefer riders with the fewest active orders to avoid overloading. + `, + + tools: [ + { + name: 'dispatch_hub_idle_parcels', + description: 'Proposes dispatching a nearby idle rider to collect stalled hub parcels.', + parameters: { + type: 'object', + properties: { + hubId: { type: 'string', description: 'Hub identifier where parcels are stalled' }, + bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs to dispatch' }, + milerId: { type: 'number', description: 'Target idle rider user ID' } + }, + required: ['bookingIds'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'dispatch_hub_parcels', + hubId: args.hubId || 'unknown', + bookingIds: args.bookingIds, + milerId: args.milerId, + blastRadius: `Dispatches rider to collect ${args.bookingIds?.length || 'stalled'} parcel(s) from hub. Updates parcel status to In-Transit.` + } + }) + } + ], + + evaluate: (rows = [], now, currentThresholds = {}) => { + const dwellLimit = currentThresholds.hubDwellMinutes ?? 45; + const findings = []; + + const stalled = rows + .filter((r) => HUB_STATUSES.has(String(r?.orderstatus || '').toLowerCase())) + .map((r) => ({ row: r, dwellMin: minutesBetween(r.orderdate, now) })) + .filter((m) => m.dwellMin !== null && m.dwellMin >= dwellLimit) + .sort((a, b) => b.dwellMin - a.dwellMin); + + if (stalled.length) { + findings.push({ + id: 'hub_parcel_stalled', + skillId: 'skill_hub_congestion', + severity: 'critical', + title: `${stalled.length} parcel${stalled.length === 1 ? '' : 's'} stalled at hub for >${dwellLimit}m`, + why: 'Parcel has been at the hub beyond the acceptable dwell time with no rider assigned for pickup.', + count: stalled.length, + rows: stalled.map((m) => m.row), + detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — at hub for ${m.dwellMin} min`), + proposal: { + label: 'Dispatch idle rider to hub', + tool: 'dispatch_hub_idle_parcels', + scope: stalled.map((m) => m.row), + blastRadius: 'Assigns nearest idle zone rider to collect and dispatch all stalled parcels.' + } + }); + } + + return findings; + } +}; diff --git a/src/lib/assistant/skills/definitions/LateDispatchSkill.js b/src/lib/assistant/skills/definitions/LateDispatchSkill.js new file mode 100644 index 0000000..5a7ed47 --- /dev/null +++ b/src/lib/assistant/skills/definitions/LateDispatchSkill.js @@ -0,0 +1,133 @@ +// ==============================|| Skill: Late Dispatch Agent ||============================== // +// +// Flags orders that have been accepted/created but not yet dispatched to a rider +// within the acceptable dispatch window during business hours. +// Late dispatch at creation cascades into late delivery at the door. + +import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; + +const PENDING_DISPATCH_STATUSES = new Set(['pending']); + +const minutesBetween = (from, to) => { + const a = parseDoormileTimestamp(from); + const b = parseDoormileTimestamp(to); + if (!a.isValid() || !b.isValid()) return null; + return b.diff(a, 'minute'); +}; + +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +export const LateDispatchSkill = { + id: 'skill_late_dispatch', + name: 'Late Dispatch Agent', + description: 'Flags orders accepted but sitting in queue without dispatch for too long. Triggers auto-assignment to prevent cascading delays.', + category: 'sla_management', + icon: 'ShieldAlert', + defaultEnabled: true, + + thresholds: { + lateDispatchMinutes: { + label: 'Dispatch Deadline', + description: 'Minutes after order creation before flagging as late dispatch.', + value: 30, + min: 10, + max: 90, + step: 5, + unit: 'min' + }, + criticalDispatchMinutes: { + label: 'Critical Dispatch Deadline', + description: 'Minutes after order creation before escalating to critical alert.', + value: 60, + min: 30, + max: 180, + step: 10, + unit: 'min' + } + }, + + instructions: ` + - Orders waiting for dispatch beyond the threshold accumulate delay that can't be recovered at the doorstep. + - Auto-assign to the nearest available rider immediately. + - Prioritize orders with the earliest SLA deadlines when multiple are pending. + `, + + tools: [ + { + name: 'trigger_auto_dispatch', + description: 'Proposes auto-assigning late-pending orders to available riders in zone.', + parameters: { + type: 'object', + properties: { + bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs requiring immediate dispatch' }, + reason: { type: 'string', description: 'Dispatch urgency reason' } + }, + required: ['bookingIds'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'auto_dispatch', + bookingIds: args.bookingIds, + reason: args.reason || 'Late dispatch threshold exceeded', + blastRadius: `Auto-assigns ${args.bookingIds?.length || 'late'} order(s) to nearest available zone riders. Rider notifications will be sent.` + } + }) + } + ], + + evaluate: (rows = [], now, currentThresholds = {}) => { + const lateThreshold = currentThresholds.lateDispatchMinutes ?? 30; + const criticalThreshold = currentThresholds.criticalDispatchMinutes ?? 60; + const findings = []; + + const allPending = rows + .filter((r) => PENDING_DISPATCH_STATUSES.has(String(r?.orderstatus || '').toLowerCase())) + .map((r) => ({ row: r, waitMin: minutesBetween(r.orderdate, now) })) + .filter((m) => m.waitMin !== null && m.waitMin >= lateThreshold) + .sort((a, b) => b.waitMin - a.waitMin); + + const critical = allPending.filter((m) => m.waitMin >= criticalThreshold); + const warning = allPending.filter((m) => m.waitMin >= lateThreshold && m.waitMin < criticalThreshold); + + if (critical.length) { + findings.push({ + id: 'late_dispatch_critical', + skillId: 'skill_late_dispatch', + severity: 'critical', + title: `${critical.length} order${critical.length === 1 ? '' : 's'} critically late for dispatch (>${criticalThreshold}m)`, + why: `Order has been sitting in queue for over ${criticalThreshold} minutes with no rider assigned. Delivery SLA is at severe risk.`, + count: critical.length, + rows: critical.map((m) => m.row), + detail: critical.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min for dispatch`), + proposal: { + label: 'Auto-dispatch to available riders', + tool: 'trigger_auto_dispatch', + scope: critical.map((m) => m.row), + blastRadius: 'Immediately assigns nearest available riders. Overrides manual queue priority.' + } + }); + } + + if (warning.length) { + findings.push({ + id: 'late_dispatch_warning', + skillId: 'skill_late_dispatch', + severity: 'warning', + title: `${warning.length} order${warning.length === 1 ? '' : 's'} approaching dispatch deadline (>${lateThreshold}m)`, + why: `Order has been waiting ${lateThreshold}+ minutes for rider assignment. Act now to prevent SLA breach.`, + count: warning.length, + rows: warning.map((m) => m.row), + detail: warning.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min`), + proposal: { + label: 'Dispatch now', + tool: 'trigger_auto_dispatch', + scope: warning.map((m) => m.row), + blastRadius: 'Assigns available riders to pending orders based on zone proximity.' + } + }); + } + + return findings; + } +}; diff --git a/src/lib/assistant/skills/definitions/RiderBatterySafetySkill.js b/src/lib/assistant/skills/definitions/RiderBatterySafetySkill.js new file mode 100644 index 0000000..ed5c084 --- /dev/null +++ b/src/lib/assistant/skills/definitions/RiderBatterySafetySkill.js @@ -0,0 +1,91 @@ +// ==============================|| Skill: Rider Battery & Device Safety ||============================== // + +const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']); +const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase()); +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +export const RiderBatterySafetySkill = { + id: 'skill_rider_battery_safety', + name: 'Rider Device & SOS Safety', + description: 'Monitors rider device telemetry to prevent unreachability due to low battery during active deliveries.', + category: 'rider_safety', + icon: 'BatteryWarning', + // OFF by default (2026-09-29): the rows this rule reads carry no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows. + // Enabled, it would read undefined on every row and report an all-clear board — + // the silent failure normalise.js warns about. Turn it on only once the data exists. + dataGap: 'no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows', + defaultEnabled: false, + + thresholds: { + criticalBatteryPercent: { + label: 'Critical Battery Level (%)', + description: 'Threshold below which rider device is considered at risk of shutdown.', + value: 15, + min: 5, + max: 30, + step: 5, + unit: '%' + } + }, + + instructions: ` + - When a rider has critical battery while carrying in-transit orders, advise charging or offloading remaining pending pickups. + `, + + tools: [ + { + name: 'alert_low_battery_rider', + description: 'Notifies rider to connect portable charger or report to nearest hub.', + parameters: { + type: 'object', + properties: { + milerId: { type: 'number', description: 'Target miler user ID' } + }, + required: ['milerId'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'battery_alert', + milerId: args.milerId, + blastRadius: `Sends device power warning notification to Rider #${args.milerId}.` + } + }) + } + ], + + evaluate: (rows = [], now, currentThresholds = {}) => { + const minBattery = currentThresholds.criticalBatteryPercent ?? 15; + const safeRows = Array.isArray(rows) ? rows : []; + + const atRisk = safeRows.filter((r) => { + if (!isOpen(r) || !r.userid) return false; + const battery = Number(r.battery_percentage || r.battery_level || r.battery || 100); + return battery > 0 && battery <= minBattery; + }); + + if (!atRisk.length) return []; + + return [ + { + id: 'rider_critical_battery', + skillId: 'skill_rider_battery_safety', + severity: 'warning', + title: `${atRisk.length} active delivery under low rider device battery (≤ ${minBattery}%)`, + why: `Rider phone is nearing shutdown, risking lost GPS telemetry and customer communication failure.`, + count: atRisk.length, + rows: atRisk, + detail: atRisk.slice(0, 8).map((r) => { + const bat = r.battery_percentage || r.battery_level || r.battery || 'Low'; + return `${ref(r)} — ${r.ridername || 'Rider'} (${bat}% battery)`; + }), + proposal: { + label: 'Send power alert', + tool: 'alert_low_battery_rider', + scope: atRisk, + blastRadius: 'Sends charging prompt to rider.' + } + } + ]; + } +}; diff --git a/src/lib/assistant/skills/definitions/SlaGuardianSkill.js b/src/lib/assistant/skills/definitions/SlaGuardianSkill.js new file mode 100644 index 0000000..a858e6a --- /dev/null +++ b/src/lib/assistant/skills/definitions/SlaGuardianSkill.js @@ -0,0 +1,157 @@ +// ==============================|| Skill: SLA Breach Guardian ||============================== // +import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; + +const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']); +const IN_FLIGHT_STATUSES = new Set(['picked', 'active']); + +const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase()); + +const minutesBetween = (from, to) => { + const a = parseDoormileTimestamp(from); + const b = parseDoormileTimestamp(to); + if (!a.isValid() || !b.isValid()) return null; + return b.diff(a, 'minute'); +}; + +const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—'); + +export const SlaGuardianSkill = { + id: 'skill_sla_guardian', + name: 'SLA Breach Guardian', + description: 'Monitors promised customer delivery ETAs and flags both breached and imminent SLA violations.', + category: 'sla_management', + icon: 'ShieldAlert', + defaultEnabled: true, + + thresholds: { + slaRiskWindowMin: { + label: 'At-Risk Warning Window', + description: 'Minutes before promised delivery window to flag an un-picked parcel as at-risk.', + value: 45, + min: 15, + max: 90, + step: 5, + unit: 'min' + }, + unassignedAgingMin: { + label: 'Unassigned Aging Threshold', + description: 'Minutes an order can remain pending with no assigned rider before raising an alarm.', + value: 60, + min: 15, + max: 120, + step: 5, + unit: 'min' + } + }, + + instructions: ` + - When orders exceed promised delivery ETA or are within the risk window without being in flight, prioritize immediate reassignment. + - Select nearby available riders with high historical on-time fulfillment rates. + `, + + tools: [ + { + name: 'reassign_sla_critical_order', + description: 'Proposes transferring an SLA-at-risk order to an optimal nearby rider.', + parameters: { + type: 'object', + properties: { + bookingId: { type: 'number', description: 'Booking ID to reassign' }, + targetMilerId: { type: 'number', description: 'Target miler user ID' }, + reason: { type: 'string', description: 'Justification' } + }, + required: ['bookingId', 'targetMilerId'] + }, + handler: async (args) => ({ + success: true, + proposal: { + action: 'reassign_miler', + bookingId: args.bookingId, + milerId: args.targetMilerId, + reason: args.reason || 'SLA risk mitigation', + blastRadius: `Reassigns parcel #${args.bookingId} and updates customer ETA tracking.` + } + }) + } + ], + + evaluate: (rows = [], now, currentThresholds = {}) => { + const riskWindow = currentThresholds.slaRiskWindowMin ?? 45; + const agingWindow = currentThresholds.unassignedAgingMin ?? 60; + const findings = []; + + // 1. Breached SLA + const breached = rows + .filter((r) => isOpen(r) && r.expecteddeliverytime) + .map((r) => ({ row: r, overdueMin: minutesBetween(r.expecteddeliverytime, now) })) + .filter((m) => m.overdueMin !== null && m.overdueMin > 0) + .sort((a, b) => b.overdueMin - a.overdueMin); + + if (breached.length) { + findings.push({ + id: 'sla_breached', + skillId: 'skill_sla_guardian', + severity: 'critical', + title: `${breached.length} ${breached.length === 1 ? 'parcel is' : 'parcels are'} past promised delivery time`, + why: 'Estimated delivery time on service option has elapsed and parcel is not yet delivered.', + count: breached.length, + rows: breached.map((m) => m.row), + detail: breached.slice(0, 8).map((m) => `${ref(m.row)} — ${m.overdueMin} min overdue, status: ${m.row.orderstatus}`), + proposal: { + label: 'Notify assigned riders', + tool: 'notifyRider', + scope: breached.filter((m) => m.row.userid).map((m) => m.row), + blastRadius: 'Sends an urgent priority notification to the assigned rider.' + } + }); + } + + // 2. At-Risk SLA + const atRisk = rows + .filter((r) => isOpen(r) && r.expecteddeliverytime && !IN_FLIGHT_STATUSES.has(String(r.orderstatus).toLowerCase())) + .map((r) => ({ row: r, dueInMin: minutesBetween(now, r.expecteddeliverytime) })) + .filter((m) => m.dueInMin !== null && m.dueInMin > 0 && m.dueInMin <= riskWindow) + .sort((a, b) => a.dueInMin - b.dueInMin); + + if (atRisk.length) { + findings.push({ + id: 'sla_at_risk', + skillId: 'skill_sla_guardian', + severity: 'warning', + title: `${atRisk.length} parcel${atRisk.length === 1 ? '' : 's'} due within ${riskWindow}m and not yet moving`, + why: 'Inside the delivery promise window but parcel pickup has not begun.', + count: atRisk.length, + rows: atRisk.map((m) => m.row), + detail: atRisk.slice(0, 8).map((m) => `${ref(m.row)} — due in ${m.dueInMin} min, status: ${m.row.orderstatus}`) + }); + } + + // 3. Unassigned Aging + const aging = rows + .filter((r) => String(r.orderstatus || '').toLowerCase() === 'pending') + .map((r) => ({ row: r, ageMin: minutesBetween(r.orderdate, now) })) + .filter((m) => m.ageMin !== null && m.ageMin >= agingWindow) + .sort((a, b) => b.ageMin - a.ageMin); + + if (aging.length) { + findings.push({ + id: 'unassigned_aging', + skillId: 'skill_sla_guardian', + severity: 'warning', + title: `${aging.length} booking${aging.length === 1 ? '' : 's'} unassigned for > ${agingWindow}m`, + why: 'Order has been sitting in queue with no assigned miler.', + count: aging.length, + rows: aging.map((m) => m.row), + detail: aging.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.ageMin} min`), + proposal: { + label: 'Auto-assign available rider', + tool: 'assignMiler', + scope: aging.map((m) => m.row), + blastRadius: 'Initiates dispatch matching across idle zone riders.' + } + }); + } + + return findings; + } +}; diff --git a/src/lib/assistant/skills/index.js b/src/lib/assistant/skills/index.js new file mode 100644 index 0000000..a349533 --- /dev/null +++ b/src/lib/assistant/skills/index.js @@ -0,0 +1,10 @@ +export { SkillRegistry, DEFAULT_SKILLS } from './SkillRegistry'; +export { useSkillRegistrySync } from './useSkillRegistrySync'; +export { SlaGuardianSkill } from './definitions/SlaGuardianSkill'; +export { DoorstepStallSkill } from './definitions/DoorstepStallSkill'; +export { FleetBalancerSkill } from './definitions/FleetBalancerSkill'; +export { HighValueCodSkill } from './definitions/HighValueCodSkill'; +export { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill'; +export { HubCongestionSkill } from './definitions/HubCongestionSkill'; +export { LateDispatchSkill } from './definitions/LateDispatchSkill'; +export { CashExposureSkill } from './definitions/CashExposureSkill'; diff --git a/src/lib/assistant/skills/useSkillRegistrySync.js b/src/lib/assistant/skills/useSkillRegistrySync.js new file mode 100644 index 0000000..799a302 --- /dev/null +++ b/src/lib/assistant/skills/useSkillRegistrySync.js @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useAiSkills } from '@/lib/doormileHooks'; +import { SkillRegistry } from './SkillRegistry'; + +/** + * Keeps the in-memory SkillRegistry in step with the backend agent registry. + * + * Mounted once, in the console shell, so the Exceptions banner and the chat's + * "what needs attention" briefing both run on the same, tuned settings. A + * change saved in Settings → Skills & Tools invalidates the registry query, + * this refetches, and every subscriber re-evaluates. + * + * `enabled` is false for partner-tenant logins: the registry is Doormile-staff + * only (the backend answers 403), so they run on code defaults, which the + * banner states. + */ +export function useSkillRegistrySync(enabled = true) { + const { data, isError } = useAiSkills({ enabled, retry: false }); + + useEffect(() => { + if (data) SkillRegistry.applyRegistryConfig(data); + }, [data]); + + useEffect(() => { + if (isError || !enabled) SkillRegistry.clearRegistryConfig(); + }, [isError, enabled]); +} diff --git a/src/lib/clientOnboarding.js b/src/lib/clientOnboarding.js new file mode 100644 index 0000000..af60974 --- /dev/null +++ b/src/lib/clientOnboarding.js @@ -0,0 +1,75 @@ +/** + * Who may onboard a new client (create a tenant and its console login). + * + * The server is the real gate — POST /admin/clients/onboard answers 403 to + * anyone outside CLIENT_ONBOARDING_OWNERS (default admin@doormile.com), and + * only for Doormile staff with roleid 1. This list only decides whether the + * console SHOWS the page and its links, so keep it matching the server's. + */ +const DEFAULT_OWNERS = 'admin@doormile.com'; + +export const CLIENT_ONBOARDING_OWNERS = String(import.meta.env?.VITE_CLIENT_ONBOARDING_OWNERS || DEFAULT_OWNERS) + .split(',') + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); + +/** + * @param {any} user the signed-in console user (AuthContext) + * @param {boolean} isClient true for a partner-tenant login + */ +export function canOnboardClients(user, isClient) { + if (!user || isClient) return false; + const email = String(user.email || '').trim().toLowerCase(); + const role = String(user.role || '').toLowerCase(); + return CLIENT_ONBOARDING_OWNERS.includes(email) && (role === 'admin' || String(user.roleid) === '1'); +} + +/** A random password an operator can hand over: 14 chars, no look-alikes. */ +export function generateClientPassword(length = 14) { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#%&*'; + const bytes = new Uint32Array(length); + globalThis.crypto.getRandomValues(bytes); + let out = ''; + for (let i = 0; i < length; i += 1) out += chars[bytes[i] % chars.length]; + // Guarantee a digit and a letter, so a generated password is never all one class. + if (!/\d/.test(out)) out = `${out.slice(0, -1)}7`; + if (!/[a-z]/i.test(out)) out = `k${out.slice(1)}`; + return out; +} + +/** Client-side mirror of the server's validation, for inline errors. */ +export function validateOnboarding(form) { + const errors = {}; + const phone = String(form.phone || '').replace(/\D/g, ''); + if (String(form.companyname || '').trim().length < 2) errors.companyname = 'Enter the company name'; + if (String(form.contactname || '').trim().length < 2) errors.contactname = "Enter the contact person's name"; + 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"; + 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'; + else if (pw.toLowerCase() === String(form.email || '').trim().toLowerCase() || pw === phone) { + errors.password = 'Must not be the email or the phone number'; + } + if (form.confirm !== pw) errors.confirm = 'The passwords do not match'; + return errors; +} + +/** + * Why onboarding cannot run, from the failed GET /admin/clients/cities. The + * page shows this instead of an empty city list: the likeliest cause is a + * console pointed at an API that does not have the onboarding endpoints yet. + */ +export function onboardingUnavailableMessage(err) { + const status = err?.response?.status; + if (status === 404) { + return 'This API server does not have client onboarding yet. The backend changes that add it need to be deployed first.'; + } + if (status === 403) { + return 'The server refused this login. Client onboarding is only for the admin@doormile.com account, signed in as Doormile staff.'; + } + if (status === 401) return 'Your session has expired. Sign in again.'; + if (!err?.response) return 'Could not reach the API server. Check your connection and try again.'; + return err.response.data?.message || `The server answered ${status}. Try again shortly.`; +} diff --git a/src/lib/distance.js b/src/lib/distance.js index 0058db3..7b07379 100644 --- a/src/lib/distance.js +++ b/src/lib/distance.js @@ -49,6 +49,11 @@ export const calculateDrivingRoute = async (origin, destination) => { const data = await response.json(); if (data.routes && data.routes.length > 0) { const route = data.routes[0]; + // `distance` stays whole km: it is what pricing and bulk upload bill on, + // and changing its precision would change invoices. `meters` is the + // unrounded road length, for display only — a 120 m drop rounds to + // 0 km here and must not be shown as "0 km" or floored to "1 km". + const meters = Math.max(0, Math.round(route.distance)); const distanceKm = Math.round(route.distance / 1000); const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null; // OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng] @@ -56,6 +61,7 @@ export const calculateDrivingRoute = async (origin, destination) => { lastRouteDurationMin = durationMin; return { distance: Math.max(0, distanceKm), + meters, minutes: durationMin, polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]], resolved: true @@ -81,6 +87,7 @@ export const calculateDrivingRoute = async (origin, destination) => { lastRouteDurationMin = null; return { distance: aerialDistance, + meters: Math.round(R * c * 1.3 * 1000), minutes: null, polyline: [[lat1, lon1], [lat2, lon2]], resolved: true @@ -95,6 +102,30 @@ export const calculateDrivingDistance = async (origin, destination) => { return res.distance; }; +/** + * Formats a road length for display: metres under 1 km (to the nearest 10 m), + * km with one decimal above it. Returns '—' when the length is unknown. + * @param {number} meters + */ +export const formatRouteDistance = (meters) => { + if (meters === null || meters === undefined) return '—'; + const m = Number(meters); + if (!Number.isFinite(m) || m < 0) return '—'; + const tens = Math.round(m / 10) * 10; + if (tens < 1000) return `${tens} m`; + return `${Number((m / 1000).toFixed(1))} km`; +}; + +/** + * Formats a drive time for display. OSRM reports seconds; a sub-minute leg + * rounds to 0 and must read "< 1 min", not fall through to a default. + * @param {number|null} minutes + */ +export const formatRouteDuration = (minutes) => { + if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return '—'; + return Number(minutes) < 1 ? '< 1 min' : `${minutes} mins`; +}; + /** * Calculates total charge based on distance and pricing tier */ diff --git a/src/lib/doormileHooks.js b/src/lib/doormileHooks.js index 46e1e87..e71ebb3 100644 --- a/src/lib/doormileHooks.js +++ b/src/lib/doormileHooks.js @@ -67,6 +67,14 @@ export const KEYS = { carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit], appLocations: ['doormile', 'app-locations'], + + // AI agent registry. One prefix, so a write invalidates agents, skills, + // tools and the audit together — a skill toggle changes an agent's counts. + aiRegistry: ['doormile', 'ai'], + aiAgents: ['doormile', 'ai', 'agents'], + aiSkills: ['doormile', 'ai', 'skills'], + aiTools: ['doormile', 'ai', 'tools'], + aiAudit: ['doormile', 'ai', 'audit'], }; /* ── Shared helpers ───────────────────────────────────────────────────────── */ @@ -179,6 +187,23 @@ export const useTenant = (id, options) => export const useCreateTenant = () => useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' }); +export const useOnboardedClients = (options) => + useQuery({ queryKey: [...KEYS.tenants, 'onboarded'], queryFn: api.getOnboardedClients, staleTime: 30_000, ...options }); + +export const useOnboardingCities = (options) => + useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options }); + +// No toasts: the page shows its own success panel and inline errors. +export const useOnboardClient = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: api.onboardClient, + onSuccess: (result) => { + if (result?.success !== false) queryClient.invalidateQueries({ queryKey: KEYS.tenants }); + }, + }); +}; + export const useUpdateTenant = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateAdminTenant(id, data), @@ -792,3 +817,57 @@ export const useOrderPercentages = (startdate, enddate, options) => staleTime: 30_000, ...options, }); + +/* ── AI agent registry (Settings → Skills & Tools) ────────────────────────── */ + +export const useAiAgents = (options) => + useQuery({ queryKey: KEYS.aiAgents, queryFn: api.getAiAgents, staleTime: 30_000, ...options }); + +export const useAiSkills = (options) => + useQuery({ queryKey: KEYS.aiSkills, queryFn: api.getAiSkills, staleTime: 30_000, ...options }); + +export const useAiTools = (options) => + useQuery({ queryKey: KEYS.aiTools, queryFn: api.getAiTools, staleTime: 5 * 60_000, ...options }); + +export const useUpdateAiSkill = () => + useDoormileMutation({ + mutationFn: ({ id, patch }) => api.updateAiSkill(id, patch), + invalidates: [KEYS.aiRegistry], + successMessage: 'Skill updated', + }); + +export const useCreateAiSkill = () => + useDoormileMutation({ + mutationFn: (skill) => api.createAiSkill(skill), + invalidates: [KEYS.aiRegistry], + successMessage: 'Skill registered', + }); + +export const useUpdateAiAgent = () => + useDoormileMutation({ + mutationFn: ({ id, patch }) => api.updateAiAgent(id, patch), + invalidates: [KEYS.aiRegistry], + successMessage: 'Agent updated', + }); + +// Read-only, and it changes as agents work: refetched every minute while open. +export const useAiInsights = (days = 7, options) => + useQuery({ + queryKey: [...KEYS.aiRegistry, 'insights', days], + queryFn: () => api.getAiInsights(days), + staleTime: 30_000, + refetchInterval: 60_000, + ...options, + }); + +// A playground run. No toasts and no invalidation: it changes nothing, and the +// Test tab shows its own result or error inline. +export const useRunAiPlayground = () => useMutation({ mutationFn: api.runAiPlayground }); + +export const useAiDecisions = (params = {}, options) => + useQuery({ + queryKey: [...KEYS.aiRegistry, 'decisions', params], + queryFn: () => api.getAiDecisions(params), + staleTime: 30_000, + ...options, + }); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index f966d06..08b8b21 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -62,7 +62,7 @@ export default function Login() { if (!validate()) return; setStatus('loading'); - const result = await login(form.email.trim(), form.password); + const result = await login(form.email.trim().toLowerCase(), form.password); if (!result.success) { setStatus('idle'); diff --git a/src/pages/doormile/agents/AgentInspectorDrawer.jsx b/src/pages/doormile/agents/AgentInspectorDrawer.jsx index 1cb3433..f4b9fa0 100644 --- a/src/pages/doormile/agents/AgentInspectorDrawer.jsx +++ b/src/pages/doormile/agents/AgentInspectorDrawer.jsx @@ -2,12 +2,9 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { AnimatePresence, motion } from 'framer-motion'; import { - Activity, - CheckCircle2, ChevronDown, ChevronUp, Copy, - ExternalLink, Radio, RefreshCw, Server, @@ -19,7 +16,7 @@ import { import toast from 'react-hot-toast'; import { cn } from '@/lib/utils'; -import { TRIGGER, agentById } from '@/lib/agentNetwork'; +import { agentById } from '@/lib/agentNetwork'; const UI_ENDPOINT = 'https://app.doormile.com/doormile/agents'; const WS_ENDPOINT = 'wss://app.doormile.com/doormile/agents/stream'; diff --git a/src/pages/doormile/agents/AgentPipeline.jsx b/src/pages/doormile/agents/AgentPipeline.jsx index b2bb6ac..ffe20aa 100644 --- a/src/pages/doormile/agents/AgentPipeline.jsx +++ b/src/pages/doormile/agents/AgentPipeline.jsx @@ -5,20 +5,14 @@ import gsap from 'gsap'; import { Activity, AlertTriangle, - ArrowRight, - Box, CheckCircle2, Cpu, - Layers, Package, - Play, Radio, RotateCcw, Server, ShieldAlert, - ShieldCheck, Sparkles, - Zap, } from 'lucide-react'; import { cn } from '@/lib/utils'; diff --git a/src/pages/doormile/agents/AgentWorkflowView.jsx b/src/pages/doormile/agents/AgentWorkflowView.jsx index 1eb26c2..c384af9 100644 --- a/src/pages/doormile/agents/AgentWorkflowView.jsx +++ b/src/pages/doormile/agents/AgentWorkflowView.jsx @@ -4,16 +4,11 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { ArrowLeft, ArrowRight, - CheckCircle2, - ChevronRight, - ExternalLink, Layers, Pause, Play, - RotateCcw, ShieldCheck, Sparkles, - Zap, } from 'lucide-react'; import { cn } from '@/lib/utils'; diff --git a/src/pages/doormile/agents/Agents.jsx b/src/pages/doormile/agents/Agents.jsx index 9b2b93c..cc3a26f 100644 --- a/src/pages/doormile/agents/Agents.jsx +++ b/src/pages/doormile/agents/Agents.jsx @@ -1,34 +1,13 @@ import React, { useState } from 'react'; -import PropTypes from 'prop-types'; import { AnimatePresence, motion } from 'framer-motion'; import { Activity, AlertTriangle, - ArrowUpRight, - Bot, - CheckCircle2, ChevronRight, - ExternalLink, - Layers, - Radio, - RefreshCw, - Server, - ShieldAlert, ShieldCheck, - Terminal, - Zap, } from 'lucide-react'; -import { PageHeader } from '@/components/ds'; -import { cn } from '@/lib/utils'; -import { - AGENTS, - LOG_ERROR_LINES, - LOG_TOTAL_LINES, - RUN_LOG, - agentById, - networkStats, -} from '@/lib/agentNetwork'; +import { SNAPSHOT_AT, SNAPSHOT_LABEL, networkStats } from '@/lib/agentNetwork'; import AgentFlowVisualizer from './AgentFlowVisualizer'; import AgentInspectorDrawer from './AgentInspectorDrawer'; import './Agents.css'; @@ -38,7 +17,16 @@ export default function Agents() { const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [activeLogModal, setActiveLogModal] = useState(null); - // Live Activity Stream (3–5 recent events, Requirement: LIVE ACTIVITY) + // Counts come from the snapshot in lib/agentNetwork.js, not from a feed. + // The status bar used to hard-code them; now they at least match the record + // the rest of the page is drawn from, and the bar says how old that record is. + const stats = networkStats(); + const idle = stats.total - stats.working - stats.degraded; + + // SAMPLE activity. The seed rows and everything AgentFlowVisualizer appends + // come from its playback loop, not from AI_engine. `AI_engine` exposes no + // endpoint this console can read (see lib/agentNetwork.js), so the panel is + // labelled as a simulation rather than as live. const [liveActivities, setLiveActivities] = useState([ { id: 1, @@ -94,14 +82,19 @@ export default function Agents() { ═══════════════════════════════════════════════════════════════════ */}
{/* Left: Identity */} -
- +
+ {/* Static, not pulsing: nothing on this page is a live feed. */} +

Doormile AI Agent Network

- - · Real-Time Workflow Visualizer - +
{/* Right: Clean Status Indicators */} @@ -109,19 +102,19 @@ export default function Agents() { {/* 1 Processing */}
- 1 Processing + {stats.working} Processing
{/* 1 Warning */}
- 1 Warning + {stats.degraded} Warning
{/* 7 Idle */}
- 7 Idle + {idle} Idle
{/* 🔒 Autonomy Locked */} @@ -130,7 +123,9 @@ export default function Agents() { title="Autonomy locked: AI observes and recommends, but cannot independently alter production systems." > - Autonomy: 0 / 8 + {/* Denominator is the agents that HAVE an autonomy gate (Dispatch, + Exception, Express). It read "0 / 8", which implied eight gates. */} + Autonomy gates on: {stats.autonomyOn} / {stats.gated}
@@ -153,11 +148,11 @@ export default function Agents() {

- Live Activity + Sample Activity

- - Latest events + + Simulation · not live data diff --git a/src/pages/doormile/clients/ClientOnboarding.jsx b/src/pages/doormile/clients/ClientOnboarding.jsx new file mode 100644 index 0000000..0f7076a --- /dev/null +++ b/src/pages/doormile/clients/ClientOnboarding.jsx @@ -0,0 +1,381 @@ +import React, { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, ShieldCheck, UserPlus } from 'lucide-react'; +import { + Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, + BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack, + StatusBadge, Surface, Switch, +} from '@/components/ds'; +import { inputVariants } from '@/components/ui/input'; +import { useAuth } from '@/lib/AuthContext'; +import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks'; +import { + canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding, +} from '@/lib/clientOnboarding'; + +/** + * Client onboarding — register a new client and give them a console login. + * + * One submit creates, in a single server transaction, the tenant (the client + * company), its console login (doormile_auth, scoped to that tenant) and its + * appusers row. The client then signs in at the normal console login and sees + * only their own data. + * + * Restricted to the onboarding owner login (admin@doormile.com). The server + * enforces that; this page only hides itself from everyone else. + */ + +const EMPTY = { + companyname: '', + contactname: '', + email: '', + phone: '', + applocationid: '', + password: '', + confirm: '', + requiredeliveryotp: false, +}; + +const SERVER_FIELD = [ + ['company name', 'companyname'], + ['contact person', 'contactname'], + ['email', 'email'], + ['mobile', 'phone'], + ['password', 'password'], + ['operating city', 'applocationid'], +]; + +/** Put a server message under the field it is about, when it names one. */ +function serverFieldOf(message) { + const m = String(message || '').toLowerCase(); + const hit = SERVER_FIELD.find(([needle]) => m.includes(needle)); + return hit ? hit[1] : null; +} + +function OnboardedList() { + const { data = [], isLoading } = useOnboardedClients(); + const columns = [ + { key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname }, + { key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail }, + { key: 'primarycontact', header: 'Contact', accessor: (r) => r.primarycontact || '—' }, + { key: 'status', header: 'Status', cell: (r) => }, + { + key: 'logincreatedat', + header: 'Login created', + accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'), + }, + ]; + return ( + +

Clients with a console login

+ `${row.tenantid}-${row.loginemail}-${i}`} + loading={isLoading} + emptyState={} + /> +
+ ); +} + +export default function ClientOnboarding() { + const navigate = useNavigate(); + const { user, isClient } = useAuth(); + const allowed = canOnboardClients(user, isClient); + + const onboard = useOnboardClient(); + const citiesQ = useOnboardingCities({ enabled: allowed, retry: false }); + const locations = citiesQ.data || []; + // When the city list cannot be read, onboarding cannot work on this server + // either (same backend, same gate) — say why instead of an empty dropdown. + const unavailable = citiesQ.isError ? onboardingUnavailableMessage(citiesQ.error) : ''; + const cities = useMemo(() => locations.filter((l) => Number(l.applocationid) > 0), [locations]); + + const [form, setForm] = useState(EMPTY); + const [errors, setErrors] = useState({}); + const [formError, setFormError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [created, setCreated] = useState(null); // { tenant, login, password } + const [copied, setCopied] = useState(false); + + const set = (key) => (value) => { + setForm((f) => ({ ...f, [key]: value })); + setErrors((e) => ({ ...e, [key]: undefined })); + }; + + if (!allowed) { + return ( + + navigate('/doormile/home') } + : { label: 'Back to clients', onClick: () => navigate('/doormile/tenants') } + } + /> + + ); + } + + const submit = (event) => { + event.preventDefault(); + setFormError(''); + const nextErrors = validateOnboarding(form); + setErrors(nextErrors); + if (Object.keys(nextErrors).length) return; + + const password = form.password; + onboard.mutate( + { + companyname: form.companyname.trim(), + contactname: form.contactname.trim(), + email: form.email.trim().toLowerCase(), + phone: form.phone.replace(/\D/g, ''), + password, + applocationid: Number(form.applocationid), + requiredeliveryotp: form.requiredeliveryotp, + }, + { + onSuccess: (res) => { + if (res?.success === false) { + setFormError(res.message || 'The client was not created.'); + return; + } + setCreated({ ...res.data, password }); + setForm(EMPTY); + setCopied(false); + }, + onError: (err) => { + const status = err?.response?.status; + const message = err?.response?.data?.message; + if (status === 403) { + setFormError('Your login is not allowed to onboard clients.'); + return; + } + const field = serverFieldOf(message); + if (field && message) setErrors((e) => ({ ...e, [field]: message })); + else setFormError(message || 'The client could not be created. Nothing was saved.'); + }, + } + ); + }; + + const copyLogin = async () => { + if (!created) return; + const text = [ + `Doormile console: ${window.location.origin}/login`, + `Email: ${created.login.email}`, + `Password: ${created.password}`, + ].join('\n'); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + } catch { + setCopied(false); + } + }; + + return ( + + + + + Clients + + + + Onboard client + + + + } + actions={ + + } + /> + + {created && ( + +

+ Console login {created.login.email} is ready. They sign in at the + usual console login and see only their own orders. +

+

+ This is the only time the password is shown — share it with the client securely. +

+
+ + +
+
+ )} + +
+ +
+
+ +
+
+

Onboard a new client

+

+ Creates the client and their console login together. If any part fails, nothing is saved. +

+
+
+ + {unavailable && ( + citiesQ.refetch()}> + Try again + + } + > + {unavailable} + + )} + + {formError && ( + + {formError} + + )} + +
+ + set('companyname')(e.target.value)} + placeholder="Peelamedu Provisions Pvt Ltd" + maxLength={120} + /> + + + + set('contactname')(e.target.value)} + placeholder="Priya Raman" + maxLength={80} + /> + + + + set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))} + placeholder="9876543210" + inputMode="numeric" + /> + + + + set('email')(e.target.value)} + placeholder="ops@client.com" + autoComplete="off" + /> + + + + + + + + set('password')(e.target.value)} + autoComplete="new-password" + maxLength={72} + /> + + + + set('confirm')(e.target.value)} + autoComplete="new-password" + maxLength={72} + /> + + +
+ + +
+ + + + +
+ +
+ + +
+
+
+ + +
+ ); +} diff --git a/src/pages/doormile/clients/Tenants.jsx b/src/pages/doormile/clients/Tenants.jsx index e9da769..9e94321 100644 --- a/src/pages/doormile/clients/Tenants.jsx +++ b/src/pages/doormile/clients/Tenants.jsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Building2, CheckCircle2, CircleSlash, IndianRupee, Mail, MapPin, Pencil, Phone, - Plus, Timer, Users, + Plus, Timer, UserPlus, Users, } from 'lucide-react'; import { Button, DataTable, Drawer, EmptyState, Field, Grid, IconButton, Input, KpiCard, @@ -14,6 +14,8 @@ import { useTenants, useUpdateTenant, useUpdateTenantLocation, } from '@/lib/doormileHooks'; import { currency, matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat'; +import { useAuth } from '@/lib/AuthContext'; +import { canOnboardClients } from '@/lib/clientOnboarding'; /** * Clients — the tenant companies whose deliveries this network carries. @@ -281,6 +283,8 @@ function PricingDrawer({ tenant, open, onClose }) { export default function Tenants() { const navigate = useNavigate(); + const { user, isClient } = useAuth(); + const canOnboard = canOnboardClients(user, isClient); const { data: tenants = [], isLoading, isFetching } = useTenants(); const updateTenant = useUpdateTenant(); @@ -456,6 +460,11 @@ export default function Tenants() { placeholder="Search by client name, email or phone" className="w-full sm:w-72" /> + {canOnboard && ( + + )} diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx index c078e4e..89b42b8 100644 --- a/src/pages/doormile/deliveries/Deliveries.jsx +++ b/src/pages/doormile/deliveries/Deliveries.jsx @@ -49,9 +49,16 @@ const STATUS_TABS = [ const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled'); +// `short` is what the list shows: the trigger's clock icon already says +// "batch", so "Morning Batch" repeated the word on every row. const BATCH_OPTIONS = [ - { id: 'all', label: 'All batches', range: 'Across the day' }, - ...BATCHES.map((batch) => ({ id: batch.id, label: batch.label, range: batchRangeLabel(batch) })), + { id: 'all', label: 'All batches', short: 'All batches', range: 'All day' }, + ...BATCHES.map((batch) => ({ + id: batch.id, + label: batch.label, + short: batch.label.replace(/\s*batch$/i, ''), + range: batchRangeLabel(batch), + })), ]; const ALL = 'all'; @@ -431,27 +438,34 @@ export default function Deliveries() { /> + {/* Batch. Sized like the Clients and Milers selects beside it. + The trigger names the batch only: SelectItem puts an option's + whole content in ItemText, so a bare copied the + time-range badge into the trigger too and needed 210px for it. + The list is a compact label + time, no badges. */} setModel(e.target.value)} - className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 focus:outline-none focus:border-brand font-medium" + className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 focus:outline-none focus:border-brand font-medium disabled:text-slate-500" > - - - + {options.map((o) => ( + + ))} - - - {/* Temperature Slider */} -
-
- - {temperature} -
- setTemperature(parseFloat(e.target.value))} - className="w-full accent-[#C8102E] cursor-pointer" - /> -
- 0.0 (Strictly Deterministic) - 1.0 (Creative) -
-
- - - {/* System Prompt Instruction */} -
- -