@rubytech/create-realagent-code 0.1.576 → 0.1.578
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/active-rules/dist/index.js +32 -1
- package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
- package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
- package/payload/platform/lib/active-rules/src/index.ts +36 -2
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
- package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
- package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
- package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
- package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
- package/payload/platform/lib/routine-templates/dist/index.d.ts +4 -4
- package/payload/platform/lib/routine-templates/dist/index.js +4 -4
- package/payload/platform/lib/routine-templates/src/index.ts +4 -4
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -2
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +17 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
- package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
- package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/platform.md +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +0 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
- package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
- package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/server/public/activity.html +4 -4
- package/payload/server/public/agents.html +3 -3
- package/payload/server/public/assets/{AdminLoginScreens-BLGZEaKU.js → AdminLoginScreens-CAZ3DSwA.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.gz +0 -0
- package/payload/server/public/assets/{AdminShell-C_M7YE5Z.js → AdminShell-BLs5v0qF.js} +1 -1
- package/payload/server/public/assets/AdminShell-BLs5v0qF.js.br +0 -0
- package/payload/server/public/assets/AdminShell-BLs5v0qF.js.gz +0 -0
- package/payload/server/public/assets/{activity-BL6qbB9a.js → activity-BwPck2yT.js} +1 -1
- package/payload/server/public/assets/activity-BwPck2yT.js.br +0 -0
- package/payload/server/public/assets/activity-BwPck2yT.js.gz +0 -0
- package/payload/server/public/assets/{admin-jnJ3P2u4.js → admin-DjtOoabA.js} +1 -1
- package/payload/server/public/assets/admin-DjtOoabA.js.br +0 -0
- package/payload/server/public/assets/admin-DjtOoabA.js.gz +0 -0
- package/payload/server/public/assets/{agents-BzhfKZDN.js → agents-IQYaRvoh.js} +1 -1
- package/payload/server/public/assets/agents-IQYaRvoh.js.br +0 -0
- package/payload/server/public/assets/agents-IQYaRvoh.js.gz +0 -0
- package/payload/server/public/assets/{browser-C-6io2B9.js → browser-CPG8w8rQ.js} +1 -1
- package/payload/server/public/assets/browser-CPG8w8rQ.js.br +0 -0
- package/payload/server/public/assets/browser-CPG8w8rQ.js.gz +0 -0
- package/payload/server/public/assets/{calendar-Dt3gS88s.js → calendar-CRwrgNdq.js} +1 -1
- package/payload/server/public/assets/calendar-CRwrgNdq.js.br +0 -0
- package/payload/server/public/assets/calendar-CRwrgNdq.js.gz +0 -0
- package/payload/server/public/assets/chat-B7yZ4KLl.js +1 -0
- package/payload/server/public/assets/chat-B7yZ4KLl.js.br +3 -0
- package/payload/server/public/assets/chat-B7yZ4KLl.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-BzKUQqLy.js +1 -0
- package/payload/server/public/assets/chevron-left-BzKUQqLy.js.br +0 -0
- package/payload/server/public/assets/clock-BafWFNGo.js +1 -0
- package/payload/server/public/assets/clock-BafWFNGo.js.br +0 -0
- package/payload/server/public/assets/{copy-TkPJgllS.js → copy-JyiMTV0k.js} +1 -1
- package/payload/server/public/assets/copy-JyiMTV0k.js.br +0 -0
- package/payload/server/public/assets/copy-JyiMTV0k.js.gz +0 -0
- package/payload/server/public/assets/data-DHtGCVrl.js +1 -0
- package/payload/server/public/assets/data-DHtGCVrl.js.br +0 -0
- package/payload/server/public/assets/data-DHtGCVrl.js.gz +0 -0
- package/payload/server/public/assets/{file-text-DyAL2DCh.js → file-text-cnHv9ZS6.js} +1 -1
- package/payload/server/public/assets/file-text-cnHv9ZS6.js.br +0 -0
- package/payload/server/public/assets/file-text-cnHv9ZS6.js.gz +0 -0
- package/payload/server/public/assets/{graph-PiNIcCfX.js → graph-lFwEUDSH.js} +1 -1
- package/payload/server/public/assets/graph-lFwEUDSH.js.br +0 -0
- package/payload/server/public/assets/graph-lFwEUDSH.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-8jKFrLHn.js → graph-labels-C6qUA0vO.js} +1 -1
- package/payload/server/public/assets/graph-labels-C6qUA0vO.js.br +0 -0
- package/payload/server/public/assets/graph-labels-C6qUA0vO.js.gz +0 -0
- package/payload/server/public/assets/{operator-DKGKGzrb.js → operator-BdoEy5mh.js} +1 -1
- package/payload/server/public/assets/operator-BdoEy5mh.js.br +0 -0
- package/payload/server/public/assets/operator-BdoEy5mh.js.gz +0 -0
- package/payload/server/public/assets/{page-DXz_K10G.js → page-DMrnnXNA.js} +1 -1
- package/payload/server/public/assets/page-DMrnnXNA.js.br +0 -0
- package/payload/server/public/assets/page-DMrnnXNA.js.gz +0 -0
- package/payload/server/public/assets/{page-BU5vlY9Y.js → page-DeMikGYL.js} +1 -1
- package/payload/server/public/assets/page-DeMikGYL.js.br +0 -0
- package/payload/server/public/assets/page-DeMikGYL.js.gz +0 -0
- package/payload/server/public/assets/play-D6OAQVJ_.js +1 -0
- package/payload/server/public/assets/play-D6OAQVJ_.js.br +0 -0
- package/payload/server/public/assets/play-D6OAQVJ_.js.gz +0 -0
- package/payload/server/public/assets/{public-BmA3-jLj.js → public-B-X1Cxfw.js} +1 -1
- package/payload/server/public/assets/public-B-X1Cxfw.js.br +0 -0
- package/payload/server/public/assets/public-B-X1Cxfw.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-fCH0LSMq.js → rotate-ccw-DVFvQAHG.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.gz +0 -0
- package/payload/server/public/assets/{routines-BeFrmkls.js → routines-CgkkGJaS.js} +1 -1
- package/payload/server/public/assets/routines-CgkkGJaS.js.br +0 -0
- package/payload/server/public/assets/routines-CgkkGJaS.js.gz +0 -0
- package/payload/server/public/assets/{skills-mjls5HBa.js → skills-CW7gVcaG.js} +1 -1
- package/payload/server/public/assets/skills-CW7gVcaG.js.br +0 -0
- package/payload/server/public/assets/skills-CW7gVcaG.js.gz +0 -0
- package/payload/server/public/assets/{tasks-p6puHHD0.js → tasks-Ddjp0Rwh.js} +1 -1
- package/payload/server/public/assets/tasks-Ddjp0Rwh.js.br +0 -0
- package/payload/server/public/assets/tasks-Ddjp0Rwh.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-CrZ1ewbd.js → triangle-alert-DUrUutX4.js} +1 -1
- package/payload/server/public/assets/triangle-alert-DUrUutX4.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-DUrUutX4.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-zOQNrIwd.js → useCopyFeedback-ClVINDrQ.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.gz +0 -0
- package/payload/server/public/assets/{useMediaQuery-BmG090FR.js → useMediaQuery-BUFkZYRs.js} +1 -1
- package/payload/server/public/assets/useMediaQuery-BUFkZYRs.js.br +0 -0
- package/payload/server/public/assets/{useMediaQuery-BmG090FR.js.gz → useMediaQuery-BUFkZYRs.js.gz} +0 -0
- package/payload/server/public/assets/{useVoiceRecorder-9pf6FFJ-.js → useVoiceRecorder-DnyqQFet.js} +1 -1
- package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.gz +0 -0
- package/payload/server/public/assets/{wrench-k4GYG5rc.js → wrench-BW28s_d0.js} +1 -1
- package/payload/server/public/assets/wrench-BW28s_d0.js.br +0 -0
- package/payload/server/public/assets/wrench-BW28s_d0.js.gz +0 -0
- package/payload/server/public/browser.html +3 -3
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +12 -12
- package/payload/server/public/data.html +10 -10
- package/payload/server/public/graph.html +8 -8
- package/payload/server/public/index.html +12 -12
- package/payload/server/public/operator.html +13 -13
- package/payload/server/public/public.html +12 -12
- package/payload/server/public/routines.html +6 -6
- package/payload/server/public/skills.html +4 -4
- package/payload/server/public/tasks.html +5 -5
- package/payload/server/server.js +1014 -375
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts +0 -6
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts.map +0 -1
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js +0 -102
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts +0 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js +0 -87
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts +0 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js +0 -79
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts +0 -35
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js +0 -56
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts +0 -24
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js +0 -87
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js.map +0 -1
- package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.br +0 -0
- package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.gz +0 -0
- package/payload/server/public/assets/activity-BL6qbB9a.js.br +0 -0
- package/payload/server/public/assets/activity-BL6qbB9a.js.gz +0 -0
- package/payload/server/public/assets/admin-jnJ3P2u4.js.br +0 -0
- package/payload/server/public/assets/admin-jnJ3P2u4.js.gz +0 -0
- package/payload/server/public/assets/agents-BzhfKZDN.js.br +0 -0
- package/payload/server/public/assets/agents-BzhfKZDN.js.gz +0 -0
- package/payload/server/public/assets/browser-C-6io2B9.js.br +0 -0
- package/payload/server/public/assets/browser-C-6io2B9.js.gz +0 -0
- package/payload/server/public/assets/calendar-Dt3gS88s.js.br +0 -0
- package/payload/server/public/assets/calendar-Dt3gS88s.js.gz +0 -0
- package/payload/server/public/assets/chat-IUOvVkL-.js +0 -1
- package/payload/server/public/assets/chat-IUOvVkL-.js.br +0 -1
- package/payload/server/public/assets/chat-IUOvVkL-.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-DSzR9zR7.js +0 -1
- package/payload/server/public/assets/chevron-left-DSzR9zR7.js.br +0 -0
- package/payload/server/public/assets/clock-C5xE5p2I.js +0 -1
- package/payload/server/public/assets/clock-C5xE5p2I.js.br +0 -0
- package/payload/server/public/assets/copy-TkPJgllS.js.br +0 -0
- package/payload/server/public/assets/copy-TkPJgllS.js.gz +0 -0
- package/payload/server/public/assets/data-Bhv0Pvfl.js +0 -1
- package/payload/server/public/assets/data-Bhv0Pvfl.js.br +0 -0
- package/payload/server/public/assets/data-Bhv0Pvfl.js.gz +0 -0
- package/payload/server/public/assets/file-text-DyAL2DCh.js.br +0 -0
- package/payload/server/public/assets/file-text-DyAL2DCh.js.gz +0 -0
- package/payload/server/public/assets/graph-PiNIcCfX.js.br +0 -0
- package/payload/server/public/assets/graph-PiNIcCfX.js.gz +0 -0
- package/payload/server/public/assets/graph-labels-8jKFrLHn.js.br +0 -0
- package/payload/server/public/assets/graph-labels-8jKFrLHn.js.gz +0 -0
- package/payload/server/public/assets/operator-DKGKGzrb.js.br +0 -0
- package/payload/server/public/assets/operator-DKGKGzrb.js.gz +0 -0
- package/payload/server/public/assets/page-BU5vlY9Y.js.br +0 -0
- package/payload/server/public/assets/page-BU5vlY9Y.js.gz +0 -0
- package/payload/server/public/assets/page-DXz_K10G.js.br +0 -0
- package/payload/server/public/assets/page-DXz_K10G.js.gz +0 -0
- package/payload/server/public/assets/play-CmSyYrB6.js +0 -1
- package/payload/server/public/assets/play-CmSyYrB6.js.br +0 -0
- package/payload/server/public/assets/play-CmSyYrB6.js.gz +0 -0
- package/payload/server/public/assets/public-BmA3-jLj.js.br +0 -0
- package/payload/server/public/assets/public-BmA3-jLj.js.gz +0 -0
- package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.gz +0 -0
- package/payload/server/public/assets/routines-BeFrmkls.js.br +0 -0
- package/payload/server/public/assets/routines-BeFrmkls.js.gz +0 -0
- package/payload/server/public/assets/skills-mjls5HBa.js.br +0 -0
- package/payload/server/public/assets/skills-mjls5HBa.js.gz +0 -0
- package/payload/server/public/assets/tasks-p6puHHD0.js.br +0 -0
- package/payload/server/public/assets/tasks-p6puHHD0.js.gz +0 -0
- package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.gz +0 -0
- package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-BmG090FR.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.gz +0 -0
- package/payload/server/public/assets/wrench-k4GYG5rc.js.br +0 -0
- package/payload/server/public/assets/wrench-k4GYG5rc.js.gz +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what Real Agent ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:3783bc8e36395aa87ab8e75dc385eaeae9bf826d201d552f5c97587f68149c05
|
|
5
5
|
brand: realagent-code
|
|
6
6
|
product-name: Real Agent
|
|
7
7
|
---
|
|
@@ -282,6 +282,8 @@ The sweep is the catch-net for `master-fd=close-failed` and any future regressio
|
|
|
282
282
|
|
|
283
283
|
`openFdCount()` reads `/proc/self/fd` directly on Linux and returns `-1` on darwin (the dev-Mac path). The fd-leak audit on the laptop: `~/maxy-code/platform/scripts/logs-read.sh --tail server 400 | grep -E '\[fd-audit\]|op=archive-release'`. Full per-spawn lifeline: `grep -E '\[rc-spawn\]|\[pty-tracker\]'` filtered by `unitToken`.
|
|
284
284
|
|
|
285
|
+
**Reader frame-strip census.** `~/maxy-code/platform/scripts/logs-read.sh --tail server 200 | grep -E '\[chat-reader\]|op=channel-unified'` pairs the per-account envelope-strip counts against the multi-door spawn count, which is the pairing the check needs: `op=frame-unstripped rows=0` is only a pass alongside `op=frame-census nested>0`, because a zero on its own cannot tell a healthy account from a census that stopped measuring. `op=frame-census accounts=0` says the pass measured nothing. `server.log` rotates on size, so `zgrep` the `.gz` siblings before concluding a window is absent. Mechanism, success and failure conditions in [`.docs/admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
|
|
286
|
+
|
|
285
287
|
**WhatsApp inbound `/input` post-condition + sink-timestamp invariant.** An accepted `/input` returns `200` the moment the PTY write lands, but a `200` is not proof a turn submitted. The manager fires a bounded post-condition poll and logs `op=input-submitted … ackMs=…` (PTY went `busy` or a new JSONL user entry appeared) or `op=input-no-turn … ptyStatus=… lastJsonlEntryAgeMs=…` when the window elapses — so an accepted-but-unsubmitted input is visible in ≈8s, not only at the 300s turn-timeout. On timeout the bridge attributes the cause from the JSONL tail: `op=turn-timeout cause=no-turn` (no completed turn) vs `cause=relay-missed` (an assistant `end_turn` landed but the follower never relayed it); a per-sender `op=inbound-outcome relay-ok=N timeout-no-turn=N timeout-relay-missed=N` tally makes the failure rate one grep. **Sink-timestamp invariant:** every `server.log` line is stamped with a leading ISO timestamp at the stdout/stderr sink of the process that emits it — two sinks, one per process (UI: `server-init.cjs`; manager: `src/install-log-sink.ts`) — because both systemd units append to one `server.log` and `append:` bypasses journald. Never hand-inline a timestamp in a log call. Full lifeline, failure signatures, and diagnostic greps in [`.docs/whatsapp-inbound-lifeline.md`](../../../.docs/whatsapp-inbound-lifeline.md).
|
|
286
288
|
|
|
287
289
|
**PTY lifecycle contract.** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `killSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the in-process tracker entry is removed before the next `/list` or `/events` tick. The tracker is a module-scoped `Map<sessionId, PtyTracker>` in `pty-spawner.ts` — the metadata-rich `SessionStore` is gone; the tracker holds only what the file system cannot (PtyHandle + pid + bridge ids + runtime flags). Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object — non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).
|
|
@@ -6160,7 +6162,7 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
|
|
|
6160
6162
|
|
|
6161
6163
|
**Reconcile by meaning on write (`profile-update`).** The existence check is two-tier. First an exact `(category, key)` match — the classic fast path, unchanged. When that misses, a semantic-neighbour lookup runs over the `preference_embedding` vector index, scoped to the same `accountId` and `scope:'admin'`, non-trashed. If the nearest neighbour is at or above `SAME_SUBJECT_COSINE` (0.85), the write lands on that node through the existing reinforce/update/contradict curve instead of minting a duplicate; the matched neighbours are returned in the tool result so the agent can classify future writes against real prior state. The confidence constants are unchanged — only the join key widens, so a rephrasing under a new `key` no longer forks the rule and a correction demotes the prior node rather than adding a contradictory one.
|
|
6162
6164
|
|
|
6163
|
-
**Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context
|
|
6165
|
+
**Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules (account owner)` block above `## Context`, whose header and first line name the owner as the subject of the rules so a third-person rule about the owner is not read as a statement about the sender. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
|
|
6164
6166
|
|
|
6165
6167
|
**Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.
|
|
6166
6168
|
|
|
@@ -9,6 +9,23 @@ Invoked by the admin agent directly.
|
|
|
9
9
|
|
|
10
10
|
This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
|
|
11
11
|
|
|
12
|
+
## 2026-08-08 (0.1.578)
|
|
13
|
+
|
|
14
|
+
- Telegram conversations now appear on the dashboard. The composer shipped two releases ago had nothing to show, because the rows behind it were never written.
|
|
15
|
+
- Telegram shows a typing indicator while the assistant is working, so a pause reads as thinking rather than silence.
|
|
16
|
+
- A message no longer arrives wrapped in its own delivery envelope. The clean view was printing the whole composed frame instead of what was written.
|
|
17
|
+
- Two phones on separate accounts no longer share one reader. Two conversations were being served by a single reader while the health check called that healthy.
|
|
18
|
+
- The check that counts answered Telegram turns can now actually register one. It could never record an answer on a turn from you, so its count was always short.
|
|
19
|
+
- The three conversion scripts left behind by the previous release are gone from the install. They were still being built and shipped with nothing able to run them.
|
|
20
|
+
|
|
21
|
+
## 2026-08-08 (0.1.577)
|
|
22
|
+
|
|
23
|
+
- Telegram messages keep arriving after a quiet spell. The reader that carries them was attached only when a conversation started, so it died once the conversation went quiet and nothing said so or restarted it. This is the same fault fixed for WhatsApp in the previous release; a standing check now covers Telegram too.
|
|
24
|
+
- Telegram now says who is writing. A message carried no sender, so a second person messaging the same assistant was answered as though they were the account owner, under the owner's own standing instructions.
|
|
25
|
+
- The standing instructions attached to a turn are now labelled as such, rather than appearing as unmarked text.
|
|
26
|
+
- The planner can now change a day, not just show one. It could display an allocated day and offer no way to alter it.
|
|
27
|
+
- A webhook address ending in a slash is no longer rejected. The guard that admits it tested the path differently from the router that serves it, so the two could disagree on the same address.
|
|
28
|
+
|
|
12
29
|
## 2026-08-08 (0.1.576)
|
|
13
30
|
|
|
14
31
|
- Installing and upgrading no longer runs data conversions on your existing records. Five had accumulated, each added to repair a state that every install has long since passed, so all five did nothing but add work and risk to every upgrade. One of them stopped the install outright after reporting it had nothing to do. Any future conversion will be something you run deliberately, not part of an upgrade.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
MAX_NOTE_LENGTH,
|
|
4
|
+
INSTANT_RE,
|
|
5
|
+
MAX_OWNER_ID_LENGTH,
|
|
4
6
|
MAX_STATUS_LENGTH,
|
|
5
7
|
WORK_DATE_RE,
|
|
6
8
|
processVisitStatus,
|
|
@@ -14,6 +16,9 @@ interface Row {
|
|
|
14
16
|
accountId: string
|
|
15
17
|
ownerId: string
|
|
16
18
|
visitId: string
|
|
19
|
+
/** The visit's CURRENT status, which the Task 2511 transition pre-read needs
|
|
20
|
+
* to know what a move is moving from. */
|
|
21
|
+
status?: string
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
/**
|
|
@@ -23,7 +28,11 @@ interface Row {
|
|
|
23
28
|
* `meta.changes` as the number of rows the SELECT admitted. A fake that always
|
|
24
29
|
* reported one change would pass a handler with no gate at all.
|
|
25
30
|
*/
|
|
26
|
-
function db(
|
|
31
|
+
function db(
|
|
32
|
+
rows: Row[],
|
|
33
|
+
person: { ownerId: string; recordScope: string; accountId: string },
|
|
34
|
+
transitions: Array<[string, string]> | string = [],
|
|
35
|
+
) {
|
|
27
36
|
const inserted: unknown[][] = []
|
|
28
37
|
const database = {
|
|
29
38
|
prepare(sql: string) {
|
|
@@ -34,7 +43,25 @@ function db(rows: Row[], person: { ownerId: string; recordScope: string; account
|
|
|
34
43
|
return stmt
|
|
35
44
|
},
|
|
36
45
|
async first<T>() {
|
|
37
|
-
|
|
46
|
+
// Three reads now use first(): the session resolve, and the Task 2511
|
|
47
|
+
// pre-read of the visit's current status with the account's published
|
|
48
|
+
// transitions. Branching on the table is what keeps the fake honest
|
|
49
|
+
// about which one answered, and the pre-read APPLIES THE SAME TWO
|
|
50
|
+
// FILTERS the insert does, so a test that deletes the scope branch in
|
|
51
|
+
// the endpoint sees the pre-read stop matching too.
|
|
52
|
+
if (!sql.includes('FROM visits')) return person as unknown as T
|
|
53
|
+
const acct = bound[0] as string
|
|
54
|
+
const visitId = bound[bound.length - 1] as string
|
|
55
|
+
let admitted = rows.filter((r) => r.accountId === acct && r.visitId === visitId)
|
|
56
|
+
if (sql.includes('AND v.ownerId = ?')) {
|
|
57
|
+
admitted = admitted.filter((r) => r.ownerId === (bound[1] as string))
|
|
58
|
+
}
|
|
59
|
+
if (admitted.length === 0) return null as unknown as T
|
|
60
|
+
return {
|
|
61
|
+
status: admitted[0].status ?? '',
|
|
62
|
+
transitions:
|
|
63
|
+
typeof transitions === 'string' ? transitions : JSON.stringify(transitions),
|
|
64
|
+
} as unknown as T
|
|
38
65
|
},
|
|
39
66
|
async run() {
|
|
40
67
|
if (!sql.startsWith('INSERT INTO visit_status')) return { meta: { changes: 0 } }
|
|
@@ -62,10 +89,25 @@ const env = (d: D1Database): PortalEnv =>
|
|
|
62
89
|
({ DB: d, BUCKET: {}, PORTAL_ACCOUNT_ID: 'acc-a' }) as unknown as PortalEnv
|
|
63
90
|
|
|
64
91
|
const ROWS: Row[] = [
|
|
65
|
-
{ accountId: 'acc-a', ownerId: 'alice', visitId: 'v1' },
|
|
66
|
-
{ accountId: 'acc-a', ownerId: 'bob', visitId: 'v2' },
|
|
67
|
-
{ accountId: 'acc-a', ownerId: '', visitId: 'v3' },
|
|
68
|
-
{ accountId: 'acc-b', ownerId: 'alice', visitId: 'v4' },
|
|
92
|
+
{ accountId: 'acc-a', ownerId: 'alice', visitId: 'v1', status: 'booked' },
|
|
93
|
+
{ accountId: 'acc-a', ownerId: 'bob', visitId: 'v2', status: 'booked' },
|
|
94
|
+
{ accountId: 'acc-a', ownerId: '', visitId: 'v3', status: 'booked' },
|
|
95
|
+
{ accountId: 'acc-b', ownerId: 'alice', visitId: 'v4', status: 'booked' },
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every move the tests BELOW this line make, declared legal.
|
|
100
|
+
*
|
|
101
|
+
* Those tests measure the scope gate and the field gates, not the transition
|
|
102
|
+
* gate Task 2511 added. Without this list each of them would be refused as an
|
|
103
|
+
* illegal move and would stop measuring what it was written to measure. The
|
|
104
|
+
* transition gate has its own describe block at the end of this file, where the
|
|
105
|
+
* list is stated per test.
|
|
106
|
+
*/
|
|
107
|
+
const DECLARED: Array<[string, string]> = [
|
|
108
|
+
['booked', 'on-site'],
|
|
109
|
+
['booked', 'done'],
|
|
110
|
+
['booked', 'on-site-at-granary-court'],
|
|
69
111
|
]
|
|
70
112
|
|
|
71
113
|
const person = (ownerId: string, recordScope: string) => ({
|
|
@@ -78,7 +120,7 @@ const body = (visitId: string, status = 'on-site') => ({ visitId, status })
|
|
|
78
120
|
|
|
79
121
|
describe('the visit-status write gate', () => {
|
|
80
122
|
it('accepts an own-scoped person setting status on their own visit', async () => {
|
|
81
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
123
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
82
124
|
const h = await processVisitStatus(body('v1'), 'sess', env(database), () => {}, 1)
|
|
83
125
|
expect(h.status).toBe(200)
|
|
84
126
|
expect(inserted).toHaveLength(1)
|
|
@@ -87,28 +129,28 @@ describe('the visit-status write gate', () => {
|
|
|
87
129
|
it('refuses an own-scoped person setting status on someone else visit', async () => {
|
|
88
130
|
// A read leak shows someone a day that is not theirs. A write leak lets
|
|
89
131
|
// them change it, which is why the gate is the same one and is structural.
|
|
90
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
132
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
91
133
|
const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
|
|
92
134
|
expect(h.status).toBe(404)
|
|
93
135
|
expect(inserted).toEqual([])
|
|
94
136
|
})
|
|
95
137
|
|
|
96
138
|
it('refuses an own-scoped person setting status on an unbound visit', async () => {
|
|
97
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
139
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
98
140
|
const h = await processVisitStatus(body('v3'), 'sess', env(database), () => {}, 1)
|
|
99
141
|
expect(h.status).toBe(404)
|
|
100
142
|
expect(inserted).toEqual([])
|
|
101
143
|
})
|
|
102
144
|
|
|
103
145
|
it('lets an account-scoped person set status on any visit for the account', async () => {
|
|
104
|
-
const { database, inserted } = db(ROWS, person('marie', 'account'))
|
|
146
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
|
|
105
147
|
const h = await processVisitStatus(body('v2'), 'sess', env(database), () => {}, 1)
|
|
106
148
|
expect(h.status).toBe(200)
|
|
107
149
|
expect(inserted).toHaveLength(1)
|
|
108
150
|
})
|
|
109
151
|
|
|
110
152
|
it('refuses another account visit even at account scope', async () => {
|
|
111
|
-
const { database, inserted } = db(ROWS, person('marie', 'account'))
|
|
153
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
|
|
112
154
|
const h = await processVisitStatus(body('v4'), 'sess', env(database), () => {}, 1)
|
|
113
155
|
expect(h.status).toBe(404)
|
|
114
156
|
expect(inserted).toEqual([])
|
|
@@ -123,7 +165,7 @@ describe('the visit-status write gate', () => {
|
|
|
123
165
|
})
|
|
124
166
|
|
|
125
167
|
it('refuses a body with no visitId or no status', async () => {
|
|
126
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
168
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
127
169
|
const e = env(database)
|
|
128
170
|
expect((await processVisitStatus({ status: 'x' }, 'sess', e, () => {}, 1)).status).toBe(400)
|
|
129
171
|
expect((await processVisitStatus({ visitId: 'v1' }, 'sess', e, () => {}, 1)).status).toBe(400)
|
|
@@ -135,7 +177,7 @@ describe('the visit-status write gate', () => {
|
|
|
135
177
|
// The value reaches :Visit.status through the pull. An unbounded free-text
|
|
136
178
|
// field written from the open web is refused at the door, not truncated:
|
|
137
179
|
// a truncated status is a different status, silently.
|
|
138
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
180
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
139
181
|
const h = await processVisitStatus(
|
|
140
182
|
body('v1', 'x'.repeat(MAX_STATUS_LENGTH + 1)),
|
|
141
183
|
'sess',
|
|
@@ -148,7 +190,7 @@ describe('the visit-status write gate', () => {
|
|
|
148
190
|
})
|
|
149
191
|
|
|
150
192
|
it('stamps the setter own id, never one the request supplies', async () => {
|
|
151
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
193
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
152
194
|
await processVisitStatus(
|
|
153
195
|
{ visitId: 'v1', status: 'on-site', ownerId: 'bob' },
|
|
154
196
|
'sess',
|
|
@@ -162,7 +204,7 @@ describe('the visit-status write gate', () => {
|
|
|
162
204
|
|
|
163
205
|
it('logs the scope and the outcome, and no status text', async () => {
|
|
164
206
|
const lines: string[] = []
|
|
165
|
-
const { database } = db(ROWS, person('alice', 'own'))
|
|
207
|
+
const { database } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
166
208
|
await processVisitStatus(
|
|
167
209
|
body('v1', 'on-site-at-granary-court'),
|
|
168
210
|
'sess',
|
|
@@ -181,7 +223,7 @@ describe('the working day a status edit may carry', () => {
|
|
|
181
223
|
const day = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'done', ...extra })
|
|
182
224
|
|
|
183
225
|
it('inserts the day, the date and the note when the claim is well formed', async () => {
|
|
184
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
226
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
185
227
|
const h = await processVisitStatus(
|
|
186
228
|
day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'second fix' }),
|
|
187
229
|
'sess',
|
|
@@ -197,7 +239,7 @@ describe('the working day a status edit may carry', () => {
|
|
|
197
239
|
|
|
198
240
|
it('defaults to no claim when the body carries no day', async () => {
|
|
199
241
|
// A status-only edit is the common case and must stay a one-field write.
|
|
200
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
242
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
201
243
|
const h = await processVisitStatus(day({}), 'sess', env(database), () => {}, 1)
|
|
202
244
|
expect(h.status).toBe(200)
|
|
203
245
|
expect(inserted[0]).toContain(0)
|
|
@@ -207,7 +249,7 @@ describe('the working day a status edit may carry', () => {
|
|
|
207
249
|
it('refuses a day outside 0..100 rather than writing it into the graph', async () => {
|
|
208
250
|
// A value above a full day is described by no shipped surface. Refused at
|
|
209
251
|
// the door, exactly as an over-long status is.
|
|
210
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
252
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
211
253
|
const e = env(database)
|
|
212
254
|
for (const d of [101, -1, 50.5, '100']) {
|
|
213
255
|
const h = await processVisitStatus(
|
|
@@ -225,7 +267,7 @@ describe('the working day a status edit may carry', () => {
|
|
|
225
267
|
it('refuses a claim whose date is missing or malformed', async () => {
|
|
226
268
|
// The date becomes part of the cost key. A claim the roll-up cannot date is
|
|
227
269
|
// told to the person now, not left sitting in the queue.
|
|
228
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
270
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
229
271
|
const e = env(database)
|
|
230
272
|
for (const wd of [undefined, '', '5th Aug', '2026-8-5']) {
|
|
231
273
|
const h = await processVisitStatus(
|
|
@@ -246,7 +288,7 @@ describe('the working day a status edit may carry', () => {
|
|
|
246
288
|
})
|
|
247
289
|
|
|
248
290
|
it('refuses a note longer than the cap rather than truncating it', async () => {
|
|
249
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
291
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
250
292
|
const h = await processVisitStatus(
|
|
251
293
|
day({
|
|
252
294
|
dayHundredths: 100,
|
|
@@ -264,7 +306,7 @@ describe('the working day a status edit may carry', () => {
|
|
|
264
306
|
|
|
265
307
|
it('logs whether the edit carried a day, and never the note', async () => {
|
|
266
308
|
const lines: string[] = []
|
|
267
|
-
const { database } = db(ROWS, person('alice', 'own'))
|
|
309
|
+
const { database } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
268
310
|
await processVisitStatus(
|
|
269
311
|
day({ dayHundredths: 100, workDate: '2026-08-05', workNote: 'granary court rewire' }),
|
|
270
312
|
'sess',
|
|
@@ -280,7 +322,7 @@ describe('the working day a status edit may carry', () => {
|
|
|
280
322
|
|
|
281
323
|
it('still refuses another person visit when the edit carries a day', async () => {
|
|
282
324
|
// The gate is the SELECT and the new fields must not have moved it.
|
|
283
|
-
const { database, inserted } = db(ROWS, person('alice', 'own'))
|
|
325
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
284
326
|
const h = await processVisitStatus(
|
|
285
327
|
{ visitId: 'v2', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
|
|
286
328
|
'sess',
|
|
@@ -292,3 +334,272 @@ describe('the working day a status edit may carry', () => {
|
|
|
292
334
|
expect(inserted).toEqual([])
|
|
293
335
|
})
|
|
294
336
|
})
|
|
337
|
+
|
|
338
|
+
describe('the move a status edit may carry (Task 2511)', () => {
|
|
339
|
+
const move = (extra: Record<string, unknown>) => ({ visitId: 'v1', status: 'on-site', ...extra })
|
|
340
|
+
|
|
341
|
+
it('inserts the new owner and the new start when both are well formed', async () => {
|
|
342
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
|
|
343
|
+
const h = await processVisitStatus(
|
|
344
|
+
move({ assignedOwnerId: 'bob', startDate: '2026-08-10T09:00:00.000Z' }),
|
|
345
|
+
'sess',
|
|
346
|
+
env(database),
|
|
347
|
+
() => {},
|
|
348
|
+
1,
|
|
349
|
+
)
|
|
350
|
+
expect(h.status).toBe(200)
|
|
351
|
+
expect(inserted[0]).toContain('bob')
|
|
352
|
+
expect(inserted[0]).toContain('2026-08-10T09:00:00.000Z')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('defaults both to empty when the body carries neither', async () => {
|
|
356
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
357
|
+
const h = await processVisitStatus(move({}), 'sess', env(database), () => {}, 1)
|
|
358
|
+
expect(h.status).toBe(200)
|
|
359
|
+
// status, statusAt, workDate, workNote, assignedOwnerId, startDate: four of
|
|
360
|
+
// those six are empty on a status-only edit.
|
|
361
|
+
expect(inserted[0].filter((v) => v === '').length).toBeGreaterThanOrEqual(4)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('refuses an offset-bearing, date-only or second-precision start rather than converting it', async () => {
|
|
365
|
+
// readCommitments compares stored start values LEXICALLY, so an
|
|
366
|
+
// offset-bearing row reads the wrong window and a real double-booking goes
|
|
367
|
+
// unreported. Refused at the door, never normalised here.
|
|
368
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
369
|
+
const e = env(database)
|
|
370
|
+
for (const s of [
|
|
371
|
+
'2026-08-10T09:00:00+01:00',
|
|
372
|
+
'2026-08-10',
|
|
373
|
+
'2026-08-10T09:00:00Z',
|
|
374
|
+
'2026-08-10 09:00:00.000Z',
|
|
375
|
+
'soon',
|
|
376
|
+
]) {
|
|
377
|
+
const h = await processVisitStatus(move({ startDate: s }), 'sess', e, () => {}, 1)
|
|
378
|
+
expect(h.status, `refuses ${s}`).toBe(400)
|
|
379
|
+
}
|
|
380
|
+
expect(inserted).toEqual([])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('refuses an over-long assignedOwnerId rather than truncating it', async () => {
|
|
384
|
+
// A truncated id matches a different person, or nobody, silently.
|
|
385
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
386
|
+
const h = await processVisitStatus(
|
|
387
|
+
move({ assignedOwnerId: 'x'.repeat(MAX_OWNER_ID_LENGTH + 1) }),
|
|
388
|
+
'sess',
|
|
389
|
+
env(database),
|
|
390
|
+
() => {},
|
|
391
|
+
1,
|
|
392
|
+
)
|
|
393
|
+
expect(h.status).toBe(400)
|
|
394
|
+
expect(inserted).toEqual([])
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('still refuses another person visit when the edit carries a move', async () => {
|
|
398
|
+
// The gate is the SELECT and the new fields must not have moved it.
|
|
399
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
400
|
+
const h = await processVisitStatus(
|
|
401
|
+
{ visitId: 'v2', status: 'on-site', assignedOwnerId: 'bob' },
|
|
402
|
+
'sess',
|
|
403
|
+
env(database),
|
|
404
|
+
() => {},
|
|
405
|
+
1,
|
|
406
|
+
)
|
|
407
|
+
expect(h.status).toBe(404)
|
|
408
|
+
expect(inserted).toEqual([])
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('accepts a start only in the shape the tree stores', () => {
|
|
412
|
+
expect(INSTANT_RE.test('2026-08-10T09:00:00.000Z')).toBe(true)
|
|
413
|
+
expect(INSTANT_RE.test('2026-08-10T09:00:00Z')).toBe(false)
|
|
414
|
+
expect(INSTANT_RE.test('2026-08-10')).toBe(false)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('names the visit and the reason when it refuses a bad instant', async () => {
|
|
418
|
+
const lines: string[] = []
|
|
419
|
+
const { database } = db(ROWS, person('alice', 'own'), DECLARED)
|
|
420
|
+
await processVisitStatus(
|
|
421
|
+
move({ startDate: 'soon' }),
|
|
422
|
+
'sess',
|
|
423
|
+
env(database),
|
|
424
|
+
(l) => lines.push(l),
|
|
425
|
+
1,
|
|
426
|
+
)
|
|
427
|
+
expect(lines.join('\n')).toContain('op=rejected')
|
|
428
|
+
expect(lines.join('\n')).toContain('reason=bad-instant')
|
|
429
|
+
expect(lines.join('\n')).toContain('v1')
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
describe('the transition gate at the door (Task 2511)', () => {
|
|
434
|
+
it('accepts a move the account declared', async () => {
|
|
435
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
|
|
436
|
+
const h = await processVisitStatus(
|
|
437
|
+
{ visitId: 'v1', status: 'on-site' },
|
|
438
|
+
'sess',
|
|
439
|
+
env(database),
|
|
440
|
+
() => {},
|
|
441
|
+
1,
|
|
442
|
+
)
|
|
443
|
+
expect(h.status).toBe(200)
|
|
444
|
+
expect(inserted).toHaveLength(1)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('refuses a move the account did not declare, and writes nothing', async () => {
|
|
448
|
+
// The refusal is here and not at the pull, which runs sixty seconds later
|
|
449
|
+
// with no screen to refuse to, and which sets the status unconditionally.
|
|
450
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
|
|
451
|
+
const h = await processVisitStatus(
|
|
452
|
+
{ visitId: 'v1', status: 'done' },
|
|
453
|
+
'sess',
|
|
454
|
+
env(database),
|
|
455
|
+
() => {},
|
|
456
|
+
1,
|
|
457
|
+
)
|
|
458
|
+
expect(h.status).toBe(400)
|
|
459
|
+
expect(h.payload).toEqual({ ok: false, error: 'illegal-transition' })
|
|
460
|
+
expect(inserted).toEqual([])
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('lets a re-time through when the status is unchanged', async () => {
|
|
464
|
+
// `[from, to]` declares a MOVE. A write whose status equals the current one
|
|
465
|
+
// is not a move, and no account declares a self-transition, so without this
|
|
466
|
+
// rule every pure re-timing would be refused.
|
|
467
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
|
|
468
|
+
const h = await processVisitStatus(
|
|
469
|
+
{ visitId: 'v1', status: 'booked', startDate: '2026-08-10T09:00:00.000Z' },
|
|
470
|
+
'sess',
|
|
471
|
+
env(database),
|
|
472
|
+
() => {},
|
|
473
|
+
1,
|
|
474
|
+
)
|
|
475
|
+
expect(h.status).toBe(200)
|
|
476
|
+
expect(inserted).toHaveLength(1)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('refuses every move when the account published no transitions', async () => {
|
|
480
|
+
// An empty list means the account declared none, which refuses everything.
|
|
481
|
+
// That is visibly wrong on screen; accepting everything would leave the page
|
|
482
|
+
// enforcing nothing and looking identical to one that enforces correctly.
|
|
483
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), [])
|
|
484
|
+
const h = await processVisitStatus(
|
|
485
|
+
{ visitId: 'v1', status: 'on-site' },
|
|
486
|
+
'sess',
|
|
487
|
+
env(database),
|
|
488
|
+
() => {},
|
|
489
|
+
1,
|
|
490
|
+
)
|
|
491
|
+
expect(h.status).toBe(400)
|
|
492
|
+
expect(inserted).toEqual([])
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('refuses every move when the published list is malformed', async () => {
|
|
496
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), 'not json')
|
|
497
|
+
const h = await processVisitStatus(
|
|
498
|
+
{ visitId: 'v1', status: 'on-site' },
|
|
499
|
+
'sess',
|
|
500
|
+
env(database),
|
|
501
|
+
() => {},
|
|
502
|
+
1,
|
|
503
|
+
)
|
|
504
|
+
expect(h.status).toBe(400)
|
|
505
|
+
expect(inserted).toEqual([])
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('answers 404 for a visit the person may not see, before any transition check', async () => {
|
|
509
|
+
const { database, inserted } = db(ROWS, person('alice', 'own'), [['booked', 'on-site']])
|
|
510
|
+
const h = await processVisitStatus(
|
|
511
|
+
{ visitId: 'v2', status: 'on-site' },
|
|
512
|
+
'sess',
|
|
513
|
+
env(database),
|
|
514
|
+
() => {},
|
|
515
|
+
1,
|
|
516
|
+
)
|
|
517
|
+
expect(h.status).toBe(404)
|
|
518
|
+
expect(inserted).toEqual([])
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('names the reason in the log, with the visit', async () => {
|
|
522
|
+
const lines: string[] = []
|
|
523
|
+
const { database } = db(ROWS, person('marie', 'account'), [['booked', 'on-site']])
|
|
524
|
+
await processVisitStatus(
|
|
525
|
+
{ visitId: 'v1', status: 'done' },
|
|
526
|
+
'sess',
|
|
527
|
+
env(database),
|
|
528
|
+
(l) => lines.push(l),
|
|
529
|
+
1,
|
|
530
|
+
)
|
|
531
|
+
expect(lines.join('\n')).toContain('op=rejected')
|
|
532
|
+
expect(lines.join('\n')).toContain('reason=illegal-transition')
|
|
533
|
+
expect(lines.join('\n')).toContain('v1')
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('still never says whether the visit exists, on a 404', async () => {
|
|
537
|
+
// One answer for "no such visit" and "not yours". Distinguishing them would
|
|
538
|
+
// let anyone enumerate the account's visit ids.
|
|
539
|
+
const { database } = db(ROWS, person('alice', 'own'), [['booked', 'on-site']])
|
|
540
|
+
const missing = await processVisitStatus(
|
|
541
|
+
{ visitId: 'nope', status: 'on-site' },
|
|
542
|
+
'sess',
|
|
543
|
+
env(database),
|
|
544
|
+
() => {},
|
|
545
|
+
1,
|
|
546
|
+
)
|
|
547
|
+
const theirs = await processVisitStatus(
|
|
548
|
+
{ visitId: 'v2', status: 'on-site' },
|
|
549
|
+
'sess',
|
|
550
|
+
env(database),
|
|
551
|
+
() => {},
|
|
552
|
+
1,
|
|
553
|
+
)
|
|
554
|
+
expect(missing.payload).toEqual(theirs.payload)
|
|
555
|
+
expect(missing.status).toBe(theirs.status)
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
describe('a move and a labour claim are separate edits (Task 2511)', () => {
|
|
560
|
+
it('refuses a body carrying both, and writes nothing', async () => {
|
|
561
|
+
// The device applies a move in one statement that writes no cost, so a row
|
|
562
|
+
// carrying both would set the status, move the worker, be claimed, and drop
|
|
563
|
+
// the day the worker stated.
|
|
564
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
|
|
565
|
+
const e = env(database)
|
|
566
|
+
for (const move of [{ assignedOwnerId: 'bob' }, { startDate: '2026-08-10T09:00:00.000Z' }]) {
|
|
567
|
+
const h = await processVisitStatus(
|
|
568
|
+
{ visitId: 'v1', status: 'on-site', dayHundredths: 100, workDate: '2026-08-05', ...move },
|
|
569
|
+
'sess',
|
|
570
|
+
e,
|
|
571
|
+
() => {},
|
|
572
|
+
1,
|
|
573
|
+
)
|
|
574
|
+
expect(h.status).toBe(400)
|
|
575
|
+
}
|
|
576
|
+
expect(inserted).toEqual([])
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('still accepts each one on its own', async () => {
|
|
580
|
+
const { database, inserted } = db(ROWS, person('marie', 'account'), DECLARED)
|
|
581
|
+
const e = env(database)
|
|
582
|
+
const labour = await processVisitStatus(
|
|
583
|
+
{ visitId: 'v1', status: 'done', dayHundredths: 100, workDate: '2026-08-05' },
|
|
584
|
+
'sess', e, () => {}, 1,
|
|
585
|
+
)
|
|
586
|
+
const move = await processVisitStatus(
|
|
587
|
+
{ visitId: 'v1', status: 'on-site', assignedOwnerId: 'bob' },
|
|
588
|
+
'sess', e, () => {}, 1,
|
|
589
|
+
)
|
|
590
|
+
expect(labour.status).toBe(200)
|
|
591
|
+
expect(move.status).toBe(200)
|
|
592
|
+
expect(inserted).toHaveLength(2)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('names the reason with the visit', async () => {
|
|
596
|
+
const lines: string[] = []
|
|
597
|
+
const { database } = db(ROWS, person('marie', 'account'), DECLARED)
|
|
598
|
+
await processVisitStatus(
|
|
599
|
+
{ visitId: 'v1', status: 'on-site', dayHundredths: 100, workDate: '2026-08-05', assignedOwnerId: 'bob' },
|
|
600
|
+
'sess', env(database), (l) => lines.push(l), 1,
|
|
601
|
+
)
|
|
602
|
+
expect(lines.join('\n')).toContain('reason=move-with-labour')
|
|
603
|
+
expect(lines.join('\n')).toContain('v1')
|
|
604
|
+
})
|
|
605
|
+
})
|